From f81eff0c64c1f67c858fdcc355cd5cff7946634f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 2 Oct 2026 01:23:19 +0800 Subject: [PATCH 01/10] fix: harden Cloudflare platform deployment and acceptance --- docs/guide/email.md | 4 +- docs/guide/platform/administration/email.md | 16 +- .../platform/installation/credentials.md | 4 +- docs/guide/platform/installation/domains.md | 28 + docs/guide/queues.md | 6 +- docs/integrations/cloudflare.md | 6 +- docs/reference/cli.md | 26 +- package.json | 2 +- packages/deploy-cloudflare/src/existing.ts | 16 + packages/deploy-cloudflare/src/framework.ts | 4 + packages/deploy-cloudflare/src/static.ts | 4 + packages/deploy-cloudflare/src/version.ts | 216 +- .../test/unit/existing.test.ts | 17 + .../test/unit/framework.test.ts | 27 + .../test/unit/static.test.ts | 7 + .../test/unit/version.test.ts | 351 + packages/edge/src/runtime.ts | 15 +- packages/edge/test/unit/runtime.test.ts | 10 + packages/platform/README.md | 2 + packages/platform/src/discovery.ts | 11 +- packages/platform/src/index.ts | 2 + packages/platform/src/manifest.ts | 69 +- packages/platform/src/worker-config.ts | 8 +- packages/platform/test/unit/dashboard.test.ts | 177 + packages/void/skills/void/SKILL.md | 4 +- packages/void/src/cli/cf-version-api.ts | 3 + packages/void/src/cli/client.ts | 6 + .../void/src/cli/cloudflare-login-output.ts | 15 + .../src/cli/cloudflare-operation-process.ts | 15 +- packages/void/src/cli/deploy.ts | 84 +- packages/void/src/cli/email.ts | 48 +- packages/void/src/cli/help.ts | 36 +- .../void/src/cli/platform-access-preflight.ts | 2 + .../void/src/cli/platform-access-provision.ts | 21 +- .../void/src/cli/platform-access-setup.ts | 6 +- packages/void/src/cli/platform-args.ts | 53 +- .../void/src/cli/platform-install-auth.ts | 4 + packages/void/src/cli/platform-installer.ts | 205 +- packages/void/src/cli/platform-lifecycle.ts | 2 + packages/void/src/cli/platform-management.ts | 3 + packages/void/src/cli/platform-plan.ts | 120 +- packages/void/src/cli/platform-setup.ts | 2 + packages/void/src/cli/wrapper.ts | 6 +- packages/void/src/index.ts | 25 + packages/void/src/plugin-routing.ts | 4 + packages/void/src/router/compile.ts | 5 +- packages/void/src/runtime/routing-worker.ts | 12 +- packages/void/src/shared/version-metadata.ts | 40 + .../void/test/fixtures/platform-access.ts | 5 +- .../integration/pages-target-assets.test.ts | 22 +- .../void/test/integration/vinext-app.test.ts | 32 +- .../void/test/unit/cf-version-runner.test.ts | 134 + packages/void/test/unit/cli-help.test.ts | 4 +- .../void/test/unit/cloudflare-deploy.test.ts | 38 +- .../test/unit/cloudflare-login-output.test.ts | 19 +- packages/void/test/unit/compile.test.ts | 40 + .../void/test/unit/email-cli-domain.test.ts | 29 +- .../test/unit/integration-helpers.test.ts | 7 + .../unit/platform-access-preflight.test.ts | 10 + .../unit/platform-access-provision.test.ts | 30 + .../test/unit/platform-access-setup.test.ts | 30 + packages/void/test/unit/platform-args.test.ts | 18 + .../unit/platform-install-cutover.test.ts | 107 +- .../void/test/unit/platform-installer.test.ts | 308 +- .../unit/platform-management-upgrade.test.ts | 20 + packages/void/test/unit/platform-plan.test.ts | 310 + .../void/test/unit/platform-setup.test.ts | 7 +- .../unit/plugin-framework-triggers.test.ts | 3 + .../test/unit/plugin-version-metadata.test.ts | 116 + .../void/test/unit/routing-runtime.test.ts | 22 + .../void/test/unit/version-metadata.test.ts | 66 + packages/void/test/unit/wrapper.test.ts | 27 + .../drizzle/0056_email_shared_recipients.sql | 51 + .../packages/api/drizzle/compatibility.json | 4 + .../api/drizzle/meta/0056_snapshot.json | 6517 +++++++++++++++++ .../packages/api/drizzle/meta/_journal.json | 7 + .../packages/api/src/admin/fate/render.ts | 2 +- .../api/src/admin/routes/auth-config.tsx | 6 +- .../api/src/admin/routes/zero-trust.tsx | 2 +- platform/packages/api/src/auth/login.ts | 7 +- .../packages/api/src/email/cf-customer.ts | 117 +- .../api/src/email/connection-schema.ts | 40 + .../api/src/email/connection-service.ts | 235 +- .../packages/api/src/email/domain-service.ts | 1 + .../api/src/email/ingress-resolution.ts | 4 + .../api/src/email/shared-recipient-service.ts | 606 ++ platform/packages/api/src/index.ts | 32 + .../packages/api/src/provisioning/cleanup.ts | 2 + platform/packages/api/src/routes/deploy.ts | 27 + platform/packages/api/src/routes/email.ts | 5 +- .../api/src/routes/platform-bootstrap.ts | 7 +- platform/packages/api/src/routes/projects.ts | 7 + .../integration/admin-auth-config.test.ts | 41 + .../test/integration/admin-dashboard.test.ts | 35 +- .../api/test/integration/admin-email.test.ts | 16 +- .../integration/api-email-domains.test.ts | 46 + .../api/test/integration/api-email.test.ts | 1 + .../integration/email-connections.test.ts | 176 +- .../test/integration/email-domain-cf-stub.ts | 114 +- .../email-shared-recipients.test.ts | 541 ++ .../packages/api/test/integration/helpers.ts | 1 + .../test/integration/native-form-browser.ts | 94 + .../operator-email-domains.test.ts | 39 +- .../integration/platform-bootstrap.test.ts | 128 + .../platform-email-bootstrap.test.ts | 128 +- .../test/integration/platform-login.test.ts | 113 + .../api/test/smoke-platform-fixture.test.ts | 96 + .../api/test/unit/email-cf-customer.test.ts | 70 + .../email-shared-recipient-migration.test.ts | 116 + .../unit/kitchen-sink-acceptance-auth.test.ts | 141 + .../unit/kitchen-sink-observations.test.ts | 120 + .../unit/kitchen-sink-revalidation.test.ts | 42 + .../test/unit/kitchen-sink-sandbox.test.ts | 100 + .../api/test/unit/redirect-validation.test.ts | 19 + .../dashboard/pages/account.server.ts | 37 +- .../dashboard/pages/auth/callback.server.ts | 20 +- .../dashboard/test/unit/account.test.ts | 25 + .../dashboard/test/unit/auth-callback.test.ts | 20 + .../kitchen-sink-email/email/_default.ts | 3 + .../email/support+[ticket].ts | 3 + .../kitchen-sink-email/email/support+vip.ts | 3 + .../kitchen-sink-email/email/support.ts | 3 + .../lib/email-acceptance.ts | 38 + .../routes/api/acceptance/email.ts | 25 + .../lib/queue-acceptance.ts | 17 + .../queues/acceptance-queue.ts | 64 + .../routes/api/acceptance/queue.ts | 37 + .../routes/api/acceptance/runtime.ts | 44 + .../routes/ws/acceptance.ws.ts | 24 + .../routes/api/acceptance/sandbox.ts | 215 + platform/scripts/kitchen-sink-observations.ts | 103 + .../scripts/kitchen-sink-platform-fixture.ts | 65 +- platform/scripts/kitchen-sink-smoke-test.md | 59 +- platform/scripts/kitchen-sink-smoke-test.ts | 234 +- .../kitchen-sink/pages/revalidate.server.ts | 6 +- playground/kitchen-sink/pages/revalidate.vue | 1 + playground/kitchen-sink/routes/api/sandbox.ts | 18 +- test/integration-helpers.ts | 1 + 138 files changed, 13593 insertions(+), 481 deletions(-) create mode 100644 packages/platform/test/unit/dashboard.test.ts create mode 100644 packages/void/src/shared/version-metadata.ts create mode 100644 packages/void/test/unit/plugin-version-metadata.test.ts create mode 100644 packages/void/test/unit/version-metadata.test.ts create mode 100644 platform/packages/api/drizzle/0056_email_shared_recipients.sql create mode 100644 platform/packages/api/drizzle/meta/0056_snapshot.json create mode 100644 platform/packages/api/src/email/shared-recipient-service.ts create mode 100644 platform/packages/api/test/integration/email-shared-recipients.test.ts create mode 100644 platform/packages/api/test/integration/native-form-browser.ts create mode 100644 platform/packages/api/test/unit/email-shared-recipient-migration.test.ts create mode 100644 platform/packages/api/test/unit/kitchen-sink-acceptance-auth.test.ts create mode 100644 platform/packages/api/test/unit/kitchen-sink-observations.test.ts create mode 100644 platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts create mode 100644 platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-email/email/_default.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-email/email/support+[ticket].ts create mode 100644 platform/scripts/fixtures/kitchen-sink-email/email/support+vip.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-email/email/support.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-email/lib/email-acceptance.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-email/routes/api/acceptance/email.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-runtime/lib/queue-acceptance.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-runtime/queues/acceptance-queue.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/queue.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/runtime.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-runtime/routes/ws/acceptance.ws.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts create mode 100644 platform/scripts/kitchen-sink-observations.ts diff --git a/docs/guide/email.md b/docs/guide/email.md index d46d4da5c..bf53f2951 100644 --- a/docs/guide/email.md +++ b/docs/guide/email.md @@ -77,7 +77,7 @@ void email domain add acme.com `add` opens a Cloudflare API-token template. Restrict the token to the selected account and mail zone before creating it; Workers Scripts permission applies across that account. The CLI accepts a masked paste or a newly copied token and asks you to confirm those restrictions. The platform encrypts the credential for the zone connection, so projects sharing that zone do not need separate ingress Workers. -When an apex already receives mail, the CLI proposes `mail.`. Use `--subdomain ` to choose another mail subdomain. Void refuses to replace a foreign enabled catch-all. A domain belongs to one project; multiple exact domains can share a zone, and a project can register more than one domain. +Platform-managed custom inbound email requires the apex of a Cloudflare zone. Cloudflare’s catch-all only covers that apex, so a subdomain of the selected zone cannot receive arbitrary addresses. Choose an apex without another mail provider, or use the platform’s shared mail address. Void preserves foreign MX records and enabled catch-alls. Each custom apex belongs to one project; a project can register more than one apex. Setup returns an operation ID. If your connection drops, the platform keeps the recorded operation and the CLI checks that operation's status. An uncertain Cloudflare write stops conflicting changes until its outcome can be established. @@ -395,7 +395,7 @@ remain available through that domain's sending capability. ### Configuring inbound delivery On an email-enabled platform, `+anything@` reaches your -deployed `email/` handlers without per-project DNS setup. A +deployed `email/` handlers without per-project DNS setup once the platform has verified your recipient rule and enabled plus addressing. Deployment reports a routing conflict or capacity limit if setup cannot complete. A [registered custom domain](#your-own-domain-on-the-platform) reaches those handlers once its inbound readiness is `ready`. Use `void email domain status` to check current provider routing and any remaining setup steps. diff --git a/docs/guide/platform/administration/email.md b/docs/guide/platform/administration/email.md index 47f29b4b3..f58a89e46 100644 --- a/docs/guide/platform/administration/email.md +++ b/docs/guide/platform/administration/email.md @@ -6,7 +6,9 @@ outline: deep ## Managing Email -These commands apply to a platform that enables email. Every project can send from and receive at `+tag@` on the platform's shared mail domain; the [email guide](/guide/email) describes what applications do with that. +These commands apply to a platform that enables email. Every project can send from `+tag@` on the platform’s shared mail domain and receive there once its recipient rule is ready; the [email guide](/guide/email) describes what applications do with that. + +Shared inbound mail uses one exact routing rule per project and enables Cloudflare’s per-zone plus addressing. Existing rules and the apex catch-all remain in place. Cloudflare normally allows [200 routing rules per domain](https://developers.cloudflare.com/email-service/platform/limits/); remove unused rules or request a higher limit if the shared domain reaches capacity. An unrelated rule for a project’s base address or a plus address blocks its setup until that conflict is removed. ### Deciding who mail may reach @@ -75,16 +77,16 @@ void platform email settings-set --domains admin Then register a domain for a project. The zone must be in the platform's Cloudflare account; the platform's own credential does the setup, and no Cloudflare credential is stored per domain: ```sh -void platform email domain-add mail.example.com --project hr-portal -void platform email domain-status mail.example.com -void platform email domain-rotate-secret mail.example.com +void platform email domain-add example.com --project hr-portal +void platform email domain-status example.com +void platform email domain-rotate-secret example.com void platform email domains ``` -Name the exact mail domain: a subdomain such as `mail.example.com` when the apex already receives mail, otherwise the apex itself. `domain-status` reports inbound, outbound, and management readiness separately. Follow any required DNS or Cloudflare dashboard step, then use `domain-sync` to reconcile. Use `domain-rotate-secret` when you need to replace the zone ingress credential explicitly. Email Sending onboarding needs Workers Paid; after upgrading, `domain-sync` re-attempts it. For a zone in another Cloudflare account, pipe an API token for that account on standard input: +Use the apex of the selected Cloudflare zone. Platform-managed custom inbound email uses its catch-all, which does not cover subdomains; choose an apex without another mail provider or use the platform’s shared mail address. `domain-status` reports inbound, outbound, and management readiness separately. Follow any required DNS or Cloudflare dashboard step, then use `domain-sync` to reconcile. Use `domain-rotate-secret` when you need to replace the zone ingress credential explicitly. Email Sending onboarding needs Workers Paid; after upgrading, `domain-sync` re-attempts it. For a zone in another Cloudflare account, pipe an API token for that account on standard input: ```sh -void platform email domain-add mail.other.example --project hr-portal --token-stdin --yes < token.txt +void platform email domain-add other.example --project hr-portal --token-stdin --yes < token.txt ``` Project owners see administrator-registered domains in `void email domain list` and `status`; `status` names the `void platform email` command for any step they cannot take themselves, and `sync` and `remove` point them at `domain-sync` and `domain-remove`. A domain an owner registered before you switched to `admin` stays theirs to renew, sync and remove. The runtime token needs the email permissions listed in the [self-hosting guide](/guide/platform/installation/credentials#runtime-token-permissions) for this to work. @@ -97,7 +99,7 @@ to finish the retained cleanup. If its token has expired or been revoked, provid a replacement scoped to the same account and zone: ```sh -void platform email domain-remove mail.other.example --token-stdin --yes < token.txt +void platform email domain-remove other.example --token-stdin --yes < token.txt ``` This recovery is available only after project deletion has begun and no other diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index d0202f8fa..79eaf6b4e 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -57,9 +57,11 @@ void platform upgrade your-installation-id Set the same two variables before `void platform install` to enable email during a new installation. Void records the pair for later upgrades; an ordinary upgrade cannot replace it. +Before installing, enable [Cloudflare Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/) for the exact shared sender domain and confirm that its MX records point to Cloudflare. For a subdomain, add that name under the zone's **Email Routing → Settings → Subdomains**. Cloudflare adds the required MX and SPF records. The installer's shared-mail bootstrap verifies those records; it does not add them. Keep existing mail-provider records on other domain names in place. + Enabling email lets administrators [register email domains for projects](/guide/platform/administration/email#registering-email-domains-for-projects) and lets projects register destination addresses through the runtime token. That needs **Email Routing Addresses: Edit** and **Email Sending: Edit** on the account, plus **Zone: Read**, **Zone Settings: Edit** and **Email Routing Rules: Edit** on the zones that will carry mail. The runtime-token link preselects them when email is enabled. Email Sending onboarding for arbitrary recipients needs Workers Paid. -The installer deploys the email gateway, prepares the shared mail route, and verifies inbound readiness before opening platform traffic. If setup fails, correct the reported permission, mail-zone configuration, or routing conflict, then rerun the same install or upgrade command. A fresh install resumes with `void platform install --resume --name `. +The installer deploys the email gateway, prepares the shared mail route, and verifies inbound readiness before opening platform traffic. If setup fails, correct the reported permission, exact-domain MX records, or routing conflict, then rerun the same install or upgrade command. A fresh install resumes with `void platform install --resume --name `; it continues the recorded email operation after the configuration is corrected. ## R2 Upload Credentials diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index 3532921f9..6a0ceb206 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -19,6 +19,8 @@ If nameservers, certificates, or project Zero Trust protection are pending, foll Use `--installation ` to select an installation explicitly, `--zone example.com` for an app domain such as `apps.example.com`, or `--dedicated-zone` for catch-all routing on a dedicated zone. Nested domains still need the wildcard certificate described below. This command adds the first domain; replacing an existing application domain is not currently supported. It uses the installed runtime and does not require `--runtime` or an app redeploy. +Choose an application domain whose wildcard leaves existing Worker Custom Domains reachable. Existing more-specific routes covering all requests can preserve those hostnames. If Void reports a conflict, choose another application domain; for a new installation, you can start with `void platform install --workers-dev` and add a suitable domain later. + Browser login sessions are specific to each origin. Apps using their own OAuth providers may need to register their new callback URLs. Void's built-in auth uses the request origin automatically unless the app overrides that configuration. ### What Changes in Testing Mode? @@ -49,6 +51,32 @@ The management token needs **SSL and Certificates: Read** (or Edit) on that zone ::: +## Optional Dashboard + +The API's `/admin/` pages are included in the core installation. You can deploy +the optional user dashboard separately and configure its HTTPS origin during +installation with `--dashboard-url https://dash.example.com`. + +For an existing installation, preview and apply the configuration with: + +```sh +void platform repair --dashboard-url https://dash.example.com --plan +void platform repair --dashboard-url https://dash.example.com --yes +``` + +The origin must contain no credentials, path, query, or fragment. Void saves it +for login callbacks and keeps it across upgrades and repairs. The command does +not create a dashboard Worker or DNS records; deploy that app separately. Omit +the option to keep the saved origin. The dashboard provides sign-in, linked +login methods, and sign-out; use the CLI for user project and team management. + +If Access protects the platform, its application must cover this dashboard +origin too. Configure the origin when installing protection. To change it on an +already protected platform, deliberately remove protection through authentication +configuration, apply the origin, then enable protection again. Choose a separate +admission rule first if signup depends on the Access gate. Maintenance stops if +the configured origin is outside the active coverage. + ## Cloudflare footprint New platform resources use deterministic `void--` names where Cloudflare allows them, such as `void-team-api`. Choose an unused installation name in the account; Void stops on an unowned name conflict instead of replacing that resource. Existing installations keep their recorded names, including older names with suffixes. diff --git a/docs/guide/queues.md b/docs/guide/queues.md index f8ba6a034..101df79f1 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -111,9 +111,13 @@ export default defineQueue(async (batch, env) => { - `maxBatchSize`: maximum number of messages per batch (default `10`) - `maxBatchTimeout`: maximum seconds to wait before delivering an incomplete batch (default `5`) -- `maxRetries`: maximum number of retries before a message is dead-lettered (default `3`) +- `maxRetries`: maximum number of retries before delivery is exhausted (default `3`) - `retryDelay`: seconds to wait between retries (default `0`) +Void-managed queues do not configure a dead-letter queue. Cloudflare permanently +deletes messages that exhaust their retries without one; see +[Cloudflare's dead-letter queue guide](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/). + ## Deployment behavior On deploy, Void includes all discovered queues in the deploy manifest. The platform provisions Cloudflare Queues, configures producer bindings on the user worker, and registers the dispatch worker as the queue consumer for relay delivery. diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 61ae5c508..438d8c7c2 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -216,7 +216,7 @@ Choose Cloudflare during `void init`, then deploy: void deploy ``` -Void opens your browser to sign in when needed. If you have access to several accounts, it asks which one to use and records the account in `void.lock.json`. Credentials are stored in your operating system's keychain. Press Ctrl+C to cancel login, or manage your session with `void cloudflare login`, `status`, and `logout`. +Void opens your browser to sign in when needed. If it doesn't open, use the login link printed in the terminal and confirm the displayed code. If you have access to several accounts, it asks which one to use and records the account in `void.lock.json`. Credentials are stored in your operating system's keychain. Press Ctrl+C to cancel login, or manage your session with `void cloudflare login`, `status`, and `logout`. To configure deployment separately from project setup, run `void connect --platform cloudflare`. It uses the same browser login and account selection as `void init`, and saves Cloudflare as the target for subsequent `void deploy` commands. @@ -242,6 +242,8 @@ If the project has no deployment destination, Void asks whether to link and depl The first handoff preserves resource bindings, production variables, encrypted secrets, event handlers, and existing routes and schedules. It builds the app, checks the uploaded version's bindings and handler set, and verifies readiness before activating it. Void saves `keep_vars: true` in your config so later deployments retain dashboard-only variables too. The active version must also be the latest uploaded version; activate or remove an unpublished candidate in Cloudflare before retrying so secret inheritance has an unambiguous source. +Readiness also needs to identify the uploaded version. Keep an existing [version metadata binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/) in your config, or allow Void to access the Worker's [Version URLs](https://developers.cloudflare.com/workers/versions-and-deployments/version-urls/) through its existing access policy. If neither is available, the handoff stops before activation and explains how to retry. + Keep this first deployment focused on the existing site. Apart from the explicit ISR cache choice below, new resources, auth setup, runtime features, database migrations, and secret overrides stop the handoff before activation. A later code-only deploy continues to preserve dashboard-managed routes and visibility. Before adding a cron, queue consumer, workflow, or visibility change when those values exist only in the dashboard, add the current `routes`, `workers_dev`, and `preview_urls` values under `cloudflare` in `void.config.ts`. If the build fails, fix it and rerun `void deploy`; Void remembers the destination and checks the existing Worker again. Pages with `prerender = true` (including automatically prerendered Markdown pages) also request ISR caching. When the existing Worker has no cache, Void asks whether to enable it and saves the choice as `routing.isr` in `void.config.ts`: @@ -362,6 +364,8 @@ With Cloudflare selected, `void secret`, `void domain`, `void project status|lis Custom domain changes update routes immediately without rewriting schedules, queues, or workflows. Removing the final custom domain uses your browser session from `void cloudflare login` or an API token with Workers Scripts: Edit permission. +The first deployment to a Custom Domain waits up to five minutes for the app to become reachable while DNS and its TLS certificate propagate. If readiness times out, the Worker is already active. Check the domain in Cloudflare and retry `void deploy --platform cloudflare` once the domain's `/__void/ready` endpoint is reachable. + `void project delete` doesn't remove direct Cloudflare resources, which may be shared. Review their use and remove them explicitly in Cloudflare. ::: details How Void checks the build diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7937eb8ba..4e96ac51d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -802,7 +802,9 @@ void platform email domain-rotate-secret void platform email domain-remove [--token-stdin] ``` -`domain-add` uses the platform's Cloudflare credential for zones in its account. For another account, pipe a scoped Cloudflare API token on standard input with `--token-stdin --yes`. Name the exact mail domain (`mail.example.com`, or the apex when it receives no mail yet). `domain-status` shows inbound, outbound, and credential-management readiness with the latest operation. A blocked operation resumes through `domain-sync`; a blocked rotation resumes through `domain-rotate-secret`, preserving already confirmed steps and its staged credential. An uncertain operation remains stopped until read-back proves the result or an administrator uses `operation-resolve`. Domains an administrator adds show `managed_by: admin`; their owners can list and inspect them but use these commands for `sync`, `domain-rotate-secret`, and `remove`. +`domain-add` uses the platform's Cloudflare credential for zones in its account. For another account, pipe a scoped Cloudflare API token on standard input with `--token-stdin --yes`. Managed custom inbound email requires the Cloudflare zone apex (`example.com`). If its MX records already belong to another mail provider, use the project's shared email address or choose another unused zone. Native Cloudflare deployments also support literal recipient addresses on subdomains; see [Your own Cloudflare account](../guide/email.md#your-own-cloudflare-account). + +`domain-status` shows inbound, outbound, and credential-management readiness with the latest operation. A blocked operation resumes through `domain-sync`; a blocked rotation resumes through `domain-rotate-secret`, preserving already confirmed steps and its staged credential. An uncertain operation remains stopped until read-back proves the result or an administrator uses `operation-resolve`. Domains an administrator adds show `managed_by: admin`; their owners can list and inspect them but use these commands for `sync`, `domain-rotate-secret`, and `remove`. If project deletion leaves cleanup blocked by an expired or revoked Cloudflare token, use `domain-remove --token-stdin --yes` with a replacement scoped to the same account and zone. This resumes the retained cleanup only when no other project uses the connection. For a live project, renew its token through `domain-add` instead. @@ -878,6 +880,7 @@ void platform install [options] [--yes] | `--no-dedicated-zone` | Skip zone-wide application catch-all routes | | `--control-plane-domain ` | Optional API custom hostname; defaults to `workers.dev` | | `--no-control-plane-domain` | Use the default `workers.dev` API hostname | +| `--dashboard-url ` | HTTPS origin of an optional dashboard deployed separately | | `--plan` | Resolve and print a read-only plan | | `--resume` | Continue the matching checkpointed installation | | `--runtime ` | Deploy a locally built, integrity-checked runtime directory | @@ -891,7 +894,13 @@ The installed platform needs a separate runtime token to provision resources for To enable email during install or upgrade, set both `VOID_EMAIL_SENDER_DOMAIN` and `VOID_EMAIL_SHARED_ZONE_ID`. Void records the pair for later upgrades; supplying only one is an error. -`--plan` shows resource names, login methods, the callback URL, and a brief credential checklist with setup links without opening credential pages or saving a draft. Cloudflare browser login can still open if needed. It highlights the next install command at the end of the output. The command preserves your configuration choices, including the default API hostname, Hyperdrive selection, login methods, and Access protection. Run that command to recalculate and confirm the plan without answering the same configuration questions again. Missing credentials are collected during installation. For unfinished installations, the plan prints the saved `--resume` command instead. Use `--auth-config` on its own for login configuration, or use `--login-methods` and the Access protection flags. +Use `--dashboard-url https://dash.example.com` when deploying the optional user +dashboard separately. The URL must be an HTTPS origin without credentials, a +path, query, or fragment. Void permits that origin's login callbacks and saves +it for later maintenance; it does not deploy a dashboard Worker. Omission keeps +an existing installation's saved dashboard origin. + +`--plan` shows resource names, login methods, the callback URL, and a brief credential checklist with setup links without opening credential pages or saving a draft. Cloudflare browser login can still open if needed. It highlights the next install command at the end of the output. The command preserves your configuration choices, including the default API hostname, Hyperdrive selection, login methods, Access protection, and dashboard origin. Run that command to recalculate and confirm the plan without answering the same configuration questions again. Missing credentials are collected during installation. For unfinished installations, the plan prints the saved `--resume` command instead. Use `--auth-config` on its own for login configuration, or use `--login-methods` and the Access protection flags. New resources use `void--` names. Existing installations keep their recorded names; an unowned name conflict stops installation. After confirmation, Void opens setup pages for missing credentials. The runtime-token link preselects required account permissions, including Workers Tail, Hyperdrive, and AI Gateway when needed; select the indicated zone for domain installs. Supplied credentials skip those pages. @@ -927,9 +936,9 @@ Use these commands to recover, update, pause, or remove an installation: ```sh void platform discover [--account ] [--installation ] -void platform upgrade [id] [--runtime ] [--plan] [--yes] +void platform upgrade [id] [--runtime ] [--dashboard-url ] [--plan] [--yes] void platform rollback [id] --runtime [--from-runtime ] [--plan] [--yes] -void platform repair [id] [--runtime ] [--plan] [--yes] +void platform repair [id] [--runtime ] [--dashboard-url ] [--plan] [--yes] void platform disable [id] [--plan] [--yes] void platform enable [id] [--runtime ] [--plan] [--yes] void platform uninstall [id] [--plan] [--purge-data] [--keep-zone] [--yes] @@ -939,6 +948,11 @@ void platform uninstall [id] [--plan] [--purge-data] [--keep-zone] [--yes] Omit `id` when only one installation is configured, or choose from the interactive picker. Non-interactive commands need an ID when several installations exist. Commands that make changes also require `--yes`; `--plan` only previews changes. +To add or explicitly change a separately deployed dashboard after installation, +use `repair --dashboard-url ` or `upgrade --dashboard-url `. +Preview with `--plan` first. Existing Access protection must cover the configured +dashboard origin before maintenance can proceed. See [Optional Dashboard](../guide/platform/installation/domains.md#optional-dashboard). + After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. An upgrade that preserves every deployed Worker also preserves its secrets. For an email-enabled installation without its encrypted recovery file, restore `VOID_PLATFORM_EMAIL_SIGNING_SECRET`; recreating only the email gateway needs that key and does not need the Cloudflare runtime token or JWT signing key. Recreating the API or proxy also requires the email key when email is enabled, in addition to their normal secrets. Recreating the API requires its original runtime-token, GitHub, R2, JWT, and project-encryption values; recreating the proxy requires the runtime token and JWT signing key. | Command | Behavior | @@ -1710,10 +1724,10 @@ Send and receive at your own domain on a Cloudflare zone you own, registered to #### `void email domain add` ``` -void email domain add [--subdomain ] [--project ] +void email domain add [--project ] ``` -Register an exact domain with the project using a scoped Cloudflare API token. The CLI opens a token template, accepts a masked paste or newly copied token, and asks you to confirm account and zone restrictions. Credentials are encrypted for the zone connection. The CLI proposes `mail.` when the apex already has MX records; `--subdomain` overrides that proposal. Several domains can share a zone connection, but each domain belongs to one project. Setup returns an operation ID so an interrupted request can be checked without restarting the operation. +Register an exact domain with the project using a scoped Cloudflare API token. The CLI opens a token template, accepts a masked paste or newly copied token, and asks you to confirm account and zone restrictions. Credentials are encrypted for the zone connection. Custom inbound email requires a Cloudflare zone apex because its catch-all does not cover subdomains. Foreign MX records and catch-alls are preserved; use another zone apex or the platform shared mail address when the apex already receives mail. Each custom apex belongs to one project. Setup returns an operation ID so an interrupted request can be checked without restarting the operation. #### `void email domain status` diff --git a/package.json b/package.json index 8ec4de6e8..160e1b288 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build:core": "vp run --filter ./packages/deploy-core --filter ./packages/deploy-cloudflare --filter ./packages/edge --filter ./packages/isr --filter ./packages/platform --filter ./packages/void build", "build:docs": "vp run --filter ./docs build", "build:platform": "vp run --filter './platform/packages/**' build", - "check": "vp run -r --parallel --concurrency-limit=4 typecheck", + "check": "vp run --filter './**' --filter '!./playground-local/**' --parallel --concurrency-limit=4 typecheck", "check:platform": "vp run --filter './platform/**' --parallel typecheck", "check:sdk": "vp run --filter './packages/**' --parallel typecheck", "deploy:docs": "vp run build:core && node packages/void/dist/cli/cli.mjs deploy", diff --git a/packages/deploy-cloudflare/src/existing.ts b/packages/deploy-cloudflare/src/existing.ts index c5dc28fc8..e35733445 100644 --- a/packages/deploy-cloudflare/src/existing.ts +++ b/packages/deploy-cloudflare/src/existing.ts @@ -556,6 +556,19 @@ export function assertExistingCloudflareConfig( } } const vars = record(config.vars) ? config.vars : {}; + const metadataBindings = existing.bindings.filter( + (binding) => binding.type === 'version_metadata', + ); + const metadata = record(config.version_metadata) ? config.version_metadata : undefined; + if ( + metadataBindings.length > 1 || + (metadataBindings.length === 1 && metadata?.binding !== metadataBindings[0]!.name) || + (metadataBindings.length === 0 && config.version_metadata !== undefined) + ) { + throw new Error( + 'Keep the existing Worker version_metadata binding when linking; linking cannot add or replace it.', + ); + } const remote = new Map(existing.bindings.map((binding) => [binding.name, binding])); for (const [name, value] of Object.entries(vars)) { const binding = remote.get(name); @@ -590,6 +603,9 @@ export function assertExistingCloudflareConfig( if (['plain_text', 'json', 'secret_text', 'secret_key'].includes(String(binding.type))) { continue; } + if (binding.type === 'version_metadata') { + continue; + } if (binding.type === 'assets') { if (built && (!record(config.assets) || config.assets.binding !== binding.name)) { throw new Error(`Keep the existing assets binding ${binding.name} when linking.`); diff --git a/packages/deploy-cloudflare/src/framework.ts b/packages/deploy-cloudflare/src/framework.ts index 08891df76..acf9b8eb4 100644 --- a/packages/deploy-cloudflare/src/framework.ts +++ b/packages/deploy-cloudflare/src/framework.ts @@ -14,6 +14,7 @@ export type CloudflareFrameworkDeployConfig = Readonly<{ assetsIgnore?: ReadonlyArray; crons?: ReadonlyArray; requiredSecrets?: ReadonlyArray; + versionMetadataBinding?: string; ai?: Readonly<{ binding: string; gateway?: string }>; }>; @@ -44,6 +45,9 @@ export function writeCloudflareFrameworkDeployConfig( upsert(['account_id'], options.accountId); upsert(['main'], options.main); upsert(['compatibility_date'], options.compatibilityDate); + if (options.versionMetadataBinding && parse(content).version_metadata === undefined) { + upsert(['version_metadata'], { binding: options.versionMetadataBinding }); + } if (options.compatibilityFlags && options.compatibilityFlags.length > 0) { const config = parse(content) as { compatibility_flags?: unknown }; const current = Array.isArray(config.compatibility_flags) diff --git a/packages/deploy-cloudflare/src/static.ts b/packages/deploy-cloudflare/src/static.ts index 06e2f5b80..cc02e25f7 100644 --- a/packages/deploy-cloudflare/src/static.ts +++ b/packages/deploy-cloudflare/src/static.ts @@ -28,11 +28,15 @@ export function renderCloudflareStaticEntry(options: { headerRules?: ReadonlyArray; redirectRules?: ReadonlyArray; fallbackRules?: ReadonlyArray; + versionMetadataBinding?: string; }): string { const config = JSON.stringify({ ...(options.headerRules?.length ? { headerRules: options.headerRules } : {}), ...(options.redirectRules?.length ? { redirectRules: options.redirectRules } : {}), ...(options.fallbackRules?.length ? { fallbackRules: options.fallbackRules } : {}), + ...(options.versionMetadataBinding + ? { versionMetadataBinding: options.versionMetadataBinding } + : {}), }); return [ `import { createStaticEdgeHandler } from ${JSON.stringify(options.runtimeImport ?? '@void/edge')};`, diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index e37f5a4f9..860416130 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -734,6 +734,27 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isCandidatePreviewUrl( + value: string, + workerName: string, + versionId: string, + alias: string, +): boolean { + try { + const url = new URL(value); + const label = url.hostname.split('.')[0]; + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + extractWorkersDevSubdomain(value) !== undefined && + (label === `${versionId.slice(0, 8)}-${workerName}` || label === `${alias}-${workerName}`) + ); + } catch { + return false; + } +} + export type CloudflareVersionActivationOptions = { /** Human-readable deployment phases; the caller owns terminal rendering. */ onProgress?: (message: string) => void; @@ -778,6 +799,10 @@ export type CloudflareVersionActivationOptions = { fetch?: typeof globalThis.fetch; sleep?: (milliseconds: number) => Promise; readinessAttempts?: number; + /** Require readiness to report the uploaded Worker's actual version UUID. */ + requireVersionMetadata?: boolean; + /** A binding-preserving handoff without metadata may use only a genuine version preview. */ + requirePreviewForReadiness?: boolean; /** Verify that omitted remote secrets come from the active Worker Version before uploading. */ verifyInheritedSecrets?: boolean; /** Pin opaque secret bindings to the live version when an unpublished upload is newer. */ @@ -853,6 +878,16 @@ async function activateCloudflareVersionWithSecretsFile( return { ok: false, reason: 'uploader-changed' }; } const previousVersionId = previous.versionId; + if (options.atomic && options.requirePreviewForReadiness) { + return { + ok: false, + reason: 'preview-unavailable', + previousVersionId, + rollback: 'not-needed', + diagnostic: + 'This binding-preserving handoff requires a candidate preview before activation. Configure version metadata on the existing Worker or retry without atomic publication; production traffic is unchanged.', + }; + } // Activation can succeed before trigger synchronization or the local receipt // cleanup fails. Once its last recorded candidate owns all live traffic, the // receipt no longer authorizes any *inactive* successor. The history checks @@ -1036,7 +1071,13 @@ async function activateCloudflareVersionWithSecretsFile( await options.pinActiveSecrets(previous.versionId, activeSecrets); } } - return bootstrapFirstDeployment({ ...options, message }, configPath, previous.versionId, true); + return bootstrapFirstDeployment( + { ...options, message }, + configPath, + previous.versionId, + true, + previousTriggerConfig, + ); } options.onProgress?.('Uploading the Worker and assets...'); const upload = await Promise.resolve( @@ -1068,7 +1109,13 @@ async function activateCloudflareVersionWithSecretsFile( options.allowFirstDeploymentBootstrap === true || (await previousIsSecretOnlyDraft())) ) { - return bootstrapFirstDeployment({ ...options, message }, configPath, previous.versionId); + return bootstrapFirstDeployment( + { ...options, message }, + configPath, + previous.versionId, + false, + previousTriggerConfig, + ); } return { ok: false, @@ -1128,7 +1175,27 @@ async function activateCloudflareVersionWithSecretsFile( } await options.recordVerifiedCandidate(versionId, previous.versionId); } - const reportedPreviewUrl = uploaded?.previewUrl; + const reportedPreviewUrl = + desiredTriggerConfig.preview_urls || + desiredTriggerConfig.preservePreviewUrls || + desiredTriggerConfig.preserve + ? uploaded?.previewUrl + : undefined; + if ( + options.requirePreviewForReadiness && + (!reportedPreviewUrl || + !isCandidatePreviewUrl(reportedPreviewUrl, options.workerName, versionId, alias)) + ) { + return { + ok: false, + reason: 'preview-unavailable', + versionId, + previousVersionId, + rollback: 'not-needed', + diagnostic: + 'This existing Worker has no version metadata binding and no genuine candidate preview URL. Keep its bindings unchanged; configure version metadata on the existing Worker or use an admitted version preview, then retry the handoff. Production traffic is unchanged.', + }; + } let authenticatedWorkersDevSubdomain = reportedPreviewUrl ? extractWorkersDevSubdomain(reportedPreviewUrl) : undefined; @@ -1142,7 +1209,13 @@ async function activateCloudflareVersionWithSecretsFile( options.allowFirstDeploymentBootstrap === true || (await previousIsSecretOnlyDraft())) ) { - return bootstrapFirstDeployment({ ...options, message }, configPath, previous.versionId); + return bootstrapFirstDeployment( + { ...options, message }, + configPath, + previous.versionId, + false, + previousTriggerConfig, + ); } readinessUrl = desiredTriggerConfig.workers_dev ? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain) @@ -1210,6 +1283,8 @@ async function activateCloudflareVersionWithSecretsFile( options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, options.readinessAttempts ?? 6, + Number.POSITIVE_INFINITY, + options.requireVersionMetadata ? versionId : undefined, ); // Account-wide Access default-deny commonly blocks Wrangler's generated preview alias even @@ -1219,7 +1294,12 @@ async function activateCloudflareVersionWithSecretsFile( // explicitly through the stable, policy-covered hostname. This requires a prior active version // whose traffic can remain untouched while the new version is staged; a truly empty first // deployment has no safe zero-percent fallback. - if (ready === 'access-denied' && !stagedVersionOverride && previous.versionId !== null) { + if ( + ready === 'access-denied' && + !stagedVersionOverride && + previous.versionId !== null && + !options.requirePreviewForReadiness + ) { const stableReadinessUrl = desiredTriggerConfig.workers_dev ? workersDevReadinessUrl( options.workerName, @@ -1277,6 +1357,8 @@ async function activateCloudflareVersionWithSecretsFile( options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, options.readinessAttempts ?? 6, + Number.POSITIVE_INFINITY, + options.requireVersionMetadata ? versionId : undefined, ); } } @@ -1814,6 +1896,7 @@ async function bootstrapFirstDeployment( configPath: string | undefined, previousVersionId: string | null, atomic = false, + previousTriggerConfig?: CloudflareTriggerConfig, ): Promise { if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; @@ -1882,25 +1965,84 @@ async function bootstrapFirstDeployment( ...(versionId ? { versionId } : {}), }; } + if (!options.verifyUploader()) { + return { + ok: false, + reason: 'uploader-changed', + versionId, + previousVersionId, + rollback: 'unsafe', + }; + } + // A version-aware atomic upload activates code without publishing routes. + // Publish only the active owner's snapshot before probing a first Custom + // Domain; enabling an unrequested workers.dev URL would expose the app. + options.onProgress?.('Updating routes and triggers...'); + const triggerOptions = { + runWrangler: options.runWrangler, + workerName: options.workerName, + configPath: options.configPath, + targetVersionId: versionId, + targetTriggerConfig: options.preserveTriggers + ? { ...desiredTriggerConfig, preserve: true } + : desiredTriggerConfig, + previousTriggerConfig, + ...(previousVersionId && previousTriggerConfig + ? { knownTriggerConfigs: { [previousVersionId]: previousTriggerConfig } } + : {}), + publishEmptyCustomDomains: options.publishEmptyCustomDomains, + publishEmptyCrons: options.publishEmptyCrons, + }; + const triggers = await stabilizeCloudflareVersionTriggers(triggerOptions); + if (!triggers.ok || !triggers.targetOwnsTraffic) { + const superseded = + triggers.activeVersionId !== undefined && + triggers.activeVersionId !== null && + triggers.activeVersionId !== versionId; + return { + ok: false, + reason: superseded ? 'deployment-changed' : 'triggers-failed', + ...(!triggers.ok ? { status: triggers.status } : {}), + versionId, + previousVersionId, + activeVersionId: triggers.activeVersionId, + rollback: superseded ? 'not-needed' : triggers.activeVersionId ? 'unavailable' : 'unsafe', + ...(superseded + ? { + triggerRepair: triggers.ok + ? 'succeeded' + : triggers.reason === 'metadata-unavailable' + ? 'unavailable' + : 'failed', + } + : {}), + }; + } const previewUrl = publishedCloudflareUrl( options.workerName, desiredTriggerConfig, reportedOrConfiguredUrl, ); + const usesCustomDomain = + !desiredTriggerConfig.workers_dev && + desiredTriggerConfig.routes.some((route) => isRecord(route) && route.custom_domain === true); + const customDomainBootstrap = !atomic && usesCustomDomain; + const readinessTimeoutMs = customDomainBootstrap ? 300_000 : 60_000; options.onProgress?.('Waiting for the Worker to become ready...'); const ready = await probeReadiness( `${previewUrl.replace(/\/$/, '')}/__void/ready`, options.workerName, - reportedUrl || desiredTriggerConfig.workers_dev ? options.readinessHeaders : undefined, + extractWorkersDevSubdomain(previewUrl) ? options.readinessHeaders : undefined, extractWorkersDevSubdomain(previewUrl), undefined, options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, - // A new Worker also needs its public route/DNS to propagate. Subsequent - // version previews can use the shorter retry window, but bootstrap needs - // more than its roughly six seconds of backoff. Keep an overall deadline. - options.readinessAttempts ?? 30, - 60_000, + // A first Custom Domain needs DNS and certificate propagation. Existing + // previews and explicitly atomic releases retain their shorter window. + // Keep an overall deadline even when requests time out individually. + options.readinessAttempts ?? (customDomainBootstrap ? 150 : 30), + readinessTimeoutMs, + options.requireVersionMetadata ? versionId : undefined, ); if (ready !== 'ready') { return { @@ -1916,6 +2058,50 @@ async function bootstrapFirstDeployment( versionId, previousVersionId, rollback: 'unavailable', + ...(ready === 'failed' && usesCustomDomain + ? { + diagnostic: + 'The Custom Domain may still be propagating DNS or its TLS certificate. Check the configured domain in Cloudflare, then retry `void deploy --platform cloudflare` once its /__void/ready endpoint is reachable.', + } + : {}), + }; + } + if (!options.verifyUploader()) { + return { + ok: false, + reason: 'uploader-changed', + versionId, + previousVersionId, + rollback: 'unsafe', + }; + } + // DNS propagation can outlast another deploy. A successful response alone + // must not record this version as live after a newer owner took over. + const ownerAfterReadiness = await readCurrentProductionVersion(options, configPath); + if (ownerAfterReadiness.kind === 'failed') { + return { + ok: false, + reason: 'status-failed', + status: ownerAfterReadiness.status, + versionId, + previousVersionId, + rollback: 'unsafe', + }; + } + if (ownerAfterReadiness.kind !== 'ok' || ownerAfterReadiness.versionId !== versionId) { + const repair = await stabilizeCloudflareVersionTriggers(triggerOptions); + return { + ok: false, + reason: 'deployment-changed', + versionId, + previousVersionId, + activeVersionId: repair.activeVersionId, + rollback: 'not-needed', + triggerRepair: repair.ok + ? 'succeeded' + : repair.reason === 'metadata-unavailable' + ? 'unavailable' + : 'failed', }; } return { @@ -2116,6 +2302,7 @@ async function probeReadiness( sleep: (milliseconds: number) => Promise, attempts: number, timeoutMs = Number.POSITIVE_INFINITY, + expectedVersionId?: string, ): Promise<'ready' | 'failed' | 'access-denied'> { const headers = guardedReadinessHeaders( url, @@ -2148,8 +2335,11 @@ async function probeReadiness( signal: AbortSignal.timeout(Math.max(1, Math.min(10_000, remaining))), }); if (response.ok) { - const body = (await response.json()) as { ready?: unknown }; - if (body.ready === true) { + const body = (await response.json()) as { ready?: unknown; versionId?: unknown }; + if ( + body.ready === true && + (expectedVersionId === undefined || body.versionId === expectedVersionId) + ) { return 'ready'; } } else if (await isCloudflareAccessDenial(response)) { diff --git a/packages/deploy-cloudflare/test/unit/existing.test.ts b/packages/deploy-cloudflare/test/unit/existing.test.ts index 6d75191a2..d9229d5f3 100644 --- a/packages/deploy-cloudflare/test/unit/existing.test.ts +++ b/packages/deploy-cloudflare/test/unit/existing.test.ts @@ -31,6 +31,23 @@ const config = { kv_namespaces: [{ binding: 'KV', id: 'existing-id' }], }; +it('preserves an existing authored version metadata binding without permitting additions', () => { + const metadata = { type: 'version_metadata', name: 'APP_VERSION' }; + const existing = { ...worker, bindings: [...worker.bindings, metadata] }; + const authored = { ...config, version_metadata: { binding: 'APP_VERSION' } }; + expect(() => assertExistingCloudflareConfig(existing, authored)).not.toThrow(); + expect(() => assertCloudflareBindingsPreserved(existing, existing)).not.toThrow(); + expect(() => assertExistingCloudflareConfig(existing, config)).toThrow('version_metadata'); + expect(() => assertExistingCloudflareConfig(worker, authored)).toThrow('cannot add or replace'); + expect(() => + assertExistingCloudflareConfig(existing, { + ...authored, + version_metadata: { binding: '__VOID_VERSION_METADATA' }, + }), + ).toThrow('cannot add or replace'); + expect(() => assertCloudflareBindingsPreserved(worker, existing)).toThrow('preserve'); +}); + describe('assets-only Worker handoff', () => { const assetsOnly: ExistingCloudflareWorker = { versionId: 'original', diff --git a/packages/deploy-cloudflare/test/unit/framework.test.ts b/packages/deploy-cloudflare/test/unit/framework.test.ts index f0ac969af..c5b017f47 100644 --- a/packages/deploy-cloudflare/test/unit/framework.test.ts +++ b/packages/deploy-cloudflare/test/unit/framework.test.ts @@ -5,6 +5,33 @@ import { describe, expect, it } from 'vite-plus/test'; import { parseRootWranglerConfig, writeCloudflareFrameworkDeployConfig } from '../../src/index.ts'; describe('Cloudflare framework deployment', () => { + it.each([undefined, 'AUTHORED_VERSION'])( + 'adds native readiness metadata without overwriting an existing binding (%s)', + (authored) => { + const root = mkdtempSync(join(tmpdir(), 'void-framework-metadata-')); + const configPath = join(root, 'wrangler.jsonc'); + writeFileSync( + configPath, + JSON.stringify(authored ? { version_metadata: { binding: authored } } : {}), + ); + const input = { + configPath, + deployPointerPath: join(root, '.wrangler/deploy/config.json'), + name: 'framework-app', + accountId: 'account-id', + main: 'dist/worker.js', + assetsDirectory: 'dist/assets', + compatibilityDate: '2026-09-11', + versionMetadataBinding: '__VOID_VERSION_METADATA', + }; + writeCloudflareFrameworkDeployConfig(input); + expect(parseRootWranglerConfig(configPath).version_metadata).toEqual({ + binding: authored ?? '__VOID_VERSION_METADATA', + }); + expect(writeCloudflareFrameworkDeployConfig(input)).toBe(false); + }, + ); + it('preserves root state and points Wrangler at the standalone Worker', () => { const root = mkdtempSync(join(tmpdir(), 'void-framework-config-')); const configPath = join(root, 'wrangler.jsonc'); diff --git a/packages/deploy-cloudflare/test/unit/static.test.ts b/packages/deploy-cloudflare/test/unit/static.test.ts index 555bd6e8f..aa746e7ef 100644 --- a/packages/deploy-cloudflare/test/unit/static.test.ts +++ b/packages/deploy-cloudflare/test/unit/static.test.ts @@ -21,6 +21,13 @@ describe('Cloudflare static deployment', () => { ); }); + it('passes an authored metadata binding to the native static readiness handler', () => { + expect(renderCloudflareStaticEntry({ versionMetadataBinding: 'APP_VERSION' })).toContain( + '"versionMetadataBinding":"APP_VERSION"', + ); + expect(renderCloudflareStaticEntry({})).not.toContain('versionMetadataBinding'); + }); + it('updates target fields, detaches worker-only configuration, and preserves migration history', () => { const root = mkdtempSync(join(tmpdir(), 'void-static-config-')); const configPath = join(root, 'wrangler.jsonc'); diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index ddb0ce53f..b4e044648 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -2339,6 +2339,10 @@ describe('Cloudflare version activation', () => { 'deployments', 'versions', 'deploy', + 'deployments', + 'triggers', + 'deployments', + 'deployments', ]); }); @@ -2403,6 +2407,72 @@ describe('Cloudflare version activation', () => { } }); + it.each([ + { atomic: false, becomesReady: true, timeout: 300_000 }, + { atomic: false, becomesReady: false, timeout: 300_000 }, + { atomic: true, becomesReady: false, timeout: 60_000 }, + ])( + 'bounds Custom Domain propagation to $timeout ms (atomic=$atomic, ready=$becomesReady)', + async ({ atomic, becomesReady, timeout }) => { + const runWrangler = runner({ + ...(atomic + ? {} + : { + status: { stdout: '', stderr: 'Worker not found. [code: 10007]', status: 1 }, + upload: { stdout: '', stderr: LOCAL_DURABLE_MIGRATION_ERROR, status: 1 }, + }), + bootstrap: ok(`Current Version ID: ${NEXT}`), + }); + let time = 0; + let probes = 0; + const clock = vi.spyOn(Date, 'now').mockImplementation(() => time); + try { + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'custom-app', + atomic, + message: encodeCloudflareVersionMessage([], { + workers_dev: false, + preview_urls: false, + routes: [{ pattern: 'app.example.com', custom_domain: true }], + crons: [], + queueConsumers: [], + workflows: [], + }), + sleep: async (milliseconds) => { + time += milliseconds; + }, + fetch: async (url, options) => { + expect(url).toBe('https://app.example.com/__void/ready'); + expect(options?.headers).toEqual({ 'user-agent': 'void-deploy-readiness/1' }); + probes++; + time += 2_000; + if (becomesReady && probes > 30) { + return Response.json({ ready: true }); + } + throw new Error('getaddrinfo ENOTFOUND app.example.com'); + }, + }); + if (becomesReady) { + expect(result).toMatchObject({ ok: true, versionId: NEXT }); + expect(probes).toBe(31); + expect(time).toBeGreaterThan(60_000); + } else { + expect(result).toMatchObject({ + ok: false, + reason: atomic ? 'atomic-readiness-failed' : 'bootstrap-readiness-failed', + diagnostic: expect.stringContaining('retry `void deploy --platform cloudflare`'), + }); + } + expect(time).toBeLessThanOrEqual(timeout + 2_000); + expect(probes).toBeLessThanOrEqual(Math.ceil(timeout / 2_000)); + } finally { + clock.mockRestore(); + } + }, + ); + it('rolls traffic back when trigger activation fails', async () => { const runWrangler = runner({ triggers: { ...ok(), status: 1 } }); const result = await activateCloudflareVersion({ @@ -2665,6 +2735,134 @@ describe('Cloudflare version activation', () => { ).resolves.toMatchObject({ ok: true, previousVersionId: null }); }); + it.each([undefined, CURRENT, CONCURRENT])( + 'rejects a stale custom-route readiness version %s before candidate activation', + async (versionId) => { + const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`) }); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'private-app', + requireVersionMetadata: true, + readinessAttempts: 1, + message: encodeCloudflareVersionMessage([], { + workers_dev: false, + preview_urls: false, + routes: [{ pattern: 'app.example.com', custom_domain: true }], + crons: [], + queueConsumers: [], + workflows: [], + }), + fetch: async () => Response.json({ ready: true, versionId }), + }); + expect(result).toMatchObject({ + ok: false, + reason: 'readiness-failed', + rollback: 'succeeded', + }); + const deployments = runWrangler.mock.calls.filter( + ([args]) => args[0] === 'versions' && args[1] === 'deploy', + ); + expect(deployments.some(([args]) => args.includes(`${NEXT}@100`))).toBe(false); + expect(deployments[0]?.[0]).toContain(`${NEXT}@0`); + expect(deployments.at(-1)?.[0]).toContain(`${CURRENT}@100`); + }, + ); + + it('waits for the actual candidate on a private custom route and ignores disabled previews', async () => { + const runWrangler = runner(); + const sleep = vi.fn(async () => {}); + const fetcher = vi.fn(async () => + Response.json({ ready: true, versionId: fetcher.mock.calls.length === 1 ? CURRENT : NEXT }), + ); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'private-app', + requireVersionMetadata: true, + readinessAttempts: 2, + readinessHeaders: { 'CF-Access-Client-Secret': 'must-not-leak' }, + message: encodeCloudflareVersionMessage([], { + workers_dev: false, + preview_urls: false, + routes: [{ pattern: 'app.example.com', custom_domain: true }], + crons: [], + queueConsumers: [], + workflows: [], + }), + fetch: fetcher, + sleep, + }); + expect(result).toMatchObject({ ok: true, versionId: NEXT }); + expect(fetcher).toHaveBeenCalledTimes(2); + for (const [url, init] of fetcher.mock.calls as unknown as Array<[string, RequestInit]>) { + expect(url).toBe('https://app.example.com/__void/ready'); + expect(init.headers).not.toHaveProperty('CF-Access-Client-Secret'); + expect(init.headers).toHaveProperty( + 'Cloudflare-Workers-Version-Overrides', + `private-app="${NEXT}"`, + ); + } + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it.each(['missing', 'stable', 'wrong-version', 'access-denied', 'atomic'])( + 'fails closed for a binding-preserving handoff without a usable preview (%s)', + async (scenario) => { + const url = + scenario === 'stable' + ? 'https://handoff.example.workers.dev' + : scenario === 'wrong-version' + ? `https://${CURRENT.slice(0, 8)}-handoff.example.workers.dev` + : `https://${NEXT.slice(0, 8)}-handoff.example.workers.dev`; + const runWrangler = runner({ + upload: ok( + `Worker Version ID: ${NEXT}${scenario === 'missing' ? '' : `\nVersion Preview URL: ${url}`}`, + ), + }); + const fetcher = vi.fn(async () => new Response('error code: 1050', { status: 403 })); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'handoff', + preserveTriggers: true, + requirePreviewForReadiness: true, + atomic: scenario === 'atomic', + readinessAttempts: 1, + message: encodeCloudflareVersionMessage([]), + fetch: fetcher, + }); + expect(result).toMatchObject({ + ok: false, + reason: scenario === 'access-denied' ? 'readiness-access-denied' : 'preview-unavailable', + }); + expect( + runWrangler.mock.calls.some(([args]) => args[0] === 'versions' && args[1] === 'deploy'), + ).toBe(false); + expect(fetcher).toHaveBeenCalledTimes(scenario === 'access-denied' ? 1 : 0); + }, + ); + + it('accepts a genuine immutable candidate preview without changing handoff bindings', async () => { + const url = `https://${NEXT.slice(0, 8)}-handoff.example.workers.dev`; + const runWrangler = runner({ + upload: ok(`Worker Version ID: ${NEXT}\nVersion Preview URL: ${url}`), + }); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'handoff', + preserveTriggers: true, + requirePreviewForReadiness: true, + message: encodeCloudflareVersionMessage([]), + fetch: async (value) => { + expect(value).toBe(`${url}/__void/ready`); + return Response.json({ ready: true }); + }, + }); + expect(result).toMatchObject({ ok: true, versionId: NEXT }); + }); + it('probes a custom route when workers.dev and preview URLs are disabled', async () => { const runWrangler = runner({ upload: ok(`Worker Version ID: ${NEXT}`), @@ -2701,6 +2899,33 @@ describe('Cloudflare version activation', () => { ); }); + it.each([false, true])('requires the served UUID after bootstrap (atomic=%s)', async (atomic) => { + const runWrangler = runner({ + ...(atomic + ? {} + : { + status: { stdout: '', stderr: 'Worker not found. [code: 10007]', status: 1 }, + upload: { stdout: '', stderr: LOCAL_DURABLE_MIGRATION_ERROR, status: 1 }, + }), + bootstrap: ok(`Current Version ID: ${NEXT}\nhttps://bootstrap.example.workers.dev`), + }); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'bootstrap', + atomic, + requireVersionMetadata: true, + readinessAttempts: 1, + message: encodeCloudflareVersionMessage([]), + fetch: async () => Response.json({ ready: true, versionId: CURRENT }), + }); + expect(result).toMatchObject({ + ok: false, + reason: atomic ? 'atomic-readiness-failed' : 'bootstrap-readiness-failed', + versionId: NEXT, + }); + }); + it('probes a custom route after first-deployment bootstrap', async () => { const triggerConfig = { workers_dev: false, @@ -2740,6 +2965,124 @@ describe('Cloudflare version activation', () => { ); }); + it('stops bootstrap before readiness when its custom domain cannot be published', async () => { + const runWrangler = runner({ + status: { stdout: '', stderr: 'Worker not found. [code: 10007]', status: 1 }, + upload: { stdout: '', stderr: LOCAL_DURABLE_MIGRATION_ERROR, status: 1 }, + bootstrap: ok(`Current Version ID: ${NEXT}`), + triggers: { stdout: '', stderr: 'Domain publication failed.', status: 1 }, + }); + const fetcher = vi.fn(); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'first-private', + message: encodeCloudflareVersionMessage([], { + workers_dev: false, + preview_urls: false, + routes: [{ pattern: 'app.example.com', custom_domain: true }], + crons: [], + queueConsumers: [], + workflows: [], + }), + fetch: fetcher, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'triggers-failed', + versionId: NEXT, + activeVersionId: NEXT, + rollback: 'unavailable', + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'repairs a concurrent bootstrap owner (during readiness=%s)', + async (duringReadiness) => { + const concurrentConfig = { + workers_dev: false, + preview_urls: false, + routes: [{ pattern: 'other.example.com', custom_domain: true }], + crons: [], + queueConsumers: [], + workflows: [], + }; + const base = runner({ + status: { stdout: '', stderr: 'Worker not found. [code: 10007]', status: 1 }, + upload: { stdout: '', stderr: LOCAL_DURABLE_MIGRATION_ERROR, status: 1 }, + bootstrap: ok(`Current Version ID: ${NEXT}`), + }); + let bootstrapped = false; + let superseded = false; + const published: Array = []; + const runWrangler = operationFixture((args: Array) => { + if (superseded && args[0] === 'deployments') { + return ok(JSON.stringify({ versions: [{ version_id: CONCURRENT, percentage: 100 }] })); + } + if (bootstrapped && args[0] === 'versions' && args[1] === 'list') { + return ok( + JSON.stringify([ + { + id: CONCURRENT, + annotations: { + 'workers/message': encodeCloudflareVersionMessage([], concurrentConfig), + }, + }, + ]), + ); + } + if (args[0] === 'triggers') { + published.push(JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8'))); + } + const result = base(args); + if (args[0] === 'deploy') { + bootstrapped = true; + superseded = !duringReadiness; + } + return result; + }); + const fetcher = vi.fn(async () => { + superseded = true; + return Response.json({ ready: true }); + }); + const result = await activateCloudflareVersion({ + runWrangler, + verifyUploader: () => true, + workerName: 'first-private', + message: encodeCloudflareVersionMessage([], { + ...concurrentConfig, + routes: [{ pattern: 'superseded.example.com', custom_domain: true }], + }), + fetch: fetcher, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'deployment-changed', + activeVersionId: CONCURRENT, + rollback: 'not-needed', + triggerRepair: 'succeeded', + }); + expect(published).toEqual([ + ...(duringReadiness + ? [ + expect.objectContaining({ + routes: [{ pattern: 'superseded.example.com', custom_domain: true }], + workers_dev: false, + preview_urls: false, + }), + ] + : []), + expect.objectContaining({ + routes: concurrentConfig.routes, + workers_dev: false, + preview_urls: false, + }), + ]); + expect(fetcher).toHaveBeenCalledTimes(duringReadiness ? 1 : 0); + }, + ); + it.each(DURABLE_MIGRATION_ERRORS)( 'bootstraps first-deploy Durable Object migrations for %s', async (error) => { @@ -2791,6 +3134,10 @@ describe('Cloudflare version activation', () => { ['deployments', 'status'], ['versions', 'upload'], ['deploy', '-c'], + ['deployments', 'status'], + ['triggers', 'deploy'], + ['deployments', 'status'], + ['deployments', 'status'], ]); expect(runWrangler.mock.calls[2]![0]).toContain('--autoconfig=false'); expect(runWrangler.mock.calls[2]![0]).toContain('--assets'); @@ -2859,6 +3206,10 @@ describe('Cloudflare version activation', () => { 'deployments', 'versions', 'deploy', + 'deployments', + 'triggers', + 'deployments', + 'deployments', ]); }); diff --git a/packages/edge/src/runtime.ts b/packages/edge/src/runtime.ts index 0bde4f573..b6df7d5d0 100644 --- a/packages/edge/src/runtime.ts +++ b/packages/edge/src/runtime.ts @@ -11,6 +11,8 @@ export type StaticEdgeEnv = Readonly<{ }>; export type StaticEdgeConfig = Readonly<{ + /** Optional native Worker self-metadata used to identify the served version. */ + versionMetadataBinding?: string; assetConfig?: AssetConfig; headerRules?: ReadonlyArray; redirectRules?: ReadonlyArray; @@ -32,7 +34,18 @@ export function createStaticEdgeHandler(config: StaticEdgeConfig): StaticEdgeHan async fetch(request, env): Promise { const originalUrl = new URL(request.url); if (request.method === 'GET' && originalUrl.pathname === '/__void/ready') { - return Response.json({ ready: true }); + if (!config.versionMetadataBinding) { + return Response.json({ ready: true }); + } + const metadata = config.versionMetadataBinding + ? (env as unknown as Record)[ + config.versionMetadataBinding + ] + : undefined; + return Response.json( + { ready: true, ...(config.versionMetadataBinding ? { versionId: metadata?.id } : {}) }, + { headers: { 'cache-control': 'no-store' } }, + ); } let effectiveUrl = new URL(originalUrl); let effectiveRequest = request; diff --git a/packages/edge/test/unit/runtime.test.ts b/packages/edge/test/unit/runtime.test.ts index 65e3017ff..886ab3fec 100644 --- a/packages/edge/test/unit/runtime.test.ts +++ b/packages/edge/test/unit/runtime.test.ts @@ -23,6 +23,16 @@ describe('createStaticEdgeHandler', () => { expect(binding.fetch).not.toHaveBeenCalled(); }); + it('returns the actual native version from the selected metadata binding without caching', async () => { + const env = { ASSETS: assets({}), USER_VERSION: { id: 'served-version' } }; + const response = await createStaticEdgeHandler({ + versionMetadataBinding: 'USER_VERSION', + }).fetch(new Request('https://example.com/__void/ready'), env); + expect(await response.json()).toEqual({ ready: true, versionId: 'served-version' }); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(env.ASSETS.fetch).not.toHaveBeenCalled(); + }); + it('serves assets through the mandatory edge Worker', async () => { const binding = assets({ '/': new Response('home') }); const response = await createStaticEdgeHandler({}).fetch(new Request('https://example.com/'), { diff --git a/packages/platform/README.md b/packages/platform/README.md index bbe0ac23e..691574a10 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -54,6 +54,8 @@ Runtime artifacts include migration SQL, the journal, and compatibility metadata Platform migrations are forward-only. `platform/packages/api/drizzle/compatibility.json` declares exact source-version/schema-digest edges whose old Workers are safe against the target schema. The runtime build validates that each source digest is a prefix of the same PostgreSQL lineage and hashes the journal and every SQL file. When adding a migration, use an expand-compatible change, add only edges verified against the old runtime, and keep destructive contract cleanup for a later release after rollback to the old Workers is no longer possible. +After rollback, an older runtime may stop partway through deleting a project that uses shared email or refuse to rename it. Restore the current runtime and reconcile the project's shared recipient cleanup before retrying deletion or renaming. + Post-success rollback uses the installed runtime's integrity-checked compatibility manifest and an explicitly supplied earlier runtime. The installation checkpoint records running-runtime provenance separately from the actual forward-only database schema. Rollback never reverses migrations, and it rejects a target whose declared safety protocols are older than the installation or authoritative database state. See the [self-hosted platform guide](../../docs/guide/self-hosted-platform.md) for the supported core feature set, Cloudflare footprint, and lifecycle commands. diff --git a/packages/platform/src/discovery.ts b/packages/platform/src/discovery.ts index 5aed40349..a938d64e8 100644 --- a/packages/platform/src/discovery.ts +++ b/packages/platform/src/discovery.ts @@ -4,7 +4,11 @@ import { parsePlatformLoginId, type PlatformLoginDescriptor, } from './authentication.ts'; -import { normalizeApplicationDomain, type PlatformCapabilities } from './manifest.ts'; +import { + normalizeApplicationDomain, + normalizePlatformDashboardUrl, + type PlatformCapabilities, +} from './manifest.ts'; export const PLATFORM_DISCOVERY_SCHEMA_VERSION = 1 as const; @@ -22,6 +26,7 @@ export type PlatformDiscoveryDocument = Readonly<{ authentication?: PlatformAuthenticationDiscovery; capabilities: PlatformCapabilities; displayName: string; + dashboardUrl?: string; installationId: string | null; platformVersion: string; proxyUrl: string; @@ -118,6 +123,9 @@ export function createPlatformDiscoveryDocument( authenticationProviders: [...input.authenticationProviders], ...(authentication ? { authentication } : {}), displayName: input.displayName.trim(), + ...(input.dashboardUrl !== undefined + ? { dashboardUrl: normalizePlatformDashboardUrl(input.dashboardUrl) } + : {}), installationId: input.installationId?.trim() || null, platformVersion: input.platformVersion.trim(), proxyUrl: normalizeOrigin(input.proxyUrl, 'proxyUrl'), @@ -221,6 +229,7 @@ export function parsePlatformDiscoveryDocument(value: unknown): PlatformDiscover ...(authentication ? { authentication } : {}), capabilities: parsedCapabilities, displayName: string('displayName'), + ...(document.dashboardUrl !== undefined ? { dashboardUrl: string('dashboardUrl') } : {}), installationId: document.installationId === null ? null : string('installationId'), platformVersion: string('platformVersion'), proxyUrl, diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index c54b93da7..522cd6f45 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -33,7 +33,9 @@ export { createPlatformDeploymentManifest, createPlatformResourceNames, normalizeApplicationDomain, + normalizePlatformDashboardUrl, normalizeInstallationSlug, + platformDashboardUrl, parsePlatformDeploymentManifest, type PlatformApplicationRoute, type PlatformCapabilities, diff --git a/packages/platform/src/manifest.ts b/packages/platform/src/manifest.ts index a6fc9e637..625bfaca0 100644 --- a/packages/platform/src/manifest.ts +++ b/packages/platform/src/manifest.ts @@ -17,6 +17,8 @@ export type PlatformCapabilities = Readonly<{ export type PlatformControlPlane = Readonly<{ apiHostname: string | null; dashboardHostname: string | null; + /** Optional separately deployed dashboard origin, including its HTTPS port. */ + dashboardUrl?: string; proxyHostname: string | null; }>; @@ -148,6 +150,41 @@ export function normalizeApplicationDomain(value: string): string { return domain; } +export function normalizePlatformDashboardUrl(value: string): string { + const text = value.trim(); + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error('The dashboard URL must be an HTTPS origin.'); + } + if ( + !/^https:\/\/[^/?#]+\/?$/i.test(text) || + /[\s\\]/.test(text) || + url.protocol !== 'https:' || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error( + 'The dashboard URL must be an HTTPS origin without credentials, a path, query, or fragment.', + ); + } + url.hostname = normalizeApplicationDomain(url.hostname); + return url.origin; +} + +export function platformDashboardUrl(deployment: PlatformDeploymentManifest): string | null { + const url = + deployment.controlPlane.dashboardUrl ?? + (deployment.controlPlane.dashboardHostname + ? `https://${deployment.controlPlane.dashboardHostname}` + : null); + return url === null ? null : normalizePlatformDashboardUrl(url); +} + export function createPlatformResourceNames(installationSlug: string): PlatformResourceNames { const slug = normalizeInstallationSlug(installationSlug); const prefix = `void-${slug}`; @@ -251,6 +288,10 @@ export function createPlatformDeploymentManifest( if (providers.includes('google') !== capabilities.googleOAuth) { throw new Error('The Google provider and googleOAuth capability must agree.'); } + const dashboardUrl = + input.controlPlane?.dashboardUrl === undefined + ? undefined + : normalizePlatformDashboardUrl(input.controlPlane.dashboardUrl); const manifest: PlatformDeploymentManifest = { application: { baseDomain, @@ -273,7 +314,17 @@ export function createPlatformDeploymentManifest( ...(input.emailSenderDomain !== undefined ? { emailSenderDomain: normalizeApplicationDomain(input.emailSenderDomain) } : {}), - controlPlane: { ...CONTROL_PLANE_DEFAULTS, ...input.controlPlane }, + controlPlane: { + ...CONTROL_PLANE_DEFAULTS, + ...input.controlPlane, + ...(dashboardUrl !== undefined + ? { + dashboardUrl, + dashboardHostname: + input.controlPlane?.dashboardHostname ?? new URL(dashboardUrl).hostname, + } + : {}), + }, displayName: requiredString(input.displayName, 'displayName').trim(), installationSlug, resources, @@ -281,6 +332,9 @@ export function createPlatformDeploymentManifest( }; createApplicationRoutes(manifest.application); for (const [key, value] of Object.entries(manifest.controlPlane)) { + if (key === 'dashboardUrl') { + continue; + } if (value !== null) { normalizeApplicationDomain(value); } @@ -288,6 +342,12 @@ export function createPlatformDeploymentManifest( throw new Error(`controlPlane.${key} must be a hostname or null.`); } } + if (manifest.controlPlane.dashboardUrl) { + const hostname = new URL(manifest.controlPlane.dashboardUrl).hostname; + if (manifest.controlPlane.dashboardHostname !== hostname) { + throw new Error('The dashboard URL and dashboard hostname must agree.'); + } + } for (const [key, value] of Object.entries(manifest.resources)) { requiredString(value, `resources.${key}`); } @@ -373,7 +433,9 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym `resources.${key}`, ); } - const readHostname = (key: keyof PlatformControlPlane): string | null => { + const readHostname = ( + key: 'apiHostname' | 'dashboardHostname' | 'proxyHostname', + ): string | null => { const hostname = controlPlane[key]; return hostname === null ? null : requiredString(hostname, `controlPlane.${key}`); }; @@ -407,6 +469,9 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym controlPlane: { apiHostname: readHostname('apiHostname'), dashboardHostname: readHostname('dashboardHostname'), + ...(controlPlane.dashboardUrl !== undefined + ? { dashboardUrl: requiredString(controlPlane.dashboardUrl, 'controlPlane.dashboardUrl') } + : {}), proxyHostname: readHostname('proxyHostname'), }, displayName: requiredString(value.displayName, 'displayName'), diff --git a/packages/platform/src/worker-config.ts b/packages/platform/src/worker-config.ts index 14cbecdc3..c276f6894 100644 --- a/packages/platform/src/worker-config.ts +++ b/packages/platform/src/worker-config.ts @@ -1,5 +1,9 @@ import { createPlatformResourceOwnershipMarker } from './installation.ts'; -import { createApplicationRoutes, type PlatformDeploymentManifest } from './manifest.ts'; +import { + createApplicationRoutes, + platformDashboardUrl, + type PlatformDeploymentManifest, +} from './manifest.ts'; export type PlatformProvisionedResourceIds = Readonly<{ hyperdrive: string; @@ -49,6 +53,7 @@ export function createPlatformWorkerConfigs(input: { const publicEndpoints = input.publicEndpoints ?? true; const trafficState = input.trafficState ?? 'enabled'; const names = deployment.resources; + const dashboardUrl = platformDashboardUrl(deployment); const sharedEmailIngress = deployment.emailSharedZoneId ? { EMAIL_SHARED_ACCOUNT_ID: accountId, @@ -160,6 +165,7 @@ export function createPlatformWorkerConfigs(input: { }, vars: { AUTH_REDIRECT_URI: `${apiUrl}/auth/callback`, + ...(dashboardUrl ? { DASHBOARD_URL: dashboardUrl } : {}), AI_GATEWAY_ID: names.aiGateway, CRON_FIRES_QUEUE_NAME: names.cronQueue, ...(input.deploymentExecution === 1 ? { DEPLOYMENTS_QUEUE_NAME: names.deploymentQueue } : {}), diff --git a/packages/platform/test/unit/dashboard.test.ts b/packages/platform/test/unit/dashboard.test.ts new file mode 100644 index 000000000..5b657827c --- /dev/null +++ b/packages/platform/test/unit/dashboard.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + createPlatformDiscoveryDocument, + parsePlatformDiscoveryDocument, +} from '../../src/discovery.ts'; +import { + createPlatformInstallationManifest, + parsePlatformInstallationManifest, + setInstallationEndpoints, +} from '../../src/installation.ts'; +import { + createPlatformDeploymentManifest, + normalizePlatformDashboardUrl, + parsePlatformDeploymentManifest, + platformDashboardUrl, +} from '../../src/manifest.ts'; +import { createPlatformWorkerConfigs } from '../../src/worker-config.ts'; + +const deploymentInput = { + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', +}; + +function configs(dashboardUrl?: string) { + const deployment = createPlatformDeploymentManifest({ + ...deploymentInput, + ...(dashboardUrl ? { controlPlane: { dashboardUrl } } : {}), + }); + return createPlatformWorkerConfigs({ + accountId: 'account', + apiUrl: 'https://api.example.com', + proxyUrl: 'https://proxy.example.com', + emailGatewayUrl: 'https://email.account.workers.dev', + deployment, + healthProbeToken: 'probe', + installationId: 'example-installation', + ownershipNonce: 'ownership', + platformVersion: '1.0.0', + resources: { + hyperdrive: 'db', + isrCacheKv: 'isr', + legacyAssetsKv: 'assets', + routingKv: 'routing', + zone: 'zone', + }, + runtimeEntrypoints: { + api: 'api.mjs', + proxy: 'proxy.mjs', + emailGateway: 'email.mjs', + tail: 'tail.mjs', + dispatch: 'dispatch.mjs', + }, + }); +} + +describe('optional platform dashboard origin', () => { + it.each([ + '', + 'not-a-url', + 'http://dash.example.com', + 'https://user:password@dash.example.com', + 'https://dash.example.com/account', + 'https://dash.example.com/../', + 'https://dash.example.com/%2e', + 'https://dash.example.com?return=evil', + 'https://dash.example.com?', + 'https://dash.example.com#fragment', + 'https://dash.example.com#', + 'https:\\dash.example.com', + 'https://dash.\nexample.com', + ])('rejects an unsafe or non-origin configuration: %j', (url) => { + expect(() => normalizePlatformDashboardUrl(url)).toThrow(); + }); + + it('normalizes an HTTPS origin while preserving its explicit port', () => { + expect(normalizePlatformDashboardUrl(' https://DASH.example.com:8443/ ')).toBe( + 'https://dash.example.com:8443', + ); + expect(normalizePlatformDashboardUrl('https://dash.example.com:443')).toBe( + 'https://dash.example.com', + ); + }); + + it('adds no dashboard binding, Worker, or paid capability by default', () => { + const deployment = createPlatformDeploymentManifest(deploymentInput); + expect(platformDashboardUrl(deployment)).toBeNull(); + expect(deployment.controlPlane.dashboardHostname).toBeNull(); + expect(deployment.controlPlane).not.toHaveProperty('dashboardUrl'); + const without = configs(); + const withDashboard = configs('https://dash.example.com:8443'); + expect(without.api.vars).not.toHaveProperty('DASHBOARD_URL'); + expect(withDashboard.api.vars).toHaveProperty('DASHBOARD_URL', 'https://dash.example.com:8443'); + expect(Object.keys(withDashboard)).toEqual(Object.keys(without)); + expect(withDashboard.api.services).toEqual(without.api.services); + expect(withDashboard.proxy.vars).not.toHaveProperty('DASHBOARD_URL'); + expect(withDashboard.api).not.toHaveProperty('send_email'); + }); + + it('round-trips the complete origin in existing deployment and installation schemas', () => { + const deployment = createPlatformDeploymentManifest({ + ...deploymentInput, + controlPlane: { dashboardUrl: 'https://dash.example.com:8443/' }, + }); + expect(deployment.controlPlane.dashboardHostname).toBe('dash.example.com'); + expect(parsePlatformDeploymentManifest(JSON.parse(JSON.stringify(deployment)))).toEqual( + deployment, + ); + const createdAt = '2026-10-01T00:00:00.000Z'; + const manifest = setInstallationEndpoints( + createPlatformInstallationManifest({ + account: { id: 'account', name: 'Account' }, + createdAt, + deployment, + id: 'installation', + ownershipNonce: 'a'.repeat(43), + platformVersion: '1.0.0', + }), + { + apiUrl: 'https://api.example.com', + proxyUrl: 'https://proxy.example.com', + dashboardUrl: platformDashboardUrl(deployment), + }, + createdAt, + ); + expect(parsePlatformInstallationManifest(JSON.parse(JSON.stringify(manifest)))).toEqual( + manifest, + ); + expect(manifest.endpoints.dashboardUrl).toBe('https://dash.example.com:8443'); + }); + + it('advertises only an explicitly configured, validated dashboard in discovery', () => { + const input = { + apiUrl: 'https://api.example.com', + proxyUrl: 'https://proxy.example.com', + applicationDomain: 'apps.example.com', + authenticationProviders: ['github'], + capabilities: createPlatformDeploymentManifest(deploymentInput).capabilities, + displayName: 'Example', + installationId: 'installation', + platformVersion: '1.0.0', + }; + expect(createPlatformDiscoveryDocument(input)).not.toHaveProperty('dashboardUrl'); + const document = createPlatformDiscoveryDocument({ + ...input, + dashboardUrl: 'https://dash.example.com:8443/', + }); + expect(parsePlatformDiscoveryDocument(document).dashboardUrl).toBe( + 'https://dash.example.com:8443', + ); + expect(() => + parsePlatformDiscoveryDocument({ + ...document, + dashboardUrl: 'https://user:password@dash.example.com', + }), + ).toThrow('HTTPS origin'); + }); + + it('retains a legacy dashboard hostname and refuses conflicting hostname metadata', () => { + const deployment = createPlatformDeploymentManifest({ + ...deploymentInput, + controlPlane: { dashboardHostname: 'dash.example.com' }, + }); + expect(platformDashboardUrl(parsePlatformDeploymentManifest(deployment))).toBe( + 'https://dash.example.com', + ); + expect(() => + createPlatformDeploymentManifest({ + ...deploymentInput, + controlPlane: { + dashboardUrl: 'https://dash.example.com', + dashboardHostname: 'other.example.com', + }, + }), + ).toThrow('must agree'); + }); +}); diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index db32d35e7..52658a860 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -77,6 +77,8 @@ mutation failure before retrying. Use `void connect` for deployment onboarding: no arguments offers Cloudflare or a Void platform, `--platform cloudflare` signs in and selects an account, and a platform URL verifies discovery and signs in with a supported provider. It preserves existing project links when connecting elsewhere. `--no-login` saves only verified Void connection metadata; authenticated headless connection requires a valid origin-scoped keychain session or `VOID_TOKEN` with matching `VOID_API_URL`. Platform installation and administration use `void platform`. +For a separately deployed user dashboard, configure its HTTPS origin with `void platform install --dashboard-url ` or `void platform repair --dashboard-url `. Preview with `--plan`; omission keeps the saved origin. This configures login callbacks and does not deploy the dashboard app. Existing Access protection must cover its origin. + For Void platform project access, use `void project team`: invite only an email already registered on that platform and assign `reader`, `collaborator`, or `admin`. The invited account uses `void connect ` followed by pending/accept/decline on that active connection, regardless of the current directory's project link or deploy target. `VOID_API_URL` overrides the connection; an unscoped `VOID_TOKEN` selects Void Cloud. Acceptance preserves directory links; run `void project link` in an unlinked checkout of the invited application. Project-scoped team management does not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner --plan` and apply the reviewed transfer with `--yes`. For a self-hosted platform's project Zero Trust policy, installation administrators use `void platform zero-trust`; pass the Access management token only through `--token-stdin`. Project owners and project administrators use `void project zero-trust status|protect|public|reconcile`; these commands do not apply to direct Cloudflare deployments. When a deploy or rollback is refused because protection is not settled, check `void project zero-trust status`; an owner or project administrator can run `void project zero-trust reconcile` before retrying. @@ -173,6 +175,6 @@ Then ask what to do next. - For Void-managed auth, `void db generate` automatically includes the production Better Auth schema in the generated migration, including configured model/field renames and plugin tables. Keep those migrations under `db/migrations/`; do not duplicate generated auth tables in the application Drizzle schema. - **Env vars:** When the project has `env.ts`, the canonical access pattern is `import { env } from "void/env"`. Declare every env key in `env.ts` via `defineEnv({...})` so values are typed and validated. Do not introduce ad-hoc `process.env.X` or untyped `c.env.X` access in new code — add the key to `env.ts` first. -For platform email domains, use `void email domain add` with an account- and zone-scoped Cloudflare API token. Read inbound, outbound, and management readiness separately; `domain sync` reconciles without rotating secrets. Use a stable `sendEmail({ idempotencyKey })` for retryable sends and inspect `void email logs` when the result is `OUTCOME_UNKNOWN`; never retry an uncertain send with a new key. Administrators can inspect retained outcomes with `void platform email logs --json`; use the project ID after deletion. Native Cloudflare email setup remains `void email setup --platform cloudflare` and does not support platform idempotency keys. +For platform email domains, use `void email domain add` with an account- and zone-scoped Cloudflare API token. Custom inbound email requires the selected Cloudflare zone’s apex; choose another apex or the shared mail address when it already has another mail provider. Read inbound, outbound, and management readiness separately; `domain sync` reconciles without rotating secrets. Use a stable `sendEmail({ idempotencyKey })` for retryable sends and inspect `void email logs` when the result is `OUTCOME_UNKNOWN`; never retry an uncertain send with a new key. Administrators can inspect retained outcomes with `void platform email logs --json`; use the project ID after deletion. Native Cloudflare email setup remains `void email setup --platform cloudflare`, supports subdomain literal addresses, and does not support platform idempotency keys. If project lookup asks for a platform upgrade, ask the platform administrator to update its runtime with `void platform upgrade`, then retry the original command. diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index 518ca13b1..da013eff9 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -111,6 +111,9 @@ function bindingsFromOutput( case 'assets': bindings.push({ name, type: 'assets' }); break; + case 'version-metadata': + bindings.push({ name, type: 'version_metadata' }); + break; default: throw new Error(`Cloudflare binding ${name} cannot be uploaded with pinned secrets.`); } diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 672eb519d..de44ca1fb 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -360,6 +360,12 @@ export type EmailSettingsResponse = { projectSlug?: string; }; recipientPolicy?: { mode: 'verified' | 'domains' | 'any'; domains: Array }; + sharedInbound?: { + recipient: string; + status: string; + reason: string | null; + observedAt: string | null; + } | null; }; export type EmailDomainResponse = { diff --git a/packages/void/src/cli/cloudflare-login-output.ts b/packages/void/src/cli/cloudflare-login-output.ts index c6c20bdc8..4ca1f82c6 100644 --- a/packages/void/src/cli/cloudflare-login-output.ts +++ b/packages/void/src/cli/cloudflare-login-output.ts @@ -11,6 +11,21 @@ export function cloudflareDeviceCode(output: string): string | null { ); } +export function cloudflareVerificationUrl(output: string): string | null { + const plain = stripVTControlCharacters(output); + return ( + /(?:Opening a link in your default browser:|Visit this link to authenticate:)\s*(https?:\/\/\S+)\s/i.exec( + plain, + )?.[1] ?? + /please visit:\s*(https?:\/\/\S+)\s/i.exec(plain)?.[1] ?? + null + ); +} + +export function printCloudflareLoginLink(url: string): void { + console.log(`Open this link if your browser did not open: ${url}`); +} + export function printCloudflareLoginPrompt(code: string | null): void { console.log( code diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 8035fbda8..58c02a37c 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -12,7 +12,12 @@ import { parse } from 'jsonc-parser'; import { dirname, join, resolve } from 'pathslash'; import { EnvHttpProxyAgent, fetch as httpFetch } from 'undici'; import { persistCfKeyringPreference, readCfSessionCredential } from './cf-session.ts'; -import { cloudflareDeviceCode, printCloudflareLoginPrompt } from './cloudflare-login-output.ts'; +import { + cloudflareDeviceCode, + cloudflareVerificationUrl, + printCloudflareLoginLink, + printCloudflareLoginPrompt, +} from './cloudflare-login-output.ts'; type Result = { status: number; @@ -124,6 +129,7 @@ function runInteractiveCf(command: Array): Promise { }); let output = ''; let announced = false; + let announcedLink = false; let settled = false; const finish = (value: Result) => { if (!settled) { @@ -140,6 +146,13 @@ function runInteractiveCf(command: Array): Promise { printCloudflareLoginPrompt(code); } } + if (!announcedLink) { + const url = cloudflareVerificationUrl(output); + if (url) { + announcedLink = true; + printCloudflareLoginLink(url); + } + } }; child.stdout.on('data', collect); child.stderr.on('data', collect); diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 72bfe99c0..e9e72d841 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -111,6 +111,11 @@ import { trustedCloudflareWorkerAccessHeaders } from '../shared/cf-access.ts'; import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; import { CLOUDFLARE_DEPLOY_BUILD_ENV, resolveRemoteMode } from '../shared/env.ts'; import { buildEnv, WRANGLER_AUTH_CREDENTIAL_ENV_VARS } from '../shared/untrusted-env.ts'; +import { + CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, + readVersionMetadataBinding, + selectNativeVersionMetadataBinding, +} from '../shared/version-metadata.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; import { getApiUrl, getToken, getTokenSource } from './auth.ts'; import { @@ -656,6 +661,7 @@ const MANAGED_DEPLOY_BUILD_ENV = { VOID_DEPLOY_BUILD: '1' } as const; function scrubManagedDeployBuildMarkers(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const { deleteEnvVarCaseAware } = requireProvisionModule(); deleteEnvVarCaseAware(env, 'VOID_DEPLOY_PROJECT_ID'); + deleteEnvVarCaseAware(env, CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV); for (const name of Object.keys(MANAGED_DEPLOY_BUILD_ENV)) { deleteEnvVarCaseAware(env, name); } @@ -5481,6 +5487,13 @@ export async function runCloudflareDeploy( // but local files cannot be serialized into the artifact this command uploads. requireProvisionModule().deleteEnvVarCaseAware(buildChildEnv, CLOUDFLARE_DEPLOY_BUILD_ENV); buildChildEnv[CLOUDFLARE_DEPLOY_BUILD_ENV] = '1'; + requireProvisionModule().deleteEnvVarCaseAware( + buildChildEnv, + CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, + ); + if (existingWorker) { + buildChildEnv[CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV] = '1'; + } // Pin NODE_ENV=production for the build too, so the build and the env-schema probes evaluate // env.ts under the SAME environment. `vite build` only DEFAULTS NODE_ENV to production when it // is unset (`if (!isNodeEnvSet) process.env.NODE_ENV = defaultNodeEnv`), so an ambient @@ -5523,6 +5536,7 @@ export async function runCloudflareDeploy( frameworkPreset, schemaFacts.requiredKeys, needsAI ? { binding: aiBindingName } : undefined, + existingWorker !== null, ); } catch (error) { log.error( @@ -6019,6 +6033,12 @@ export async function runCloudflareDeploy( workerName, configPath: generatedConfigPath, preserveTriggers: existingWorker !== null, + requireVersionMetadata: + existingWorker === null || + readVersionMetadataBinding(parseRootWranglerConfig(generatedConfigPath)) !== undefined, + requirePreviewForReadiness: + existingWorker !== null && + readVersionMetadataBinding(parseRootWranglerConfig(generatedConfigPath)) === undefined, expectedPreviousVersionId: existingWorker?.versionId, trustedInheritanceVersionIds: existingWorker ? candidateReceipt?.parentVersionId === existingWorker.versionId @@ -6210,6 +6230,9 @@ export function formatCloudflareActivationError( } return 'deploy: the verified Wrangler uploader changed before version activation.'; case 'status-failed': + if (result.versionId) { + return `deploy: Worker version ${result.versionId} was published, but current deployment status could not be verified${status}. Inspect \`void project status\` before retrying.`; + } return `deploy: could not snapshot the current Cloudflare deployment${status}; no version was uploaded.`; case 'deployment-changed': return result.triggerRepair === 'succeeded' @@ -6239,6 +6262,9 @@ export function formatCloudflareActivationError( case 'upload-output-invalid': return 'deploy: Wrangler uploaded a version but did not report its version id; production traffic is unchanged.'; case 'preview-unavailable': + if (result.diagnostic) { + return `deploy: candidate readiness cannot be verified.\n\n${result.diagnostic}`; + } return [ 'deploy: the uploaded Worker Version has no preview URL, so Void cannot verify readiness.', 'Set CLOUDFLARE_WORKERS_SUBDOMAIN to your account subdomain (for example "my-team")', @@ -6252,7 +6278,7 @@ export function formatCloudflareActivationError( return 'deploy: Cloudflare published atomically but did not report a version id or URL. Inspect `void project status` immediately.'; case 'atomic-readiness-failed': case 'atomic-readiness-access-denied': - return 'deploy: the atomic Worker deployment is live, but /__void/ready could not be verified. Inspect `void project status` and Worker logs immediately.'; + return `deploy: the atomic Worker deployment is live, but /__void/ready could not be verified. Inspect \`void project status\` and Worker logs immediately.${diagnostic}`; case 'readiness-failed': return [ 'deploy: the uploaded Worker Version did not pass /__void/ready.', @@ -6274,11 +6300,13 @@ export function formatCloudflareActivationError( 'did not report its version id and workers.dev URL. Inspect `void project status` before retrying.', ].join('\n'); case 'bootstrap-readiness-failed': - return [ - 'deploy: the first Worker deployment did not pass /__void/ready.', - 'Cloudflare requires this bootstrap to be non-versioned, so the new Worker is already active;', - 'inspect its logs and `void project status` before retrying.', - ].join('\n'); + return ( + [ + 'deploy: the first Worker deployment did not pass /__void/ready.', + 'Cloudflare requires this bootstrap to be non-versioned, so the new Worker is already active;', + 'inspect its logs and `void project status` before retrying.', + ].join('\n') + diagnostic + ); case 'bootstrap-readiness-access-denied': return [ 'deploy: Cloudflare Access blocked the first Worker deployment readiness probe.', @@ -6297,7 +6325,7 @@ export function formatCloudflareActivationError( : result.rollback === 'unavailable' ? result.previousVersionId === null ? 'This was the first deployment, so no previous version was available to restore.' - : 'Traffic was restored to the previous version, but its trigger configuration was unavailable. Inspect its routes, schedules, queues, and workflows in Cloudflare before retrying.' + : 'Traffic or trigger restoration could not be completed. Inspect its routes, schedules, queues, and workflows in Cloudflare before retrying.' : 'Automatic traffic restoration failed; inspect `void project status` immediately.'; return `deploy: the Worker activated, but Cloudflare trigger synchronization failed${status}. ${rollback}`; } @@ -6341,6 +6369,7 @@ export async function prepareCloudflareFrameworkArtifact( preset: FrameworkPreset, requiredSecretNames: ReadonlyArray = [], ai?: { binding: string }, + preserveBindings = false, ): Promise { const workerMain = join(root, preset.workerDir, preset.workerMain); const assetsDirectory = join(root, preset.assetsDir); @@ -6358,6 +6387,11 @@ export async function prepareCloudflareFrameworkArtifact( const crons = analysis.jobs; const queues = analysis.queues; const revalidate = collectRevalidate(voidConfig, null); + const rootConfig = parseRootWranglerConfig(configPath); + const versionMetadataBinding = selectNativeVersionMetadataBinding(rootConfig, { + nativeBuild: true, + preserveBindings, + }); await generateDeployWrapperIfNeeded(root, join(root, preset.workerDir), preset.workerMain, { crons, queues, @@ -6366,6 +6400,7 @@ export async function prepareCloudflareFrameworkArtifact( hasMigrations: false, migrationDialect: analysis.dialect, readiness: true, + versionMetadataBinding, ...(hasRevalidate(revalidate) && { isr: { revalidate: revalidate!, @@ -6376,7 +6411,6 @@ export async function prepareCloudflareFrameworkArtifact( if (preset.cloudflareArtifact === 'vite-plugin') { return; } - const rootConfig = parseRootWranglerConfig(configPath); writeCloudflareFrameworkDeployConfig({ configPath, deployPointerPath: deployPointerPath(root), @@ -6391,6 +6425,7 @@ export async function prepareCloudflareFrameworkArtifact( compatibilityFlags: preset.cloudflareCompatibilityFlags, crons: [...new Set(crons.flatMap((job) => job.crons))], requiredSecrets: requiredSecretNames, + versionMetadataBinding, ...(ai ? { ai: { ...ai, gateway: voidConfig.ai?.gateway } } : {}), ...(preset.workerDir === preset.assetsDir ? { @@ -6660,15 +6695,6 @@ async function runCloudflareStaticDeploy( }); const entryPath = join(root, '.void', 'deploy', 'cloudflare', 'static-edge.mjs'); mkdirSync(dirname(entryPath), { recursive: true }); - writeFileSync( - entryPath, - renderCloudflareStaticEntry({ - runtimeImport: 'void/edge', - headerRules: manifest.headerRules, - redirectRules: manifest.redirectRules, - fallbackRules: manifest.fallbackRules, - }), - ); const workerName = readWranglerTopLevelString(rootConfig, 'name') ?? deriveWorkerName(root); // Static deployment needs a fetch-only entry and must detach Worker-only @@ -6691,6 +6717,28 @@ async function runCloudflareStaticDeploy( assetConfig: manifest.assetConfig ?? { not_found_handling: 'none' }, }); deduplicateGeneratedCompatibilityFlags(staticConfigPath, configPath); + const staticConfig = parseRootWranglerConfig(staticConfigPath); + const versionMetadataBinding = selectNativeVersionMetadataBinding(staticConfig, { + nativeBuild: true, + preserveBindings: existingWorker !== null, + }); + if (versionMetadataBinding && staticConfig.version_metadata === undefined) { + writeFileSync( + staticConfigPath, + `${JSON.stringify({ ...staticConfig, version_metadata: { binding: versionMetadataBinding } }, null, 2)}\n`, + { mode: 0o600 }, + ); + } + writeFileSync( + entryPath, + renderCloudflareStaticEntry({ + runtimeImport: 'void/edge', + headerRules: manifest.headerRules, + redirectRules: manifest.redirectRules, + fallbackRules: manifest.fallbackRules, + versionMetadataBinding, + }), + ); if (existingWorker) { assertExistingCloudflareConfig(existingWorker, parseRootWranglerConfig(staticConfigPath), true); @@ -6768,6 +6816,8 @@ async function runCloudflareStaticDeploy( verifyUploader: cfOutput ? cfUploaderIsPinned : uploaderIsPinned, configPath: staticConfigPath, preserveTriggers: existingWorker !== null, + requireVersionMetadata: existingWorker === null || versionMetadataBinding !== undefined, + requirePreviewForReadiness: existingWorker !== null && versionMetadataBinding === undefined, expectedPreviousVersionId: existingWorker?.versionId, trustedInheritanceVersionIds: existingWorker ? candidateReceipt?.parentVersionId === existingWorker.versionId diff --git a/packages/void/src/cli/email.ts b/packages/void/src/cli/email.ts index 89e23373f..eb0bb0027 100644 --- a/packages/void/src/cli/email.ts +++ b/packages/void/src/cli/email.ts @@ -509,6 +509,11 @@ export async function emailDomainAdd( if (!isValidEmailDomainInput(domain)) { return fail(`email: invalid domain '${rawDomain}'.`); } + if (subdomain !== undefined) { + return fail( + 'email: Platform-managed custom email requires a Cloudflare zone apex because inbound email uses its catch-all. Omit --subdomain, choose an unused zone apex, or use the platform shared mail address.', + ); + } let target: { emailDomain: string; forced: boolean }; try { target = resolveEmailDomainTarget(domain, subdomain); @@ -516,39 +521,20 @@ export async function emailDomainAdd( return fail(err instanceof Error ? err.message : String(err)); } - // The MX decision. Forced (--subdomain) needs no default, only a warning - // when the named host already carries MX; unforced reads the apex and - // falls back to mail.. Shared by the owner flow and the printed - // administrator command, so the handoff names the target setup accepts. + // Keep the requested apex. The server checks DNS and zone authority again. const dns = deps.dns ?? (await createNodeEmailDns()); const decideTarget = async (): Promise => { - if (target.forced) { - const mx = await lookupMxForRender(dns, target.emailDomain); - if (mx !== null && mx.length > 0) { - log.warn( - `MX ${target.emailDomain} already carries MX (${mx.join(', ')}) — the platform re-checks over public DNS and may refuse it.`, - ); - } + const mx = await lookupMxForRender(dns, domain); + if (mx === null) { + log.info( + `MX could not read ${domain}'s MX from here — the platform decides over public DNS.`, + ); + } else if (mx.some((exchange) => !/\.mx\.cloudflare\.net$/i.test(exchange))) { + fail( + `email: ${domain} already receives mail from another provider. Platform custom inbound email requires an unused Cloudflare zone apex; choose another apex or use the platform shared mail address.`, + ); } else { - const mx = await lookupMxForRender(dns, domain); - if (mx === null) { - log.info( - `MX could not read ${domain}'s MX from here — the platform decides over public DNS.`, - ); - } else if (mx.length > 0) { - target = { emailDomain: `mail.${domain}`, forced: false }; - log.info( - `MX ${domain} carries MX for ${mx[0]} → apex is off limits\n using ${target.emailDomain} [change with --subdomain]`, - ); - const subMx = await lookupMxForRender(dns, target.emailDomain); - if (subMx !== null && subMx.length > 0) { - log.warn( - `MX ${target.emailDomain} also carries MX (${subMx.join(', ')}) — the platform may refuse it; pick another with --subdomain.`, - ); - } - } else { - log.info(`MX ${domain} has no MX records → using the apex`); - } + log.info(`MX using the requested apex ${domain}`); } }; @@ -562,7 +548,7 @@ export async function emailDomainAdd( const settings = await client.getEmailSettings(projectId); if (settings.domains.management === 'admin') { const rows = await client.listEmailDomains(projectId); - const candidates = [target.emailDomain, ...(target.forced ? [] : [`mail.${domain}`])]; + const candidates = [target.emailDomain]; const existing = candidates .map((candidate) => rows.find((row) => row.domain === candidate && row.managed_by !== 'admin'), diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 5c3effd78..b47dd0c4b 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -465,6 +465,10 @@ const HELP_TREE: HelpTree = { command: '--no-control-plane-domain', description: 'Use the default workers.dev API hostname', }, + { + command: '--dashboard-url ', + description: 'HTTPS origin of a separately deployed dashboard', + }, { command: '--plan', description: 'Print the exact plan without changing Cloudflare' }, { command: '--resume', description: 'Resume the matching local installation' }, { @@ -816,7 +820,7 @@ const HELP_TREE: HelpTree = { 'void email disallow
[options]', ), // The one nested group: verbs parse in email-args.ts (`domain `). - // Void-platform-only, so `--project` is global here; `--subdomain` is `add`-only. + // Void-platform-only; custom inbound requires a zone apex. domain: { summary: 'Send and receive at your own domain on a Cloudflare zone.', usage: ['void email domain [] [options]'], @@ -843,25 +847,12 @@ const HELP_TREE: HelpTree = { description: 'Disable the domain assignment and reconcile shared resources', }, ], - options: [ - { - command: '--subdomain ', - description: 'On add: register this subdomain instead of the proposed mail.', - }, - { command: '--project ', description: 'Project name override' }, - ], + options: [{ command: '--project ', description: 'Project name override' }], children: { add: { - summary: - 'Register a domain on a Cloudflare zone you own for sending and receiving. Proposes mail. when the apex already carries MX.', + summary: 'Register a Cloudflare zone apex you own for sending and receiving.', usage: ['void email domain add [options]'], - options: [ - { - command: '--subdomain ', - description: 'Register this subdomain instead of the proposed mail.', - }, - { command: '--project ', description: 'Project name override' }, - ], + options: [{ command: '--project ', description: 'Project name override' }], }, status: withProjectOption( 'Show a registered domain and its recorded readiness.', @@ -1319,7 +1310,7 @@ function lifecycleHelp( return { summary, usage: [ - `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'} [--yes]`, + `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ]' : ''} [--yes]`, ], options: [ { command: '--plan', description: 'Print the exact plan without making changes' }, @@ -1331,6 +1322,15 @@ function lifecycleHelp( description: 'Deploy an integrity-checked runtime directory instead of the package', }, ]), + ...(command === 'upgrade' || command === 'repair' + ? [ + { + command: '--dashboard-url ', + description: + 'Configure a separately deployed dashboard; omission keeps its saved origin', + }, + ] + : []), { command: '--yes', description: 'Acknowledge non-interactive Cloudflare changes' }, ], }; diff --git a/packages/void/src/cli/platform-access-preflight.ts b/packages/void/src/cli/platform-access-preflight.ts index 1eaf60276..0f2b8d5ab 100644 --- a/packages/void/src/cli/platform-access-preflight.ts +++ b/packages/void/src/cli/platform-access-preflight.ts @@ -32,6 +32,7 @@ export async function verifyInstalledPlatformAccessProtection(input: { client: CloudflareReader; manifest: PlatformInstallationManifest; plan: Pick; + dashboardUrl?: string | null; protection: PlatformAccessProtection | null; checkpoint: Readonly>; }): Promise { @@ -113,6 +114,7 @@ export async function verifyInstalledPlatformAccessProtection(input: { const requiredOrigins = [ input.plan.apiUrl, input.plan.proxyUrl, + ...(input.dashboardUrl ? [input.dashboardUrl] : []), ...(input.manifest.endpoints.dashboardUrl ? [input.manifest.endpoints.dashboardUrl] : []), ].map((value) => new URL(value).origin); if ( diff --git a/packages/void/src/cli/platform-access-provision.ts b/packages/void/src/cli/platform-access-provision.ts index b16f709b6..d90deac5a 100644 --- a/packages/void/src/cli/platform-access-provision.ts +++ b/packages/void/src/cli/platform-access-provision.ts @@ -64,6 +64,22 @@ function text(value: unknown, name: string): string { } return value; } +export function platformAccessIdentityProvider(provider: ObjectValue): { + id: string; + name: string; +} { + const id = text(provider.id, 'identity provider ID'); + // Cloudflare's built-in provider can have an empty display name. Identity + // selection still uses its validated ID, including in the interactive picker. + const name = provider.name; + return { + id, + name: + name == null || (typeof name === 'string' && !name.trim()) + ? id + : text(name, 'identity provider name'), + }; +} function ids(value: unknown): Array { if (!Array.isArray(value)) { return []; @@ -268,10 +284,7 @@ export async function inspectPlatformAccess( applications: [{ origin: 'https://validation.example.com', audience: 'validation' }], }); const providers = await client.list('identity_providers'); - const identityProviders = providers.map((provider) => ({ - id: text(provider.id, 'identity provider ID'), - name: text(provider.name ?? provider.id, 'identity provider name'), - })); + const identityProviders = providers.map(platformAccessIdentityProvider); if ( !input.identityProviderIds.length || !input.policyIds.length || diff --git a/packages/void/src/cli/platform-access-setup.ts b/packages/void/src/cli/platform-access-setup.ts index cc06d8fb7..14acdd6a5 100644 --- a/packages/void/src/cli/platform-access-setup.ts +++ b/packages/void/src/cli/platform-access-setup.ts @@ -13,6 +13,7 @@ import type { PlatformInstallationManifest } from '@void/platform'; import { cfAccessHeaders } from '../shared/cf-access.ts'; import { PlatformAccessClient, + platformAccessIdentityProvider, provisionPlatformAccess, type PlatformAccessCheckpoint, type PlatformAccessProvisionResult, @@ -354,9 +355,10 @@ export async function preparePlatformAccessSetup(input: { required: true, options: providers .filter((provider) => typeof provider.id === 'string') + .map(platformAccessIdentityProvider) .map((provider) => ({ - value: String(provider.id), - label: stripVTControlCharacters(String(provider.name ?? provider.id)), + value: provider.id, + label: stripVTControlCharacters(provider.name), })), }), ) diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index e8289a9cf..2ca65cb7b 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -1,4 +1,8 @@ -import { PLATFORM_LOGIN_KINDS, type PlatformLoginKind } from '@void/platform'; +import { + normalizePlatformDashboardUrl, + PLATFORM_LOGIN_KINDS, + type PlatformLoginKind, +} from '@void/platform'; import { operatorGroups, parseOperatorArgs, type OperatorArgs } from './operator-args.ts'; import { parsePlatformConfigArgs, type PlatformConfigArgs } from './platform-config-args.ts'; import type { SubcommandDef } from './subcommand-prompt.ts'; @@ -37,7 +41,11 @@ export const platformSubcommands: Array = [ }, { name: 'upgrade', arg: '[id]', description: 'Upgrade an installed Void platform' }, { name: 'rollback', arg: '[id]', description: 'Restore a compatible earlier platform runtime' }, - { name: 'repair', arg: '[id]', description: 'Reconcile an installed Void platform' }, + { + name: 'repair', + arg: '[id]', + description: 'Reconcile a platform or configure its dashboard origin', + }, { name: 'disable', arg: '[id]', description: 'Disable traffic without deleting data' }, { name: 'enable', arg: '[id]', description: 'Restore traffic for a disabled platform' }, { name: 'uninstall', arg: '[id]', description: 'Safely remove an installed Void platform' }, @@ -70,6 +78,7 @@ export type PlatformArgs = account: string; applicationDomain: string; controlPlaneDomain: string; + dashboardUrl?: string; dedicatedZone: boolean; displayName: string; name: string; @@ -82,6 +91,7 @@ export type PlatformArgs = } | { subcommand: 'upgrade' | 'repair' | 'disable' | 'enable'; + dashboardUrl?: string; selector: string; plan: boolean; runtime: string; @@ -142,10 +152,18 @@ function parseSelector( function parseLifecycleSelector( rest: Array, subcommand: 'upgrade' | 'rollback' | 'repair' | 'disable' | 'enable', -): { selector: string; plan: boolean; runtime: string; fromRuntime: string; yes: boolean } { +): { + selector: string; + plan: boolean; + runtime: string; + fromRuntime: string; + yes: boolean; + dashboardUrl?: string; +} { const positional: Array = []; let runtime = ''; let fromRuntime = ''; + let dashboardUrl: string | undefined; for (let index = 0; index < rest.length; index++) { const arg = rest[index]!; if (arg === '--runtime') { @@ -155,6 +173,10 @@ function parseLifecycleSelector( const [value, next] = readValue(rest, index, arg); runtime = value; index = next; + } else if (arg === '--dashboard-url' && (subcommand === 'upgrade' || subcommand === 'repair')) { + const [value, next] = readValue(rest, index, arg); + dashboardUrl = normalizePlatformDashboardUrl(value); + index = next; } else if (arg === '--from-runtime' && subcommand === 'rollback') { const [value, next] = readValue(rest, index, arg); fromRuntime = value; @@ -167,7 +189,12 @@ function parseLifecycleSelector( if (subcommand === 'rollback' && !runtime) { throw new Error('platform: rollback requires --runtime .'); } - return { ...parsed, runtime, fromRuntime }; + return { + ...parsed, + runtime, + fromRuntime, + ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), + }; } export function parsePlatformArgs(args: Array): PlatformArgs { @@ -281,10 +308,20 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand === 'disable' || subcommand === 'enable' ) { - const { selector, plan, runtime, fromRuntime, yes } = parseLifecycleSelector(rest, subcommand); + const { selector, plan, runtime, fromRuntime, yes, dashboardUrl } = parseLifecycleSelector( + rest, + subcommand, + ); return subcommand === 'rollback' ? { subcommand, selector, plan, runtime, fromRuntime, yes } - : { subcommand, selector, plan, runtime, yes }; + : { + subcommand, + selector, + plan, + runtime, + yes, + ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), + }; } if (subcommand === 'uninstall') { const parsed = parseSelector( @@ -324,6 +361,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { '--hyperdrive', '--application-domain', '--control-plane-domain', + '--dashboard-url', '--display-name', '--name', '--runtime', @@ -407,6 +445,9 @@ export function parsePlatformArgs(args: Array): PlatformArgs { account: values.get('--account') ?? '', applicationDomain: values.get('--application-domain') ?? '', controlPlaneDomain: values.get('--control-plane-domain') ?? '', + ...(values.has('--dashboard-url') + ? { dashboardUrl: normalizePlatformDashboardUrl(values.get('--dashboard-url')!) } + : {}), dedicatedZone, displayName: values.get('--display-name') ?? '', name: values.get('--name') ?? '', diff --git a/packages/void/src/cli/platform-install-auth.ts b/packages/void/src/cli/platform-install-auth.ts index 168aa7cd1..6f61023a0 100644 --- a/packages/void/src/cli/platform-install-auth.ts +++ b/packages/void/src/cli/platform-install-auth.ts @@ -15,6 +15,7 @@ import { parsePlatformAdmissionPolicy, parsePlatformAuthenticationSetup, parsePlatformLoginConfiguration, + platformDashboardUrl, type PlatformDeploymentManifest, type PlatformLoginKind, } from '@void/platform'; @@ -209,6 +210,9 @@ export async function collectInstallationAuthentication( (accessEntry && (!selection.file || selection.file.cloudflareAccess))) ? await preparePlatformAccessSetup({ plan, + ...(platformDashboardUrl(plan.deployment) + ? { additionalOrigins: [platformDashboardUrl(plan.deployment)!] } + : {}), ownershipNonce: access.ownershipNonce, protection: !!selection.authentication.accessProtection, ...(accessEntry diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index f73151c86..040dead6c 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -36,6 +36,7 @@ import { createPlatformInstallationManifest, createPlatformResourceOwnershipMarker, createPlatformWorkerConfigs, + normalizeApplicationDomain, parsePlatformInstallationManifest, parsePlatformResourceOwnershipMarker, PLATFORM_DISABLED_KEY, @@ -48,6 +49,7 @@ import { recordInstallationStep, setInstallationDesiredState, setInstallationEndpoints, + platformDashboardUrl, transitionInstallation, type InstallationResource, type PlatformInstallationManifest, @@ -1701,6 +1703,7 @@ export async function installPlatform( client: dependencies.client, manifest, plan, + dashboardUrl: platformDashboardUrl(plan.deployment), protection, checkpoint: secretCheckpoint(secrets, workerSecretUpdateIntent), }); @@ -2352,7 +2355,11 @@ export async function installPlatform( manifest = setInstallationEndpoints( manifest, - { apiUrl: plan.apiUrl, dashboardUrl: null, proxyUrl: plan.proxyUrl }, + { + apiUrl: plan.apiUrl, + dashboardUrl: platformDashboardUrl(plan.deployment), + proxyUrl: plan.proxyUrl, + }, now(), ); if (preserveDisabled) { @@ -6220,13 +6227,11 @@ function parseWorkerCutoverSnapshot(value: string): PlatformWorkerCutoverSnapsho } } -function interruptedWorkerCutover( - manifest: PlatformInstallationManifest, -): InterruptedWorkerCutover | null { +function workerCutoverGenerations(manifest: PlatformInstallationManifest) { const generations = new Map< number, { - resolved: boolean; + resolution: 'committed' | 'restored' | null; staged: Map< PlatformWorkerKey, { @@ -6244,11 +6249,11 @@ function interruptedWorkerCutover( if (resolution) { const generation = Number(resolution[1]); const entry = generations.get(generation) ?? { - resolved: false, + resolution: null, staged: new Map(), unsafeTraffic: new Set(), }; - entry.resolved = true; + entry.resolution = resolution[2] as 'committed' | 'restored'; generations.set(generation, entry); continue; } @@ -6257,7 +6262,7 @@ function interruptedWorkerCutover( if (unsafeTraffic) { const generation = Number(unsafeTraffic[1]); const entry = generations.get(generation) ?? { - resolved: false, + resolution: null, staged: new Map(), unsafeTraffic: new Set(), }; @@ -6277,15 +6282,21 @@ function interruptedWorkerCutover( throw new Error(`platform: Invalid Worker cutover checkpoint ${step.key}.`); } const entry = generations.get(generation) ?? { - resolved: false, + resolution: null, staged: new Map(), unsafeTraffic: new Set(), }; entry.staged.set(staged[2] as PlatformWorkerKey, snapshot); generations.set(generation, entry); } - const unresolved = [...generations.entries()] - .filter(([, entry]) => !entry.resolved && entry.staged.size > 0) + return generations; +} + +function interruptedWorkerCutover( + manifest: PlatformInstallationManifest, +): InterruptedWorkerCutover | null { + const unresolved = [...workerCutoverGenerations(manifest).entries()] + .filter(([, entry]) => entry.resolution === null && entry.staged.size > 0) .sort(([left], [right]) => right - left)[0]; return unresolved ? { @@ -6310,8 +6321,10 @@ export async function verifyPlatformWorkerCutoverVersions( 'dispatch', ], ): Promise { - const cutover = interruptedWorkerCutover(manifest); - if (cutover?.generation !== generation || cutover.staged.size !== workerKeys.length) { + // Shared email setup commits the retained Worker set before its provider + // mutations. Final verification must still inspect that exact generation. + const cutover = workerCutoverGenerations(manifest).get(generation); + if (!cutover || cutover.resolution === 'restored' || cutover.staged.size !== workerKeys.length) { throw new Error( `platform: Worker cutover generation ${generation} is missing a complete immutable-version checkpoint.`, ); @@ -6673,45 +6686,155 @@ export async function bootstrapPlatformSharedEmail( healthProbeToken: string, fetcher: typeof fetch = fetch, ): Promise { - const response = await fetcher(`${apiUrl}/internal/platform/email-bootstrap`, { - method: 'POST', - headers: { - 'X-Void-Platform-Probe': healthProbeToken, - ...cfAccessHeaders(process.env, apiUrl), - }, - signal: AbortSignal.timeout(30_000), - redirect: 'manual', - }); - let value: unknown; - try { - value = await response.json(); - } catch { - throw new Error( - `platform: Shared email bootstrap failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`, + // One bootstrap request drives several durable PostgreSQL/provider steps. + // A timeout does not cancel its recorded operation; replay the same endpoint + // so its idempotency key and step leases reconcile rather than repeat writes. + const deadline = Date.now() + 5 * 60_000; + let operationId: string | null = null; + const timedOut = () => + new Error( + `platform: Shared email bootstrap did not finish within five minutes at ${apiUrl}.${operationId ? ` Operation ${operationId} remains incomplete.` : ''} Traffic remains disabled. Retry the installation's repair or resume command to reconcile the existing email operation.`, ); + const waitForProgress = async () => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw timedOut(); + } + await new Promise((resolve) => setTimeout(resolve, Math.min(1_000, remaining))); + }; + let response: Response; + let value: unknown; + let status: Record | null; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw timedOut(); + } + const signal = AbortSignal.timeout(Math.min(120_000, remaining)); + try { + response = await fetcher(`${apiUrl}/internal/platform/email-bootstrap`, { + method: 'POST', + headers: { + 'X-Void-Platform-Probe': healthProbeToken, + ...cfAccessHeaders(process.env, apiUrl), + }, + signal, + redirect: 'manual', + }); + } catch (error) { + if (signal.aborted || (error instanceof Error && error.name === 'TimeoutError')) { + await waitForProgress(); + continue; + } + throw new Error( + `platform: Shared email bootstrap request failed at ${apiUrl}. Traffic remains disabled. Retry the installation's repair or resume command to reconcile the existing email operation.`, + ); + } + try { + value = await response.json(); + } catch (error) { + if (signal.aborted || (error instanceof Error && error.name === 'TimeoutError')) { + await waitForProgress(); + continue; + } + throw new Error( + `platform: Shared email bootstrap failed at ${apiUrl} (HTTP ${response.status}). Traffic remains disabled.`, + ); + } + status = + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + const observedOperationId = + typeof status?.operationId === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(status.operationId) + ? status.operationId + : null; + if (status?.version === 1 && observedOperationId) { + if (operationId && operationId !== observedOperationId) { + throw new Error( + 'platform: Shared email bootstrap operation identity changed while waiting. Traffic remains disabled. Inspect the installation before retrying.', + ); + } + operationId = observedOperationId; + } + if ( + [200, 202, 503].includes(response.status) && + status?.version === 1 && + status.state === 'incomplete' && + observedOperationId && + (status.operationStatus === 'pending' || status.operationStatus === 'running') && + status.failureCode == null + ) { + await waitForProgress(); + continue; + } + break; } - const status = - value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; if ( !response.ok || status?.version !== 1 || status.state !== 'ready' || - typeof status.operationId !== 'string' || - status.operationId.length === 0 + operationId === null || + status.operationId !== operationId || + (status.operationStatus != null && status.operationStatus !== 'completed') || + status.failureCode != null ) { - const detail = - status && typeof status.operationId === 'string' - ? ` Operation ${status.operationId}: ${typeof status.operationStatus === 'string' ? status.operationStatus : 'incomplete'}.` - : status && typeof status.operationStatus === 'string' - ? ` Operation status: ${status.operationStatus}.` - : ''; + const code = (value: unknown) => + typeof value === 'string' && /^[a-z][a-z0-9_-]{0,63}$/.test(value) ? value : null; + const operationStatus = code(status?.operationStatus); + const operationId = + typeof status?.operationId === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(status.operationId) + ? status.operationId + : null; + const details = operationId + ? [`Operation ${operationId}: ${operationStatus ?? 'incomplete'}.`] + : operationStatus + ? [`Operation status: ${operationStatus}.`] + : []; + const currentStep = code(status?.currentStep); + const failureCode = code(status?.failureCode); + if (currentStep) { + details.push(`Step: ${currentStep}.`); + } + if (failureCode) { + details.push(`Failure: ${failureCode}.`); + } + let domain: string | null = null; + if (typeof status?.domain === 'string') { + try { + domain = normalizeApplicationDomain(status.domain); + } catch { + // Provider bodies are not diagnostic authority; show only a valid domain. + } + } + const inboundStatus = code(status?.inboundStatus); + if (domain) { + details.push(`Domain: ${domain}${inboundStatus ? ` (inbound: ${inboundStatus})` : ''}.`); + } + const mxDetail = domain + ? `MX records for ${domain} do not route mail to Cloudflare Email Routing` + : null; + const safeDetails = [ + ...(domain + ? [mxDetail, `enabled explicit Cloudflare email rules conflict with ${domain}`] + : []), + 'zone catch-all does not point to this ingress', + 'another enabled catch-all owns this zone', + ]; + if (typeof status?.failureDetail === 'string' && safeDetails.includes(status.failureDetail)) { + details.push(`${status.failureDetail}.`); + if (currentStep === 'inbound_probe' && status.failureDetail === mxDetail) { + details.push( + `Enable Email Routing for ${domain} in the Cloudflare dashboard, then resume this installation.`, + ); + } + } + const detail = details.length ? ` ${details.join(' ')}` : ''; throw new Error( `platform: Shared email bootstrap did not reach inbound readiness at ${apiUrl} (HTTP ${response.status}).${detail} Traffic remains disabled.`, ); } - return status.operationId; + return operationId; } export function createPlatformHealthProbeToken(ownershipNonce: string): string { diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index df7d638a3..0638758a2 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -553,6 +553,7 @@ export async function runPlatformLifecycleCommand(args: PlatformArgs): Promise { + // Validate before even the first provider read; omission retains recorded intent. + const dashboardUrl = + input.dashboardUrl !== undefined + ? normalizePlatformDashboardUrl(input.dashboardUrl) + : existing + ? (existing.deployment.controlPlane.dashboardUrl ?? + (existing.endpoints.dashboardUrl + ? normalizePlatformDashboardUrl(existing.endpoints.dashboardUrl) + : null) ?? + platformDashboardUrl(existing.deployment)) + : null; const accounts = await client.listAccounts(); const account = resolveAccount(accounts, input.accountId); if (existing && existing.account.id !== account.id) { @@ -196,7 +212,8 @@ export async function createPlatformInstallPlan( }, controlPlane: { apiHostname: input.controlPlaneDomain || null, - dashboardHostname: null, + dashboardHostname: dashboardUrl ? new URL(dashboardUrl).hostname : null, + ...(dashboardUrl ? { dashboardUrl } : {}), proxyHostname: null, }, displayName: input.displayName, @@ -222,13 +239,25 @@ export async function createPlatformInstallPlan( capabilities: { ...existingWithoutEmailZone.capabilities, sandbox: true }, } : existingWithoutEmailZone; + const existingWithDashboard = { + ...existingWithSandbox, + // The dashboard is separately deployed. Only the explicit option or the + // normalized recorded origin can update this configuration during maintenance. + controlPlane: { + ...existingWithSandbox.controlPlane, + dashboardHostname: deployment.controlPlane.dashboardHostname, + ...(deployment.controlPlane.dashboardUrl + ? { dashboardUrl: deployment.controlPlane.dashboardUrl } + : {}), + }, + }; const comparableExistingWithoutEmailZone = existing && deployment.capabilities.email && !existing.deployment.capabilities.email ? { - ...existingWithSandbox, - capabilities: { ...existingWithSandbox.capabilities, email: true }, + ...existingWithDashboard, + capabilities: { ...existingWithDashboard.capabilities, email: true }, } - : existingWithSandbox; + : existingWithDashboard; if ( existing && (JSON.stringify(comparableExistingWithoutEmailZone) !== @@ -387,7 +416,11 @@ export async function createPlatformInstallPlan( resource.name === expectedWorker && resource.state === 'ready', ); - const conflicts = (await client.listWorkerRoutes(zone.id)).filter( + const [workerRoutes, workerDomains] = await Promise.all([ + client.listWorkerRoutes(zone.id), + client.listWorkerDomains(account.id), + ]); + const conflicts = workerRoutes.filter( (route) => expectedPatterns.has(route.pattern) && (route.script !== expectedWorker || !ownedWorker), ); @@ -397,6 +430,14 @@ export async function createPlatformInstallPlan( `platform: Application route ${JSON.stringify(conflict.pattern)} is already assigned to ${JSON.stringify(conflict.script ?? 'no Worker')}. Choose another application domain.`, ); } + assertApplicationRoutesPreserveWorkerDomains( + [...expectedPatterns], + expectedWorker, + ownedWorker === true, + zone.name, + workerRoutes, + workerDomains, + ); } if (deployment.controlPlane.apiHostname) { const apiHostname = deployment.controlPlane.apiHostname; @@ -1160,6 +1201,72 @@ const RESOURCE_NAME_KEY_BY_ACTION: Readonly< 'usage-queue': 'usageQueue', }; +function assertApplicationRoutesPreserveWorkerDomains( + patterns: ReadonlyArray, + dispatchWorker: string, + ownedDispatchWorker: boolean, + zoneName: string, + workerRoutes: ReadonlyArray, + workerDomains: ReadonlyArray, +): void { + const plannedRoutes = patterns.map(parseWorkerRoutePattern); + const existingRoutes = workerRoutes.map((route) => ({ + ...parseWorkerRoutePattern(route.pattern), + script: route.script, + })); + for (const domain of workerDomains) { + const hostname = domain.hostname.toLowerCase(); + const inZone = domain.zoneName + ? domain.zoneName.toLowerCase() === zoneName.toLowerCase() + : hostname === zoneName.toLowerCase() || hostname.endsWith(`.${zoneName.toLowerCase()}`); + if (!inZone || (domain.service === dispatchWorker && ownedDispatchWorker)) { + continue; + } + for (const planned of plannedRoutes) { + if (!workerRouteHostMatches(planned.host, hostname)) { + continue; + } + // Routes run before Custom Domain origins. Only more-specific routes covering every + // path and both schemes preserve the origin; a no-Worker route is also an exclusion. + // https://developers.cloudflare.com/workers/configuration/routing/routes/#matching-behavior + const protectedByExistingRoutes = ['http', 'https'].every((scheme) => + existingRoutes.some( + (route) => + route.script !== dispatchWorker && + route.path === '/*' && + (!route.scheme || route.scheme === scheme) && + workerRouteHostMatches(route.host, hostname) && + route.host.replace(/^\*/, '').length > planned.host.replace(/^\*/, '').length, + ), + ); + if (!protectedByExistingRoutes) { + throw new Error( + `platform: Application route ${JSON.stringify(planned.pattern)} would intercept Worker custom domain ${JSON.stringify(domain.hostname)}, currently served by ${JSON.stringify(domain.service)}. Choose another application domain, or use \`void platform install --workers-dev\` for a new installation and add a suitable domain later.`, + ); + } + } + } +} + +function parseWorkerRoutePattern(pattern: string): { + host: string; + path: string; + pattern: string; + scheme: string | null; +} { + const match = /^(?:(https?):\/\/)?([^/]+)(\/.*)?$/.exec(pattern); + return { + host: match?.[2]?.toLowerCase() ?? '', + path: match?.[3] ?? '/', + pattern, + scheme: match?.[1] ?? null, + }; +} + +function workerRouteHostMatches(host: string, hostname: string): boolean { + return host.startsWith('*') ? hostname.endsWith(host.slice(1)) : host === hostname; +} + export function platformProvisionIntentHash( manifest: Pick, key: string, @@ -1239,6 +1346,9 @@ export function formatPlatformInstallPlan(plan: PlatformInstallPlan): string { : `${pc.bold('Applications:')} workers.dev for testing. Add a domain with void platform domain set `, `${pc.bold('Control plane:')} ${plan.apiUrl}`, `${pc.bold('Resource proxy:')} ${plan.proxyUrl}`, + ...(platformDashboardUrl(plan.deployment) + ? [`${pc.bold('Dashboard:')} ${platformDashboardUrl(plan.deployment)} (deploy separately)`] + : []), `${pc.bold('Login methods:')} ${plan.deployment.authentication.providers.join(', ')}`, `${pc.bold('Access protection:')} ${plan.deployment.authentication.accessProtection ? 'Enabled' : 'Not enabled'}`, `${pc.bold('Login callback:')} ${plan.apiUrl}/auth/callback`, diff --git a/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index d756010d5..d218202dc 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -199,6 +199,7 @@ export function formatPlatformInstallCommand(options: { applicationDomain: string; zoneName: string; controlPlaneDomain: string; + dashboardUrl?: string; dedicatedZone: boolean; workersDev: boolean; authConfig?: string; @@ -253,6 +254,7 @@ export function formatPlatformInstallCommand(options: { options.authentication.accessProtection ? '--access-protection' : '--no-access-protection', ); } + flag('--dashboard-url', options.dashboardUrl ?? ''); flag('--runtime', options.runtime); if (options.yes) { parts.push('--yes'); diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 2327ac942..41e06e735 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -26,6 +26,8 @@ export type WrapperFeatures = { hasMigrations?: boolean; /** Expose the readiness endpoint used by direct version activation. */ readiness?: boolean; + /** Preserve an authored metadata binding name for native readiness proof. */ + versionMetadataBinding?: string; /** Database dialect for migration handler. */ migrationDialect?: 'sqlite' | 'postgresql' | 'mysql'; /** Direct Cloudflare ISR policy; managed deployments cache in dispatch instead. */ @@ -491,7 +493,9 @@ function generateWrapperSource( features.hasMigrations ) { lines.push(' if (request.method === "GET" && url.pathname === "/__void/ready") {'); - lines.push(' return Response.json({ ready: true });'); + lines.push( + ` return Response.json({ ready: true${features.versionMetadataBinding ? `, versionId: env[${JSON.stringify(features.versionMetadataBinding)}]?.id` : ''} }${features.versionMetadataBinding ? ', { headers: { "cache-control": "no-store" } }' : ''});`, + ); lines.push(' }'); } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index ca20eb14b..1b9ddb25f 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -32,6 +32,7 @@ import { ensureWranglerCompat, ensureWranglerCompatibilityDate, ensureWranglerNodejsAlsFlag, + findBindingNameOwner, hasDurableObjectSqliteMigration, LIVE_MIGRATION_TAG, normalizeWranglerCompatibilityFlags, @@ -94,6 +95,10 @@ import { resolveRemoteMode, stripInternalEnvKeys, } from './shared/env.ts'; +import { + CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, + selectNativeVersionMetadataBinding, +} from './shared/version-metadata.ts'; import { validateSsrEntry } from './ssr/entry.ts'; import { injectSvelteKitHooks } from './sveltekit-hooks-transform.ts'; @@ -1260,6 +1265,7 @@ export function voidPlugin(options?: { // Tracks whether vite is running in dev or build mode. Set early in // envPlugin.config so mergeBindings can gate credential injection on 'serve'. let viteCommand: 'serve' | 'build' = 'build'; + let versionMetadataBinding: string | undefined; // Merge curated wrangler fields from void.config.ts const wranglerCompat = isNodeTarget(config.target) ? undefined : readWranglerCompat(root, config); @@ -1800,6 +1806,24 @@ export function voidPlugin(options?: { } } + versionMetadataBinding = selectNativeVersionMetadataBinding( + resolved as Record, + { + nativeBuild: viteCommand === 'build' && process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] === '1', + preserveBindings: process.env[CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV] === '1', + }, + ); + if (versionMetadataBinding) { + const owner = findBindingNameOwner(result, 'version_metadata', versionMetadataBinding); + if (owner) { + throw new Error( + `Cloudflare version metadata binding ${versionMetadataBinding} conflicts with ${owner}.`, + ); + } + if (!resolved.version_metadata) { + result.version_metadata = { binding: versionMetadataBinding }; + } + } return result as Partial; } @@ -2402,6 +2426,7 @@ export function voidPlugin(options?: { routingPlugin(root, { getApplicationAnalysis, isAuthEnabled: () => authEnabled, + getVersionMetadataBinding: () => versionMetadataBinding, devTriggerToken, headConfig: config.head, output: config.output, diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 1fe26b92e..2cf23fea6 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -74,6 +74,7 @@ type RoutingPluginOptions = { refresh?: boolean, ) => ApplicationAnalysis; isAuthEnabled: () => boolean; + getVersionMetadataBinding?: () => string | undefined; devTriggerToken?: string; headConfig?: import('./pages/head.ts').HeadConfig; output?: 'server' | 'static'; @@ -459,6 +460,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug writeFileIfChanged( join(outDir, 'entry.ts'), compileWorkerEntry({ + versionMetadataBinding: options.getVersionMetadataBinding?.(), remote, webSocketClasses: options.websocketTombstones, runtimeEnvPath: compileOptions.runtimeEnvPath, @@ -615,6 +617,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug writeFileIfChanged( join(outDir, 'entry.ts'), compileWorkerEntry({ + versionMetadataBinding: options.getVersionMetadataBinding?.(), hasQueues: scan!.queues.length > 0, remote, assetsDir: resolvedConfig?.build.assetsDir, @@ -762,6 +765,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug writeFileIfChanged( join(outDir, 'entry.ts'), compileWorkerEntry({ + versionMetadataBinding: options.getVersionMetadataBinding?.(), hasQueues: scan!.queues.length > 0, remote, assetsDir: resolvedConfig?.build.assetsDir, diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 61493b070..0efe36b81 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1172,6 +1172,8 @@ function deriveEmailRuntimePath(runtimeEnvPath: string): string { } export interface WorkerEntryOptions { + /** Native readiness proves the actual served Worker version through this binding. */ + versionMetadataBinding?: string; hasQueues?: boolean; remote?: { remotePath: string; @@ -1234,6 +1236,7 @@ export interface WorkerEntryOptions { export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { const { hasQueues = false, + versionMetadataBinding, remote, assetsDir = 'assets', webSocketClasses = [], @@ -1330,7 +1333,7 @@ const __filterInternalBindings = (env) => __filterBindings(env, ${JSON.stringify app, assets: serveWithAssets, withRawEnv: __withRawEnv, withRuntimeEnv, filterBindings: __filterInternalBindings, internalRoute: __voidHandleInternalRoute, - collectPrerenderPaths: __voidCollectPrerenderPaths${envSchemaModule ? ', validateEnv: _validateEnvOnce' : ''}${remotePath ? ', remoteEnv: (env) => ' + createRemoteEnvCall : ''}${hasEmail ? ', emailReceipt: __handleEmailReceipt' : ''}${isr ? ', isr: { deploymentId: __VOID_ISR_DEPLOYMENT_ID, handle: (request, env, ctx, render) => handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, pages: __VOID_ISR_PAGES, pathRevalidate: __VOID_ISR_PATH_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID, queryAllowlist: ' + JSON.stringify(isr.queryAllowlist) + ' }, render) }' : ''} + collectPrerenderPaths: __voidCollectPrerenderPaths${versionMetadataBinding ? ', versionMetadataBinding: ' + JSON.stringify(versionMetadataBinding) : ''}${envSchemaModule ? ', validateEnv: _validateEnvOnce' : ''}${remotePath ? ', remoteEnv: (env) => ' + createRemoteEnvCall : ''}${hasEmail ? ', emailReceipt: __handleEmailReceipt' : ''}${isr ? ', isr: { deploymentId: __VOID_ISR_DEPLOYMENT_ID, handle: (request, env, ctx, render) => handleIsr(request, env, ctx, { revalidate: __VOID_ISR_REVALIDATE, pages: __VOID_ISR_PAGES, pathRevalidate: __VOID_ISR_PATH_REVALIDATE, deploymentId: __VOID_ISR_DEPLOYMENT_ID, queryAllowlist: ' + JSON.stringify(isr.queryAllowlist) + ' }, render) }' : ''} })`; const scheduledHandler = remotePath ? `async function handleScheduled(controller, env, ctx) { diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index 24d6286e2..c4754ae6a 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -115,6 +115,8 @@ export type WorkerFetchOptions = { withRuntimeEnv: (env: RuntimeEnvironment, fn: () => Promise) => Promise; filterBindings: (env: RuntimeEnvironment) => RuntimeEnvironment; validateEnv?: (env: RuntimeEnvironment) => Promise; + /** Authored/generated version metadata binding used to prove the served native version. */ + versionMetadataBinding?: string; remoteEnv?: (env: RuntimeEnvironment) => RuntimeEnvironment; internalRoute: ( request: Request, @@ -164,7 +166,15 @@ export function createWorkerFetch( return options.emailReceipt(request, env, ctx); } if (request.method === 'GET' && url.pathname === '/__void/ready') { - return Response.json({ ready: true }); + const metadata = options.versionMetadataBinding + ? (env[options.versionMetadataBinding] as { id?: unknown } | undefined) + : undefined; + return Response.json( + { ready: true, ...(options.versionMetadataBinding ? { versionId: metadata?.id } : {}) }, + options.versionMetadataBinding + ? { headers: { 'cache-control': 'no-store' } } + : undefined, + ); } const internal = await options.internalRoute(request, env, ctx); if (internal) { diff --git a/packages/void/src/shared/version-metadata.ts b/packages/void/src/shared/version-metadata.ts new file mode 100644 index 000000000..6434cbfe7 --- /dev/null +++ b/packages/void/src/shared/version-metadata.ts @@ -0,0 +1,40 @@ +import { bindingNamesForField, findBindingNameOwner } from '../cli/wrangler.ts'; + +/** Build-only marker: an existing-site handoff must not add Worker bindings. */ +export const CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV = 'VOID_CLOUDFLARE_PRESERVE_BINDINGS'; +export const DEFAULT_VERSION_METADATA_BINDING = '__VOID_VERSION_METADATA'; + +/** Keep the authored singleton name, and reject a cross-type binding collision. */ +export function readVersionMetadataBinding(config: Record): string | undefined { + const names = bindingNamesForField('version_metadata', config.version_metadata); + const name = names[0]; + if (!name) { + return undefined; + } + const owner = findBindingNameOwner(config, 'version_metadata', name); + if (owner) { + throw new Error(`Cloudflare version metadata binding ${name} conflicts with ${owner}.`); + } + return name; +} + +/** Self-metadata adds no remote resource or plan entitlement. */ +export function selectNativeVersionMetadataBinding( + config: Record, + options: { nativeBuild: boolean; preserveBindings: boolean }, +): string | undefined { + if (!options.nativeBuild) { + return undefined; + } + const authored = readVersionMetadataBinding(config); + if (authored || options.preserveBindings) { + return authored; + } + const owner = findBindingNameOwner(config, 'version_metadata', DEFAULT_VERSION_METADATA_BINDING); + if (owner) { + throw new Error( + `Cloudflare version metadata binding ${DEFAULT_VERSION_METADATA_BINDING} conflicts with ${owner}.`, + ); + } + return DEFAULT_VERSION_METADATA_BINDING; +} diff --git a/packages/void/test/fixtures/platform-access.ts b/packages/void/test/fixtures/platform-access.ts index 2b6316924..935fb62b1 100644 --- a/packages/void/test/fixtures/platform-access.ts +++ b/packages/void/test/fixtures/platform-access.ts @@ -7,11 +7,13 @@ export function platformAccessFixture(): { calls: Array<{ method: string; path: string; body?: RecordValue }>; apps: Map; tokens: Map; + identityProviders: Array; policy: RecordValue; loseNextApplicationResponse: () => void; } { const apps = new Map(); const tokens = new Map(); + const identityProviders: Array = [{ id: 'company-idp', name: 'Company SSO' }]; const policy: RecordValue = { id: 'employees', name: 'Employees and managed devices', @@ -32,7 +34,7 @@ export function platformAccessFixture(): { if (target === 'organizations') { result = { auth_domain: 'company.cloudflareaccess.com' }; } else if (target === 'identity_providers') { - result = [{ id: 'company-idp', name: 'Company SSO' }]; + result = identityProviders; } else if (target === 'policies') { result = [policy]; } else if (target === 'policies/employees') { @@ -87,6 +89,7 @@ export function platformAccessFixture(): { calls, apps, tokens, + identityProviders, policy, loseNextApplicationResponse() { loseApplicationResponse = true; diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index 223e8e8e6..10d4433a4 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -22,7 +22,27 @@ const targets = [ ]; for (const { target, command, args } of targets) { - const available = spawn.sync(command, ['--version']).status === 0; + let available = spawn.sync(command, ['--version']).status === 0; + if (target === 'deno' && available) { + // Deno resolves the user's npm configuration before evaluating even a + // no-import script. Check that optional runtime without altering registries; + // failures in the built app still fail normally after this probe succeeds. + const probe = spawn.sync( + command, + ['eval', '--no-config', '--no-npm', '--no-remote', 'void 0;'], + { encoding: 'utf8', timeout: 10_000 }, + ); + if (probe.status !== 0) { + if (probe.stderr?.includes('Failed to resolve .npmrc options')) { + available = false; + console.warn( + 'Skipping optional Deno Pages tests: the installed runtime cannot resolve user .npmrc configuration in a no-import preflight. No registry settings were changed.', + ); + } else { + throw new Error(`Deno no-import preflight failed (exit ${probe.status ?? 'unknown'}).`); + } + } + } describe.skipIf(!available)(`${target} production Pages assets`, () => { let root: string; let playground: IsolatedPlayground; diff --git a/packages/void/test/integration/vinext-app.test.ts b/packages/void/test/integration/vinext-app.test.ts index 57744fdba..1ffca2dc8 100644 --- a/packages/void/test/integration/vinext-app.test.ts +++ b/packages/void/test/integration/vinext-app.test.ts @@ -2,10 +2,12 @@ import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { + createIsolatedPlayground, getPort, waitForServer, killChild, collectServerOutput, + type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); @@ -16,32 +18,21 @@ let DEV_BASE: string; describe('playground/vinext-app dev', () => { let child: ReturnType; let serverOutput = () => ''; + let playground: IsolatedPlayground; beforeAll(async () => { + playground = createIsolatedPlayground(ROOT, PLAYGROUND); DEV_PORT = await getPort(); DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - child = spawn( - 'vp', - [ - 'run', - '--filter', - `./${PLAYGROUND}`, - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(DEV_PORT)], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', }, - ); + }); serverOutput = collectServerOutput(child); @@ -55,6 +46,7 @@ describe('playground/vinext-app dev', () => { if (child) { await killChild(child); } + playground?.cleanup(); if (serverOutput().includes('error when starting dev server')) { throw new Error(serverOutput()); } diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 9224192cd..2e360c008 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -12,6 +12,7 @@ import { afterEach, expect, it, vi } from 'vite-plus/test'; import { activateCloudflareVersion, deployCloudflareVersionTriggers, + encodeCloudflareVersionMessage, } from '../../../deploy-cloudflare/src/version.ts'; import { operationFixture, @@ -303,6 +304,7 @@ async function verifyPinnedUpload(atomic: boolean): Promise { const prepared = await output( { secrets: { required: ['AUTH_SECRET'] }, + version_metadata: { binding: 'APP_VERSION' }, keep_secrets: true, keep_vars: true, assets: { directory: '../client', binding: 'ASSETS', not_found_handling: 'none' }, @@ -397,6 +399,7 @@ async function verifyPinnedUpload(atomic: boolean): Promise { { name: 'AUTH_SECRET', type: 'inherit', version_id: activeVersionId }, { name: 'KEY', type: 'inherit', version_id: activeVersionId }, { name: 'DASHBOARD_VAR', type: 'inherit', version_id: activeVersionId }, + { name: 'APP_VERSION', type: 'version_metadata' }, ]), ); expect(body.modules).toEqual([expect.objectContaining({ name: 'index.js' })]); @@ -450,6 +453,137 @@ it('uploads live secrets and multiple asset batches when the latest upload is in it('uploads live secrets and multiple asset batches for atomic publication', () => verifyPinnedUpload(true)); +it.each([false, true])( + 'publishes the intended custom domain before pinned-secret bootstrap readiness (atomic=%s)', + async (atomic) => { + const current = '11111111-1111-4111-8111-111111111111'; + const next = '22222222-2222-4222-8222-222222222222'; + const domain = 'app.example.com'; + const triggerConfig = { + workers_dev: false, + preview_urls: false, + routes: [{ pattern: domain, custom_domain: true }], + crons: [], + queueConsumers: [], + workflows: [], + }; + const prepared = await output({ + workers_dev: false, + preview_urls: false, + routes: triggerConfig.routes, + secrets: { required: ['AUTH_SECRET'] }, + }); + const events: Array = []; + let active = current; + let published = false; + const server = createServer(async (request, response) => { + request.resume(); + response.writeHead(200, { 'Content-Type': 'application/json' }); + const upload = request.method === 'POST'; + if (upload && request.url?.endsWith('?deploy=true')) { + active = next; + events.push('activate'); + } + response.end( + JSON.stringify({ + success: true, + errors: [], + result: upload + ? { id: next, urls: [] } + : { id: current, bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }] }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const pinActiveSecrets = createActiveSecretPinner({ + output: prepared, + handoffPending: false, + requiredNames: ['AUTH_SECRET'], + })!; + // A secret-only first Worker can already require version-pinned inheritance. + await pinActiveSecrets(current, [{ name: 'AUTH_SECRET', type: 'secret_text' }]); + const runCf = async (args: Array, options: { cwd: string }) => { + if (args.slice(0, 3).join(' ') === 'workers deployments list') { + return { + status: 0, + stderr: '', + stdout: JSON.stringify({ + deployments: [{ versions: [{ version_id: active, percentage: 100 }] }], + }), + }; + } + if (args.slice(0, 3).join(' ') === 'workers versions get') { + return { + status: 0, + stderr: '', + stdout: JSON.stringify({ + id: args[3], + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + }), + }; + } + expect(args).toEqual(['workers', 'triggers', 'deploy', '--prebuilt']); + const config = JSON.parse(readFileSync(getWorkerConfigPath(options.cwd), 'utf8')); + expect(active).toBe(next); + expect(config.domains).toEqual([domain]); + expect(config.workersDev).toBe(false); + expect(config.previewUrls).toBe(false); + expect(config.env).toEqual({ AUTH_SECRET: { type: 'secret' } }); + published = true; + events.push('publish-domain'); + return { status: 0, stdout: '', stderr: '' }; + }; + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + runCf, + verifyResources: createCfResourceVerifier({ + configPath: join(roots.at(-1)!, 'dist/ssr/wrangler.json'), + runCf, + runWrangler: () => { + throw new Error('An app without paid bindings must not probe paid services.'); + }, + verifyCompatibilityRunner: () => true, + }), + pinnedUpload: { + accountId: 'account-id', + apiBaseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`, + getToken: async () => 'synthetic-token', + verifyUploader: () => true, + }, + }); + const result = await activateCloudflareVersion({ + runWrangler: runner, + workerName: 'void-test', + configPath: join(roots.at(-1)!, 'dist/ssr/wrangler.json'), + verifyUploader: () => true, + pinActiveSecrets, + atomic, + allowFirstDeploymentBootstrap: true, + readinessAttempts: 1, + message: encodeCloudflareVersionMessage([], triggerConfig), + fetch: async (url) => { + expect(url).toBe(`https://${domain}/__void/ready`); + events.push('readiness'); + return Response.json({ ready: published }); + }, + }); + expect(result, JSON.stringify({ result, events })).toMatchObject({ + ok: true, + versionId: next, + previewUrl: `https://${domain}`, + }); + expect(events).toEqual(['activate', 'publish-domain', 'readiness']); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await prepared.dispose(); + } + }, +); + it('does not pin active secrets during an existing-Worker handoff', async () => { const prepared = await output(); try { diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index fb338a314..ab8c9f272 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -198,7 +198,7 @@ describe('rendered help advertises the flags the parser honours', () => { ]) { expect(page).toContain(verb); } - expect(page).toContain('--subdomain '); + expect(page).not.toContain('--subdomain'); expect(page).toContain('--project '); for (const verb of ['add', 'status', 'list', 'sync', 'rotate-secret', 'remove']) { @@ -207,6 +207,8 @@ describe('rendered help advertises the flags the parser honours', () => { expect(render(['email', 'domain', 'add'])).toContain( 'void email domain add [options]', ); + expect(render(['email', 'domain', 'add'])).not.toContain('--subdomain'); + expect(render(['email', 'domain', 'add'])).toContain('--project '); const removePage = render(['email', 'domain', 'remove']); expect(removePage).not.toContain('--force'); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 3728c5131..928af2a34 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -260,6 +260,20 @@ describe('Cloudflare activation diagnostics', () => { expect(message).toContain('create an Access application'); }); + it.each(['bootstrap-readiness-failed', 'atomic-readiness-failed'] as const)( + 'prints Custom Domain propagation retry guidance after %s', + (reason) => { + const message = formatCloudflareActivationError({ + ok: false, + reason, + diagnostic: 'Check DNS and TLS, then retry `void deploy --platform cloudflare`.', + }); + expect(message).toContain('Worker'); + expect(message).toContain('Check DNS and TLS'); + expect(message).toContain('retry `void deploy --platform cloudflare`'); + }, + ); + it('distinguishes opaque trigger recovery from a first deployment', () => { const opaque = formatCloudflareActivationError({ ok: false, @@ -267,8 +281,8 @@ describe('Cloudflare activation diagnostics', () => { rollback: 'unavailable', previousVersionId: '11111111-1111-4111-8111-111111111111', }); - expect(opaque).toContain('Traffic was restored to the previous version'); - expect(opaque).toContain('trigger configuration was unavailable'); + expect(opaque).toContain('Traffic or trigger restoration could not be completed'); + expect(opaque).not.toContain('Traffic was restored'); expect(opaque).not.toContain('first deployment'); expect( @@ -280,6 +294,21 @@ describe('Cloudflare activation diagnostics', () => { }), ).toContain('This was the first deployment'); }); + + it('reports uncertain status after publication without claiming nothing was uploaded', () => { + const published = formatCloudflareActivationError({ + ok: false, + reason: 'status-failed', + versionId: '22222222-2222-4222-8222-222222222222', + status: 1, + }); + expect(published).toContain('was published'); + expect(published).toContain('Inspect `void project status` before retrying'); + expect(published).not.toContain('no version was uploaded'); + expect( + formatCloudflareActivationError({ ok: false, reason: 'status-failed', status: 1 }), + ).toContain('no version was uploaded'); + }); }); /** No-schema (nothing-to-gate) env-insensitive facts. */ @@ -2329,6 +2358,7 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => { expect(config).toMatchObject({ name: 'spaapp', account_id: 'acc123', + version_metadata: { binding: '__VOID_VERSION_METADATA' }, main: 'static-edge.mjs', assets: { directory: '../../../dist', @@ -2394,6 +2424,8 @@ describe('runCloudflareDeploy — static, SPA, and SSG apps', () => { const { deps, exitCalls } = harness({ activateVersion: async (options) => { readinessHeaders = options.readinessHeaders; + expect(options.requireVersionMetadata).toBe(true); + expect(options.requirePreviewForReadiness).toBe(false); return { ok: true, versionId: '22222222-2222-4222-8222-222222222222', @@ -3462,7 +3494,7 @@ describe('runCloudflareDeploy — secrets gate', () => { explicitBootstrapProof = options.allowFirstDeploymentBootstrap; return activateCloudflareVersionPrimitive({ ...options, - fetch: async () => Response.json({ ready: true }), + fetch: async () => Response.json({ ready: true, versionId: next }), }); }, }); diff --git a/packages/void/test/unit/cloudflare-login-output.test.ts b/packages/void/test/unit/cloudflare-login-output.test.ts index 0ff1acc21..46f51cede 100644 --- a/packages/void/test/unit/cloudflare-login-output.test.ts +++ b/packages/void/test/unit/cloudflare-login-output.test.ts @@ -2,6 +2,7 @@ import { stripVTControlCharacters } from 'node:util'; import { expect, it, vi } from 'vite-plus/test'; import { cloudflareDeviceCode, + cloudflareVerificationUrl, printCloudflareLoginPrompt, } from '../../src/cli/cloudflare-login-output.ts'; import { encodeCloudflareOperation } from '../../src/cli/cloudflare-process.ts'; @@ -19,6 +20,19 @@ it('extracts the device code from cf output, including streamed and colored outp ).toBe('a1B2c3D4'); }); +it('extracts only the complete verification URL supplied by cf', () => { + const url = 'https://dash.cloudflare.com/oauth2/device/verify?user_code=a1B2c3D4'; + expect(cloudflareVerificationUrl(`Opening a link in your default browser: ${url}\n`)).toBe(url); + expect(cloudflareVerificationUrl(`To authorize cf, please visit:\n\n ${url}\n`)).toBe(url); + expect( + cloudflareVerificationUrl(`Visit this link to authenticate: \u001b[34m${url}\u001b[0m\n`), + ).toBe(url); + expect( + cloudflareVerificationUrl('Opening a link in your default browser: https://dash.cloud'), + ).toBeNull(); + expect(cloudflareVerificationUrl('and enter the code:\n a1B2c3D4\n')).toBeNull(); +}); + it('prints only the short Void prompt with the actual code', () => { const printed = vi.spyOn(console, 'log').mockImplementation(() => {}); try { @@ -47,7 +61,8 @@ child.spawn = () => { process.stderr = new EventEmitter(); setImmediate(() => { process.stderr.emit('data', Buffer.from('Attempting to login via OAuth Device Authorization Grant...\\nand enter the code:\\n a1B2')); - process.stderr.emit('data', Buffer.from('c3D4\\nOpening a link in your default browser: https://dash.cloudflare.com/oauth2/device/verify?user_code=a1B2c3D4\\nSuccessfully logged in.\\nConfig file: sample\\n')); + process.stderr.emit('data', Buffer.from('c3D4\\nOpening a link in your default browser: https://dash.cloudflare.com/oauth2/device/verify?user_code=a1B2')); + process.stderr.emit('data', Buffer.from('c3D4\\nSuccessfully logged in.\\nConfig file: sample\\n')); process.emit('close', 0, null); }); return process; @@ -93,7 +108,7 @@ syncBuiltinESMExports(); ); expect(output.status, output.stderr).toBe(0); expect(stripVTControlCharacters(output.stdout.trim())).toBe( - 'Opening Cloudflare login in your browser. Confirm the code a1B2c3D4 in your browser. Press Ctrl+C to cancel.', + 'Opening Cloudflare login in your browser. Confirm the code a1B2c3D4 in your browser. Press Ctrl+C to cancel.\nOpen this link if your browser did not open: https://dash.cloudflare.com/oauth2/device/verify?user_code=a1B2c3D4', ); expect(output.stdout + output.stderr).not.toContain('Attempting to login'); expect(output.stdout + output.stderr).not.toContain('Config file:'); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 70746e63d..47fafd480 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -27,6 +27,7 @@ import { createManifestLoader, createSsrFallback, createAssetNotFound, + createWorkerFetch, type RoutingRuntime, } from '../../src/runtime/routing.ts'; @@ -1626,6 +1627,45 @@ describe('default-target dev parity', () => { }); describe('compileWorkerEntry', () => { + it('serves the actual metadata version without echoing the requested version override', async () => { + const code = compileWorkerEntry({ versionMetadataBinding: 'AUTHORED_VERSION' }); + const source = code.match( + /const __voidFetch = (__createWorkerFetch\([\s\S]*?\n {2}\}\));/, + )?.[1]; + expect(source).toBeDefined(); + const handler = new Function( + '__createWorkerFetch', + 'app', + 'serveWithAssets', + '__withRawEnv', + 'withRuntimeEnv', + '__filterInternalBindings', + '__voidHandleInternalRoute', + '__voidCollectPrerenderPaths', + `return (${source});`, + )( + createWorkerFetch, + { fetch: () => new Response('application') }, + (_request: Request, _env: object, response: Response) => response, + (_env: object, callback: () => Promise) => callback(), + (_env: object, callback: () => Promise) => callback(), + (env: object) => env, + async () => null, + async () => [], + ) as (request: Request, env: Record, ctx: object) => Promise; + const response = await handler( + new Request('https://app.example/__void/ready', { + headers: { 'Cloudflare-Workers-Version-Overrides': 'app="new-version"' }, + }), + { AUTHORED_VERSION: { id: 'old-version', tag: 'private-tag', timestamp: 'private-time' } }, + {}, + ); + expect(await response.json()).toEqual({ ready: true, versionId: 'old-version' }); + expect(response.headers.get('cache-control')).toBe('no-store'); + const missing = await handler(new Request('https://app.example/__void/ready'), {}, {}); + expect(await missing.json()).toEqual({ ready: true }); + }); + it('re-exports typed Durable Object modules as worker classes', () => { const code = compileWorkerEntry({ durableObjects: [ diff --git a/packages/void/test/unit/email-cli-domain.test.ts b/packages/void/test/unit/email-cli-domain.test.ts index 933d39b00..7c965e271 100644 --- a/packages/void/test/unit/email-cli-domain.test.ts +++ b/packages/void/test/unit/email-cli-domain.test.ts @@ -300,25 +300,28 @@ describe('emailDomainAdd', () => { expect(api.addEmailDomain).not.toHaveBeenCalled(); }); - it('chooses a mail subdomain when the apex already receives mail', async () => { + it('refuses foreign apex mail without proposing an unsupported subdomain or opening credentials', async () => { const api = client(); - const resolveMx = vi.fn(async (name: string) => - name === 'acme.com' ? [{ exchange: 'mx.example.net', priority: 10 }] : [], - ); - await emailDomainAdd(api, 'prj_1', 'acme.com', undefined, addDeps({ dns: { resolveMx } })); - expect(api.addEmailDomain).toHaveBeenCalledWith( - 'prj_1', - expect.objectContaining({ domain: 'mail.acme.com' }), + const deps = addDeps({ + dns: { resolveMx: async () => [{ exchange: 'mx.example.net', priority: 10 }] }, + }); + await expect(emailDomainAdd(api, 'prj_1', 'acme.com', undefined, deps)).rejects.toThrow( + 'exit 1', ); + expect(printed(errorSpy)).toContain('unused Cloudflare zone apex'); + expect(deps.openUrl).not.toHaveBeenCalled(); + expect(api.addEmailDomain).not.toHaveBeenCalled(); }); - it('honors an explicitly selected mail subdomain', async () => { + it('refuses --subdomain before any credential prompt', async () => { const api = client(); - await emailDomainAdd(api, 'prj_1', 'acme.com', 'notifications', addDeps()); - expect(api.addEmailDomain).toHaveBeenCalledWith( - 'prj_1', - expect.objectContaining({ domain: 'notifications.acme.com' }), + const deps = addDeps(); + await expect(emailDomainAdd(api, 'prj_1', 'acme.com', 'notifications', deps)).rejects.toThrow( + 'exit 1', ); + expect(printed(errorSpy)).toContain('Omit --subdomain'); + expect(deps.openUrl).not.toHaveBeenCalled(); + expect(api.addEmailDomain).not.toHaveBeenCalled(); }); it('rejects invalid domains before opening a browser', async () => { diff --git a/packages/void/test/unit/integration-helpers.test.ts b/packages/void/test/unit/integration-helpers.test.ts index 57977bb16..77916643a 100644 --- a/packages/void/test/unit/integration-helpers.test.ts +++ b/packages/void/test/unit/integration-helpers.test.ts @@ -10,6 +10,9 @@ describe('shouldCopyPlaygroundPath', () => { false, ); expect(shouldCopyPlaygroundPath(sourceRoot, String.raw`${sourceRoot}\.void`)).toBe(false); + expect( + shouldCopyPlaygroundPath(sourceRoot, String.raw`${sourceRoot}\.next\types\routes.d.ts`), + ).toBe(false); expect(shouldCopyPlaygroundPath(sourceRoot, String.raw`${sourceRoot}\src\index.ts`)).toBe(true); }); @@ -17,7 +20,11 @@ describe('shouldCopyPlaygroundPath', () => { const sourceRoot = '/workspace/playground/spa'; expect(shouldCopyPlaygroundPath(sourceRoot, `${sourceRoot}/dist`)).toBe(false); + expect(shouldCopyPlaygroundPath(sourceRoot, `${sourceRoot}/.next/types/routes.d.ts`)).toBe( + false, + ); expect(shouldCopyPlaygroundPath(sourceRoot, `${sourceRoot}/src/dist/example.ts`)).toBe(true); + expect(shouldCopyPlaygroundPath(sourceRoot, `${sourceRoot}/src/.next/example.ts`)).toBe(true); }); }); diff --git a/packages/void/test/unit/platform-access-preflight.test.ts b/packages/void/test/unit/platform-access-preflight.test.ts index b47d2931d..d4251702f 100644 --- a/packages/void/test/unit/platform-access-preflight.test.ts +++ b/packages/void/test/unit/platform-access-preflight.test.ts @@ -79,6 +79,16 @@ async function fixture() { } describe('installed Access protection preflight', () => { + it('refuses a newly configured dashboard outside the active protected-origin coverage', async () => { + const { env, input } = await fixture(); + await expect( + verifyInstalledPlatformAccessProtection({ + ...input, + dashboardUrl: 'https://dash.example.org', + }), + ).rejects.toThrow('complete origin coverage'); + expect(env.calls.every((call) => call.method === 'GET')).toBe(true); + }); it('checks current policies, audiences, coverage, and machine admission with reads only', async () => { const { env, input } = await fixture(); await verifyInstalledPlatformAccessProtection(input); diff --git a/packages/void/test/unit/platform-access-provision.test.ts b/packages/void/test/unit/platform-access-provision.test.ts index 58e09463c..420de47b4 100644 --- a/packages/void/test/unit/platform-access-provision.test.ts +++ b/packages/void/test/unit/platform-access-provision.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import { + inspectPlatformAccess, provisionPlatformAccess, type PlatformAccessCheckpoint, type PlatformAccessProvisionInput, @@ -16,6 +17,35 @@ const base: PlatformAccessProvisionInput = { }; describe('Cloudflare Access provisioning', () => { + it.each(['', ' ', undefined, null])( + 'uses the provider ID for a missing built-in provider label (%j) without changing identity selection', + async (name) => { + const env = fixture(); + env.identityProviders[0] = { id: 'company-idp', type: 'cloudflare', name }; + const result = await inspectPlatformAccess(env.client, base); + expect(result.identityProviders).toEqual([{ id: 'company-idp', name: 'company-idp' }]); + expect(result.policies).toEqual([env.policy]); + expect(env.calls.every((call) => call.method === 'GET')).toBe(true); + }, + ); + it.each([ + { id: '', name: '' }, + { name: '' }, + { id: 'company-idp', name: 123 }, + { id: 'company-idp', name: 'x'.repeat(8193) }, + ])('rejects malformed provider identity or label before writes (%j)', async (provider) => { + const env = fixture(); + env.identityProviders[0] = provider; + await expect( + provisionPlatformAccess( + env.client, + { ...base, login: { id: 'access', label: 'Company Access' } }, + undefined, + () => {}, + ), + ).rejects.toThrow('Cloudflare Access did not return identity provider'); + expect(env.calls.every((call) => call.method === 'GET')).toBe(true); + }); it('connects externally managed protection and records its service token without writes', async () => { const env = fixture(); env.tokens.set('ci-token', { id: 'ci-token', name: 'Managed CI', client_id: 'external-ci' }); diff --git a/packages/void/test/unit/platform-access-setup.test.ts b/packages/void/test/unit/platform-access-setup.test.ts index ea97286f9..dae0d4934 100644 --- a/packages/void/test/unit/platform-access-setup.test.ts +++ b/packages/void/test/unit/platform-access-setup.test.ts @@ -25,6 +25,36 @@ afterEach(() => { }); describe('Access installer setup', () => { + it('creates and resumes login with the unnamed built-in Cloudflare provider', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const env = platformAccessFixture(); + env.identityProviders[0] = { id: 'company-idp', type: 'cloudflare', name: '' }; + const checkpoint: Record = {}; + const input = { + plan, + ownershipNonce: 'owned-installation', + login: { id: 'access', label: 'Cloudflare Access' }, + protection: false, + yes: true, + spec: { + mode: 'create' as const, + identityProviderIds: ['company-idp'], + policyIds: ['employees'], + }, + client: env.transport as PlatformCloudflareClient, + checkpoint, + save: (values: Record) => Object.assign(checkpoint, values), + }; + const result = await preparePlatformAccessSetup(input); + expect(result?.login?.configuration.kind).toBe('cloudflare-access'); + expect([...env.apps.values()][0]).toMatchObject({ + allowed_idps: ['company-idp'], + policies: [{ id: 'employees', precedence: 1 }], + }); + expect(await preparePlatformAccessSetup({ ...input, spec: undefined })).toEqual(result); + expect(env.calls.filter((call) => call.method === 'POST')).toHaveLength(1); + expect(env.tokens.size).toBe(0); + }); it('publishes recovered ambiguous resource IDs without replacing deployment configuration', () => { const deployment = createPlatformDeploymentManifest({ application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, diff --git a/packages/void/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index a349f0cbf..c9916559b 100644 --- a/packages/void/test/unit/platform-args.test.ts +++ b/packages/void/test/unit/platform-args.test.ts @@ -34,6 +34,24 @@ describe('platform args', () => { expect(() => parsePlatformArgs(['install', ...flags])).toThrow(); } }); + + it('configures dashboard origins only in install, upgrade, and repair', () => { + for (const command of ['install', 'upgrade', 'repair']) { + expect( + parsePlatformArgs([command, '--dashboard-url', 'https://DASH.example.com:8443/']), + ).toMatchObject({ dashboardUrl: 'https://dash.example.com:8443' }); + expect(parsePlatformArgs([command])).not.toHaveProperty('dashboardUrl'); + expect(() => + parsePlatformArgs([command, '--dashboard-url', 'https://dash.example.com/account']), + ).toThrow('HTTPS origin'); + expect(() => parsePlatformArgs([command, '--dashboard-url'])).toThrow('requires a value'); + } + for (const command of ['enable', 'disable', 'rollback', 'uninstall']) { + expect(() => + parsePlatformArgs([command, '--dashboard-url', 'https://dash.example.com']), + ).toThrow(); + } + }); it('parses resumable domain setup without confusing app-domain commands', () => { expect( parsePlatformArgs(['domain', 'set', 'example.com', '--installation', 'team', '--plan']), diff --git a/packages/void/test/unit/platform-install-cutover.test.ts b/packages/void/test/unit/platform-install-cutover.test.ts index f8a9aa243..9a74a2722 100644 --- a/packages/void/test/unit/platform-install-cutover.test.ts +++ b/packages/void/test/unit/platform-install-cutover.test.ts @@ -5,12 +5,14 @@ import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { CloudflareOperation } from '@void/deploy-cloudflare'; -import type { PlatformInstallationManifest } from '@void/platform'; +import { PLATFORM_DISABLED_KEY, type PlatformInstallationManifest } from '@void/platform'; import { afterEach, beforeEach, expect, it, vi } from 'vite-plus/test'; import { hyperdriveOriginFromConnectionString } from '../../src/cli/platform-cloudflare.ts'; import { createPlatformInstallationSecrets, installPlatform, + verifyPlatformWorkerCutoverVersions, + type PlatformInstallerDependencies, } from '../../src/cli/platform-installer.ts'; import { createPlatformInstallPlan } from '../../src/cli/platform-plan.ts'; import { listInstallationManifests } from '../../src/cli/platform-registry.ts'; @@ -293,17 +295,25 @@ async function createFakeCloudflare() { }, kv, planningClient, + replaceActiveVersion: (name: string) => { + const worker = workers.get(name)!; + const active = worker.versions.get(worker.deployments[0]!.versions[0]!.version_id)!; + const replacement = { ...structuredClone(active), id: randomUUID() }; + worker.versions.set(replacement.id, replacement); + activate(name, replacement.id); + }, }; } const DATABASE_URL = 'postgres://user:password@db.example.com/testing'; -function installationSecrets() { +function installationSecrets(sharedEmail = false) { return createPlatformInstallationSecrets({ bootstrapGitHubId: '583231', bootstrapGitHubLogin: 'octocat', cloudflareApiToken: 'test-token', databaseUrl: DATABASE_URL, + emailEnabled: sharedEmail, githubClientId: 'id', githubClientSecret: 'secret', jwtSecret: TEST_PLATFORM_JWT_SECRET, @@ -316,6 +326,7 @@ function installationSecrets() { function planInstallation( cloud: Awaited>, existing: PlatformInstallationManifest | null, + sharedEmail = false, ) { return createPlatformInstallPlan( cloud.planningClient, @@ -325,6 +336,9 @@ function planInstallation( controlPlaneDomain: '', dedicatedZone: false, displayName: 'Testing', + ...(sharedEmail + ? { emailSenderDomain: 'mail.example.com', emailSharedZoneId: 'email-zone' } + : {}), installationSlug: 'testing', workersDev: true, zoneName: '', @@ -336,9 +350,13 @@ function planInstallation( async function runInstall( cloud: Awaited>, existing: PlatformInstallationManifest | null, + options: { + sharedEmail?: boolean; + bootstrapEmail?: PlatformInstallerDependencies['bootstrapEmail']; + } = {}, ) { - const plan = await planInstallation(cloud, existing); - return installPlatform(plan, installationSecrets(), existing, { + const plan = await planInstallation(cloud, existing, options.sharedEmail); + return installPlatform(plan, installationSecrets(options.sharedEmail), existing, { acquireDatabaseLifecycleLock: async () => Object.assign(async () => {}, { prepareSandboxDrain: async () => ({ @@ -360,6 +378,7 @@ async function runInstall( migrateDatabase: async () => {}, revalidateBeforeWorkerDeploy: async () => {}, verifyHealth: async () => {}, + ...(options.bootstrapEmail ? { bootstrapEmail: options.bootstrapEmail } : {}), }); } @@ -441,3 +460,83 @@ it('retries a restored enabling generation with every Worker restaged', async () expect(stagedKeys(retried.manifest, 4)).toEqual([...WORKER_KEYS].sort()); expect(workerStepKeys(retried.manifest)).toContain('worker-cutover:4:committed'); }); + +it('rechecks the committed immutable versions after shared email routing is prepared', async () => { + const cloud = await createFakeCloudflare(); + const bootstrapEmail = vi.fn(async () => { + expect(workerStepKeys(currentManifest())).toContain('worker-cutover:2:committed'); + return 'email-operation'; + }); + + const result = await runInstall(cloud, null, { sharedEmail: true, bootstrapEmail }); + + expect(result.manifest.status).toBe('installed'); + expect(bootstrapEmail).toHaveBeenCalledOnce(); + expect(stagedKeys(result.manifest, 2)).toEqual([...WORKER_KEYS].sort()); + const routing = result.manifest.resources.find((resource) => resource.key === 'routing-kv')!; + expect(cloud.kv.has(`${routing.id}/${PLATFORM_DISABLED_KEY}`)).toBe(false); +}); + +it('rejects a concurrent version replacement after shared email commits the Worker generation', async () => { + const cloud = await createFakeCloudflare(); + const plan = await planInstallation(cloud, null, true); + const bootstrapEmail = async () => { + cloud.replaceActiveVersion(plan.deployment.resources.apiWorker); + return 'email-operation'; + }; + + await expect(runInstall(cloud, null, { sharedEmail: true, bootstrapEmail })).rejects.toThrow( + 'changed concurrently from staged version', + ); + + const failed = currentManifest(); + expect(failed.status).toBe('failed'); + expect(workerStepKeys(failed)).toContain('worker-cutover:2:committed'); + expect(workerStepKeys(failed)).not.toContain('worker-cutover:2:restored'); + const routing = failed.resources.find((resource) => resource.key === 'routing-kv')!; + expect(cloud.kv.has(`${routing.id}/${PLATFORM_DISABLED_KEY}`)).toBe(true); +}); + +it('resumes a shared email failure with a fresh verified immutable generation', async () => { + const cloud = await createFakeCloudflare(); + const bootstrapEmail = vi + .fn() + .mockRejectedValueOnce(new Error('mail unavailable')) + .mockResolvedValue('email-operation'); + await expect(runInstall(cloud, null, { sharedEmail: true, bootstrapEmail })).rejects.toThrow( + 'mail unavailable', + ); + const failed = currentManifest(); + expect(workerStepKeys(failed)).toContain('worker-cutover:2:committed'); + + const result = await runInstall(cloud, failed, { sharedEmail: true, bootstrapEmail }); + + expect(result.manifest.status).toBe('installed'); + expect(bootstrapEmail).toHaveBeenCalledTimes(2); + expect(stagedKeys(result.manifest, 4)).toEqual([...WORKER_KEYS].sort()); + expect(workerStepKeys(result.manifest)).toContain('worker-cutover:4:committed'); +}); + +it('does not use a restored generation as authority to enable traffic', async () => { + const cloud = await createFakeCloudflare(); + const result = await runInstall(cloud, null); + const restored = { + ...result.manifest, + steps: result.manifest.steps.map((step) => + step.key === 'worker-cutover:2:committed' + ? { ...step, key: 'worker-cutover:2:restored', resultHash: 'restored' } + : step, + ), + }; + const runner = vi.fn(); + await expect( + verifyPlatformWorkerCutoverVersions( + restored, + 2, + {} as Parameters[2], + 'account', + runner, + ), + ).rejects.toThrow('missing a complete immutable-version checkpoint'); + expect(runner).not.toHaveBeenCalled(); +}); diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 8fd202a4d..f64119c9c 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -457,9 +457,13 @@ describe('platform installer', () => { }, ); - it.each([false, true])( - 'installs all four Workers without a zone and probes the actual dispatch hostname (credential draft: %s)', - async (fromDraft) => { + it.each([ + { fromDraft: false, dashboardUrl: undefined }, + { fromDraft: true, dashboardUrl: undefined }, + { fromDraft: true, dashboardUrl: 'https://dash.example.org:8443' }, + ])( + 'installs core Workers without a zone and probes the dispatch hostname (draft: $fromDraft, dashboard: $dashboardUrl)', + async ({ fromDraft, dashboardUrl }) => { const directory = mkdtempSync(join(tmpdir(), 'void-platform-workers-dev-test-')); directories.push(directory); process.env.VOID_PLATFORM_REGISTRY_DIR = directory; @@ -480,6 +484,7 @@ describe('platform installer', () => { zoneName: '', workersDev: true, controlPlaneDomain: '', + ...(dashboardUrl ? { dashboardUrl } : {}), dedicatedZone: false, displayName: 'Testing', installationSlug: 'testing', @@ -564,6 +569,15 @@ describe('platform installer', () => { }, }); expect(result.manifest.status).toBe('installed'); + expect(result.manifest.endpoints.dashboardUrl).toBe(dashboardUrl ?? null); + const apiConfig = configs.find( + (config) => config.name === plan.deployment.resources.apiWorker, + )!; + if (dashboardUrl) { + expect(apiConfig.vars).toHaveProperty('DASHBOARD_URL', dashboardUrl); + } else { + expect(apiConfig.vars).not.toHaveProperty('DASHBOARD_URL'); + } expect( result.manifest.resources.some( (resource) => resource.kind === 'zone' || resource.kind === 'dns-record', @@ -2229,6 +2243,184 @@ describe('platform installer', () => { expect(requests.every((entry) => entry.redirect === 'manual')).toBe(true); }); + describe('shared email bootstrap progress', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('allows a durable bootstrap response to take longer than thirty seconds', async () => { + vi.useFakeTimers(); + const timeouts = vi.spyOn(AbortSignal, 'timeout').mockImplementation((delay) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('timeout', 'TimeoutError')), delay); + return controller.signal; + }); + const fetcher = vi.fn( + async (_input: string | URL | Request, init?: RequestInit) => + new Promise((resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal!.reason)); + setTimeout( + () => resolve(Response.json({ version: 1, state: 'ready', operationId: 'eop-slow' })), + 35_000, + ); + }), + ); + const result = bootstrapPlatformSharedEmail( + 'https://api.example.com', + 'probe-token', + fetcher as typeof fetch, + ).then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + + await vi.advanceTimersByTimeAsync(35_000); + + expect(await result).toEqual({ value: 'eop-slow' }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(timeouts).toHaveBeenCalledWith(120_000); + }); + + it('replays the same protected request after a timeout and polls its durable operation', async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockRejectedValueOnce(new DOMException('untrusted-provider-secret', 'TimeoutError')) + .mockResolvedValueOnce( + Response.json( + { + version: 1, + state: 'incomplete', + operationId: 'eop-resume', + operationStatus: 'running', + currentStep: 'subaddressing_ensure', + failureCode: null, + }, + { status: 503 }, + ), + ) + .mockResolvedValueOnce( + Response.json({ version: 1, state: 'ready', operationId: 'eop-resume' }), + ); + const result = bootstrapPlatformSharedEmail( + 'https://api.example.com', + 'probe-token', + fetcher, + ); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(await result).toBe('eop-resume'); + expect(fetcher).toHaveBeenCalledTimes(3); + for (const [url, init] of fetcher.mock.calls) { + expect(url).toBe('https://api.example.com/internal/platform/email-bootstrap'); + expect(init).toMatchObject({ method: 'POST', redirect: 'manual' }); + expect(new Headers(init.headers).get('X-Void-Platform-Probe')).toBe('probe-token'); + } + }); + + it('bounds repeated timeouts and reports the named bootstrap without provider details', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const timeouts = vi.spyOn(AbortSignal, 'timeout'); + const fetcher = vi.fn(async () => { + vi.setSystemTime(Date.now() + timeouts.mock.calls.at(-1)![0]); + throw new DOMException('untrusted-provider-secret', 'TimeoutError'); + }); + const result = bootstrapPlatformSharedEmail( + 'https://api.example.com', + 'probe-token', + fetcher, + ).catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(2_000); + + const error = String(await result); + expect(error).toContain('Shared email bootstrap did not finish within five minutes'); + expect(error).toContain('Traffic remains disabled'); + expect(error).toContain('repair or resume command'); + expect(error).not.toContain('untrusted-provider-secret'); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(timeouts.mock.calls.map(([delay]) => delay)).toEqual([120_000, 120_000, 58_000]); + }); + + it.each(['blocked', 'unknown', 'failed'])( + 'does not replay a terminal %s operation', + async (operationStatus) => { + const fetcher = vi.fn(async () => + Response.json( + { + version: 1, + state: 'incomplete', + operationId: 'eop-terminal', + operationStatus, + currentStep: 'subaddressing_ensure', + failureCode: 'outcome_unknown', + }, + { status: 503 }, + ), + ); + + await expect( + bootstrapPlatformSharedEmail('https://api.example.com', 'probe-token', fetcher), + ).rejects.toThrow(`Operation eop-terminal: ${operationStatus}`); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); + + it('rejects a changed durable operation identity while polling', async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json( + { + version: 1, + state: 'incomplete', + operationId: 'eop-first', + operationStatus: 'pending', + }, + { status: 503 }, + ), + ) + .mockResolvedValueOnce( + Response.json({ version: 1, state: 'ready', operationId: 'eop-other' }), + ); + const result = bootstrapPlatformSharedEmail( + 'https://api.example.com', + 'probe-token', + fetcher, + ).catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(String(await result)).toContain('operation identity changed'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it.each([ + { operationId: 'provider-secret\nAuthorization: Bearer private-value' }, + { operationId: 'eop-contradictory', operationStatus: 'blocked' }, + { operationId: 'eop-contradictory', operationStatus: 'unknown' }, + { operationId: 'eop-contradictory', operationStatus: 'pending' }, + { operationId: 'eop-contradictory', failureCode: 'outcome_unknown' }, + ])('rejects malformed or contradictory ready checkpoints: %j', async (fields) => { + const fetcher = vi.fn(async () => Response.json({ version: 1, state: 'ready', ...fields })); + + const error = await bootstrapPlatformSharedEmail( + 'https://api.example.com', + 'probe-token', + fetcher, + ).catch((error: unknown) => error); + + expect(String(error)).toContain('did not reach inbound readiness'); + expect(String(error)).not.toContain('provider-secret'); + expect(String(error)).not.toContain('private-value'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + }); + it('requires the shared email bootstrap probe to report durable inbound readiness', async () => { const requests: Array<{ headers: Headers; method?: string; redirect?: RequestRedirect }> = []; const readyFetcher = (async (_input: string | URL | Request, init?: RequestInit) => { @@ -2262,6 +2454,46 @@ describe('platform installer', () => { ).rejects.toThrow('Operation eop-blocked: blocked. Traffic remains disabled'); }); + it('reports safe shared-email routing prerequisites without exposing provider details', async () => { + const fetcher = (failureDetail: string) => + (async () => + Response.json( + { + version: 1, + state: 'incomplete', + operationId: 'eop-blocked', + operationStatus: 'blocked', + currentStep: 'inbound_probe', + failureCode: 'blocked', + domain: 'mail.example.com', + inboundStatus: 'needs_dns', + failureDetail, + providerError: 'secret-provider-token', + }, + { status: 503 }, + )) as typeof fetch; + const mxDetail = + 'MX records for mail.example.com do not route mail to Cloudflare Email Routing'; + await expect( + bootstrapPlatformSharedEmail('https://api.example.com', 'probe-token', fetcher(mxDetail)), + ).rejects.toThrow( + 'Step: inbound_probe. Failure: blocked. Domain: mail.example.com (inbound: needs_dns). MX records for mail.example.com do not route mail to Cloudflare Email Routing. Enable Email Routing for mail.example.com in the Cloudflare dashboard, then resume this installation.', + ); + try { + await bootstrapPlatformSharedEmail( + 'https://api.example.com', + 'probe-token', + fetcher('provider rejected Bearer secret-provider-token'), + ); + throw new Error('Expected readiness failure'); + } catch (error) { + expect(String(error)).toContain('Step: inbound_probe'); + expect(String(error)).not.toContain('secret-provider-token'); + expect(String(error)).not.toContain('provider rejected'); + expect(String(error)).not.toContain('Enable Email Routing'); + } + }); + it.each([ 'https://api.example.com/health', 'https://api.example.com/.well-known/void-platform', @@ -2616,6 +2848,76 @@ describe('platform installer', () => { expect(connection.queries).not.toContain('ROLLBACK'); }); + it('migrates the exact canonical 0.22.0 predecessor to the recipient deletion fence and refuses unreviewed versions', async () => { + const database = readPlatformRuntimeManifest().database; + const baseline = database.migrations.filter((migration) => migration.index <= 55); + expect(baseline.at(-1)?.tag).toBe('0055_durable_deployment_execution'); + expect(database.migrations.at(-1)?.tag).toBe('0056_email_shared_recipients'); + const rows = baseline.map((migration, index) => ({ + id: index + 1, + hash: migration.sha256, + created_at: migration.timestampMillis, + })); + const rejected = migrationConnection(rows); + await expect( + preparePlatformDatabaseConnection( + rejected, + getPlatformRuntimeDirectory(), + database, + '0.21.9', + true, + ), + ).rejects.toThrow('cannot safely roll back'); + expect(rejected.queries).not.toContain('BEGIN'); + const accepted = migrationConnection(rows); + const result = await preparePlatformDatabaseConnection( + accepted, + getPlatformRuntimeDirectory(), + database, + '0.22.0', + true, + ); + expect(result.appliedMigrations).toBe(1); + expect( + accepted.queries.some((query) => + query.includes('CREATE TRIGGER "void_shared_email_recipient_teardown_guard"'), + ), + ).toBe(true); + expect(accepted.rows).toHaveLength(database.migrations.length); + }); + + it('rejects the isolated legacy email0054 fork without rewriting its history or applying canonical migrations', async () => { + const database = readPlatformRuntimeManifest().database; + const commonPrefix = database.migrations.filter((migration) => migration.index <= 53); + const email = database.migrations.find( + (migration) => migration.tag === '0056_email_shared_recipients', + )!; + expect(email).toBeDefined(); + // The unreleased E2E runtime applied these same SQL bytes under its original + // 0054 timestamp. Equivalent SQL is not an exact canonical migration lineage. + const rows = [ + ...commonPrefix.map((migration, index) => ({ + id: index + 1, + hash: migration.sha256, + created_at: migration.timestampMillis, + })), + { id: commonPrefix.length + 1, hash: email.sha256, created_at: 1790860822162 }, + ]; + const connection = migrationConnection(rows); + await expect( + preparePlatformDatabaseConnection( + connection, + getPlatformRuntimeDirectory(), + database, + '0.21.9', + true, + ), + ).rejects.toThrow('not an exact prefix'); + expect(connection.queries).not.toContain('BEGIN'); + expect(connection.rows).toEqual(rows); + expect(connection.queries.some((query) => query.startsWith('INSERT INTO'))).toBe(false); + }); + it('requires an exact rollback compatibility edge before an existing install migrates', async () => { const base = migrationSubset(1); const sourceDigest = platformDatabaseSchemaDigest(base.lineage, []); diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index da3882dbb..b2cbdaa21 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -286,6 +286,26 @@ describe('installed platform upgrade', () => { ); }); + it.each(['upgrade', 'repair'] as const)( + 'passes an explicitly configured dashboard origin through %s planning', + async (subcommand) => { + await runInstalledPlatformCommand({ + subcommand, + selector: state.manifest!.id, + plan: true, + runtime: '', + yes: true, + dashboardUrl: 'https://dash.example.org:8443', + }); + expect(createPlatformInstallPlan).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ dashboardUrl: 'https://dash.example.org:8443' }), + expect.anything(), + expect.anything(), + ); + }, + ); + it('borrows database preparation from the held lifecycle lock', async () => { await runInstalledPlatformCommand({ plan: false, diff --git a/packages/void/test/unit/platform-plan.test.ts b/packages/void/test/unit/platform-plan.test.ts index d25f70a26..af68e3277 100644 --- a/packages/void/test/unit/platform-plan.test.ts +++ b/packages/void/test/unit/platform-plan.test.ts @@ -6,6 +6,7 @@ import { createPlatformResourceNames, recordInstallationResource, recordInstallationStep, + setInstallationEndpoints, } from '@void/platform'; import { describe, expect, it, vi } from 'vite-plus/test'; import { pc } from '../../src/cli/output.ts'; @@ -13,6 +14,7 @@ import { PlatformCloudflareApiError, PlatformCloudflareClient, } from '../../src/cli/platform-cloudflare.ts'; +import { platformDomainTarget } from '../../src/cli/platform-domain.ts'; import { installPlatform } from '../../src/cli/platform-installer.ts'; import { verifyPlatformAiGatewayPlan } from '../../src/cli/platform-lifecycle.ts'; import { @@ -181,6 +183,60 @@ describe('platform installation planning', () => { ); }); + it('keeps dashboard configuration optional and preserves it across resumed lifecycle plans', async () => { + const without = await createPlatformInstallPlan(client(), input, null); + const withDashboard = await createPlatformInstallPlan( + client(), + { ...input, dashboardUrl: 'https://dash.example.org:8443/' }, + null, + ); + expect(without.deployment.controlPlane.dashboardHostname).toBeNull(); + expect(without.deployment.controlPlane).not.toHaveProperty('dashboardUrl'); + expect(withDashboard.actions).toEqual(without.actions); + expect(withDashboard.deployment.capabilities).toEqual(without.deployment.capabilities); + expect(withDashboard.installationId).toEqual(without.installationId); + const recorded = createPlatformInstallationManifest({ + account: withDashboard.account, + createdAt: '2026-10-01T00:00:00.000Z', + deployment: withDashboard.deployment, + id: withDashboard.installationId, + ownershipNonce: 'a'.repeat(43), + platformVersion: '1.0.0', + }); + const resumed = await createPlatformInstallPlan(client(), input, recorded, { + repairMissing: true, + }); + expect(resumed.deployment.controlPlane).toMatchObject({ + dashboardHostname: 'dash.example.org', + dashboardUrl: 'https://dash.example.org:8443', + }); + const changed = await createPlatformInstallPlan( + client(), + { ...input, dashboardUrl: 'https://new.example.org' }, + recorded, + ); + expect(changed.deployment.controlPlane.dashboardUrl).toBe('https://new.example.org'); + expect(formatPlatformInstallPlan(resumed)).toContain('https://dash.example.org:8443'); + }); + + it('retains an existing advertised origin and rejects malformed configuration before provider reads', async () => { + const recorded = setInstallationEndpoints( + legacyInstallation(), + { apiUrl: null, proxyUrl: null, dashboardUrl: 'https://legacy.example.org:8443' }, + '2026-09-14T00:00:01.000Z', + ); + const plan = await createPlatformInstallPlan(client(), input, recorded); + expect(plan.deployment.controlPlane.dashboardUrl).toBe('https://legacy.example.org:8443'); + const listAccounts = vi.fn(async () => []); + await expect( + createPlatformInstallPlan( + client({ listAccounts }), + { ...input, dashboardUrl: 'https://user:secret@dash.example.org' }, + null, + ), + ).rejects.toThrow('HTTPS origin'); + expect(listAccounts).not.toHaveBeenCalled(); + }); it('adopts an explicitly selected Hyperdrive without coupling its name to the installation', async () => { const fake = client({ listHyperdriveConfigs: async () => [ @@ -879,6 +935,257 @@ describe('platform installation planning', () => { ).rejects.toThrow('Application route'); }); + it.each(['fresh install', 'upgrade', 'domain set'])( + 'preserves unrelated Worker custom domains during %s', + async (operation) => { + let existing = operation === 'upgrade' ? legacyInstallation() : null; + if (operation === 'domain set') { + const initial = await createPlatformInstallPlan( + client(), + { ...input, applicationDomain: '', zoneName: '', workersDev: true }, + null, + ); + const manifest = { + ...createPlatformInstallationManifest({ + account: initial.account, + createdAt: '2026-09-14T00:00:00.000Z', + deployment: initial.deployment, + id: initial.installationId, + ownershipNonce: '0123456789abcdef0123456789abcdef', + platformVersion: '1.0.0', + }), + status: 'installed' as const, + }; + existing = { + ...manifest, + deployment: createPlatformDeploymentManifest({ + ...manifest.deployment, + application: platformDomainTarget(manifest, 'example.app', 'example.app', false), + }), + }; + } + await expect( + createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'ai.example.app', id: 'custom-domain', service: 'existing-ai' }, + ], + }), + input, + existing, + ), + ).rejects.toThrow( + 'would intercept Worker custom domain "ai.example.app", currently served by "existing-ai". Choose another application domain, or use `void platform install --workers-dev`', + ); + }, + ); + + it.each([ + { + hostname: 'ai.example.app', + routes: [{ pattern: 'ai.example.app/*', script: 'existing-ai' }], + }, + { hostname: 'ai.example.app', routes: [{ pattern: 'ai.example.app/*', script: null }] }, + { + hostname: 'ai.example.app', + routes: [ + { pattern: 'http://ai.example.app/*', script: null }, + { pattern: 'https://ai.example.app/*', script: 'existing-ai' }, + ], + }, + { + hostname: 'ai.services.example.app', + routes: [{ pattern: '*.services.example.app/*', script: 'existing-ai' }], + }, + ])( + 'preserves custom domains protected by more-specific routes: $routes', + async ({ hostname, routes }) => { + const plan = await createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname, id: 'custom-domain', service: 'existing-ai' }, + ], + listWorkerRoutes: async () => + routes.map((route, index) => ({ ...route, id: `${index}` })), + }), + input, + null, + ); + expect(plan.deployment.application.baseDomain).toBe('example.app'); + }, + ); + + it.each([ + { pattern: 'ai.example.app', script: 'existing-ai' }, + { pattern: 'ai.example.app/', script: null }, + { pattern: 'ai.example.app/api/*', script: 'existing-ai' }, + { pattern: 'https://ai.example.app/*', script: 'existing-ai' }, + { pattern: 'ai.example.app/*', script: 'void-example-dispatch' }, + ])('rejects routes that leave custom-domain traffic exposed: $pattern', async (route) => { + await expect( + createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'ai.example.app', id: 'custom-domain', service: 'existing-ai' }, + ], + listWorkerRoutes: async () => [{ ...route, id: 'existing-route' }], + }), + input, + null, + ), + ).rejects.toThrow('would intercept Worker custom domain'); + }); + + it('leaves apex and other application namespaces outside a shared-zone wildcard', async () => { + const plan = await createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'example.app', id: 'apex', service: 'existing-apex' }, + { + hostname: 'ai.other.app', + id: 'other-zone', + service: 'existing-ai', + zoneName: 'other.app', + }, + ], + }), + input, + null, + ); + expect(plan.deployment.application.dedicatedZone).toBe(false); + const nested = await createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'ai.example.app', id: 'existing-ai', service: 'existing-ai' }, + { hostname: 'apps.example.app', id: 'application-apex', service: 'existing-apps' }, + ], + }), + { ...input, applicationDomain: 'apps.example.app' }, + null, + ); + expect(nested.deployment.application.baseDomain).toBe('apps.example.app'); + }); + + it('preserves unrelated apex custom domains against dedicated-zone catch-all routes', async () => { + await expect( + createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'example.app', id: 'apex', service: 'existing-apex' }, + ], + }), + { ...input, dedicatedZone: true }, + null, + ), + ).rejects.toThrow('Application route "*/*" would intercept Worker custom domain "example.app"'); + }); + + it('does not infer custom-domain ownership from a matching new dispatch Worker name', async () => { + await expect( + createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'ai.example.app', id: 'custom-domain', service: 'void-example-dispatch' }, + ], + }), + input, + null, + ), + ).rejects.toThrow('would intercept Worker custom domain'); + }); + + it('preserves custom-domain origins owned by a recorded legacy dispatch Worker', async () => { + const original = legacyInstallation(); + const workerName = original.deployment.resources.dispatchWorker; + const manifest = recordInstallationResource( + original, + { + configHash: 'legacy-worker-config', + id: workerName, + key: 'dispatch-worker', + kind: 'worker', + name: workerName, + ownership: 'created', + state: 'ready', + }, + '2026-09-14T00:00:01.000Z', + ); + const plan = await createPlatformInstallPlan( + client({ + findZone: async () => ({ + id: 'zone', + name: 'example.app', + nameServers: [], + status: 'active', + }), + listWorkerDomains: async () => [ + { hostname: 'owned.example.app', id: 'custom-domain', service: workerName }, + ], + listWorkerNames: async () => [workerName], + }), + input, + manifest, + ); + expect(plan.deployment.resources.dispatchWorker).toBe(workerName); + expect(plan.actions.find((action) => action.key === 'dispatch-worker')).toMatchObject({ + action: 'update', + id: workerName, + }); + }); + + it('does not inspect custom domains for an isolated workers.dev installation', async () => { + const listWorkerDomains = vi.fn().mockRejectedValue(new Error('Unexpected domain inspection')); + const plan = await createPlatformInstallPlan( + client({ listWorkerDomains }), + { ...input, applicationDomain: '', zoneName: '', workersDev: true }, + null, + ); + expect(plan.deployment.application.workersDev?.subdomain).toBe('example'); + expect(listWorkerDomains).not.toHaveBeenCalled(); + }); + it('does not treat a local provision intent as remote ownership proof', async () => { let manifest = createPlatformInstallationManifest({ account: { id: 'account', name: 'Account' }, @@ -1225,6 +1532,9 @@ describe('platform installation planning', () => { }), getWorkerPlainTextBindings: async () => ({ PLATFORM_OWNERSHIP_MARKER: marker }), listWorkerNames: async () => [workerName], + listWorkerDomains: async () => [ + { hostname: 'owned.example.app', id: 'custom-domain', service: workerName }, + ], listWorkerRoutes: async () => [ { id: 'route', pattern: '*.example.app/*', script: workerName }, ], diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 97022df88..e178ed470 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -42,7 +42,7 @@ function plan(domain = ''): PlatformInstallPlan { }; } describe('platform credential setup links', () => { - it('round-trips planned defaults, Hyperdrive, and login choices into install arguments', () => { + it('round-trips planned defaults, Hyperdrive, dashboard, and login choices into install arguments', () => { const command = formatPlatformInstallCommand({ commandName: 'void', accountId: 'account', @@ -51,6 +51,7 @@ describe('platform credential setup links', () => { applicationDomain: 'example.com', zoneName: 'example.com', controlPlaneDomain: '', + dashboardUrl: 'https://dash.example.com:8443', dedicatedZone: false, workersDev: false, authentication: { providers: ['google', 'oidc'], configurable: true, accessProtection: true }, @@ -64,6 +65,7 @@ describe('platform credential setup links', () => { hyperdrive: 'drive-id', loginMethods: ['google', 'oidc'], accessProtection: true, + dashboardUrl: 'https://dash.example.com:8443', }); }); it('keeps planned domain, authentication, and runtime choices in a copyable command', () => { @@ -78,13 +80,14 @@ describe('platform credential setup links', () => { dedicatedZone: true, workersDev: false, authConfig: './auth config.json', + dashboardUrl: 'https://dash.example.com:8443', runtime: './build output', yes: true, }); const quotedName = process.platform === 'win32' ? "'Team''s Platform'" : "'Team'\\''s Platform'"; expect(command).toBe( - `void-dev platform install --name team --account account --display-name ${quotedName} --application-domain apps.example.com --zone example.com --dedicated-zone --control-plane-domain api.example.com --auth-config './auth config.json' --runtime './build output' --yes`, + `void-dev platform install --name team --account account --display-name ${quotedName} --application-domain apps.example.com --zone example.com --dedicated-zone --control-plane-domain api.example.com --auth-config './auth config.json' --dashboard-url https://dash.example.com:8443 --runtime './build output' --yes`, ); }); diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index cdf11ec2f..54910858e 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -114,6 +115,8 @@ describe('frameworkTriggersPlugin', () => { const target = frameworkTriggersDispatchPath(root); mkdirSync(join(root, '.void'), { recursive: true }); writeFileSync(target, '// stale\n', { mode: 0o644 }); + // Seed the legacy permissions even under a restrictive developer umask. + chmodSync(target, 0o644); expect(statSync(target).mode & 0o777).toBe(0o644); const plugin = frameworkTriggersPlugin(root, { devTriggerToken: 'dev-token-test' }); diff --git a/packages/void/test/unit/plugin-version-metadata.test.ts b/packages/void/test/unit/plugin-version-metadata.test.ts new file mode 100644 index 000000000..535a9b679 --- /dev/null +++ b/packages/void/test/unit/plugin-version-metadata.test.ts @@ -0,0 +1,116 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ConfigEnv, Plugin, UserConfig } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { CLOUDFLARE_DEPLOY_BUILD_ENV } from '../../src/shared/env.ts'; +import { + CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, + DEFAULT_VERSION_METADATA_BINDING, +} from '../../src/shared/version-metadata.ts'; + +type Config = { config: (resolved: Record) => Record }; +vi.mock('@cloudflare/vite-plugin', () => ({ + cloudflare(config: Config): Array { + return [{ name: 'mock:cloudflare', api: { config } }]; + }, +})); +vi.mock('../../src/cli/auth.ts', async (original) => ({ + ...(await original()), + getToken: () => null, +})); + +const roots: Array = []; +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +async function generate(resolved: Record, command: 'build' | 'serve' = 'build') { + const root = mkdtempSync(join(tmpdir(), 'void-version-binding-')); + roots.push(root); + mkdirSync(join(root, 'routes'), { recursive: true }); + writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })); + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ worker: { compatibility_date: '2026-09-11' } }), + ); + writeFileSync(join(root, 'routes/index.ts'), 'export const GET = () => ({ ok: true });'); + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + const plugins = voidPlugin(); + const hook = plugins.find((plugin) => plugin.name === 'void:env')?.config; + const configure = typeof hook === 'function' ? hook : hook?.handler; + if (!configure) { + throw new Error('Void environment hook missing'); + } + (configure as (config: UserConfig, environment: ConfigEnv) => void)( + {}, + { command, mode: command === 'build' ? 'production' : 'development' }, + ); + const cloudflare = plugins.find((plugin) => plugin.name === 'mock:cloudflare'); + if (!cloudflare) { + throw new Error('Cloudflare configuration callback missing'); + } + const patch = (cloudflare.api as { config: Config }).config.config(resolved); + const routingHook = plugins.find((plugin) => plugin.name === 'void:routing')?.buildStart; + const buildStart = typeof routingHook === 'function' ? routingHook : routingHook?.handler; + if (!buildStart) { + throw new Error('Void routing hook missing'); + } + await (buildStart as () => Promise)(); + return { patch, entry: readFileSync(join(root, '.void/entry.ts'), 'utf8') }; +} + +describe('native metadata configuration and generated runtime', () => { + it('adds Free-compatible self-metadata and threads its name to readiness without paid bindings', async () => { + vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, '1'); + const providerFetch = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Unexpected provider probe')); + const { patch, entry } = await generate({}); + expect(patch.version_metadata).toEqual({ binding: DEFAULT_VERSION_METADATA_BINDING }); + expect(entry).toContain(`versionId: env["${DEFAULT_VERSION_METADATA_BINDING}"]?.id`); + expect(patch.containers).toBeUndefined(); + expect(patch.ai).toBeUndefined(); + expect(patch.hyperdrive).toBeUndefined(); + expect(providerFetch).not.toHaveBeenCalled(); + }); + + it('preserves an authored name in the binding and generated handler', async () => { + vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, '1'); + const resolved = { version_metadata: { binding: 'AUTHORED_VERSION' } }; + const { patch, entry } = await generate(resolved); + expect(patch.version_metadata).toBeUndefined(); + expect(resolved.version_metadata).toEqual({ binding: 'AUTHORED_VERSION' }); + expect(entry).toContain('versionId: env["AUTHORED_VERSION"]?.id'); + }); + + it('leaves handoff bindings and its legacy runtime unchanged when no metadata exists', async () => { + vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, '1'); + vi.stubEnv(CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, '1'); + const { patch, entry } = await generate({}); + expect(patch.version_metadata).toBeUndefined(); + expect(entry).not.toContain('versionId: env['); + }); + + it.each(['build', 'serve'] as const)( + 'does not inject metadata into a %s outside native deployment', + async (command) => { + vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, ''); + const { patch, entry } = await generate({}, command); + expect(patch.version_metadata).toBeUndefined(); + expect(entry).not.toContain('versionId: env['); + }, + ); + + it('fails before upload when an application variable occupies the internal name', async () => { + vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, '1'); + await expect( + generate({ vars: { [DEFAULT_VERSION_METADATA_BINDING]: 'application-value' } }), + ).rejects.toThrow('conflicts with'); + }); +}); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 9eeeb2cf6..374605dae 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -147,6 +147,28 @@ function worker(overrides: Partial[0]> = {} }); } describe('typed Worker fetch execution', () => { + it('proves the actual named metadata version and never echoes requested override or private metadata', async () => { + const fetch = worker({ versionMetadataBinding: '__VOID_VERSION_METADATA' }); + const request = new Request('https://app.test/__void/ready', { + headers: { 'Cloudflare-Workers-Version-Overrides': 'app="candidate-version"' }, + }); + const response = await fetch( + request, + { + __VOID_VERSION_METADATA: { + id: 'served-version', + tag: 'private-tag', + timestamp: 'private-time', + }, + }, + context, + ); + expect(await response.json()).toEqual({ ready: true, versionId: 'served-version' }); + expect(response.headers.get('cache-control')).toBe('no-store'); + const missing = await fetch(request, {}, context); + expect(await missing.json()).toEqual({ ready: true }); + expect(missing.headers.get('cache-control')).toBe('no-store'); + }); it('validates readiness and app requests while discovery skips validation and checks its token', async () => { const validateEnv = vi.fn(async () => {}); const fetch = worker({ validateEnv }); diff --git a/packages/void/test/unit/version-metadata.test.ts b/packages/void/test/unit/version-metadata.test.ts new file mode 100644 index 000000000..8c36392bb --- /dev/null +++ b/packages/void/test/unit/version-metadata.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + DEFAULT_VERSION_METADATA_BINDING, + readVersionMetadataBinding, + selectNativeVersionMetadataBinding, +} from '../../src/shared/version-metadata.ts'; + +const native = { nativeBuild: true, preserveBindings: false }; + +describe('native readiness metadata binding', () => { + it('selects only self-metadata for ordinary native builds without mutating configuration', () => { + const config = Object.freeze({}); + expect(selectNativeVersionMetadataBinding(config, native)).toBe( + DEFAULT_VERSION_METADATA_BINDING, + ); + expect(config).toEqual({}); + }); + + it('leaves managed, development and ordinary Vite builds without an added binding', () => { + expect( + selectNativeVersionMetadataBinding({}, { nativeBuild: false, preserveBindings: false }), + ).toBeUndefined(); + }); + + it('does not add a binding while handing off an existing site', () => { + expect( + selectNativeVersionMetadataBinding({}, { ...native, preserveBindings: true }), + ).toBeUndefined(); + }); + + it.each([false, true])( + 'keeps the authored metadata name during handoff=%s', + (preserveBindings) => { + const config = { version_metadata: { binding: 'AUTHORED_VERSION' } }; + expect(selectNativeVersionMetadataBinding(config, { ...native, preserveBindings })).toBe( + 'AUTHORED_VERSION', + ); + expect(config).toEqual({ version_metadata: { binding: 'AUTHORED_VERSION' } }); + }, + ); + + it.each([ + { vars: { [DEFAULT_VERSION_METADATA_BINDING]: 'authored' } }, + { kv_namespaces: [{ binding: DEFAULT_VERSION_METADATA_BINDING, id: 'kv-id' }] }, + { queues: { producers: [{ binding: DEFAULT_VERSION_METADATA_BINDING, queue: 'jobs' }] } }, + { secrets: { required: [DEFAULT_VERSION_METADATA_BINDING] } }, + ])('rejects an occupied default name before upload: %j', (config) => { + expect(() => selectNativeVersionMetadataBinding(config, native)).toThrow('conflicts with'); + }); + + it('rejects an authored name that shadows another binding', () => { + expect(() => + readVersionMetadataBinding({ + version_metadata: { binding: 'VERSION' }, + ai: { binding: 'VERSION' }, + }), + ).toThrow('conflicts with AI'); + }); + + it.each([{}, { binding: '' }, 'VERSION', [{ binding: 'VERSION' }]])( + 'fails closed on malformed authored metadata: %j', + (value) => { + expect(() => readVersionMetadataBinding({ version_metadata: value })).toThrow(); + }, + ); +}); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 0d6083c3f..2f877ccec 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -32,6 +32,33 @@ describe('repeated wrapper generation', () => { } }); + it('reports the served framework version through its authored metadata binding', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-wrapper-version-')); + roots.push(root); + writeFileSync( + join(root, 'index.mjs'), + 'export default { fetch() { throw new Error("app reached"); } };', + ); + await generateWrapper(root, 'index.mjs', root, { + ...base, + readiness: true, + versionMetadataBinding: 'AUTHORED_VERSION', + }); + const worker = (await import(pathToFileURL(join(root, 'index.mjs')).href)).default; + const response = await worker.fetch( + new Request('https://app.example/__void/ready', { + headers: { 'Cloudflare-Workers-Version-Overrides': 'app="new-version"' }, + }), + { AUTHORED_VERSION: { id: 'old-version', tag: 'private-tag' } }, + {}, + ); + expect(await response.json()).toEqual({ ready: true, versionId: 'old-version' }); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect( + await (await worker.fetch(new Request('https://app.example/__void/ready'), {}, {})).json(), + ).toEqual({ ready: true }); + }); + it('preserves the real application across repeated skip-build preparation', async () => { const root = mkdtempSync(join(tmpdir(), 'void-repeat-wrapper-')); roots.push(root); diff --git a/platform/packages/api/drizzle/0056_email_shared_recipients.sql b/platform/packages/api/drizzle/0056_email_shared_recipients.sql new file mode 100644 index 000000000..c8b7ac5da --- /dev/null +++ b/platform/packages/api/drizzle/0056_email_shared_recipients.sql @@ -0,0 +1,51 @@ +CREATE TABLE "email_shared_recipients" ( + "id" text PRIMARY KEY NOT NULL, + "installation_id" text NOT NULL, + "project_id" text NOT NULL, + "route_id" text NOT NULL, + "recipient" text NOT NULL, + "rule_name" text NOT NULL, + "rule_id" text, + "create_started_at" timestamp with time zone, + "removing" boolean DEFAULT false NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "reason" text, + "attempt" integer DEFAULT 0 NOT NULL, + "lease_started_at" timestamp with time zone, + "observed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "email_shared_recipients_project_address_uq" ON "email_shared_recipients" USING btree ("installation_id","project_id","recipient");--> statement-breakpoint +CREATE UNIQUE INDEX "email_shared_recipients_rule_name_uq" ON "email_shared_recipients" USING btree ("rule_name");--> statement-breakpoint +CREATE INDEX "email_shared_recipients_reconcile_idx" ON "email_shared_recipients" USING btree ("status","updated_at"); +--> statement-breakpoint +-- Keep older runtimes from releasing a reusable slug while a provider rule +-- or uncertain creation remains. Current cleanup marks rows removed only +-- after exact provider absence readback. The ledger intentionally has no FK +-- cascade: its ownership history must survive project deletion. +CREATE FUNCTION "void_shared_email_recipient_teardown_guard"() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND NEW.slug IS NOT DISTINCT FROM OLD.slug THEN + RETURN NEW; + END IF; + IF EXISTS ( + SELECT 1 FROM "email_shared_recipients" + WHERE "project_id" = OLD.id AND "status" <> 'removed' + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23503', + MESSAGE = 'Shared email recipient cleanup is not complete; use a current Void Platform runtime to reconcile cleanup before deleting or renaming this project'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER "void_shared_email_recipient_teardown_guard" +BEFORE DELETE OR UPDATE OF "slug" ON "projects" +FOR EACH ROW EXECUTE FUNCTION "void_shared_email_recipient_teardown_guard"(); diff --git a/platform/packages/api/drizzle/compatibility.json b/platform/packages/api/drizzle/compatibility.json index 37372c7cd..a410cf9d9 100644 --- a/platform/packages/api/drizzle/compatibility.json +++ b/platform/packages/api/drizzle/compatibility.json @@ -73,6 +73,10 @@ { "platformVersion": "0.22.0", "schemaDigest": "bb024ebddf7375f4f01a3a3d8c2853212f7d9c1355c8fa8d6262134025c3c429" + }, + { + "platformVersion": "0.22.0", + "schemaDigest": "bef2a1cfaad1dcad4aca2a4932ef9aa9b9d8025dca495e3b1027902405da902a" } ] } diff --git a/platform/packages/api/drizzle/meta/0056_snapshot.json b/platform/packages/api/drizzle/meta/0056_snapshot.json new file mode 100644 index 000000000..7c1fc5a9b --- /dev/null +++ b/platform/packages/api/drizzle/meta/0056_snapshot.json @@ -0,0 +1,6517 @@ +{ + "id": "8f9c66e3-2d0e-4619-a5eb-3e9ddf2c5b99", + "prevId": "b3ef90f9-f31d-4e4f-b402-9d54b8d520db", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_operations": { + "name": "deployment_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_active_id": { + "name": "original_active_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_digest": { + "name": "input_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_prefix": { + "name": "input_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_dispatch_at": { + "name": "next_dispatch_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seq": { + "name": "last_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_deleted": { + "name": "inputs_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_operations_key": { + "name": "idx_deployment_operations_key", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_operations_dispatch": { + "name": "idx_deployment_operations_dispatch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_operations_project_id_projects_id_fk": { + "name": "deployment_operations_project_id_projects_id_fk", + "tableFrom": "deployment_operations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_user_id": { + "name": "billing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": ["hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destination_cleanups": { + "name": "email_destination_cleanups", + "schema": "", + "columns": { + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destination_cleanups_due": { + "name": "idx_email_destination_cleanups_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_destination_cleanups_status_check": { + "name": "email_destination_cleanups_status_check", + "value": "\"email_destination_cleanups\".\"status\" IN ('pending', 'deleting', 'completed')" + }, + "email_destination_cleanups_attempts_check": { + "name": "email_destination_cleanups_attempts_check", + "value": "\"email_destination_cleanups\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "address"] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token', 'platform')" + }, + "email_domains_platform_token_check": { + "name": "email_domains_platform_token_check", + "value": "(\"email_domains\".\"credential_kind\" = 'platform' AND \"email_domains\".\"token_ciphertext\" IS NULL AND \"email_domains\".\"token_iv\" IS NULL AND \"email_domains\".\"token_key_version\" IS NULL) OR (\"email_domains\".\"credential_kind\" <> 'platform' AND \"email_domains\".\"token_ciphertext\" IS NOT NULL AND \"email_domains\".\"token_iv\" IS NOT NULL AND \"email_domains\".\"token_key_version\" IS NOT NULL)" + }, + "email_domains_managed_by_check": { + "name": "email_domains_managed_by_check", + "value": "\"email_domains\".\"managed_by\" IN ('owner', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": ["installation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "invitations_email_delivery_status_check": { + "name": "invitations_email_delivery_status_check", + "value": "\"invitations\".\"email_delivery_status\" IS NULL OR \"invitations\".\"email_delivery_status\" IN ('sent', 'failed', 'unavailable')" + } + }, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": ["project_id", "from_name", "to_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": ["connection_id", "revision"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_events": { + "name": "project_access_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitation_email_snapshot": { + "name": "invitation_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_role": { + "name": "new_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_access_events_project_created": { + "name": "idx_project_access_events_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_access_events_subject_created": { + "name": "idx_project_access_events_subject_created", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_events_project_id_projects_id_fk": { + "name": "project_access_events_project_id_projects_id_fk", + "tableFrom": "project_access_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_events_previous_role_check": { + "name": "project_access_events_previous_role_check", + "value": "\"project_access_events\".\"previous_role\" IS NULL OR \"project_access_events\".\"previous_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_new_role_check": { + "name": "project_access_events_new_role_check", + "value": "\"project_access_events\".\"new_role\" IS NULL OR \"project_access_events\".\"new_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_action_check": { + "name": "project_access_events_action_check", + "value": "\"project_access_events\".\"action\" IN ('invited', 'invitation_resent', 'member_added', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')" + } + }, + "isRLSEnabled": false + }, + "public.project_cleanup_jobs": { + "name": "project_cleanup_jobs", + "schema": "", + "columns": { + "target": { + "name": "target", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_cleanup_jobs_due": { + "name": "project_cleanup_jobs_due", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email_snapshot": { + "name": "target_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_invitations_project_status": { + "name": "idx_project_invitations_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_target_status": { + "name": "idx_project_invitations_target_status", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_pending_target": { + "name": "idx_project_invitations_pending_target", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_target_user_id_users_id_fk": { + "name": "project_invitations_target_user_id_users_id_fk", + "tableFrom": "project_invitations", + "tableTo": "users", + "columnsFrom": ["target_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_invitations_role_check": { + "name": "project_invitations_role_check", + "value": "\"project_invitations\".\"role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_invitations_status_check": { + "name": "project_invitations_status_check", + "value": "\"project_invitations\".\"status\" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_members_user_project": { + "name": "idx_project_members_user_project", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_members_role_check": { + "name": "project_members_role_check", + "value": "\"project_members\".\"role\" IN ('reader', 'collaborator', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": ["project_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_ownership_periods": { + "name": "project_ownership_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "changed_by_user_id": { + "name": "changed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_ownership_periods_owner_time": { + "name": "idx_project_ownership_periods_owner_time", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_project_time": { + "name": "idx_project_ownership_periods_project_time", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_open": { + "name": "idx_project_ownership_periods_open", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_ownership_periods\".\"ends_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_ownership_periods_project_id_projects_id_fk": { + "name": "project_ownership_periods_project_id_projects_id_fk", + "tableFrom": "project_ownership_periods", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_ownership_periods_time_check": { + "name": "project_ownership_periods_time_check", + "value": "\"project_ownership_periods\".\"ends_at\" IS NULL OR \"project_ownership_periods\".\"ends_at\" >= \"project_ownership_periods\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_zero_trust": { + "name": "project_zero_trust", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "desired_protected": { + "name": "desired_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "effective_protected": { + "name": "effective_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "bypass_application_id": { + "name": "bypass_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bypass_application_name": { + "name": "bypass_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_id": { + "name": "protected_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_name": { + "name": "protected_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_audience": { + "name": "protected_application_audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_cursor": { + "name": "routing_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciled_operation": { + "name": "reconciled_operation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_zero_trust_status": { + "name": "idx_project_zero_trust_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_zero_trust_project_id_projects_id_fk": { + "name": "project_zero_trust_project_id_projects_id_fk", + "tableFrom": "project_zero_trust", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_zero_trust_status_check": { + "name": "project_zero_trust_status_check", + "value": "\"project_zero_trust\".\"status\" IN ('ready', 'reconciling', 'error')" + }, + "project_zero_trust_ready_check": { + "name": "project_zero_trust_ready_check", + "value": "\"project_zero_trust\".\"status\" <> 'ready' OR \"project_zero_trust\".\"desired_protected\" = \"project_zero_trust\".\"effective_protected\"" + } + }, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'void'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "type", "binding_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_cache_outbox": { + "name": "email_cache_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_cache_outbox_version_uq": { + "name": "email_cache_outbox_version_uq", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_cache_outbox_pending_idx": { + "name": "email_cache_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_credentials": { + "name": "email_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_on": { + "name": "expires_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_credentials_scope_idx": { + "name": "email_credentials_scope_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_credentials_material_check": { + "name": "email_credentials_material_check", + "value": "(\"email_credentials\".\"kind\" = 'platform' AND \"email_credentials\".\"ciphertext\" IS NULL AND \"email_credentials\".\"iv\" IS NULL AND \"email_credentials\".\"key_version\" IS NULL) OR (\"email_credentials\".\"kind\" = 'api_token' AND \"email_credentials\".\"ciphertext\" IS NOT NULL AND \"email_credentials\".\"iv\" IS NOT NULL AND \"email_credentials\".\"key_version\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_domain_routes": { + "name": "email_domain_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addressing": { + "name": "addressing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "desired_state": { + "name": "desired_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outbound_reason": { + "name": "outbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_observed_at": { + "name": "outbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_domain_routes_domain_uq": { + "name": "email_domain_routes_domain_uq", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_connection_idx": { + "name": "email_domain_routes_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_project_idx": { + "name": "email_domain_routes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domain_routes_connection_id_email_zone_connections_id_fk": { + "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_domain_routes", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_domain_routes_assignment_check": { + "name": "email_domain_routes_assignment_check", + "value": "(\"email_domain_routes\".\"addressing\" = 'custom' AND \"email_domain_routes\".\"project_id\" IS NOT NULL) OR (\"email_domain_routes\".\"addressing\" = 'shared' AND \"email_domain_routes\".\"project_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_operation_steps": { + "name": "email_operation_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_identity": { + "name": "resource_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_operation_steps_order_uq": { + "name": "email_operation_steps_order_uq", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operation_steps_operation_id_email_operations_id_fk": { + "name": "email_operation_steps_operation_id_email_operations_id_fk", + "tableFrom": "email_operation_steps", + "tableTo": "email_operations", + "columnsFrom": ["operation_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_operations": { + "name": "email_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_generation": { + "name": "route_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_operations_key_uq": { + "name": "email_operations_key_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_work_idx": { + "name": "email_operations_work_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_connection_idx": { + "name": "email_operations_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operations_connection_id_email_zone_connections_id_fk": { + "name": "email_operations_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_operations_route_id_email_domain_routes_id_fk": { + "name": "email_operations_route_id_email_domain_routes_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_domain_routes", + "columnsFrom": ["route_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_zone_connections": { + "name": "email_zone_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_worker_name": { + "name": "ingress_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingress_version": { + "name": "ingress_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "active_secret_generation": { + "name": "active_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staged_secret_generation": { + "name": "staged_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staged_secret_ciphertext": { + "name": "staged_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_iv": { + "name": "staged_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_key_version": { + "name": "staged_secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_hash": { + "name": "previous_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "management_status": { + "name": "management_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'probe_failed'" + }, + "management_reason": { + "name": "management_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "management_observed_at": { + "name": "management_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_zone_connections_zone_uq": { + "name": "email_zone_connections_zone_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_ingress_uq": { + "name": "email_zone_connections_ingress_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingress_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_manager_idx": { + "name": "email_zone_connections_manager_idx", + "columns": [ + { + "expression": "manager_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_zone_connections_manager_check": { + "name": "email_zone_connections_manager_check", + "value": "(\"email_zone_connections\".\"managed_by\" = 'owner' AND \"email_zone_connections\".\"manager_user_id\" IS NOT NULL) OR (\"email_zone_connections\".\"managed_by\" = 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.email_action_recipients": { + "name": "email_action_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_email_action_recipients_project_reserved": { + "name": "idx_email_action_recipients_project_reserved", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_action_recipients_expiring": { + "name": "idx_email_action_recipients_expiring", + "columns": [ + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"email_action_recipients\".\"state\" = 'reserved'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_action_recipients_action_id_email_actions_id_fk": { + "name": "email_action_recipients_action_id_email_actions_id_fk", + "tableFrom": "email_action_recipients", + "tableTo": "email_actions", + "columnsFrom": ["action_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_action_recipients_action_recipient_uq": { + "name": "email_action_recipients_action_recipient_uq", + "nullsNotDistinct": false, + "columns": ["action_id", "recipient"] + } + }, + "policies": {}, + "checkConstraints": { + "email_action_recipients_state_check": { + "name": "email_action_recipients_state_check", + "value": "\"email_action_recipients\".\"state\" IN ('rejected', 'reserved', 'attempt_started', 'provider_accepted', 'failed', 'outcome_unknown', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.email_actions": { + "name": "email_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_actions_project_created": { + "name": "idx_email_actions_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_actions_receipt_id_email_receipts_id_fk": { + "name": "email_actions_receipt_id_email_receipts_id_fk", + "tableFrom": "email_actions", + "tableTo": "email_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_actions_project_key_uq": { + "name": "email_actions_project_key_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "email_actions_kind_check": { + "name": "email_actions_kind_check", + "value": "\"email_actions\".\"kind\" IN ('direct', 'forward', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.email_monthly_usage": { + "name": "email_monthly_usage", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_attempts": { + "name": "recipient_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inbound_receipts": { + "name": "inbound_receipts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_monthly_usage_project_month_uq": { + "name": "email_monthly_usage_project_month_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "month"] + } + }, + "policies": {}, + "checkConstraints": { + "email_monthly_usage_nonnegative_check": { + "name": "email_monthly_usage_nonnegative_check", + "value": "\"email_monthly_usage\".\"recipient_attempts\" >= 0" + }, + "email_monthly_usage_inbound_nonnegative_check": { + "name": "email_monthly_usage_inbound_nonnegative_check", + "value": "\"email_monthly_usage\".\"inbound_receipts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_project_controls": { + "name": "email_project_controls", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "burst_limit": { + "name": "burst_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "email_project_controls_project_id_projects_id_fk": { + "name": "email_project_controls_project_id_projects_id_fk", + "tableFrom": "email_project_controls", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_receipts": { + "name": "email_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_reject": { + "name": "final_reject", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_receipts_project_created": { + "name": "idx_email_receipts_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unresolved_attempts": { + "name": "email_unresolved_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_unresolved_attempts_project": { + "name": "idx_email_unresolved_attempts_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_unresolved_attempts_domain": { + "name": "idx_email_unresolved_attempts_domain", + "columns": [ + { + "expression": "sender_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_shared_recipients": { + "name": "email_shared_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_started_at": { + "name": "create_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removing": { + "name": "removing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_shared_recipients_project_address_uq": { + "name": "email_shared_recipients_project_address_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_rule_name_uq": { + "name": "email_shared_recipients_rule_name_uq", + "columns": [ + { + "expression": "rule_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_reconcile_idx": { + "name": "email_shared_recipients_reconcile_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/platform/packages/api/drizzle/meta/_journal.json b/platform/packages/api/drizzle/meta/_journal.json index 22dfe1816..841909ee9 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -344,6 +344,13 @@ "when": 1790864531640, "tag": "0055_durable_deployment_execution", "breakpoints": true + }, + { + "idx": 56, + "version": "7", + "when": 1790875562555, + "tag": "0056_email_shared_recipients", + "breakpoints": true } ] } diff --git a/platform/packages/api/src/admin/fate/render.ts b/platform/packages/api/src/admin/fate/render.ts index ecf0d23a3..acd17108d 100644 --- a/platform/packages/api/src/admin/fate/render.ts +++ b/platform/packages/api/src/admin/fate/render.ts @@ -92,7 +92,7 @@ export async function renderFateListPage( username: context.get('user').login, }; if (page === 'authSettings' || page === 'authNew' || page === 'authEdit') { - context.header('Referrer-Policy', 'no-referrer'); + context.header('Referrer-Policy', 'strict-origin'); } if (context.req.header('X-VoidPages') === 'true') { return renderAdminPage(context, page, title, props); diff --git a/platform/packages/api/src/admin/routes/auth-config.tsx b/platform/packages/api/src/admin/routes/auth-config.tsx index a4c076cb3..9a943e227 100644 --- a/platform/packages/api/src/admin/routes/auth-config.tsx +++ b/platform/packages/api/src/admin/routes/auth-config.tsx @@ -22,9 +22,11 @@ import { renderAdminPage } from '../react/server.ts'; const app = new Hono(); const HOME = '/admin/config/auth'; app.use('*', async (context, next) => { - context.header('Cache-Control', 'no-store'); - context.header('Referrer-Policy', 'no-referrer'); await next(); + // React pages return their own Response; set form-compatible privacy headers + // on that final response, keeping callback paths and queries out of referrers. + context.res.headers.set('Cache-Control', 'no-store'); + context.res.headers.set('Referrer-Policy', 'strict-origin'); }); app.onError((error, context) => { if (error instanceof OperatorError || error instanceof AuthenticationConfigurationError) { diff --git a/platform/packages/api/src/admin/routes/zero-trust.tsx b/platform/packages/api/src/admin/routes/zero-trust.tsx index d9719baf6..d2e70b8ce 100644 --- a/platform/packages/api/src/admin/routes/zero-trust.tsx +++ b/platform/packages/api/src/admin/routes/zero-trust.tsx @@ -17,7 +17,7 @@ const app = new Hono(); app.use('*', async (context, next) => { await next(); context.res.headers.set('Cache-Control', 'no-store'); - context.res.headers.set('Referrer-Policy', 'no-referrer'); + context.res.headers.set('Referrer-Policy', 'strict-origin'); }); app.onError(renderOperationError); diff --git a/platform/packages/api/src/auth/login.ts b/platform/packages/api/src/auth/login.ts index 3950c98cf..7e30c9261 100644 --- a/platform/packages/api/src/auth/login.ts +++ b/platform/packages/api/src/auth/login.ts @@ -538,7 +538,9 @@ export async function completePlatformLogin(context: Context): Promise' + escapeHtml( @@ -606,6 +608,9 @@ export async function completePlatformLogin(context: Context<ApiEnv>): Promise<R context.header('Cache-Control', 'no-store'); context.header('Referrer-Policy', 'no-referrer'); if (attempt.purpose === 'link') { + // Native confirmation needs its same-origin Origin header; retain only + // the origin so OAuth callback codes and state stay out of referrers. + context.header('Referrer-Policy', 'strict-origin'); const proof = randomLoginProof(); await db.insert(platformAuthAttempts).values({ id: saved.testId!, diff --git a/platform/packages/api/src/email/cf-customer.ts b/platform/packages/api/src/email/cf-customer.ts index 9f046adbc..516fc1a2a 100644 --- a/platform/packages/api/src/email/cf-customer.ts +++ b/platform/packages/api/src/email/cf-customer.ts @@ -78,8 +78,46 @@ export type CfEmailRoutingRule = Readonly<{ enabled: boolean; name: string | null; addresses: ReadonlyArray<string>; + id: string | null; + workerName: string | null; + /** Only a single literal `to` matcher and a single Worker action can be owned. */ + literalWorker: boolean; }>; +function parseRoutingRule(value: unknown): CfEmailRoutingRule { + const rule = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}; + const matchers = Array.isArray(rule.matchers) ? rule.matchers : []; + const actions = Array.isArray(rule.actions) ? rule.actions : []; + const addresses = matchers.flatMap((matcher: unknown) => { + if (!matcher || typeof matcher !== 'object') { + return []; + } + const m = matcher as Record<string, unknown>; + return m.type === 'literal' && m.field === 'to' && typeof m.value === 'string' + ? [m.value.toLowerCase()] + : []; + }); + const action = + actions.length === 1 && actions[0] && typeof actions[0] === 'object' + ? (actions[0] as Record<string, unknown>) + : null; + const workerName = + action?.type === 'worker' && + Array.isArray(action.value) && + action.value.length === 1 && + typeof action.value[0] === 'string' + ? action.value[0] + : null; + return { + id: typeof rule.id === 'string' && rule.id ? rule.id : null, + enabled: rule.enabled === true, + name: typeof rule.name === 'string' ? rule.name : null, + addresses, + workerName, + literalWorker: matchers.length === 1 && addresses.length === 1 && workerName !== null, + }; +} + /** One entry of the sending-subdomain list; name + enabled are required on the wire. */ export type CfSendingSubdomain = Readonly<{ name: string; @@ -135,6 +173,18 @@ export type CfCustomerClient = Readonly<{ getCatchAll: (zoneId: string) => Promise<CfCatchAllRule | null>; /** Enabled explicit address rules outrank a zone catch-all. */ listRoutingRules: (zoneId: string) => Promise<ReadonlyArray<CfEmailRoutingRule>>; + getRoutingRule: (zoneId: string, ruleId: string) => Promise<CfEmailRoutingRule | null>; + putLiteralRule: ( + zoneId: string, + ruleId: string | null, + input: { + name: string; + address: string; + workerName: string; + }, + ) => Promise<CfEmailRoutingRule>; + deleteRoutingRule: (zoneId: string, ruleId: string) => Promise<void>; + enableSubaddressing: (zoneId: string) => Promise<void>; /** PUT catch-all with wrangler's reset shape: one drop action, disabled. */ resetCatchAll: (zoneId: string) => Promise<void>; /** @@ -352,26 +402,7 @@ export function createCfCustomerClient(credential: string): CfCustomerClient { throw new Error('Cloudflare routing rules response was not an array'); } for (const value of result) { - if (!value || typeof value !== 'object') { - continue; - } - const rule = value as { enabled?: unknown; name?: unknown; matchers?: unknown }; - const addresses = Array.isArray(rule.matchers) - ? rule.matchers.flatMap((matcher: unknown) => { - if (!matcher || typeof matcher !== 'object') { - return []; - } - const m = matcher as { type?: unknown; field?: unknown; value?: unknown }; - return m.type === 'literal' && typeof m.value === 'string' - ? [m.value.toLowerCase()] - : []; - }) - : []; - rules.push({ - enabled: rule.enabled === true, - name: typeof rule.name === 'string' ? rule.name : null, - addresses, - }); + rules.push(parseRoutingRule(value)); } if (result.length < 50) { break; @@ -383,6 +414,52 @@ export function createCfCustomerClient(credential: string): CfCustomerClient { return rules; }, + getRoutingRule: async (zoneId, ruleId) => { + try { + return parseRoutingRule( + await call(`/zones/${zoneId}/email/routing/rules/${encodeURIComponent(ruleId)}`), + ); + } catch (error) { + if (error instanceof CloudflareApiError && error.status === 404) { + return null; + } + throw error; + } + }, + putLiteralRule: async (zoneId, ruleId, input) => + parseRoutingRule( + await call( + `/zones/${zoneId}/email/routing/rules${ruleId ? `/${encodeURIComponent(ruleId)}` : ''}`, + { + method: ruleId ? 'PUT' : 'POST', + body: JSON.stringify({ + name: input.name, + enabled: true, + matchers: [{ type: 'literal', field: 'to', value: input.address }], + actions: [{ type: 'worker', value: [input.workerName] }], + }), + }, + ), + ), + deleteRoutingRule: async (zoneId, ruleId) => { + try { + await call(`/zones/${zoneId}/email/routing/rules/${encodeURIComponent(ruleId)}`, { + method: 'DELETE', + }); + } catch (error) { + if (!(error instanceof CloudflareApiError && error.status === 404)) { + throw error; + } + } + }, + // PATCH changes only plus-address matching; DNS and other zone settings are preserved. + enableSubaddressing: async (zoneId) => { + await call(`/zones/${zoneId}/email/routing`, { + method: 'PATCH', + body: JSON.stringify({ support_subaddress: true }), + }); + }, + resetCatchAll: async (zoneId) => { await call(`/zones/${zoneId}/email/routing/rules/catch_all`, { method: 'PUT', diff --git a/platform/packages/api/src/email/connection-schema.ts b/platform/packages/api/src/email/connection-schema.ts index 6a9113239..5ef713752 100644 --- a/platform/packages/api/src/email/connection-schema.ts +++ b/platform/packages/api/src/email/connection-schema.ts @@ -1,5 +1,6 @@ import { sql } from 'drizzle-orm'; import { + boolean, check, index, integer, @@ -12,6 +13,45 @@ import { // Ownership IDs remain as audit identities after a user or project is deleted; // authorization always joins the current user/project records. +/** Retained provider ownership, including ambiguous writes and project deletion. */ +export const emailSharedRecipients = pgTable( + 'email_shared_recipients', + { + id: text('id').primaryKey(), + installation_id: text('installation_id').notNull(), + project_id: text('project_id').notNull(), + route_id: text('route_id').notNull(), + recipient: text('recipient').notNull(), + rule_name: text('rule_name').notNull(), + rule_id: text('rule_id'), + create_started_at: timestamp('create_started_at', { mode: 'string', withTimezone: true }), + removing: boolean('removing').notNull().default(false), + status: text('status') + .notNull() + .default('pending') + .$type<'pending' | 'reconciling' | 'ready' | 'blocked' | 'unknown' | 'removed'>(), + reason: text('reason'), + attempt: integer('attempt').notNull().default(0), + lease_started_at: timestamp('lease_started_at', { mode: 'string', withTimezone: true }), + observed_at: timestamp('observed_at', { mode: 'string', withTimezone: true }), + created_at: timestamp('created_at', { mode: 'string', withTimezone: true }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { mode: 'string', withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex('email_shared_recipients_project_address_uq').on( + table.installation_id, + table.project_id, + table.recipient, + ), + uniqueIndex('email_shared_recipients_rule_name_uq').on(table.rule_name), + index('email_shared_recipients_reconcile_idx').on(table.status, table.updated_at), + ], +); + /** One Cloudflare catch-all and ingress Worker per account/zone in an installation. */ export const emailZoneConnections = pgTable( 'email_zone_connections', diff --git a/platform/packages/api/src/email/connection-service.ts b/platform/packages/api/src/email/connection-service.ts index 0eb043b9c..4ddb895c8 100644 --- a/platform/packages/api/src/email/connection-service.ts +++ b/platform/packages/api/src/email/connection-service.ts @@ -138,12 +138,23 @@ const SYNC_STEPS = [ const SIBLING_STEPS = [ 'routing_enable', 'gateway_probe', + 'catchall_ensure', 'inbound_probe', 'sending_onboard', 'publish', ] as const; const SHARED_SYNC_STEPS = [ 'routing_enable', + 'subaddressing_ensure', + 'inbound_probe', + 'sending_onboard', + 'publish', +] as const; +// Custom apex routes can share the installation Worker, while their catch-all +// still needs to be provisioned. Native Worker secrets are deployment-owned. +const NATIVE_CUSTOM_SYNC_STEPS = [ + 'routing_enable', + 'catchall_ensure', 'inbound_probe', 'sending_onboard', 'publish', @@ -160,6 +171,7 @@ type StepKind = | (typeof SETUP_STEPS)[number] | (typeof STAGED_SECRET_REPAIR_STEPS)[number] | (typeof REMOVE_STEPS)[number] + | (typeof SHARED_SYNC_STEPS)[number] | (typeof ROTATE_STEPS)[number]; const OPERATOR_RECOVERABLE_WRITE_STEPS = [ 'routing_enable', @@ -167,6 +179,7 @@ const OPERATOR_RECOVERABLE_WRITE_STEPS = [ 'probe_route_enable', 'secret_put', 'catchall_ensure', + 'subaddressing_ensure', 'sending_onboard', 'catchall_detach', 'ingress_delete', @@ -537,6 +550,9 @@ function emailStepResourceIdentity( if (kind === 'catchall_ensure' || kind === 'catchall_detach') { return `${base}/catch-all/${kind === 'catchall_ensure' ? 'ensure' : 'detach'}`; } + if (kind === 'subaddressing_ensure') { + return `${base}/settings/subaddressing`; + } if (kind === 'ingress_delete') { return `${base}/relay/delete`; } @@ -1211,9 +1227,36 @@ async function forgetUnusedEmailCredential( export async function inspectEmailInboundRoute( client: ReturnType<typeof createCfCustomerClient>, - connection: Pick<typeof emailZoneConnections.$inferSelect, 'zone_id' | 'ingress_worker_name'>, + connection: Pick< + typeof emailZoneConnections.$inferSelect, + 'zone_id' | 'zone_name' | 'ingress_worker_name' + >, domain: string, + addressing: 'custom' | 'shared' = 'custom', ): Promise<string | null> { + if (addressing === 'custom' && domain !== connection.zone_name) { + return 'Cloudflare catch-all routing requires a zone apex; use the apex domain for custom inbound email'; + } + if (addressing === 'shared') { + const [settings, exchanges] = await Promise.all([ + client.getRoutingSettings(connection.zone_id), + lookupMxOverDns(domain), + ]); + if (!exchanges.length || exchanges.some((exchange) => !isCloudflareEmailRoutingMx(exchange))) { + return `MX records for ${domain} do not route mail to Cloudflare Email Routing`; + } + if ( + !settings || + typeof settings !== 'object' || + (settings as { enabled?: unknown }).enabled !== true || + (settings as { support_subaddress?: unknown }).support_subaddress !== true + ) { + return 'Cloudflare Email Routing and subaddressing must be enabled for shared email'; + } + // Domain readiness does not assert recipient coverage. Each project's + // exact provider rule is verified by the shared-recipient service. + return null; + } const [catchall, rules, exchanges] = await Promise.all([ client.getCatchAll(connection.zone_id), client.listRoutingRules(connection.zone_id), @@ -1338,6 +1381,12 @@ export async function requestEmailDomainAdd( if (!zone) { throw new EmailOperationError(`no Cloudflare zone found for ${domain}`, 404); } + if (domain !== zone.name) { + throw new EmailOperationError( + 'Cloudflare catch-all routing requires a zone apex; use the apex domain for custom inbound email', + 400, + ); + } const shared = env as Bindings & { EMAIL_SHARED_ACCOUNT_ID?: string; EMAIL_SHARED_ZONE_ID?: string; @@ -1666,7 +1715,7 @@ export async function requestEmailDomainAdd( desired_generation: generation, }); const steps = isSharedGatewayConnection(env, connection) - ? SHARED_SYNC_STEPS + ? NATIVE_CUSTOM_SYNC_STEPS : connection.staged_secret_generation !== null ? STAGED_SECRET_REPAIR_STEPS : connection.inbound_status !== 'ready' || @@ -1839,7 +1888,7 @@ export async function requestEmailDomainAdd( desired_generation: connection.desired_generation, }); const steps = isSharedGatewayConnection(env, connection) - ? SHARED_SYNC_STEPS + ? NATIVE_CUSTOM_SYNC_STEPS : repairStaged ? STAGED_SECRET_REPAIR_STEPS : created || connection.inbound_status !== 'ready' @@ -1920,7 +1969,7 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis } gatewayOrigin(env); const installationId = emailInstallationId(env); - const key = `shared:${domain}`; + const key = `shared:${domain}:literal-v1`; const previous = ( await db .select() @@ -1951,13 +2000,9 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis 503, ); } - const existingRule = await client.getCatchAll(zone.id); const worker = shared.EMAIL_SHARED_INGRESS_NAME; - if (existingRule?.enabled && existingRule.workerName !== worker) { - throw new EmailOperationError('shared sender zone has a foreign catch-all', 409); - } - const routeId = generateId('eroute'), - connectionId = generateId('econ'), + let routeId = generateId('eroute'); + const connectionId = generateId('econ'), credentialId = generateId('ecred'), operationId = generateId('eop'); const secret = await encryptPlatformCredential( @@ -1983,7 +2028,7 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis .orderBy(sql`${emailOperations.created_at} DESC`) .limit(1) )[0]; - if (latest) { + if (latest && !['completed', 'cancelled'].includes(latest.status)) { await resumeBlockedEmailOperationTx( tx, env, @@ -1991,8 +2036,12 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis { kind: 'system' }, existingRoute.id, ); + return latest.id; } - return latest?.id ?? null; + if (existingRoute.desired_state !== 'active') { + throw new EmailOperationError('shared email domain is disabled'); + } + routeId = existingRoute.id; } let connection = ( await tx @@ -2008,7 +2057,6 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis .for('update') .limit(1) )[0]; - const created = !connection; if (connection && connection.managed_by !== 'admin') { throw new EmailOperationError('shared sender zone connection must be administrator-managed'); } @@ -2050,22 +2098,49 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis .limit(1) )[0]!; } - await tx.insert(emailDomainRoutes).values({ - id: routeId, - domain, - connection_id: connection.id, - project_id: null, - addressing: 'shared', - managed_by: 'admin', - desired_state: 'active', - }); + if (existingRoute) { + // A completed legacy catch-all observation says nothing about shared + // subdomain recipients or plus addressing. Re-probe before readiness. + await tx + .update(emailZoneConnections) + .set({ + inbound_status: 'pending', + inbound_reason: null, + inbound_observed_at: null, + updated_at: sql`now()`, + }) + .where(eq(emailZoneConnections.id, connection.id)); + } + const [route] = existingRoute + ? await tx + .update(emailDomainRoutes) + .set({ + inbound_status: 'pending', + inbound_reason: null, + desired_generation: sql`${emailDomainRoutes.desired_generation} + 1`, + updated_at: sql`now()`, + }) + .where(eq(emailDomainRoutes.id, routeId)) + .returning() + : await tx + .insert(emailDomainRoutes) + .values({ + id: routeId, + domain, + connection_id: connection.id, + project_id: null, + addressing: 'shared', + managed_by: 'admin', + desired_state: 'active', + }) + .returning(); const requestDigest = await digest({ shared: domain, connectionId: connection.id }); await tx.insert(emailOperations).values({ id: operationId, installation_id: installationId, connection_id: connection.id, route_id: routeId, - route_generation: 1, + route_generation: route!.desired_generation, actor_kind: 'system', actor_scope: 'system:shared', kind: 'setup', @@ -2075,10 +2150,12 @@ export async function ensureSharedEmailConnection(db: Db, env: Bindings): Promis }); // Native gateway is deployed with the platform. Uploading the customer // relay source here would replace it and sever shared mail delivery. - const steps = created - ? (['catchall_ensure', 'inbound_probe', 'publish'] as const) - : (['inbound_probe', 'publish'] as const); - const stepRoute: EmailStepRoute = { id: routeId, domain, desired_generation: 1 }; + const steps = ['routing_enable', 'subaddressing_ensure', 'inbound_probe', 'publish'] as const; + const stepRoute: EmailStepRoute = { + id: routeId, + domain, + desired_generation: route!.desired_generation, + }; for (let i = 0; i < steps.length; i++) { await tx.insert(emailOperationSteps).values({ id: generateId('estep'), @@ -2643,7 +2720,9 @@ async function requestExistingOperation( const steps = kind === 'sync' ? isSharedGatewayConnection(env, connection) - ? SHARED_SYNC_STEPS + ? route.addressing === 'shared' + ? SHARED_SYNC_STEPS + : NATIVE_CUSTOM_SYNC_STEPS : connection.staged_secret_generation !== null ? STAGED_SECRET_REPAIR_STEPS : connection.inbound_status !== 'ready' || route.inbound_status !== 'ready' @@ -3141,6 +3220,19 @@ async function executeEmailStep( throw new EmailOperationError('route is unavailable'); } const subdomain = route.domain !== connection.zone_name; + if (subdomain && route.addressing === 'custom') { + throw new EmailOperationError( + 'Cloudflare catch-all routing requires a zone apex; use the apex domain for custom inbound email', + ); + } + if (route.addressing === 'shared') { + const exchanges = await emailOperationPreflight(() => lookupMxOverDns(route.domain)); + if (exchanges.some((exchange) => !isCloudflareEmailRoutingMx(exchange))) { + throw new EmailOperationError( + `${route.domain} already has MX records for another mail provider; choose an unused shared mail domain`, + ); + } + } await client.enableRouting(connection.zone_id, subdomain ? route.domain : undefined); return { domain: route.domain }; } @@ -3232,7 +3324,46 @@ async function executeEmailStep( } return { generation: connection.staged_secret_generation }; } + if ( + kind === 'subaddressing_ensure' || + (kind === 'catchall_ensure' && route?.addressing === 'shared') + ) { + if (!route || route.addressing !== 'shared') { + throw new EmailOperationError('shared email route is unavailable'); + } + // Compatibility for interrupted catch-all-era shared bootstrap operations. + if (kind === 'catchall_ensure') { + await client.enableRouting( + connection.zone_id, + route.domain === connection.zone_name ? undefined : route.domain, + ); + } + const settings = await emailOperationPreflight(() => + client.getRoutingSettings(connection.zone_id), + ); + if ( + !settings || + typeof settings !== 'object' || + (settings as { support_subaddress?: unknown }).support_subaddress !== true + ) { + await client.enableSubaddressing(connection.zone_id); + } + const verified = await client.getRoutingSettings(connection.zone_id); + if ( + !verified || + typeof verified !== 'object' || + (verified as { support_subaddress?: unknown }).support_subaddress !== true + ) { + throw new EmailOperationError('Cloudflare did not enable shared email subaddressing', 503); + } + return { subaddressing: true }; + } if (kind === 'catchall_ensure') { + if (route && route.domain !== connection.zone_name) { + throw new EmailOperationError( + 'Cloudflare catch-all routing requires a zone apex; use the apex domain for custom inbound email', + ); + } const current = await emailOperationPreflight(() => client.getCatchAll(connection.zone_id)); if (current?.enabled && current.workerName !== connection.ingress_worker_name) { throw new EmailOperationError('another enabled catch-all owns this zone'); @@ -3246,7 +3377,12 @@ async function executeEmailStep( if (!route) { throw new EmailOperationError('email route is unavailable'); } - const issue = await inspectEmailInboundRoute(client, connection, route.domain); + const issue = await inspectEmailInboundRoute( + client, + connection, + route.domain, + route.addressing, + ); if (issue) { await db .update(emailDomainRoutes) @@ -3999,13 +4135,46 @@ export async function reconcileUnknownEmailOperation( .limit(1) )[0] : null; - if (route && !(await inspectEmailInboundRoute(client, connection, route.domain))) { + if ( + route && + !(await inspectEmailInboundRoute(client, connection, route.domain, route.addressing)) + ) { outcome = { ready: true }; } + } else if (step.kind === 'subaddressing_ensure') { + const settings = await client.getRoutingSettings(connection.zone_id); + if ( + settings && + typeof settings === 'object' && + (settings as { support_subaddress?: unknown }).support_subaddress === true + ) { + outcome = { subaddressing: true }; + } } else if (step.kind === 'catchall_ensure') { - const catchall = await client.getCatchAll(connection.zone_id); - if (catchall?.enabled && catchall.workerName === connection.ingress_worker_name) { - outcome = { workerName: connection.ingress_worker_name }; + const route = op.route_id + ? ( + await db + .select() + .from(emailDomainRoutes) + .where(eq(emailDomainRoutes.id, op.route_id)) + .limit(1) + )[0] + : null; + if (route?.addressing === 'shared') { + // Legacy shared-domain operations used this step for an apex-only + // catch-all. Recovery must confirm the new subdomain prerequisites. + if (!(await inspectEmailInboundRoute(client, connection, route.domain, 'shared'))) { + outcome = { subaddressing: true }; + } + } else { + const catchall = await client.getCatchAll(connection.zone_id); + if ( + catchall?.enabled && + catchall.workerName === connection.ingress_worker_name && + route?.domain === connection.zone_name + ) { + outcome = { workerName: connection.ingress_worker_name }; + } } } else if (step.kind === 'catchall_detach') { const catchall = await client.getCatchAll(connection.zone_id); diff --git a/platform/packages/api/src/email/domain-service.ts b/platform/packages/api/src/email/domain-service.ts index d4a7ac30c..85a7817a3 100644 --- a/platform/packages/api/src/email/domain-service.ts +++ b/platform/packages/api/src/email/domain-service.ts @@ -184,6 +184,7 @@ export async function getEmailDomainStatusForProject( client, snapshot.connection, snapshot.route.domain, + snapshot.route.addressing, ); try { const senders = await client.listSendingSubdomains(snapshot.connection.zone_id); diff --git a/platform/packages/api/src/email/ingress-resolution.ts b/platform/packages/api/src/email/ingress-resolution.ts index be3946cf0..0ddb4b5ce 100644 --- a/platform/packages/api/src/email/ingress-resolution.ts +++ b/platform/packages/api/src/email/ingress-resolution.ts @@ -16,6 +16,7 @@ import { resolveEmailIngress, } from './connection-service.ts'; import { EmailInternalBodyError, readBoundedInternalJson } from './internal-body.ts'; +import { getSharedEmailRecipientReadiness } from './shared-recipient-service.ts'; const app = new Hono<ApiEnv>(); app.use('*', async (context, next) => { @@ -242,6 +243,9 @@ app.post('/resolve-ingress', async (context) => { } projectId = project.id; slug = project.slug; + if ((await getSharedEmailRecipientReadiness(db, context.env, projectId))?.status !== 'ready') { + return context.json({ error: 'shared recipient routing is not ready' }, 503); + } } else { const project = ( await db diff --git a/platform/packages/api/src/email/shared-recipient-service.ts b/platform/packages/api/src/email/shared-recipient-service.ts new file mode 100644 index 000000000..496da346b --- /dev/null +++ b/platform/packages/api/src/email/shared-recipient-service.ts @@ -0,0 +1,606 @@ +import { and, asc, eq, ne, sql } from 'drizzle-orm'; +import type { Db } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { generateId } from '../id.ts'; +import { CloudflareApiError } from '../provisioning/cloudflare-api.ts'; +import { projects } from '../schema.ts'; +import { createCfCustomerClient, type CfEmailRoutingRule } from './cf-customer.ts'; +import { + emailDomainRoutes, + emailSharedRecipients, + emailZoneConnections, +} from './connection-schema.ts'; +import { + credentialFor, + EmailOperationError, + emailInstallationId, + inspectEmailInboundRoute, +} from './connection-service.ts'; + +type Bindings = ApiEnv['Bindings']; +type Recipient = typeof emailSharedRecipients.$inferSelect; +const LEASE_MS = 15 * 60_000; + +async function sharedRoute(db: Db, env: Bindings) { + if (!env.EMAIL_SENDER_DOMAIN) { + return null; + } + const [row] = await db + .select() + .from(emailDomainRoutes) + .innerJoin(emailZoneConnections, eq(emailZoneConnections.id, emailDomainRoutes.connection_id)) + .where( + and( + eq(emailDomainRoutes.domain, env.EMAIL_SENDER_DOMAIN.toLowerCase()), + eq(emailDomainRoutes.addressing, 'shared'), + eq(emailDomainRoutes.desired_state, 'active'), + ), + ) + .limit(1); + if (!row) { + return null; + } + const connection = row.email_zone_connections; + const configured = env as Bindings & { + EMAIL_SHARED_ACCOUNT_ID?: string; + EMAIL_SHARED_ZONE_ID?: string; + EMAIL_SHARED_INGRESS_NAME?: string; + }; + if ( + connection.installation_id !== emailInstallationId(env) || + connection.account_id !== env.CF_ACCOUNT_ID || + connection.account_id !== configured.EMAIL_SHARED_ACCOUNT_ID || + connection.zone_id !== configured.EMAIL_SHARED_ZONE_ID || + connection.ingress_worker_name !== configured.EMAIL_SHARED_INGRESS_NAME || + connection.managed_by !== 'admin' + ) { + throw new EmailOperationError( + 'shared email ingress identity does not match this installation', + 503, + ); + } + return { route: row.email_domain_routes, connection }; +} + +export function isOwnedSharedRecipientRule( + rule: CfEmailRoutingRule, + row: Pick<Recipient, 'rule_id' | 'rule_name' | 'recipient'>, + worker: string, +) { + return Boolean( + rule.id && + (!row.rule_id || rule.id === row.rule_id) && + rule.name === row.rule_name && + rule.literalWorker && + rule.workerName === worker && + rule.addresses.length === 1 && + rule.addresses[0] === row.recipient, + ); +} + +function ruleConflicts(rule: CfEmailRoutingRule, recipient: string) { + const at = recipient.lastIndexOf('@'); + const local = recipient.slice(0, at), + domain = recipient.slice(at + 1); + return rule.addresses.some((address) => { + const split = address.lastIndexOf('@'); + return ( + address.slice(split + 1) === domain && address.slice(0, split).split('+', 1)[0] === local + ); + }); +} + +async function ownershipName(installation: string, projectId: string, recipient: string) { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(JSON.stringify([installation, projectId, recipient])), + ); + return `void-shared-${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`; +} + +function failureReason(error: unknown) { + if (error instanceof EmailOperationError) { + return error.message; + } + if (error instanceof CloudflareApiError && error.status === 403) { + return 'Cloudflare denied shared recipient management; grant Email Routing Rules Write and Zone Settings Write'; + } + if ( + error instanceof CloudflareApiError && + (error.status === 409 || /limit|maximum|capacity/i.test(error.message)) + ) { + return 'Cloudflare could not allocate an Email Routing rule; remove unused rules or review the Email Routing limits at https://developers.cloudflare.com/email-service/platform/limits/'; + } + return 'Cloudflare shared recipient routing could not be verified; retry reconciliation'; +} + +async function finish( + db: Db, + row: Recipient, + values: Partial<typeof emailSharedRecipients.$inferInsert>, +) { + const rows = await db + .update(emailSharedRecipients) + .set({ ...values, lease_started_at: null, observed_at: sql`now()`, updated_at: sql`now()` }) + .where( + and( + eq(emailSharedRecipients.id, row.id), + eq(emailSharedRecipients.attempt, row.attempt), + eq(emailSharedRecipients.status, 'reconciling'), + eq(emailSharedRecipients.removing, row.removing), + ), + ) + .returning({ id: emailSharedRecipients.id }); + return rows.length === 1; +} + +/** Provider reads can outlive a lease. Renew only the exact current claim; + * a zero-row CAS must stop before any provider write or ownership checkpoint. */ +async function renewClaim( + db: Db, + row: Recipient, + values: Partial<typeof emailSharedRecipients.$inferInsert> = {}, +) { + const rows = await db + .update(emailSharedRecipients) + .set({ ...values, lease_started_at: sql`now()`, updated_at: sql`now()` }) + .where( + and( + eq(emailSharedRecipients.id, row.id), + eq(emailSharedRecipients.attempt, row.attempt), + eq(emailSharedRecipients.status, 'reconciling'), + eq(emailSharedRecipients.removing, row.removing), + ), + ) + .returning({ id: emailSharedRecipients.id }); + return rows.length === 1; +} + +/** No transaction spans provider I/O. The project lock fences teardown and slug reuse. */ +export async function reconcileSharedEmailRecipient( + db: Db, + env: Bindings, + projectId: string, +): Promise<void> { + const shared = await sharedRoute(db, env); + if (!shared) { + return; + } + const project = (await db.select().from(projects).where(eq(projects.id, projectId)).limit(1))[0]; + if (!project || project.status !== 'active') { + return; + } + const recipient = `${project.slug}@${shared.route.domain}`; + await removeSharedEmailRecipients(db, env, projectId, recipient); + const name = await ownershipName(shared.connection.installation_id, projectId, recipient); + const claimed = await db.transaction(async (tx) => { + const [lockedProject] = await tx + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .for('update'); + if ( + !lockedProject || + lockedProject.status !== 'active' || + lockedProject.slug !== project.slug + ) { + return null; + } + await tx + .insert(emailSharedRecipients) + .values({ + id: generateId('erec'), + installation_id: shared.connection.installation_id, + project_id: projectId, + route_id: shared.route.id, + recipient, + rule_name: name, + }) + .onConflictDoNothing(); + const [row] = await tx + .select() + .from(emailSharedRecipients) + .where( + and( + eq(emailSharedRecipients.installation_id, shared.connection.installation_id), + eq(emailSharedRecipients.project_id, projectId), + eq(emailSharedRecipients.recipient, recipient), + ), + ) + .for('update'); + if ( + !row || + row.removing || + row.status === 'removed' || + (row.status === 'reconciling' && + Date.parse(row.lease_started_at ?? '') > Date.now() - LEASE_MS) + ) { + return null; + } + const [claimedRow] = await tx + .update(emailSharedRecipients) + .set({ + status: 'reconciling', + attempt: row.attempt + 1, + lease_started_at: sql`now()`, + updated_at: sql`now()`, + }) + .where(eq(emailSharedRecipients.id, row.id)) + .returning(); + return claimedRow!; + }); + if (!claimed) { + return; + } + let row = claimed; + try { + // Record the refusal so scheduled repair remains fair even for long names. + if (recipient.length > 90) { + throw new EmailOperationError( + 'Shared email address exceeds Cloudflare’s 90-character routing-rule limit; use a shorter project slug or shared mail domain', + 400, + ); + } + const client = createCfCustomerClient(await credentialFor(env, db, shared.connection)); + const domainIssue = await inspectEmailInboundRoute( + client, + shared.connection, + shared.route.domain, + 'shared', + ); + if (domainIssue) { + throw new EmailOperationError(domainIssue); + } + const rules = await client.listRoutingRules(shared.connection.zone_id); + const named = rules.filter((rule) => rule.name === row.rule_name); + if ( + named.length > 1 || + named.some( + (rule) => !isOwnedSharedRecipientRule(rule, row, shared.connection.ingress_worker_name), + ) + ) { + throw new EmailOperationError( + 'Shared recipient rule ownership changed; restore the recorded rule before reconciliation', + ); + } + if ( + rules.some( + (rule) => + ruleConflicts(rule, recipient) && + !isOwnedSharedRecipientRule(rule, row, shared.connection.ingress_worker_name), + ) + ) { + throw new EmailOperationError( + `An unrelated Cloudflare Email Routing rule owns ${recipient} or one of its plus addresses; remove that conflict before reconciliation`, + ); + } + let rule = named[0]; + if (row.rule_id && !rule) { + const existing = await client.getRoutingRule(shared.connection.zone_id, row.rule_id); + if (existing) { + throw new EmailOperationError( + 'Recorded shared recipient rule ownership changed; restore it before reconciliation', + ); + } + if (!(await renewClaim(db, row, { rule_id: null, create_started_at: null }))) { + return; + } + row = { ...row, rule_id: null, create_started_at: null }; + } + if (!rule) { + if (row.create_started_at) { + await finish(db, row, { + status: 'unknown', + reason: + 'A previous recipient-rule creation has an unknown outcome; inspect Cloudflare before retrying or deleting this project', + }); + return; + } + if ( + rules.filter((candidate) => + candidate.addresses.some((address) => address.endsWith(`@${shared.route.domain}`)), + ).length >= 200 + ) { + throw new EmailOperationError( + 'Shared mail domain reached Cloudflare’s 200 routing-rule limit; remove unused rules or request a higher limit at https://developers.cloudflare.com/email-service/platform/limits/', + ); + } + // Persist creation intent before POST. A lost response is recovered only + // by exact deterministic ownership; absence never authorizes another POST. + const intent = new Date().toISOString(); + if (!(await renewClaim(db, row, { create_started_at: intent }))) { + return; + } + row = { ...row, create_started_at: intent }; + rule = await client.putLiteralRule(shared.connection.zone_id, null, { + name: row.rule_name, + address: recipient, + workerName: shared.connection.ingress_worker_name, + }); + } else if (!rule.enabled) { + if (!(await renewClaim(db, row))) { + return; + } + rule = await client.putLiteralRule(shared.connection.zone_id, rule.id, { + name: row.rule_name, + address: recipient, + workerName: shared.connection.ingress_worker_name, + }); + } + if (!isOwnedSharedRecipientRule(rule, row, shared.connection.ingress_worker_name)) { + throw new EmailOperationError('Cloudflare returned an incomplete shared recipient rule'); + } + if (!(await renewClaim(db, row, { rule_id: rule.id, create_started_at: null }))) { + return; + } + row = { ...row, rule_id: rule.id }; + const live = await client.getRoutingRule(shared.connection.zone_id, rule.id!); + if ( + !live?.enabled || + !isOwnedSharedRecipientRule(live, row, shared.connection.ingress_worker_name) + ) { + throw new EmailOperationError('Cloudflare did not confirm the exact shared recipient rule'); + } + const after = await client.listRoutingRules(shared.connection.zone_id); + if ( + after.some((candidate) => candidate.id !== row.rule_id && ruleConflicts(candidate, recipient)) + ) { + throw new EmailOperationError('A conflicting recipient rule appeared during reconciliation'); + } + await finish(db, row, { status: 'ready', reason: null, create_started_at: null }); + } catch (error) { + const rejected = + error instanceof CloudflareApiError && + error.status >= 400 && + error.status < 500 && + error.status !== 408; + await finish(db, row, { + status: row.create_started_at && !row.rule_id && !rejected ? 'unknown' : 'blocked', + reason: failureReason(error), + ...(rejected ? { create_started_at: null } : {}), + }); + } +} + +export async function getSharedEmailRecipientReadiness( + db: Db, + env: Bindings, + projectId: string, + verifyProvider = false, +) { + const shared = await sharedRoute(db, env); + if (!shared) { + return null; + } + const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + if (!project) { + return null; + } + const recipient = `${project.slug}@${shared.route.domain}`; + const [row] = await db + .select() + .from(emailSharedRecipients) + .where( + and( + eq(emailSharedRecipients.installation_id, shared.connection.installation_id), + eq(emailSharedRecipients.project_id, projectId), + eq(emailSharedRecipients.recipient, recipient), + ), + ) + .limit(1); + let status = + project.status !== 'active' + ? 'disabled' + : row?.status === 'ready' && + row.rule_id && + !row.removing && + shared.route.inbound_status === 'ready' + ? 'ready' + : row?.status === 'ready' + ? 'pending' + : (row?.status ?? 'pending'); + let reason = + row?.reason ?? (shared.route.inbound_status !== 'ready' ? shared.route.inbound_reason : null); + if (verifyProvider && status === 'ready' && row?.rule_id) { + try { + const client = createCfCustomerClient(await credentialFor(env, db, shared.connection)); + const [domainIssue, rule, rules] = await Promise.all([ + inspectEmailInboundRoute(client, shared.connection, shared.route.domain, 'shared'), + client.getRoutingRule(shared.connection.zone_id, row.rule_id), + client.listRoutingRules(shared.connection.zone_id), + ]); + reason = + domainIssue ?? + (!rule?.enabled || + !isOwnedSharedRecipientRule(rule, row, shared.connection.ingress_worker_name) + ? 'The exact shared recipient rule is missing or changed' + : rules.some( + (candidate) => candidate.id !== row.rule_id && ruleConflicts(candidate, recipient), + ) + ? 'An unrelated rule conflicts with this shared recipient or its plus addresses' + : null); + } catch (error) { + reason = failureReason(error); + } + if (reason) { + status = 'blocked'; + } + await db + .update(emailSharedRecipients) + .set({ status: status === 'ready' ? 'ready' : 'blocked', reason, observed_at: sql`now()` }) + .where( + and( + eq(emailSharedRecipients.id, row.id), + eq(emailSharedRecipients.status, 'ready'), + eq(emailSharedRecipients.attempt, row.attempt), + ), + ); + } + return { recipient, status, reason, observedAt: row?.observed_at ?? null }; +} + +/** Critical cleanup: retain the project's reusable slug until exact deletion readback succeeds. */ +export async function removeSharedEmailRecipients( + db: Db, + env: Bindings, + projectId: string, + exceptRecipient?: string, +): Promise<void> { + const rows = await db + .select() + .from(emailSharedRecipients) + .where( + and( + eq(emailSharedRecipients.project_id, projectId), + ne(emailSharedRecipients.status, 'removed'), + exceptRecipient ? ne(emailSharedRecipients.recipient, exceptRecipient) : undefined, + ), + ); + for (const initial of rows) { + const [connectionRow] = await db + .select() + .from(emailDomainRoutes) + .innerJoin(emailZoneConnections, eq(emailZoneConnections.id, emailDomainRoutes.connection_id)) + .where(eq(emailDomainRoutes.id, initial.route_id)) + .limit(1); + if ( + !connectionRow || + connectionRow.email_zone_connections.installation_id !== initial.installation_id + ) { + throw new EmailOperationError('Shared recipient cleanup identity is unavailable'); + } + const connection = connectionRow.email_zone_connections; + const configured = env as Bindings & { + EMAIL_SHARED_ACCOUNT_ID?: string; + EMAIL_SHARED_ZONE_ID?: string; + EMAIL_SHARED_INGRESS_NAME?: string; + }; + if ( + connectionRow.email_domain_routes.addressing !== 'shared' || + initial.installation_id !== emailInstallationId(env) || + connection.managed_by !== 'admin' || + connection.account_id !== env.CF_ACCOUNT_ID || + connection.account_id !== configured.EMAIL_SHARED_ACCOUNT_ID || + connection.zone_id !== configured.EMAIL_SHARED_ZONE_ID || + connection.ingress_worker_name !== configured.EMAIL_SHARED_INGRESS_NAME + ) { + throw new EmailOperationError( + 'Shared recipient cleanup identity does not match this installation', + ); + } + const row = await db.transaction(async (tx) => { + const [current] = await tx + .select() + .from(emailSharedRecipients) + .where(eq(emailSharedRecipients.id, initial.id)) + .for('update'); + if (!current || current.status === 'removed') { + return null; + } + if ( + current.status === 'reconciling' && + Date.parse(current.lease_started_at ?? '') > Date.now() - LEASE_MS + ) { + throw new EmailOperationError( + 'Shared recipient reconciliation is still running; retry project deletion', + ); + } + const [claimed] = await tx + .update(emailSharedRecipients) + .set({ + removing: true, + status: 'reconciling', + attempt: current.attempt + 1, + lease_started_at: sql`now()`, + updated_at: sql`now()`, + }) + .where(eq(emailSharedRecipients.id, current.id)) + .returning(); + return claimed!; + }); + if (!row) { + continue; + } + try { + const client = createCfCustomerClient(await credentialFor(env, db, connection)); + const rules = await client.listRoutingRules(connection.zone_id); + const owned = rules.filter( + (rule) => rule.name === row.rule_name || (row.rule_id && rule.id === row.rule_id), + ); + if ( + owned.length > 1 || + owned.some((rule) => !isOwnedSharedRecipientRule(rule, row, connection.ingress_worker_name)) + ) { + throw new EmailOperationError( + 'Shared recipient cleanup ownership changed; restore the recorded rule before deleting this project', + ); + } + // Listing can be stale. A retained ID must be absent from an exact GET + // before its reusable address can be released. + const rule = + owned[0] ?? + (row.rule_id ? await client.getRoutingRule(connection.zone_id, row.rule_id) : null); + if (rule && !isOwnedSharedRecipientRule(rule, row, connection.ingress_worker_name)) { + throw new EmailOperationError( + 'Shared recipient cleanup ownership changed; restore the recorded rule before deleting this project', + ); + } + if (!rule && row.create_started_at && !row.rule_id) { + throw new EmailOperationError( + 'Shared recipient creation has an unknown outcome; resolve it before deleting this project', + ); + } + if (rule) { + const exact = await client.getRoutingRule(connection.zone_id, rule.id!); + if (exact && !isOwnedSharedRecipientRule(exact, row, connection.ingress_worker_name)) { + throw new EmailOperationError('Shared recipient ownership changed before deletion'); + } + if (exact) { + if (!(await renewClaim(db, row))) { + throw new EmailOperationError( + 'Shared recipient cleanup was superseded; retry project deletion', + ); + } + await client.deleteRoutingRule(connection.zone_id, exact.id!); + } + if (await client.getRoutingRule(connection.zone_id, rule.id!)) { + throw new EmailOperationError('Cloudflare did not confirm shared recipient deletion'); + } + } + if (!(await finish(db, row, { status: 'removed', reason: null, create_started_at: null }))) { + throw new EmailOperationError( + 'Shared recipient cleanup was superseded; retry project deletion', + ); + } + } catch (error) { + await finish(db, row, { status: 'blocked', reason: failureReason(error) }); + throw error; + } + } +} + +/** Fair bounded repair includes drifted ready rows; provider writes remain identity-fenced. */ +export async function reconcileSharedEmailRecipients(db: Db, env: Bindings, limit = 10) { + if (!(await sharedRoute(db, env))) { + return; + } + const rows = await db + .select({ id: projects.id }) + .from(projects) + .leftJoin( + emailSharedRecipients, + and( + eq(emailSharedRecipients.project_id, projects.id), + eq( + emailSharedRecipients.recipient, + sql`${projects.slug} || '@' || ${env.EMAIL_SENDER_DOMAIN!.toLowerCase()}`, + ), + ), + ) + .where(eq(projects.status, 'active')) + .orderBy(sql`${emailSharedRecipients.updated_at} asc nulls first`, asc(projects.id)) + .limit(limit); + for (const row of rows) { + await reconcileSharedEmailRecipient(db, env, row.id); + } +} diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index fbccf7784..605934a40 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -59,8 +59,10 @@ import { runEmailOperation, flushEmailCacheOutbox, } from './email/connection-service.ts'; +import { isValidEmailDomain, MAX_DOMAIN_LENGTH } from './email/domain-name.ts'; import emailIngressResolution from './email/ingress-resolution.ts'; import { repairEmailRecipientPolicyMirror } from './email/settings.ts'; +import { reconcileSharedEmailRecipients } from './email/shared-recipient-service.ts'; import type { ApiEnv } from './env.ts'; import { ErrorAlertMonitor } from './error-alert-monitor.ts'; import { cancelContainerBuildDos } from './github/cancel-builds.ts'; @@ -141,6 +143,24 @@ import { } from './zero-trust/routing.ts'; import { markProjectCustomDomainsChanged } from './zero-trust/state.ts'; +function sharedEmailBootstrapFailureDetail( + domain: string | null, + detail: string | null, +): string | null { + // Provider errors may contain arbitrary response content. Only expose the + // readiness reasons generated by our own route inspector to the installer. + if (!domain || domain.length > MAX_DOMAIN_LENGTH || !isValidEmailDomain(domain)) { + return null; + } + const safeReasons = [ + `MX records for ${domain} do not route mail to Cloudflare Email Routing`, + 'zone catch-all does not point to this ingress', + `enabled explicit Cloudflare email rules conflict with ${domain}`, + 'another enabled catch-all owns this zone', + ]; + return detail !== null && safeReasons.includes(detail) ? detail : null; +} + function isSandboxDrainMaintenanceRequest(request: Request): boolean { const path = new URL(request.url).pathname; const method = request.method; @@ -298,6 +318,7 @@ const app = new Hono<ApiEnv>() : {}), capabilities, displayName: context.env.PLATFORM_DISPLAY_NAME ?? 'Void Cloud', + ...(context.env.DASHBOARD_URL ? { dashboardUrl: context.env.DASHBOARD_URL } : {}), installationId: context.env.PLATFORM_INSTALLATION_ID ?? null, platformVersion: context.env.PLATFORM_VERSION ?? 'hosted', proxyUrl: @@ -334,6 +355,7 @@ const app = new Hono<ApiEnv>() .select({ currentStep: emailOperations.current_step, failureCode: emailOperations.failure_code, + failureDetail: emailOperations.failure_detail, routeId: emailOperations.route_id, status: emailOperations.status, }) @@ -346,6 +368,7 @@ const app = new Hono<ApiEnv>() await db .select({ desiredState: emailDomainRoutes.desired_state, + domain: emailDomainRoutes.domain, inboundStatus: emailDomainRoutes.inbound_status, }) .from(emailDomainRoutes) @@ -366,6 +389,12 @@ const app = new Hono<ApiEnv>() operationStatus: operation?.status ?? 'missing', currentStep: operation?.currentStep ?? null, failureCode: operation?.failureCode ?? null, + failureDetail: sharedEmailBootstrapFailureDetail( + route?.domain ?? null, + operation?.failureDetail ?? null, + ), + domain: route?.domain ?? null, + inboundStatus: route?.inboundStatus ?? null, }, 503, ); @@ -616,6 +645,9 @@ async function scheduled( await reconcilePendingEmailOperations(db, env).catch((error) => console.error('[scheduled] email operation reconciliation failed:', error), ); + await reconcileSharedEmailRecipients(db, env).catch((error) => + console.error('[scheduled] shared email recipient reconciliation failed:', error), + ); await flushEmailCacheOutbox(db, env.ROUTING).catch((error) => console.error('[scheduled] email display publication failed:', error), ); diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index f1b189f0d..7b659489e 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -35,6 +35,7 @@ import { import { drainProjectEmailAdmissions } from '../email/admission.ts'; import { emailDomainRoutes, emailZoneConnections } from '../email/connection-schema.ts'; import { requestEmailDomainRemove, runEmailOperation } from '../email/connection-service.ts'; +import { removeSharedEmailRecipients } from '../email/shared-recipient-service.ts'; import type { ApiEnv } from '../env.ts'; import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; import { generateId } from '../id.ts'; @@ -865,6 +866,7 @@ export async function teardownProject( } await cleanupCriticalResources(context, projectId, project); + await removeSharedEmailRecipients(context.get('db'), context.env, projectId); // Customer-side cleanup is durable and may continue after project deletion. await cleanupProjectEmailAssignments(context, projectId); const { warnings: bestEffortWarnings, cleanedCfIds } = await cleanupBestEffortResources( diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index 9517277e1..b2d628c87 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -94,6 +94,10 @@ import { type DeploymentOperation, } from '../deployment-execution.ts'; import { sendDeployFailedEmail } from '../email.ts'; +import { + getSharedEmailRecipientReadiness, + reconcileSharedEmailRecipient, +} from '../email/shared-recipient-service.ts'; import type { ApiEnv } from '../env.ts'; import { generateId } from '../id.ts'; import { checkCliVersionOrRespond } from '../middleware/cli-version.ts'; @@ -2086,6 +2090,29 @@ async function handleManifestOnlyDeploy(context: Context<ApiEnv>): Promise<Respo 400, ); } + if ( + incomingManifest.emailProtocolVersion === 1 && + getPlatformCapabilities(context.env).email && + context.env.EMAIL_SENDER_DOMAIN + ) { + await reconcileSharedEmailRecipient(context.get('db'), context.env, projectId); + const readiness = await getSharedEmailRecipientReadiness( + context.get('db'), + context.env, + projectId, + ); + if (readiness?.status !== 'ready') { + return context.json( + { + event: 'error', + message: + readiness?.reason ?? + 'Shared email recipient routing is not ready; ask a platform administrator to reconcile shared email', + }, + 409, + ); + } + } if (incomingManifest.sandbox && incomingManifest.sandbox.controllerProtocol !== 2) { return context.json( { diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index c43844dd5..fa676a0fc 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -32,6 +32,7 @@ import { getEmailRecipientPolicy, repairEmailRecipientPolicyMirror, } from '../email/settings.ts'; +import { getSharedEmailRecipientReadiness } from '../email/shared-recipient-service.ts'; import type { ApiEnv } from '../env.ts'; import { isForeignKeyViolation, isUniqueViolation } from '../pg-errors.ts'; import { @@ -533,15 +534,17 @@ const app = new Hono<ApiEnv>() if (access instanceof Response) { return access; } - const [management, recipientPolicy] = await Promise.all([ + const [management, recipientPolicy, sharedInbound] = await Promise.all([ getEmailDomainManagement(db), getEmailRecipientPolicy(db), + getSharedEmailRecipientReadiness(db, context.env, projectId, true), ]); // Republish a drifted recipient-policy mirror for the proxy's local gate. await repairEmailRecipientPolicyMirror(db, context.env.ROUTING); return context.json({ domains: { management, projectSlug: access.project.slug }, recipientPolicy: { mode: recipientPolicy.mode, domains: [...recipientPolicy.domains] }, + sharedInbound, }); }) diff --git a/platform/packages/api/src/routes/platform-bootstrap.ts b/platform/packages/api/src/routes/platform-bootstrap.ts index 932f089d8..be53dff0b 100644 --- a/platform/packages/api/src/routes/platform-bootstrap.ts +++ b/platform/packages/api/src/routes/platform-bootstrap.ts @@ -21,7 +21,12 @@ import { getPlatformPageTitle } from '../platform-identity.ts'; const app = new Hono<ApiEnv>(); const noStore: MiddlewareHandler<ApiEnv> = async (context, next) => { context.header('Cache-Control', 'no-store'); - context.header('Referrer-Policy', 'no-referrer'); + // Native form submissions need their same-origin Origin header. no-referrer + // serializes that header as null, while strict-origin hides paths and codes. + context.header( + 'Referrer-Policy', + context.req.path === '/setup' ? 'strict-origin' : 'no-referrer', + ); await next(); }; for (const path of [ diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index ceb4fdf73..b59a28c83 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -54,6 +54,7 @@ import { tryCreateEmailDestination, ACTIVE_BUILD_STATUSES, } from '../db.ts'; +import { reconcileSharedEmailRecipient } from '../email/shared-recipient-service.ts'; import { type ApiEnv, isDevBypass } from '../env.ts'; import { validateSubmittedExecutionPlans } from '../migrations/execution-plan.ts'; import { ensureTrackingTable, queryD1, runRemoteMigrations } from '../migrations/remote-runner.ts'; @@ -743,6 +744,12 @@ const app = new Hono<ApiEnv>() const userId = context.get('user').id; try { const project = await createProject(context.get('db'), userId, body.slug); + if (getPlatformCapabilities(context.env).email && context.env.EMAIL_SENDER_DOMAIN) { + await scheduleAfterResponse( + context, + reconcileSharedEmailRecipient(context.get('db'), context.env, project.id), + ); + } let zeroTrust: Awaited<ReturnType<typeof initializeNewProjectZeroTrust>> = null; let zeroTrustWarning: string | undefined; try { diff --git a/platform/packages/api/test/integration/admin-auth-config.test.ts b/platform/packages/api/test/integration/admin-auth-config.test.ts index 6600f2931..ca27adfcb 100644 --- a/platform/packages/api/test/integration/admin-auth-config.test.ts +++ b/platform/packages/api/test/integration/admin-auth-config.test.ts @@ -15,6 +15,7 @@ import { platformLoginConnections, } from '../../src/schema.ts'; import { clearTables, createTestUser, setupEnv, teardownEnv, type TestEnv } from './helpers.ts'; +import { nativeFormSubmission } from './native-form-browser.ts'; let env: TestEnv; let cookie: string; @@ -53,6 +54,46 @@ function request(path: string, form?: Record<string, string>, requestOrigin = or ); } describe('browser authentication configuration', () => { + it.each([home, `${home}/new?kind=oidc`, `${home}/connections/google`])( + 'keeps form-compatible origin-only referrers on %s and page navigation', + async (path) => { + for (const pageNavigation of [false, true]) { + const response = await app.fetch( + new Request(`${origin}${path}`, { + headers: { Cookie: cookie, ...(pageNavigation ? { 'X-VoidPages': 'true' } : {}) }, + }), + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('Referrer-Policy')).toBe('strict-origin'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + } + }, + ); + + it('accepts a native provider-settings form without leaking the page query', async () => { + const input = { + env, + origin, + cookie, + path: `${home}/connections/google?test=private-test-query`, + fields: { label: 'Native Google login', clientId: env.GOOGLE_CLIENT_ID }, + button: 'Save configuration', + }; + const suppressed = await nativeFormSubmission({ ...input, noReferrer: true }); + expect(suppressed).toEqual({ + pagePolicy: 'strict-origin', + submission: { origin: 'null', referrer: undefined, status: 403 }, + }); + expect((await readLoginConnection(env.db, 'google'))?.revision).toBe(1); + expect(await env.db.select().from(operatorEvents)).toHaveLength(0); + expect(await nativeFormSubmission(input)).toEqual({ + pagePolicy: 'strict-origin', + submission: { origin, referrer: `${origin}/`, status: 302 }, + }); + expect((await readLoginConnection(env.db, 'google'))?.revision).toBe(2); + }); + it('links from authentication settings to the unified Access page without secrets', async () => { const response = await request(home); expect(response.status).toBe(200); diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 87fa333b8..5164b4dac 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -39,6 +39,7 @@ import { skipOrphanSweepDelay, type TestEnv, } from './helpers.ts'; +import { nativeFormSubmission } from './native-form-browser.ts'; let env: TestEnv; let manifestCounter = 0; @@ -524,6 +525,34 @@ function expectNoZeroTrustSecrets(text: string) { } describe('admin Zero Trust', () => { + test('admits native setup form validation while retaining its exact-origin CSRF guard', async () => { + const admin = await createTestUser(env.db, 'zt-native', 'github', undefined, 'free', 'admin'); + const origin = 'https://api.example.com'; + const input = { + env, + origin, + cookie: `admin_token=${admin.adminJwt}`, + path: '/admin/zero-trust?test=private-query', + fields: { + apiToken: 'synthetic-form-token', + identityProviderIds: ' ', + policyIds: 'policy-1', + }, + button: 'Save and reconcile', + }; + expect(await nativeFormSubmission({ ...input, noReferrer: true })).toEqual({ + pagePolicy: 'strict-origin', + submission: { origin: 'null', referrer: undefined, status: 403 }, + }); + expect(await env.db.select().from(operatorEvents)).toHaveLength(0); + expect(await nativeFormSubmission(input)).toEqual({ + pagePolicy: 'strict-origin', + submission: { origin, referrer: `${origin}/`, status: 400 }, + }); + // Empty provider IDs fail validation before any Cloudflare request or mutation. + expect(await env.db.select().from(platformSettings)).toHaveLength(0); + }); + test('renders the installation page without exposing a credential', async () => { const admin = await createTestUser( env.db, @@ -545,7 +574,7 @@ describe('admin Zero Trust', () => { const res = await req('/admin/zero-trust', admin.adminJwt); expect(res.status).toBe(200); expect(res.headers.get('Cache-Control')).toBe('no-store'); - expect(res.headers.get('Referrer-Policy')).toBe('no-referrer'); + expect(res.headers.get('Referrer-Policy')).toBe('strict-origin'); const html = await res.text(); const visible = visibleMarkup(html); expect(visible).toContain('Project Zero Trust'); @@ -670,7 +699,7 @@ describe('admin Zero Trust', () => { expect(navigation.status).toBe(200); expect(navigation.headers.get('X-VoidPages')).toBe('true'); expect(navigation.headers.get('Cache-Control')).toBe('no-store'); - expect(navigation.headers.get('Referrer-Policy')).toBe('no-referrer'); + expect(navigation.headers.get('Referrer-Policy')).toBe('strict-origin'); const pageObject = (await navigation.json()) as { component: string; shared: object }; expect(pageObject.component).toBe('zeroTrust'); expect(pageObject.shared).not.toHaveProperty('adminFateSnapshot'); @@ -804,7 +833,7 @@ describe('admin Zero Trust', () => { expect(res.status).toBe(400); expect(res.headers.get('Cache-Control')).toBe('no-store'); - expect(res.headers.get('Referrer-Policy')).toBe('no-referrer'); + expect(res.headers.get('Referrer-Policy')).toBe('strict-origin'); const html = await res.text(); expect(visibleMarkup(html)).toContain('identity providers must contain between 1 and 16 ids'); expect(html).not.toContain('form-token-value'); diff --git a/platform/packages/api/test/integration/admin-email.test.ts b/platform/packages/api/test/integration/admin-email.test.ts index dd8355ed3..fa7dade07 100644 --- a/platform/packages/api/test/integration/admin-email.test.ts +++ b/platform/packages/api/test/integration/admin-email.test.ts @@ -744,32 +744,30 @@ describe('admin email domain controls', () => { }, }); const add = await post(`/admin/email/${projectId}/domains/add`, adminJwt, { - domain: 'mail.example.com', + domain: 'example.com', }); expect(add.status).toBe(302); const html = await (await get(`/admin/email/${projectId}`, adminJwt)).text(); - expect(html).toContain('mail.example.com'); + expect(html).toContain('example.com'); expect(html).toContain('Rotate secret'); expect(html).not.toContain(env.CF_API_TOKEN); expect(await env.db.select().from(emailZoneConnections)).toHaveLength(1); const rotate = await post( - `/admin/email/${projectId}/domains/mail.example.com/rotate-secret`, + `/admin/email/${projectId}/domains/example.com/rotate-secret`, adminJwt, ); expect(rotate.status).toBe(302); expect(callsByTag(stub.calls, 'secretPut')).toHaveLength(2); const unconfirmed = await post( - `/admin/email/${projectId}/domains/mail.example.com/revoke`, + `/admin/email/${projectId}/domains/example.com/revoke`, adminJwt, { confirm: 'wrong' }, ); expect(unconfirmed.status).toBe(400); expect((await env.db.select().from(emailDomainRoutes))[0]?.desired_state).toBe('active'); - const removed = await post( - `/admin/email/${projectId}/domains/mail.example.com/revoke`, - adminJwt, - { confirm: slug }, - ); + const removed = await post(`/admin/email/${projectId}/domains/example.com/revoke`, adminJwt, { + confirm: slug, + }); expect(removed.status).toBe(302); expect((await env.db.select().from(emailDomainRoutes))[0]?.desired_state).toBe('removed'); }); diff --git a/platform/packages/api/test/integration/api-email-domains.test.ts b/platform/packages/api/test/integration/api-email-domains.test.ts index 64896f99a..698f6e849 100644 --- a/platform/packages/api/test/integration/api-email-domains.test.ts +++ b/platform/packages/api/test/integration/api-email-domains.test.ts @@ -30,8 +30,54 @@ import { let env: TestEnv; const token = 'customer-scoped-token'; +// Each fixture domain below is the apex of its own visible test zone. const zones = { 'example.com': { id: 'zone-example', name: 'example.com', accountId: 'customer-account' }, + 'expiry.example.com': { + id: 'zone-expiry-example-com', + name: 'expiry.example.com', + accountId: 'customer-account', + }, + 'gone.example.com': { + id: 'zone-gone-example-com', + name: 'gone.example.com', + accountId: 'customer-account', + }, + 'lease.example.com': { + id: 'zone-lease-example-com', + name: 'lease.example.com', + accountId: 'customer-account', + }, + 'mail.example.com': { + id: 'zone-mail-example-com', + name: 'mail.example.com', + accountId: 'customer-account', + }, + 'moved.example.com': { + id: 'zone-moved-example-com', + name: 'moved.example.com', + accountId: 'customer-account', + }, + 'nlease.example.com': { + id: 'zone-nlease-example-com', + name: 'nlease.example.com', + accountId: 'customer-account', + }, + 'other.example.com': { + id: 'zone-other-example-com', + name: 'other.example.com', + accountId: 'customer-account', + }, + 'status.example.com': { + id: 'zone-status-example-com', + name: 'status.example.com', + accountId: 'customer-account', + }, + 'team.example.com': { + id: 'zone-team-example-com', + name: 'team.example.com', + accountId: 'customer-account', + }, }; function runtime() { return { ...env, EMAIL_GATEWAY_ORIGIN: 'https://email-gateway.example.test' } as typeof env; diff --git a/platform/packages/api/test/integration/api-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index 0a5605b74..c3a9332e2 100644 --- a/platform/packages/api/test/integration/api-email.test.ts +++ b/platform/packages/api/test/integration/api-email.test.ts @@ -2122,6 +2122,7 @@ describe('GET /projects/:id/email/settings', () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ domains: { management: 'self-serve', projectSlug: 'settings-project' }, + sharedInbound: null, recipientPolicy: { mode: 'verified', domains: [] }, }); expect(await env.ROUTING.get('email-recipient-policy')).toBe( diff --git a/platform/packages/api/test/integration/email-connections.test.ts b/platform/packages/api/test/integration/email-connections.test.ts index 6a20c7b73..7a7f20e70 100644 --- a/platform/packages/api/test/integration/email-connections.test.ts +++ b/platform/packages/api/test/integration/email-connections.test.ts @@ -52,9 +52,81 @@ import { type TestEnv, } from './helpers.ts'; -const zones = { - 'example.com': { id: 'zone-example', name: 'example.com', accountId: 'customer-account' }, -}; +// Each fixture domain below is the apex of its own visible test zone. +const zones = Object.fromEntries( + [ + 'example.com', + 'active.example.com', + 'alerts.example.com', + 'blocked-removal.example.com', + 'blocked-rotation.example.com', + 'catchall-preflight.example.com', + 'concurrent-one.example.com', + 'concurrent-two.example.com', + 'first.example.com', + 'foreign.example.com', + 'gone.example.com', + 'hr.example.com', + 'late-failure.example.com', + 'late-success.example.com', + 'limits.example.com', + 'lost.example.com', + 'mail.example.com', + 'manager-retained-replacement.example.com', + 'manager-retained-staged.example.com', + 'manager.example.com', + 'mutation-race.example.com', + 'mutation-replay.example.com', + 'mx.example.com', + 'not-applied-delete.example.com', + 'not-applied-rotation.example.com', + 'occupied.example.com', + 'one.example.com', + 'other.example.com', + 'pending-original.example.com', + 'pending-replacement.example.com', + 'pending-rotation.example.com', + 'permission.example.com', + 'probe-removal.example.com', + 'probe-route.example.com', + 'queue-a.example.com', + 'queue-b.example.com', + 'race.example.com', + 'readd-conflict.example.com', + 'readd-staged.example.com', + 'readd.example.com', + 'recover-relay.example.com', + 'recover-routing.example.com', + 'recovery-timezone.example.com', + 'refused.example.com', + 'replacement-staged.example.com', + 'replay.example.com', + 'rotate.example.com', + 'rotation-credential.example.com', + 'rotation-repair.example.com', + 'second.example.com', + 'sending-negative.example.com', + 'sending-removal.example.com', + 'sending.example.com', + 'setup-repair.example.com', + 'stale-removal.example.com', + 'status-removal.example.com', + 'support.example.com', + 'sync-staged-rotation.example.com', + 'timeout.example.com', + 'transfer.example.com', + 'two.example.com', + 'unknown-delete.example.com', + 'unknown.example.com', + ].map((name) => [ + name, + { + id: name === 'example.com' ? 'zone-example' : 'zone-' + name.replaceAll('.', '-'), + name, + accountId: 'customer-account', + }, + ]), +); const TOKEN = 'customer-scoped-token'; let env: TestEnv; function bindings(): ApiEnv['Bindings'] { @@ -280,7 +352,7 @@ describe('durable zone email connections', () => { expect(callsByTag(stub.calls, 'secretPut')).toHaveLength(1); }); - it('repairs a pending reused connection and its superseded original route', async () => { + it('repairs a pending apex connection while fencing its superseded original operation', async () => { const owner = await createTestUser(env.db); const project = await createTestProject(env.db, owner.id, 'pending-connection-repair'); const failures: NonNullable<Parameters<typeof stubCustomerCf>[0]>['failOn'] = { @@ -308,13 +380,13 @@ describe('durable zone email connections', () => { delete failures.routingDns; const replacement = await requestEmailDomainAdd(env.db, bindings(), { projectId: project.id, - domain: 'pending-replacement.example.com', + domain: 'pending-original.example.com', token: 'corrected-customer-token', actor, }); await runEmailOperation(env.db, bindings(), replacement.operationId); - const replacementRoute = (await resolveEmailDomain(env.db, 'pending-replacement.example.com'))!; + const replacementRoute = (await resolveEmailDomain(env.db, 'pending-original.example.com'))!; expect(replacementRoute.route.inbound_status).toBe('ready'); expect(replacementRoute.connection.desired_generation).toBeGreaterThan( blocked.desired_generation, @@ -340,7 +412,7 @@ describe('durable zone email connections', () => { (await resolveEmailDomain(env.db, 'pending-original.example.com'))?.route.inbound_status, ).toBe('ready'); expect(callsByTag(stub.calls, 'scriptPut')).toHaveLength(2); - expect(callsByTag(stub.calls, 'secretPut')).toHaveLength(2); + expect(callsByTag(stub.calls, 'secretPut')).toHaveLength(1); }); it('blocks a failed provider read before its write and resumes without an ambiguous mutation', async () => { @@ -543,7 +615,7 @@ describe('durable zone email connections', () => { delete failures.scriptPut; const second = await requestEmailDomainAdd(env.db, bindings(), { projectId: project.id, - domain: 'mail.other.test', + domain: 'other.test', actor, token: TOKEN, }); @@ -1275,7 +1347,7 @@ describe('durable zone email connections', () => { { credentialStatus: 'expired', interruptStep: 'catchall_detach' }, { credentialStatus: 'expired', interruptStep: 'ingress_delete' }, ] as const)( - 'keeps sibling ingress with $credentialStatus credentials after interruption at $interruptStep', + 'keeps unrelated zone ingress with $credentialStatus credentials after interruption at $interruptStep', async ({ credentialStatus, interruptStep }) => { const user = await createTestUser(env.db); const first = await createTestProject(env.db, user.id, 'disabled-first'); @@ -1299,7 +1371,15 @@ describe('durable zone email connections', () => { .update(emailDomainRoutes) .set({ desired_state: 'disabled', inbound_status: 'disabled' }) .where(eq(emailDomainRoutes.id, b.routeId)); - await env.db.update(emailCredentials).set({ status: credentialStatus }); + await env.db + .update(emailCredentials) + .set({ status: credentialStatus }) + .where( + eq( + emailCredentials.id, + (await resolveEmailDomain(env.db, 'two.example.com'))!.connection.credential_id!, + ), + ); const callsBeforeRemoval = stub.calls.length; const remove = await requestEmailDomainRemove(env.db, bindings(), a.routeId, { kind: 'owner', @@ -1324,8 +1404,17 @@ describe('durable zone email connections', () => { ); await env.db .update(emailOperationSteps) - .set({ started_at: sql`now() - interval '10 minutes'` }) + .set({ started_at: sql`now() - interval '25 hours'` }) .where(eq(emailOperationSteps.operation_id, remove)); + await runEmailOperation(env.db, bindings(), remove); + const fence = await inspectUnknownEmailOperationRecovery(env.db, bindings(), remove); + await resolveUnknownEmailOperation(env.db, bindings(), { + ...fence, + ended: true, + outcome: 'not-applied', + reason: + 'Stub provider audit confirms the interrupted driver did not execute its deletion', + }); } await runEmailOperation(env.db, bindings(), remove); expect((await resolveEmailDomain(env.db, 'one.example.com'))?.route.desired_state).toBe( @@ -1335,8 +1424,8 @@ describe('durable zone email connections', () => { (await env.db.select().from(emailOperations).where(eq(emailOperations.id, remove)))[0] ?.status, ).toBe('completed'); - expect(stub.calls).toHaveLength(callsBeforeRemoval); - expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(0); + expect(stub.calls.length).toBeGreaterThan(callsBeforeRemoval); + expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(1); expect((await resolveEmailDomain(env.db, 'two.example.com'))?.route.desired_state).toBe( 'disabled', ); @@ -1448,7 +1537,7 @@ describe('durable zone email connections', () => { expect(await env.db.select().from(emailCacheOutbox)).toHaveLength(3); }); - it.each(['readd-staged.example.com', 'replacement-staged.example.com'])( + it.each(['readd-staged.example.com'])( 'retires a blocked rotation when the last removed connection is reused by %s', async (replacementDomain) => { const user = await createTestUser(env.db); @@ -1509,7 +1598,7 @@ describe('durable zone email connections', () => { }, ); - it('retires a blocked rotation when a manager-retained connection gets a new domain', async () => { + it('retires a blocked rotation when a manager-retained connection reuses its zone apex', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'manager-retained-staged'); const failures: NonNullable<Parameters<typeof stubCustomerCf>[0]>['failOn'] = {}; @@ -1544,16 +1633,13 @@ describe('durable zone email connections', () => { delete failures.secretPut; const replacement = await requestEmailDomainAdd(env.db, bindings(), { projectId: project.id, - domain: 'manager-retained-replacement.example.com', + domain: 'manager-retained-staged.example.com', actor, token: 'manager-retained-corrected-token', }); await runEmailOperation(env.db, bindings(), replacement.operationId); - const repaired = (await resolveEmailDomain( - env.db, - 'manager-retained-replacement.example.com', - ))!; + const repaired = (await resolveEmailDomain(env.db, 'manager-retained-staged.example.com'))!; expect(repaired.route.inbound_status).toBe('ready'); expect(repaired.connection.active_secret_generation).toBe(original.active_secret_generation); expect(repaired.connection.staged_secret_generation).toBeNull(); @@ -1785,7 +1871,7 @@ describe('durable zone email connections', () => { await resolveEmailIngress(env.db, { installationId: 'void-hosted', accountId: 'customer-account', - zoneId: 'zone-example', + zoneId: zones['transfer.example.com'].id, ingressName: (await resolveEmailDomain(env.db, 'transfer.example.com'))!.connection .ingress_worker_name, recipient: 'hello@transfer.example.com', @@ -1846,7 +1932,7 @@ describe('durable zone email connections', () => { const live = await resolveEmailIngress(env.db, { installationId: 'void-hosted', accountId: 'customer-account', - zoneId: 'zone-example', + zoneId: zones['transfer.example.com'].id, ingressName: (await resolveEmailDomain(env.db, 'transfer.example.com'))!.connection .ingress_worker_name, recipient: 'hello@transfer.example.com', @@ -1857,7 +1943,7 @@ describe('durable zone email connections', () => { expect(Number(newHint)).toBeGreaterThan(Number(oldHint)); }); - it('serializes two sibling removals behind an unresolved connection write', async () => { + it('keeps independent apex removal progress separate from an unresolved zone write', async () => { const user = await createTestUser(env.db); const aProject = await createTestProject(env.db, user.id, 'queued-a'); const bProject = await createTestProject(env.db, user.id, 'queued-b'); @@ -1889,7 +1975,7 @@ describe('durable zone email connections', () => { const queuedB = ( await env.db.select().from(emailOperations).where(eq(emailOperations.id, removeB)) )[0]!; - expect(queuedB.current_step).toBe(`wait:${removeA}`); + expect(queuedB.current_step).toBe(`wait:${b.operationId}`); await runEmailOperation(env.db, bindings(), removeA); await runEmailOperation(env.db, bindings(), removeB); expect( @@ -1910,7 +1996,7 @@ describe('durable zone email connections', () => { 'removed', ); }); - it('shares one ingress across exact sibling routes and retains it on one removal', async () => { + it('keeps independent apex ingress and credentials isolated when one zone is removed', async () => { const user = await createTestUser(env.db); const first = await createTestProject(env.db, user.id, 'first-mail'); const second = await createTestProject(env.db, user.id, 'second-mail'); @@ -1940,14 +2026,14 @@ describe('durable zone email connections', () => { resolveEmailDomain(env.db, 'support.example.com'), env.db.select().from(emailZoneConnections), ]); - expect(connections).toHaveLength(1); - expect(aRoute?.connection.id).toBe(bRoute?.connection.id); + expect(connections).toHaveLength(2); + expect(aRoute?.connection.id).not.toBe(bRoute?.connection.id); expect(aRoute?.route.project_id).toBe(first.id); expect(bRoute?.route.project_id).toBe(second.id); expect(aRoute?.route.outbound_status).toBe('ready'); expect(bRoute?.route.outbound_status).toBe('ready'); expect(callsByTag(stub.calls, 'onboardSending')).toHaveLength(2); - expect(callsByTag(stub.calls, 'scriptPut')).toHaveLength(1); + expect(callsByTag(stub.calls, 'scriptPut')).toHaveLength(2); const removed = await requestEmailDomainRemove(env.db, bindings(), a.routeId, { kind: 'owner', userId: user.id, @@ -1959,7 +2045,7 @@ describe('durable zone email connections', () => { expect((await resolveEmailDomain(env.db, 'support.example.com'))?.route.inbound_status).toBe( 'ready', ); - expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(0); + expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(1); expect( (await resolveEmailDomain(env.db, 'support.example.com'))?.connection.credential_id, ).not.toBeNull(); @@ -2836,7 +2922,7 @@ describe('durable zone email connections', () => { project = await createTestProject(env.db, owner.id, 'shared-sibling'); const shared = { ...bindings(), - EMAIL_SENDER_DOMAIN: 'example.com', + EMAIL_SENDER_DOMAIN: 'mail.example.com', EMAIL_SHARED_ACCOUNT_ID: env.CF_ACCOUNT_ID, EMAIL_SHARED_ZONE_ID: 'platform-mail-zone', EMAIL_SHARED_INGRESS_NAME: 'void-email-gateway', @@ -2849,18 +2935,18 @@ describe('durable zone email connections', () => { accountId: env.CF_ACCOUNT_ID, }, }, - mx: { 'example.com': ['route1.mx.cloudflare.net'] }, + mx: { 'mail.example.com': ['route1.mx.cloudflare.net'] }, }); const bootstrap = await ensureSharedEmailConnection(env.db, shared); expect(bootstrap).toBeTruthy(); await runEmailOperation(env.db, shared, bootstrap!); const added = await requestEmailDomainAdd(env.db, shared, { projectId: project.id, - domain: 'alerts.example.com', + domain: 'example.com', actor: { kind: 'admin', userId: owner.id }, }); await runEmailOperation(env.db, shared, added.operationId); - const route = await resolveEmailDomain(env.db, 'alerts.example.com'); + const route = await resolveEmailDomain(env.db, 'example.com'); expect(route?.route.inbound_status).toBe('ready'); expect(route?.route.outbound_status).toBe('ready'); expect(route?.connection.ingress_worker_name).toBe('void-email-gateway'); @@ -2869,7 +2955,7 @@ describe('durable zone email connections', () => { accountId: env.CF_ACCOUNT_ID, zoneId: 'platform-mail-zone', ingressName: 'void-email-gateway', - recipient: 'inbox@alerts.example.com', + recipient: 'inbox@example.com', }); expect(nativeRoute?.route.project_id).toBe(project.id); expect(nativeRoute?.route.addressing).toBe('custom'); @@ -2880,7 +2966,7 @@ describe('durable zone email connections', () => { await runEmailOperation(env.db, shared, sync); const retried = await requestEmailDomainAdd(env.db, shared, { projectId: project.id, - domain: 'alerts.example.com', + domain: 'example.com', actor: { kind: 'admin', userId: owner.id }, attemptId: 'shared-active-retry', }); @@ -2898,20 +2984,18 @@ describe('durable zone email connections', () => { await runEmailOperation(env.db, shared, remove); const readded = await requestEmailDomainAdd(env.db, shared, { projectId: project.id, - domain: 'alerts.example.com', + domain: 'example.com', actor: { kind: 'admin', userId: owner.id }, attemptId: 'shared-removed-retry', }); await runEmailOperation(env.db, shared, readded.operationId); - expect((await resolveEmailDomain(env.db, 'alerts.example.com'))?.route.inbound_status).toBe( - 'ready', - ); + expect((await resolveEmailDomain(env.db, 'example.com'))?.route.inbound_status).toBe('ready'); const finalRemove = await requestEmailDomainRemove(env.db, shared, added.routeId, { kind: 'admin', userId: owner.id, }); await runEmailOperation(env.db, shared, finalRemove); - const removedRoute = (await resolveEmailDomain(env.db, 'alerts.example.com'))!.route; + const removedRoute = (await resolveEmailDomain(env.db, 'example.com'))!.route; expect(removedRoute.desired_state).toBe('removed'); expect( await env.db.transaction((tx) => disableEmailDomainRouteAndDrainTx(tx, removedRoute.id)), @@ -2919,16 +3003,18 @@ describe('durable zone email connections', () => { cancelled: 0, inFlight: 0, }); - const repeatedRemoval = (await resolveEmailDomain(env.db, 'alerts.example.com'))!.route; + const repeatedRemoval = (await resolveEmailDomain(env.db, 'example.com'))!.route; expect(repeatedRemoval.desired_state).toBe('removed'); expect(repeatedRemoval.desired_generation).toBe(removedRoute.desired_generation); expect(callsByTag(stub.calls, 'scriptPut')).toHaveLength(0); expect(callsByTag(stub.calls, 'secretPut')).toHaveLength(0); expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(0); - expect((await resolveEmailDomain(env.db, 'example.com'))?.route.desired_state).toBe('active'); + expect((await resolveEmailDomain(env.db, 'mail.example.com'))?.route.desired_state).toBe( + 'active', + ); }); - it('requires the zone manager for sibling claims and blocks credential renewal while outcome is unknown', async () => { + it('rejects another project claiming an assigned apex and blocks credential renewal while outcome is unknown', async () => { const manager = await createTestUser(env.db, 'zone-manager'), other = await createTestUser(env.db, 'zone-other'); const first = await createTestProject(env.db, manager.id, 'manager-project'), @@ -2945,11 +3031,11 @@ describe('durable zone email connections', () => { await expect( requestEmailDomainAdd(env.db, bindings(), { projectId: second.id, - domain: 'other.example.com', + domain: 'manager.example.com', actor: { kind: 'owner', userId: other.id }, token: TOKEN, }), - ).rejects.toThrow('manager approval'); + ).rejects.toThrow('assigned to another project'); const connection = (await resolveEmailDomain(env.db, 'manager.example.com'))!.connection; const rotation = await requestEmailSecretRotation(env.db, bindings(), connection.id, { kind: 'owner', diff --git a/platform/packages/api/test/integration/email-domain-cf-stub.ts b/platform/packages/api/test/integration/email-domain-cf-stub.ts index 18d84db2d..759d57f91 100644 --- a/platform/packages/api/test/integration/email-domain-cf-stub.ts +++ b/platform/packages/api/test/integration/email-domain-cf-stub.ts @@ -23,6 +23,11 @@ export type StubFailTag = | 'catchAllGet' | 'routingRulesGet' | 'routingSettingsGet' + | 'subaddressing' + | 'literalRuleCreate' + | 'literalRuleUpdate' + | 'literalRuleGet' + | 'literalRuleDelete' | 'scriptDelete'; export type RecordedCall = { @@ -52,7 +57,13 @@ export function stubCustomerCf( catchAllRule?: { enabled: boolean; workerName: string | null } | null; /** GET email/routing's result; default { enabled: true }. */ routingSettings?: Record<string, unknown>; - routingRules?: Array<{ enabled: boolean; addresses: Array<string> }>; + routingRules?: Array<{ + enabled: boolean; + addresses: Array<string>; + id?: string; + name?: string; + workerName?: string; + }>; /** GET sending/subdomains entries; default []. */ sendingSubdomains?: ReadonlyArray<{ name: string; enabled: boolean }>; failOn?: Partial<Record<StubFailTag, { message: string; status: number; code?: number }>>; @@ -79,6 +90,54 @@ export function stubCustomerCf( const secretHashes = new Map<string, string>(); const relaySecrets = new Map<string, string>(); const scriptSubdomains = new Map<string, boolean>(); + const routingSettingsStates = new Map<string, Record<string, unknown>>(); + const routingRuleStates = new Map<string, Map<string, Record<string, unknown>>>(); + const rulesFor = (zoneId: string) => { + if (!routingRuleStates.has(zoneId)) { + routingRuleStates.set( + zoneId, + new Map( + (config.routingRules ?? []).map((rule, index) => { + const id = rule.id ?? `foreign-rule-${index}`; + return [ + id, + { + id, + name: rule.name ?? null, + enabled: rule.enabled, + matchers: rule.addresses.map((value) => ({ type: 'literal', field: 'to', value })), + actions: [{ type: 'worker', value: [rule.workerName ?? 'foreign-ingress'] }], + }, + ]; + }), + ), + ); + } + // Status tests may introduce a foreign rule after setup to model drift. + for (const [index, rule] of (config.routingRules ?? []).entries()) { + const id = rule.id ?? `foreign-rule-${index}`; + if (!routingRuleStates.get(zoneId)!.has(id)) { + routingRuleStates.get(zoneId)!.set(id, { + id, + name: rule.name ?? null, + enabled: rule.enabled, + matchers: rule.addresses.map((value) => ({ type: 'literal', field: 'to', value })), + actions: [{ type: 'worker', value: [rule.workerName ?? 'foreign-ingress'] }], + }); + } + } + return routingRuleStates.get(zoneId)!; + }; + const settingsFor = (zoneId: string) => { + if (!routingSettingsStates.has(zoneId)) { + routingSettingsStates.set(zoneId, { + enabled: true, + support_subaddress: false, + ...config.routingSettings, + }); + } + return routingSettingsStates.get(zoneId)!; + }; const catchAllStateFor = (zoneId: string) => { if (!catchAllStates.has(zoneId)) { catchAllStates.set( @@ -269,22 +328,63 @@ export function stubCustomerCf( if (failure) { return failure; } - return cfSuccess(config.routingSettings ?? { enabled: true }); + return cfSuccess(settingsFor(path.split('/')[2]!)); + } + + if (/\/zones\/[^/]+\/email\/routing$/.test(path) && method === 'PATCH') { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + await record({ tag: 'subaddressing', method, url, body }); + if (config.failBeforeEffect?.includes('subaddressing')) { + return fail('subaddressing') ?? cfSuccess({}); + } + Object.assign(settingsFor(path.split('/')[2]!), body); + return fail('subaddressing') ?? cfSuccess(settingsFor(path.split('/')[2]!)); } if (/\/zones\/[^/]+\/email\/routing\/rules\?/.test(path) && method === 'GET') { await record({ tag: 'routingRulesGet', method, url }); + const params = new URL(url).searchParams; + const page = Number(params.get('page') ?? 1), + perPage = Number(params.get('per_page') ?? 50); return ( fail('routingRulesGet') ?? cfSuccess( - (config.routingRules ?? []).map((rule) => ({ - enabled: rule.enabled, - matchers: rule.addresses.map((value) => ({ type: 'literal', field: 'to', value })), - })), + [...rulesFor(path.split('/')[2]!).values()].slice((page - 1) * perPage, page * perPage), ) ); } + const literal = /^\/zones\/([^/]+)\/email\/routing\/rules(?:\/([^/?]+))?$/.exec(path); + if (literal && literal[2] !== 'catch_all') { + const rules = rulesFor(literal[1]!); + if (method === 'GET' && literal[2]) { + await record({ tag: 'literalRuleGet', method, url }); + return ( + fail('literalRuleGet') ?? + (rules.has(literal[2]) ? cfSuccess(rules.get(literal[2])) : cfError('not found', 404)) + ); + } + if (method === 'POST' || method === 'PUT') { + const tag = method === 'POST' ? 'literalRuleCreate' : 'literalRuleUpdate'; + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : {}; + await record({ tag, method, url, body }); + if (config.failBeforeEffect?.includes(tag)) { + return fail(tag) ?? cfSuccess({}); + } + const id = literal[2] ?? `owned-rule-${rules.size + 1}`; + rules.set(id, { ...body, id }); + return fail(tag) ?? cfSuccess(rules.get(id)); + } + if (method === 'DELETE' && literal[2]) { + await record({ tag: 'literalRuleDelete', method, url }); + if (config.failBeforeEffect?.includes('literalRuleDelete')) { + return fail('literalRuleDelete') ?? cfSuccess({}); + } + rules.delete(literal[2]); + return fail('literalRuleDelete') ?? cfSuccess({}); + } + } + const catchAllMatch = /\/zones\/([^/]+)\/email\/routing\/rules\/catch_all$/.exec(path); if (catchAllMatch && method === 'GET') { await record({ tag: 'catchAllGet', method, url }); @@ -393,7 +493,7 @@ export function stubCustomerCf( }); vi.stubGlobal('fetch', fn); - return { calls, fn, catchAllStates, secretHashes }; + return { calls, fn, catchAllStates, secretHashes, routingRuleStates, routingSettingsStates }; } export function callsByTag(calls: Array<RecordedCall>, tag: StubFailTag): Array<RecordedCall> { diff --git a/platform/packages/api/test/integration/email-shared-recipients.test.ts b/platform/packages/api/test/integration/email-shared-recipients.test.ts new file mode 100644 index 000000000..2b28657a9 --- /dev/null +++ b/platform/packages/api/test/integration/email-shared-recipients.test.ts @@ -0,0 +1,541 @@ +import { eq, sql } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { deleteProjectCascade } from '../../src/db.ts'; +import { emailSharedRecipients } from '../../src/email/connection-schema.ts'; +import { + ensureSharedEmailConnection, + resolveEmailDomain, + runEmailOperation, +} from '../../src/email/connection-service.ts'; +import { + getSharedEmailRecipientReadiness, + reconcileSharedEmailRecipient, + reconcileSharedEmailRecipients, + removeSharedEmailRecipients, +} from '../../src/email/shared-recipient-service.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import app from '../../src/index.ts'; +import { projects, resources } from '../../src/schema.ts'; +import { callsByTag, stubCustomerCf } from './email-domain-cf-stub.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + HYPERDRIVE: { + ...env.HYPERDRIVE, + host: 'localhost', + ip: '127.0.0.1', + port: 5432, + user: 'test', + password: 'inert-test-password', + database: 'test', + connect() { + throw new Error('Tests use the injected database'); + }, + }, + CRON_FIRES_QUEUE: { + send: vi.fn(), + sendBatch: vi.fn(), + metrics: async () => ({ backlogCount: 0, backlogBytes: 0 }), + }, + PLATFORM_INSTALLATION_ID: 'installation-shared-test', + EMAIL_GATEWAY_ORIGIN: 'https://email-gateway.example.test', + EMAIL_SENDER_DOMAIN: 'mail.example.com', + EMAIL_SHARED_ACCOUNT_ID: env.CF_ACCOUNT_ID, + EMAIL_SHARED_ZONE_ID: 'shared-zone', + EMAIL_SHARED_INGRESS_NAME: 'native-gateway', + } as ApiEnv['Bindings']; +} +async function setup(config: NonNullable<Parameters<typeof stubCustomerCf>[0]> = {}) { + const provider = stubCustomerCf({ + zones: { + 'example.com': { id: 'shared-zone', name: 'example.com', accountId: env.CF_ACCOUNT_ID }, + }, + mx: { 'mail.example.com': ['route1.mx.cloudflare.net'] }, + ...config, + }); + const operation = await ensureSharedEmailConnection(env.db, bindings()); + await runEmailOperation(env.db, bindings(), operation!); + expect((await resolveEmailDomain(env.db, 'mail.example.com'))?.route.inbound_status).toBe( + 'ready', + ); + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'shared-project'); + return { provider, user, project }; +} +async function ledger() { + return (await env.db.select().from(emailSharedRecipients))[0]!; +} + +function pauseProviderRead( + provider: ReturnType<typeof stubCustomerCf>, + matches: (url: string) => boolean, +) { + const original = provider.fn.getMockImplementation()!; + let release!: () => void; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + let paused = false, + selected = false; + provider.fn.mockImplementation(async (...args) => { + const response = await original(...args); + if (!selected && (!args[1]?.method || args[1].method === 'GET') && matches(String(args[0]))) { + selected = true; + paused = true; + await gate; + } + return response; + }); + return { + release, + wait: () => vi.waitFor(() => expect(paused).toBe(true), { timeout: 1000, interval: 1 }), + }; +} +async function expireRecipientLease(id: string) { + await env.db + .update(emailSharedRecipients) + .set({ lease_started_at: sql`now() - interval '16 minutes'` }) + .where(eq(emailSharedRecipients.id, id)); +} + +describe('managed shared subdomain recipients', () => { + it('stops stale creation after cleanup has claimed the ledger and a replacement reuses the slug', async () => { + const { project, user, provider } = await setup(); + const paused = pauseProviderRead(provider, (url) => url.includes('/email/routing/rules?page=')); + const stale = reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await paused.wait(); + const old = await ledger(); + await expireRecipientLease(old.id); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + await removeSharedEmailRecipients(env.db, bindings(), project.id); + await deleteProjectCascade(env.db, project.id); + const replacement = await createTestProject(env.db, user.id, project.slug); + await reconcileSharedEmailRecipient(env.db, bindings(), replacement.id); + paused.release(); + await stale; + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + expect( + ( + await env.db + .select() + .from(emailSharedRecipients) + .where(eq(emailSharedRecipients.id, old.id)) + )[0], + ).toMatchObject({ status: 'removed', create_started_at: null }); + expect( + await getSharedEmailRecipientReadiness(env.db, bindings(), replacement.id, true), + ).toMatchObject({ status: 'ready' }); + }); + it('stops a stale re-enable from updating a replacement rule even if the provider recycles its ID', async () => { + const { project, user, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const old = await ledger(); + provider.routingRuleStates.get('shared-zone')!.get(old.rule_id!)!.enabled = false; + const paused = pauseProviderRead(provider, (url) => url.includes('/email/routing/rules?page=')); + const stale = reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await paused.wait(); + await expireRecipientLease(old.id); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + await removeSharedEmailRecipients(env.db, bindings(), project.id); + await deleteProjectCascade(env.db, project.id); + const replacement = await createTestProject(env.db, user.id, project.slug); + await reconcileSharedEmailRecipient(env.db, bindings(), replacement.id); + paused.release(); + await stale; + expect(callsByTag(provider.calls, 'literalRuleUpdate')).toHaveLength(0); + expect( + await getSharedEmailRecipientReadiness(env.db, bindings(), replacement.id, true), + ).toMatchObject({ status: 'ready' }); + }); + it('stops stale cleanup before DELETE after another cleanup and a replacement own the address', async () => { + const { project, user, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const old = await ledger(); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + const paused = pauseProviderRead(provider, (url) => + url.endsWith(`/email/routing/rules/${old.rule_id}`), + ); + const stale = removeSharedEmailRecipients(env.db, bindings(), project.id); + await paused.wait(); + await expireRecipientLease(old.id); + await removeSharedEmailRecipients(env.db, bindings(), project.id); + await deleteProjectCascade(env.db, project.id); + const replacement = await createTestProject(env.db, user.id, project.slug); + await reconcileSharedEmailRecipient(env.db, bindings(), replacement.id); + paused.release(); + await expect(stale).rejects.toThrow('superseded'); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(1); + expect( + await getSharedEmailRecipientReadiness(env.db, bindings(), replacement.id, true), + ).toMatchObject({ status: 'ready' }); + }); + it('fences the legacy database cascade and slug changes until exact recipient cleanup completes', async () => { + const { project, user, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await env.db.insert(resources).values({ + id: 'legacy-cascade-resource', + project_id: project.id, + type: 'kv', + binding_name: 'CACHE', + cf_id: 'owned-kv', + }); + // This is the existing cascade used by old Workers, without the new + // provider cleanup hook. Its transaction must not release the slug. + await expect(deleteProjectCascade(env.db, project.id)).rejects.toThrow(); + await expect( + env.db.update(projects).set({ slug: 'reassigned-slug' }).where(eq(projects.id, project.id)), + ).rejects.toThrow(); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ slug: project.slug }); + expect(await ledger()).toMatchObject({ status: 'ready' }); + expect( + await env.db.select().from(resources).where(eq(resources.project_id, project.id)), + ).toHaveLength(1); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(0); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + await removeSharedEmailRecipients(env.db, bindings(), project.id); + await deleteProjectCascade(env.db, project.id); + expect(await ledger()).toMatchObject({ status: 'removed', project_id: project.id }); + const replacement = await createTestProject(env.db, user.id, project.slug); + expect(replacement.id).not.toBe(project.id); + }); + it('reports verified project readiness and blocks it when provider plus addressing drifts', async () => { + const { project, provider, user } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const request = () => + app.fetch( + new Request(`http://localhost/projects/${project.id}/email/settings`, { + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + bindings(), + ); + expect(await (await request()).json()).toMatchObject({ + sharedInbound: { recipient: 'shared-project@mail.example.com', status: 'ready' }, + }); + provider.routingSettingsStates.get('shared-zone')!.support_subaddress = false; + expect(await (await request()).json()).toMatchObject({ + sharedInbound: { status: 'blocked', reason: expect.stringContaining('subaddress') }, + }); + expect(await ledger()).toMatchObject({ status: 'blocked' }); + }); + + it('keeps a reusable slug reserved through the real teardown route after an uncertain recipient create', async () => { + const { project, user, provider } = await setup({ + failOn: { literalRuleCreate: { status: 503, message: 'unknown outcome' } }, + failBeforeEffect: ['literalRuleCreate'], + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const deleted = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + bindings(), + ); + expect(deleted.status).not.toBe(200); + expect( + (await env.db.select().from(projects).where(eq(projects.id, project.id)))[0], + ).toMatchObject({ slug: project.slug, status: 'deleting' }); + const reused = await app.fetch( + new Request('http://localhost/projects', { + method: 'POST', + headers: { Authorization: `Bearer ${user.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: project.slug }), + }), + bindings(), + ); + expect(reused.status).toBe(409); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(0); + }); + it('verifies an exact owned recipient and plus addressing without changing foreign rules or catch-all', async () => { + const { project, provider } = await setup({ + catchAllRule: { enabled: true, workerName: 'unrelated-apex' }, + routingRules: [ + { + id: 'foreign', + name: 'Existing support', + enabled: true, + addresses: ['support@mail.example.com'], + workerName: 'support-worker', + }, + ], + }); + expect(await getSharedEmailRecipientReadiness(env.db, bindings(), project.id)).toMatchObject({ + status: 'pending', + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const row = await ledger(); + expect(row).toMatchObject({ + recipient: 'shared-project@mail.example.com', + status: 'ready', + rule_id: expect.any(String), + create_started_at: null, + }); + expect(await getSharedEmailRecipientReadiness(env.db, bindings(), project.id)).toMatchObject({ + status: 'ready', + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + expect(callsByTag(provider.calls, 'literalRuleGet')).toHaveLength(1); + expect(callsByTag(provider.calls, 'catchAll')).toHaveLength(0); + expect(provider.routingRuleStates.get('shared-zone')?.get('foreign')).toMatchObject({ + name: 'Existing support', + actions: [{ type: 'worker', value: ['support-worker'] }], + }); + expect(callsByTag(provider.calls, 'subaddressing')[0]?.body).toEqual({ + support_subaddress: true, + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + }); + + it('blocks foreign base and plus addresses without taking over provider rules', async () => { + const { project, provider } = await setup({ + routingRules: [ + { + enabled: true, + addresses: ['shared-project+vip@mail.example.com'], + workerName: 'other-worker', + }, + ], + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ + status: 'blocked', + rule_id: null, + reason: expect.stringContaining('unrelated'), + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(0); + expect(callsByTag(provider.calls, 'literalRuleUpdate')).toHaveLength(0); + }); + + it('recovers a lost create response from exact ownership without creating a duplicate', async () => { + const config: NonNullable<Parameters<typeof stubCustomerCf>[0]> = { + failOn: { literalRuleCreate: { status: 503, message: 'provider response lost' } }, + }; + const { project, provider } = await setup(config); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ + status: 'unknown', + rule_id: null, + create_started_at: expect.any(String), + }); + delete config.failOn!.literalRuleCreate; + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ + status: 'ready', + rule_id: expect.any(String), + create_started_at: null, + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + }); + + it('does not retry an ambiguous create whose exact rule is absent, or release its slug for reuse', async () => { + const { project, provider } = await setup({ + failOn: { literalRuleCreate: { status: 503, message: 'unknown create' } }, + failBeforeEffect: ['literalRuleCreate'], + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ status: 'unknown' }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + await expect(removeSharedEmailRecipients(env.db, bindings(), project.id)).rejects.toThrow( + 'unknown outcome', + ); + expect(await env.db.select().from(projects).where(eq(projects.id, project.id))).toHaveLength(1); + }); + + it('fails readback and cleanup closed when the exact recorded rule changes action', async () => { + const { project, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const row = await ledger(); + provider.routingRuleStates.get('shared-zone')!.get(row.rule_id!)!.actions = [ + { type: 'worker', value: ['foreign-worker'] }, + ]; + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ + status: 'blocked', + reason: expect.stringContaining('ownership'), + }); + await expect(removeSharedEmailRecipients(env.db, bindings(), project.id)).rejects.toThrow( + 'ownership', + ); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(0); + }); + + it('removes only the exact owned rule before a new project can reuse the address', async () => { + const { project, user, provider } = await setup({ + routingRules: [{ id: 'foreign', enabled: true, addresses: ['support@mail.example.com'] }], + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const old = await ledger(); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + await removeSharedEmailRecipients(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ status: 'removed' }); + expect(provider.routingRuleStates.get('shared-zone')!.has('foreign')).toBe(true); + await env.db.delete(projects).where(eq(projects.id, project.id)); + const replacement = await createTestProject(env.db, user.id, project.slug); + await reconcileSharedEmailRecipient(env.db, bindings(), replacement.id); + const rows = await env.db.select().from(emailSharedRecipients); + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.project_id === replacement.id)).toMatchObject({ + status: 'ready', + rule_name: expect.not.stringMatching(old.rule_name), + }); + const deletes = callsByTag(provider.calls, 'literalRuleDelete').length; + await removeSharedEmailRecipients(env.db, bindings(), project.id); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(deletes); + }); + + it('preserves a disabled owned rule identity when enabling it again', async () => { + const { project, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const row = await ledger(); + provider.routingRuleStates.get('shared-zone')!.get(row.rule_id!)!.enabled = false; + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ status: 'ready', rule_id: row.rule_id }); + expect(callsByTag(provider.calls, 'literalRuleUpdate')).toHaveLength(1); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + }); + + it('reports the provider capacity limit before creating another literal rule', async () => { + const { project, provider } = await setup({ + routingRules: Array.from({ length: 200 }, (_, index) => ({ + enabled: true, + addresses: [`address-${index}@mail.example.com`], + })), + }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ + status: 'blocked', + reason: expect.stringContaining('200 routing-rule limit'), + }); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(0); + }); + + it('does not overlap a live reconciliation or claim readiness from domain provisioning alone', async () => { + const { project, provider } = await setup(); + let unblock!: () => void; + const blocked = new Promise<void>((resolve) => { + unblock = resolve; + }); + const original = provider.fn.getMockImplementation()!; + let pending = false; + provider.fn.mockImplementation(async (...args) => { + if (args[1]?.method === 'POST' && String(args[0]).endsWith('/email/routing/rules')) { + pending = true; + await blocked; + } + return original(...args); + }); + const first = reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await vi.waitFor(() => expect(pending).toBe(true), { timeout: 1000, interval: 1 }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await expect(removeSharedEmailRecipients(env.db, bindings(), project.id)).rejects.toThrow( + 'still running', + ); + expect( + (await getSharedEmailRecipientReadiness(env.db, bindings(), project.id))?.status, + ).not.toBe('ready'); + unblock(); + await first; + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(1); + }); + + it('repairs missing project recipients before cycling through already ready rows', async () => { + const { project, user } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const second = await createTestProject(env.db, user.id, 'second-project'); + await reconcileSharedEmailRecipients(env.db, bindings(), 1); + expect(await getSharedEmailRecipientReadiness(env.db, bindings(), second.id)).toMatchObject({ + status: 'ready', + }); + }); + it('pins the returned provider ID and refuses an identically named replacement', async () => { + const { project, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + const row = await ledger(); + const rules = provider.routingRuleStates.get('shared-zone')!; + const rule = rules.get(row.rule_id!)!; + rules.delete(row.rule_id!); + rules.set('replacement-id', { ...rule, id: 'replacement-id' }); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ status: 'blocked', rule_id: row.rule_id }); + await expect(removeSharedEmailRecipients(env.db, bindings(), project.id)).rejects.toThrow( + 'ownership', + ); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(0); + }); + it('keeps deletion fenced when configured installation identity changes', async () => { + const { project, provider } = await setup(); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + await expect( + removeSharedEmailRecipients( + env.db, + { ...bindings(), EMAIL_SHARED_ZONE_ID: 'foreign-zone' } as ApiEnv['Bindings'], + project.id, + ), + ).rejects.toThrow('identity'); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(0); + expect(await ledger()).toMatchObject({ status: 'ready' }); + }); + it('recovers a lost delete response only after exact absence readback', async () => { + const config: NonNullable<Parameters<typeof stubCustomerCf>[0]> = { failOn: {} }; + const { project, provider } = await setup(config); + await reconcileSharedEmailRecipient(env.db, bindings(), project.id); + config.failOn!.literalRuleDelete = { status: 503, message: 'response lost' }; + await expect(removeSharedEmailRecipients(env.db, bindings(), project.id)).rejects.toThrow(); + expect(await ledger()).toMatchObject({ status: 'blocked', removing: true }); + delete config.failOn!.literalRuleDelete; + await removeSharedEmailRecipients(env.db, bindings(), project.id); + expect(await ledger()).toMatchObject({ status: 'removed' }); + expect(callsByTag(provider.calls, 'literalRuleDelete')).toHaveLength(1); + }); + it('refuses custom subdomain catch-all setup before DNS, rule, or Worker writes', async () => { + const { project, provider, user } = await setup(); + const { requestEmailDomainAdd } = await import('../../src/email/connection-service.ts'); + await expect( + requestEmailDomainAdd(env.db, bindings(), { + projectId: project.id, + domain: 'custom.example.com', + actor: { kind: 'admin', userId: user.id }, + }), + ).rejects.toThrow('zone apex'); + expect(callsByTag(provider.calls, 'scriptPut')).toHaveLength(0); + expect(callsByTag(provider.calls, 'catchAll')).toHaveLength(0); + expect(callsByTag(provider.calls, 'routingDns')).toHaveLength(1); + }); +}); diff --git a/platform/packages/api/test/integration/helpers.ts b/platform/packages/api/test/integration/helpers.ts index 15d32f69e..ad341f294 100644 --- a/platform/packages/api/test/integration/helpers.ts +++ b/platform/packages/api/test/integration/helpers.ts @@ -534,6 +534,7 @@ export async function clearTables(db: Db): Promise<void> { schema.emailDestinationCleanups, schema.emailOperationSteps, schema.emailOperations, + schema.emailSharedRecipients, schema.emailDomainRoutes, schema.emailZoneConnections, schema.emailCredentials, diff --git a/platform/packages/api/test/integration/native-form-browser.ts b/platform/packages/api/test/integration/native-form-browser.ts new file mode 100644 index 000000000..edbb1c8f9 --- /dev/null +++ b/platform/packages/api/test/integration/native-form-browser.ts @@ -0,0 +1,94 @@ +import { chromium } from 'playwright'; +import { app } from '../../src/index.ts'; +import type { TestEnv } from './helpers.ts'; + +/** Exercise the rendered native form locally, without provider or public HTTP. */ +export async function nativeFormSubmission(input: { + env: TestEnv; + origin: string; + cookie: string; + path: string; + fields: Record<string, string>; + button: string; + noReferrer?: boolean; +}) { + const browser = await chromium.launch(); + let pagePolicy: string | null = null; + let submission: + | { origin: string | undefined; referrer: string | undefined; status: number } + | undefined; + try { + const context = await browser.newContext({ javaScriptEnabled: false }); + const split = input.cookie.indexOf('='); + await context.addCookies([ + { + name: input.cookie.slice(0, split), + value: input.cookie.slice(split + 1), + url: input.origin, + httpOnly: true, + secure: true, + }, + ]); + const page = await context.newPage(); + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== input.origin || request.resourceType() !== 'document') { + await route.abort(); + return; + } + const headers = await request.allHeaders(); + const response = await app.fetch( + new Request(request.url(), { + method: request.method(), + headers, + ...(request.method() === 'POST' ? { body: request.postData() ?? '' } : {}), + }), + input.env, + ); + const responseHeaders = Object.fromEntries(response.headers); + if (request.method() === 'GET') { + pagePolicy = response.headers.get('Referrer-Policy'); + if (input.noReferrer) { + responseHeaders['referrer-policy'] = 'no-referrer'; + } + } else { + submission = { origin: headers.origin, referrer: headers.referer, status: response.status }; + } + // Preserve actual API status evidence without letting a redirect escape + // Playwright's first-request routing and resolve an external fixture host. + if (response.status >= 300 && response.status < 400) { + delete responseHeaders.location; + await route.fulfill({ + status: 200, + headers: responseHeaders, + body: '<h1>Local form handoff</h1>', + }); + return; + } + await route.fulfill({ + status: response.status, + headers: responseHeaders, + body: Buffer.from(await response.arrayBuffer()), + }); + }); + await page.goto(`${input.origin}${input.path}`); + for (const [name, value] of Object.entries(input.fields)) { + await page.locator(`input[name="${name}"]`).fill(value); + } + const button = page.getByRole('button', { name: input.button, exact: true }); + const formAction = await button.evaluate( + (element) => (element as HTMLButtonElement).form?.action, + ); + if (!formAction) { + throw new Error('The native submit button has no form action.'); + } + await Promise.all([page.waitForURL(formAction), button.click()]); + if (!submission) { + throw new Error('The native form did not reach the local API.'); + } + return { pagePolicy, submission }; + } finally { + await browser.close(); + } +} diff --git a/platform/packages/api/test/integration/operator-email-domains.test.ts b/platform/packages/api/test/integration/operator-email-domains.test.ts index 89b3f5711..2d4c5b509 100644 --- a/platform/packages/api/test/integration/operator-email-domains.test.ts +++ b/platform/packages/api/test/integration/operator-email-domains.test.ts @@ -39,9 +39,28 @@ import { } from './helpers.ts'; let env: TestEnv, operatorToken: string; -const zones = { - 'example.com': { id: 'zone-platform', name: 'example.com', accountId: CF_ACCOUNT_ID }, -}; +// Each custom mail hostname is the apex of its own test zone. +const zones = Object.fromEntries( + [ + 'email-gateway.example.test', + 'mail.example.com', + 'recovery.example.com', + 'blocked.example.com', + 'deleted-cleanup.example.com', + 'deleted-owner-cleanup.example.com', + 'replacement-cleanup.example.com', + 'guarded-replacement.example.com', + 'replacement-sibling.example.com', + 'teardown-first.example.com', + 'teardown-second.example.com', + 'stale-teardown.example.com', + 'unknown-cleanup.example.com', + ].map((name) => [ + name, + { id: 'zone-' + name.replaceAll('.', '-'), name, accountId: CF_ACCOUNT_ID }, + ]), +); + const OWNER_TOKEN = 'owner-scoped-token'; function bindings(): ApiEnv['Bindings'] { return { @@ -495,13 +514,17 @@ describe('operator email domain management', () => { (await env.db.select().from(emailOperations)).filter( (operation) => operation.kind === 'remove', ), - ).toHaveLength(1); - expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(0); + ).toHaveLength(2); + expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(1); expect((await request('/email/domains/teardown-second.example.com/remove', {})).status).toBe( 200, ); - expect((await env.db.select().from(emailZoneConnections))[0]?.credential_id).not.toBeNull(); + expect( + (await env.db.select().from(emailZoneConnections)).find( + (row) => row.zone_name === 'teardown-second.example.com', + )?.credential_id, + ).not.toBeNull(); expect( ( await app.fetch( @@ -517,7 +540,7 @@ describe('operator email domain management', () => { const removals = (await env.db.select().from(emailOperations)).filter( (operation) => operation.kind === 'remove', ); - expect(removals).toHaveLength(3); + expect(removals).toHaveLength(4); expect(removals).toContainEqual( expect.objectContaining({ route_id: secondAdded.routeId, @@ -526,7 +549,7 @@ describe('operator email domain management', () => { }), ); expect((await env.db.select().from(emailZoneConnections))[0]?.credential_id).toBeNull(); - expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(1); + expect(callsByTag(stub.calls, 'scriptDelete')).toHaveLength(2); }); it('does not let a stale project teardown remove a reassigned route', async () => { diff --git a/platform/packages/api/test/integration/platform-bootstrap.test.ts b/platform/packages/api/test/integration/platform-bootstrap.test.ts index 0a786ca10..6e1d61b44 100644 --- a/platform/packages/api/test/integration/platform-bootstrap.test.ts +++ b/platform/packages/api/test/integration/platform-bootstrap.test.ts @@ -4,6 +4,7 @@ import { } from '@void/platform'; import { eq, sql } from 'drizzle-orm'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { chromium } from 'playwright'; import { afterAll, afterEach, @@ -644,6 +645,133 @@ describe('provider-independent first administrator setup', () => { expect(await env.db.select().from(users)).toHaveLength(0); }); + it('accepts native browser submissions for setup and administrator confirmation without exposing callback details', async () => { + expect((await initialize()).status).toBe(200); + const browser = await chromium.launch(); + const submissions: Array<{ + path: string; + origin: string | undefined; + referrer: string | undefined; + status: number; + }> = []; + let suppressSetupOrigin = false; + try { + const page = await browser.newPage(); + // Fulfill every browser request locally, retaining real browser form, + // cookie, referrer-policy, and Origin behavior without external traffic. + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === issuer && url.pathname === '/authorize') { + authorization = url; + await route.fulfill({ + contentType: 'text/html', + body: '<h1>Local identity provider</h1>', + }); + return; + } + if (url.origin !== origin) { + await route.abort(); + return; + } + const headers = await request.allHeaders(); + const response = await app.fetch( + new Request(request.url(), { + method: request.method(), + headers, + ...(request.method() === 'POST' ? { body: request.postData() ?? '' } : {}), + }), + bindings(), + ); + if (request.method() === 'POST') { + submissions.push({ + path: url.pathname, + origin: headers.origin, + referrer: headers.referer, + status: response.status, + }); + } + // Playwright does not route subsequent requests in a redirect chain. + // Keep the real API response evidence, then serve a local handoff page + // so the browser never resolves an external provider or fixture origin. + if (request.method() === 'POST' && response.status === 302) { + if (url.pathname === '/auth/bootstrap') { + authorization = new URL(response.headers.get('Location')!); + } else { + expect(response.headers.get('Location')).toBe('/admin/'); + } + const responseHeaders = Object.fromEntries(response.headers); + delete responseHeaders.location; + await route.fulfill({ + status: 200, + headers: responseHeaders, + body: + url.pathname === '/auth/bootstrap' + ? '<h1>Local provider handoff</h1>' + : '<h1>Administrator ready</h1>', + }); + return; + } + const responseHeaders = Object.fromEntries(response.headers); + if (suppressSetupOrigin && url.pathname === '/setup') { + responseHeaders['referrer-policy'] = 'no-referrer'; + } + await route.fulfill({ + status: response.status, + headers: responseHeaders, + body: Buffer.from(await response.arrayBuffer()), + }); + }); + // Reproduce the original header conflict with the real Origin guard. + suppressSetupOrigin = true; + await page.goto(`${origin}/setup`); + await page.getByLabel('Setup code').fill(code); + await Promise.all([ + page.waitForURL(`${origin}/auth/bootstrap`), + page.getByRole('button', { name: 'Continue with Company SSO' }).click(), + ]); + expect(submissions).toEqual([ + { path: '/auth/bootstrap', origin: 'null', referrer: undefined, status: 403 }, + ]); + expect(await env.db.select().from(platformAuthAttempts)).toHaveLength(0); + submissions.length = 0; + suppressSetupOrigin = false; + await page.goto(`${origin}/setup`); + await page.getByRole('button', { name: 'Continue with Company SSO' }).click(); + expect(submissions).toEqual([]); + expect(await page.locator('input[name="code"]:invalid').count()).toBe(1); + await page.getByLabel('Setup code').fill(code); + await Promise.all([ + page.waitForURL(`${origin}/auth/bootstrap`), + page.getByRole('button', { name: 'Continue with Company SSO' }).click(), + ]); + expect(submissions).toEqual([ + { path: '/auth/bootstrap', origin, referrer: `${origin}/`, status: 302 }, + ]); + await page.getByRole('heading', { name: 'Local provider handoff' }).waitFor(); + await page.goto(authorization.href); + await page.goto( + `${origin}/auth/callback?code=provider-code&state=${authorization.searchParams.get('state')}`, + ); + await page.getByRole('heading', { name: 'Confirm the first administrator' }).waitFor(); + expect(await env.db.select().from(users)).toHaveLength(0); + await Promise.all([ + page.waitForURL(`${origin}/auth/bootstrap/confirm`), + page.getByRole('button', { name: 'Make this account the platform administrator' }).click(), + ]); + expect(submissions).toEqual([ + { path: '/auth/bootstrap', origin, referrer: `${origin}/`, status: 302 }, + { path: '/auth/bootstrap/confirm', origin, referrer: `${origin}/`, status: 302 }, + ]); + await page.getByRole('heading', { name: 'Administrator ready' }).waitFor(); + const accounts = await env.db.select().from(users); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ role: 'admin', provider_id: 'administrator-subject' }); + } finally { + await browser.close(); + } + }); + it('bootstraps an OIDC-only installation only after the owner reviews and confirms the identity', async () => { expect((await initialize()).status).toBe(200); expect((await initialize()).status).toBe(200); diff --git a/platform/packages/api/test/integration/platform-email-bootstrap.test.ts b/platform/packages/api/test/integration/platform-email-bootstrap.test.ts index 8e481456a..d16c53f29 100644 --- a/platform/packages/api/test/integration/platform-email-bootstrap.test.ts +++ b/platform/packages/api/test/integration/platform-email-bootstrap.test.ts @@ -10,7 +10,13 @@ import { it, vi, } from 'vite-plus/test'; -import { emailDomainRoutes, emailOperations } from '../../src/email/connection-schema.ts'; +import { createCfCustomerClient } from '../../src/email/cf-customer.ts'; +import { + emailDomainRoutes, + emailOperations, + emailOperationSteps, + emailZoneConnections, +} from '../../src/email/connection-schema.ts'; import * as emailConnections from '../../src/email/connection-service.ts'; import type { ApiEnv } from '../../src/env.ts'; import { app } from '../../src/index.ts'; @@ -70,6 +76,78 @@ function request(token = 'probe-token') { } describe('POST /internal/platform/email-bootstrap', () => { + it('invalidates completed legacy catch-all readiness and durably verifies subaddressing on upgrade', async () => { + const provider = stubCustomerCf({ + zones: { + 'example.com': { + id: 'platform-mail-zone', + name: 'example.com', + accountId: env.CF_ACCOUNT_ID, + }, + }, + mx: { 'mail.example.com': ['route1.mx.cloudflare.net'] }, + catchAllRule: { enabled: true, workerName: 'void-email-gateway' }, + }); + // Retain the real installed identities and completed operation, but model + // the old bootstrap's exact key/step and provider observation. + const first = await emailConnections.ensureSharedEmailConnection(env.db, bindings()); + await emailConnections.runEmailOperation(env.db, bindings(), first!); + const [legacy] = await env.db.select().from(emailOperations); + const [originalRoute] = await env.db.select().from(emailDomainRoutes); + await env.db + .update(emailOperations) + .set({ idempotency_key: 'shared:mail.example.com' }) + .where(eq(emailOperations.id, first!)); + const [plusStep] = (await env.db.select().from(emailOperationSteps)).filter( + (step) => step.kind === 'subaddressing_ensure', + ); + await env.db + .update(emailOperationSteps) + .set({ kind: 'catchall_ensure', outcome: { workerName: 'void-email-gateway' } }) + .where(eq(emailOperationSteps.id, plusStep!.id)); + provider.routingSettingsStates.get('platform-mail-zone')!.support_subaddress = false; + provider.calls.length = 0; + + const upgraded = await emailConnections.ensureSharedEmailConnection(env.db, bindings()); + expect(upgraded).not.toBe(first); + expect((await env.db.select().from(emailDomainRoutes))[0]).toMatchObject({ + id: originalRoute!.id, + inbound_status: 'pending', + desired_generation: originalRoute!.desired_generation + 1, + }); + expect((await env.db.select().from(emailZoneConnections))[0]).toMatchObject({ + id: legacy!.connection_id, + inbound_status: 'pending', + }); + const pending = (await env.db.select().from(emailOperationSteps)).filter( + (step) => step.operation_id === upgraded, + ); + expect(pending.map((step) => step.kind)).toEqual([ + 'routing_enable', + 'subaddressing_ensure', + 'inbound_probe', + 'publish', + ]); + + // The installer endpoint resumes this exact new operation, without + // trusting the completed legacy checkpoint or duplicating PATCH. + const response = await app.fetch(request(), bindings()); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ state: 'ready', operationId: upgraded }); + expect(callsByTag(provider.calls, 'subaddressing')[0]?.body).toEqual({ + support_subaddress: true, + }); + expect(callsByTag(provider.calls, 'subaddressing')).toHaveLength(1); + expect(callsByTag(provider.calls, 'catchAll')).toHaveLength(0); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(0); + expect( + await createCfCustomerClient(bindings().CF_API_TOKEN).getCatchAll('platform-mail-zone'), + ).toMatchObject({ enabled: true, workerName: 'void-email-gateway' }); + const retry = await app.fetch(request(), bindings()); + expect(retry.status).toBe(200); + expect(callsByTag(provider.calls, 'subaddressing')).toHaveLength(1); + expect(await env.db.select().from(emailOperations)).toHaveLength(2); + }); it('retains the durable operation ID if execution fails after setup was recorded', async () => { stubCustomerCf({ zones: { @@ -110,7 +188,8 @@ describe('POST /internal/platform/email-bootstrap', () => { state: 'ready', operationId: expect.any(String), }); - expect(callsByTag(cloudflare.calls, 'catchAll')).toHaveLength(1); + expect(callsByTag(cloudflare.calls, 'catchAll')).toHaveLength(0); + expect(callsByTag(cloudflare.calls, 'subaddressing')).toHaveLength(1); const operation = (await env.db.select().from(emailOperations))[0]; const route = ( await env.db @@ -142,16 +221,47 @@ describe('POST /internal/platform/email-bootstrap', () => { }, }, mx: { 'mail.example.com': ['route1.mx.cloudflare.net'] }, - failOn: { catchAll: { message: 'permission denied', status: 403 } }, + failOn: { subaddressing: { message: 'permission denied: provider-secret', status: 403 } }, + failBeforeEffect: ['subaddressing'], }); const response = await app.fetch(request(), bindings()); expect(response.status).toBe(503); - expect(await response.json()).toMatchObject({ + const body = await response.json(); + expect(body).toMatchObject({ version: 1, state: 'incomplete', operationStatus: 'blocked', + currentStep: 'subaddressing_ensure', + failureCode: 'blocked', + failureDetail: null, + domain: 'mail.example.com', + inboundStatus: 'pending', }); + expect(JSON.stringify(body)).not.toContain('provider-secret'); + }); + + it('provisions the shared subdomain without taking over unrelated literal rules or the apex catch-all', async () => { + const provider = stubCustomerCf({ + zones: { + 'example.com': { + id: 'platform-mail-zone', + name: 'example.com', + accountId: env.CF_ACCOUNT_ID, + }, + }, + mx: { 'mail.example.com': ['route1.mx.cloudflare.net'] }, + routingRules: [{ enabled: true, addresses: ['support@mail.example.com'] }], + catchAllRule: { enabled: true, workerName: 'unrelated-apex-worker' }, + }); + + const response = await app.fetch(request(), bindings()); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ state: 'ready' }); + expect(callsByTag(provider.calls, 'catchAll')).toHaveLength(0); + expect(callsByTag(provider.calls, 'literalRuleCreate')).toHaveLength(0); + expect(callsByTag(provider.calls, 'literalRuleUpdate')).toHaveLength(0); + expect(callsByTag(provider.calls, 'routingDns')[0]?.body).toEqual({ name: 'mail.example.com' }); }); it('resumes the same blocked shared operation after configuration is corrected', async () => { @@ -163,7 +273,7 @@ describe('POST /internal/platform/email-bootstrap', () => { accountId: env.CF_ACCOUNT_ID, }, }, - mx: {}, + mx: { 'mail.example.com': [] }, }; const cloudflare = stubCustomerCf(config); @@ -175,6 +285,11 @@ describe('POST /internal/platform/email-bootstrap', () => { state: 'incomplete', operationStatus: 'blocked', currentStep: 'inbound_probe', + failureCode: 'blocked', + failureDetail: + 'MX records for mail.example.com do not route mail to Cloudflare Email Routing', + domain: 'mail.example.com', + inboundStatus: 'drifted', }); config.mx!['mail.example.com'] = ['route1.mx.cloudflare.net']; @@ -185,7 +300,8 @@ describe('POST /internal/platform/email-bootstrap', () => { state: 'ready', operationId: blockedBody.operationId, }); - expect(callsByTag(cloudflare.calls, 'catchAll')).toHaveLength(1); + expect(callsByTag(cloudflare.calls, 'catchAll')).toHaveLength(0); + expect(callsByTag(cloudflare.calls, 'routingDns')).toHaveLength(1); expect(await env.db.select().from(emailOperations)).toHaveLength(1); }); }); diff --git a/platform/packages/api/test/integration/platform-login.test.ts b/platform/packages/api/test/integration/platform-login.test.ts index 4ffc0d28b..b25229520 100644 --- a/platform/packages/api/test/integration/platform-login.test.ts +++ b/platform/packages/api/test/integration/platform-login.test.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { chromium } from 'playwright'; import { afterAll, afterEach, @@ -242,6 +243,118 @@ describe('configured platform login routes', () => { env, ); } + it('confirms a real native linking form while withholding OAuth callback details', async () => { + const human = await ordinarySession(); + const browser = await chromium.launch(); + const submissions: Array<{ + origin: string | undefined; + referrer: string | undefined; + status: number; + }> = []; + let suppressOrigin = true; + try { + const context = await browser.newContext(); + await context.addCookies([ + { + name: 'void_account_session', + value: human.token, + url: origin, + secure: true, + httpOnly: true, + }, + ]); + const page = await context.newPage(); + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === issuer && url.pathname === '/authorize') { + authorization = url; + await route.fulfill({ + contentType: 'text/html', + body: '<h1>Local identity provider</h1>', + }); + return; + } + if (url.origin !== origin) { + await route.abort(); + return; + } + const headers = await request.allHeaders(); + const response = await app.fetch( + new Request(request.url(), { + method: request.method(), + headers, + ...(request.method() === 'POST' ? { body: request.postData() ?? '' } : {}), + }), + env, + ); + if (request.method() === 'POST') { + submissions.push({ + origin: headers.origin, + referrer: headers.referer, + status: response.status, + }); + } + const responseHeaders = Object.fromEntries(response.headers); + if (response.status === 302) { + authorization = new URL(response.headers.get('Location')!); + delete responseHeaders.location; + await route.fulfill({ + status: 200, + headers: responseHeaders, + body: '<h1>Local provider handoff</h1>', + }); + return; + } + if (url.pathname === '/auth/callback') { + expect(response.headers.get('Referrer-Policy')).toBe('strict-origin'); + if (suppressOrigin) { + responseHeaders['referrer-policy'] = 'no-referrer'; + } + } + await route.fulfill({ + status: response.status, + headers: responseHeaders, + body: Buffer.from(await response.arrayBuffer()), + }); + }); + async function confirmInBrowser() { + const launch = await launchLink(human.token); + expect(launch.status).toBe(200); + await page.goto((await launch.json<{ url: string }>()).url); + await page.goto(authorization.href); + await page.goto( + `${origin}/auth/callback?code=provider-code&state=${authorization.searchParams.get('state')}`, + ); + await page + .getByRole('heading', { name: 'Link this identity to your Void account?' }) + .waitFor(); + await Promise.all([ + page.waitForURL(`${origin}/auth/link/confirm`), + page.getByRole('button', { name: 'Link this identity' }).click(), + ]); + } + await confirmInBrowser(); + expect(submissions).toEqual([{ origin: 'null', referrer: undefined, status: 400 }]); + expect( + await env.db + .select() + .from(platformLoginIdentities) + .where(eq(platformLoginIdentities.connection_id, 'company')), + ).toHaveLength(0); + suppressOrigin = false; + await confirmInBrowser(); + expect(submissions.at(-1)).toEqual({ origin, referrer: `${origin}/`, status: 200 }); + expect( + await env.db + .select() + .from(platformLoginIdentities) + .where(eq(platformLoginIdentities.connection_id, 'company')), + ).toMatchObject([{ user_id: human.user.id, subject: 'employee' }]); + } finally { + await browser.close(); + } + }); it('links an ordinary account only after recent login, provider proof, and explicit browser confirmation', async () => { const human = await ordinarySession(); const launch = await launchLink(human.token); diff --git a/platform/packages/api/test/smoke-platform-fixture.test.ts b/platform/packages/api/test/smoke-platform-fixture.test.ts index b3d2394d8..08437b4f8 100644 --- a/platform/packages/api/test/smoke-platform-fixture.test.ts +++ b/platform/packages/api/test/smoke-platform-fixture.test.ts @@ -25,6 +25,70 @@ describe('hosted kitchen-sink smoke fixture', () => { } }); + it('includes the Sandbox example when explicitly enabled without copying local secrets', () => { + const sandboxFixture = prepareKitchenSinkPlatformFixture({ sandbox: true }); + try { + for (const path of [ + 'routes/api/sandbox.ts', + 'pages/sandbox.vue', + 'pages/sandbox.server.ts', + 'lib/sandbox-demo.ts', + 'routes/api/acceptance/sandbox.ts', + ]) { + expect(existsSync(resolve(sandboxFixture.root, path))).toBe(true); + } + for (const path of ['pages/layout.vue', 'pages/index.vue']) { + expect(readFileSync(resolve(sandboxFixture.root, path), 'utf8')).toContain( + 'href="/sandbox"', + ); + } + expect(existsSync(resolve(sandboxFixture.root, '.env'))).toBe(false); + } finally { + sandboxFixture.cleanup(); + } + }); + + it('adds email sending and inbound acceptance routes only when explicitly enabled', () => { + expect(existsSync(resolve(fixture.root, 'routes/api/acceptance/email.ts'))).toBe(false); + const emailFixture = prepareKitchenSinkPlatformFixture({ email: true }); + try { + for (const path of [ + 'routes/api/acceptance/email.ts', + 'lib/email-acceptance.ts', + 'email/support.ts', + 'email/support+[ticket].ts', + 'email/support+vip.ts', + 'email/_default.ts', + ]) { + expect(existsSync(resolve(emailFixture.root, path))).toBe(true); + } + expect( + readFileSync(resolve(emailFixture.root, 'routes/api/acceptance/email.ts'), 'utf8'), + ).toContain("typeof secret !== 'string' || !secret"); + expect(existsSync(resolve(emailFixture.root, '.env'))).toBe(false); + } finally { + emailFixture.cleanup(); + } + }); + + it('adds isolated retry and runtime probes only when explicitly enabled', () => { + expect(existsSync(resolve(fixture.root, 'queues/acceptance-queue.ts'))).toBe(false); + const runtimeFixture = prepareKitchenSinkPlatformFixture({ runtime: true }); + try { + for (const path of [ + 'queues/acceptance-queue.ts', + 'lib/queue-acceptance.ts', + 'routes/api/acceptance/queue.ts', + 'routes/api/acceptance/runtime.ts', + 'routes/ws/acceptance.ws.ts', + ]) { + expect(existsSync(resolve(runtimeFixture.root, path))).toBe(true); + } + } finally { + runtimeFixture.cleanup(); + } + }); + it('builds the hosted app with its supported bindings and no Sandbox container', () => { const build = spawn.sync('vp', ['build'], { cwd: fixture.root, @@ -46,4 +110,36 @@ describe('hosted kitchen-sink smoke fixture', () => { expect(config.ai).toEqual({ binding: 'AI' }); expect(config.queues.producers.length).toBeGreaterThan(0); }, 120_000); + + it('builds and typechecks the opt-in Sandbox and email acceptance app', () => { + const acceptanceFixture = prepareKitchenSinkPlatformFixture({ + sandbox: true, + email: true, + runtime: true, + }); + try { + for (const args of [['build'], ['exec', 'tsc', '--noEmit']]) { + const result = spawn.sync('vp', args, { + cwd: acceptanceFixture.root, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, VOID_DEPLOY_PROJECT_ID: 'runtime-acceptance' }, + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + } + const config = JSON.parse( + readFileSync(resolve(acceptanceFixture.root, 'dist/ssr/wrangler.json'), 'utf8'), + ); + expect(config.secrets.required).toContain('SECRET_KEY'); + expect(config.queues.consumers).toContainEqual( + expect.objectContaining({ queue: 'acceptance-queue', max_batch_size: 1, max_retries: 1 }), + ); + expect(config.durable_objects.bindings).toContainEqual( + expect.objectContaining({ name: 'SANDBOX', class_name: 'SandboxV1' }), + ); + } finally { + acceptanceFixture.cleanup(); + } + }, 120_000); }); diff --git a/platform/packages/api/test/unit/email-cf-customer.test.ts b/platform/packages/api/test/unit/email-cf-customer.test.ts index ea617fe61..be1d7f4c9 100644 --- a/platform/packages/api/test/unit/email-cf-customer.test.ts +++ b/platform/packages/api/test/unit/email-cf-customer.test.ts @@ -431,4 +431,74 @@ describe('cf-customer client', () => { await expect(client.deleteRelayScript(ACCOUNT, RELAY)).rejects.toThrow('boom'); }); }); + describe('owned literal recipient rules', () => { + const wire = { + id: 'rule-1', + name: 'owned-name', + enabled: true, + matchers: [{ type: 'literal', field: 'to', value: 'project@mail.example.com' }], + actions: [{ type: 'worker', value: ['gateway'] }], + }; + it('PATCHes only the per-zone subaddress flag', async () => { + fetchMock.mockResolvedValueOnce(cfSuccess({ support_subaddress: true })); + await createCfCustomerClient(CREDENTIAL).enableSubaddressing(ZONE); + const { url, init } = lastCall(fetchMock); + expect(url).toBe(CF_CUSTOMER_API_BASE + '/zones/' + ZONE + '/email/routing'); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ support_subaddress: true }); + }); + it.each([null, 'rule-1'])( + 'creates or updates one exact literal rule without a catch-all: %s', + async (id) => { + fetchMock.mockResolvedValueOnce(cfSuccess(wire)); + const rule = await createCfCustomerClient(CREDENTIAL).putLiteralRule(ZONE, id, { + name: wire.name, + address: 'project@mail.example.com', + workerName: 'gateway', + }); + expect(rule).toMatchObject({ + id: wire.id, + name: wire.name, + literalWorker: true, + workerName: 'gateway', + addresses: ['project@mail.example.com'], + }); + const { url, init } = lastCall(fetchMock); + expect(url).toBe( + CF_CUSTOMER_API_BASE + '/zones/' + ZONE + '/email/routing/rules' + (id ? '/' + id : ''), + ); + expect(init.method).toBe(id ? 'PUT' : 'POST'); + expect(JSON.parse(init.body as string)).toEqual({ + name: wire.name, + enabled: true, + matchers: wire.matchers, + actions: wire.actions, + }); + }, + ); + it.each([ + { matchers: [{ type: 'all' }] }, + { matchers: [...wire.matchers, ...wire.matchers] }, + { actions: [{ type: 'worker', value: ['gateway', 'foreign'] }] }, + { actions: [{ type: 'forward', value: ['foreign@example.com'] }] }, + ])( + 'does not claim malformed or broader provider rules as a literal Worker rule', + async (change) => { + fetchMock.mockResolvedValueOnce(cfSuccess({ ...wire, ...change })); + expect( + await createCfCustomerClient(CREDENTIAL).getRoutingRule(ZONE, 'rule-1'), + ).toMatchObject({ literalWorker: false }); + }, + ); + it('treats exact GET/DELETE 404 as absent, and preserves permission errors', async () => { + fetchMock + .mockResolvedValueOnce(cfError('not found', 404)) + .mockResolvedValueOnce(cfError('not found', 404)) + .mockResolvedValueOnce(cfError('denied', 403)); + const client = createCfCustomerClient(CREDENTIAL); + expect(await client.getRoutingRule(ZONE, 'rule-1')).toBeNull(); + await expect(client.deleteRoutingRule(ZONE, 'rule-1')).resolves.toBeUndefined(); + await expect(client.deleteRoutingRule(ZONE, 'rule-1')).rejects.toMatchObject({ status: 403 }); + }); + }); }); diff --git a/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts b/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts new file mode 100644 index 000000000..9dcf1169b --- /dev/null +++ b/platform/packages/api/test/unit/email-shared-recipient-migration.test.ts @@ -0,0 +1,116 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; +import { platformDatabaseSchemaDigest } from '@void/platform/installer'; +import { expect, it } from 'vite-plus/test'; + +it('declares only the exact canonical 0.22.0 predecessor and fences legacy deletion for every unresolved recipient state', async () => { + const root = resolve(import.meta.dirname, '../../drizzle'); + const journal = JSON.parse(readFileSync(resolve(root, 'meta/_journal.json'), 'utf8')) as { + entries: Array<{ idx: number; tag: string; when: number }>; + }; + const compatibility = JSON.parse(readFileSync(resolve(root, 'compatibility.json'), 'utf8')) as { + lineage: string; + rollbackSafeFrom: Array<{ platformVersion: string; schemaDigest: string }>; + }; + const baseline = journal.entries.filter((entry) => entry.idx <= 55); + expect(baseline.at(-1)?.tag).toBe('0055_durable_deployment_execution'); + expect(baseline.slice(-2)).toMatchObject([ + { idx: 54, tag: '0054_project_mode', when: 1790841384175 }, + { idx: 55, tag: '0055_durable_deployment_execution', when: 1790864531640 }, + ]); + expect(journal.entries.at(-1)).toMatchObject({ idx: 56, tag: '0056_email_shared_recipients' }); + expect(journal.entries.at(-1)!.when).toBeGreaterThan(baseline.at(-1)!.when); + const previousSnapshot = JSON.parse( + readFileSync(resolve(root, 'meta/0055_snapshot.json'), 'utf8'), + ); + const snapshot = JSON.parse(readFileSync(resolve(root, 'meta/0056_snapshot.json'), 'utf8')); + expect(snapshot.prevId).toBe(previousSnapshot.id); + expect(snapshot.id).not.toBe(previousSnapshot.id); + expect(snapshot.tables).toEqual({ + ...previousSnapshot.tables, + 'public.email_shared_recipients': expect.any(Object), + }); + const schemaDigest = platformDatabaseSchemaDigest( + compatibility.lineage, + baseline.map((entry) => ({ + index: entry.idx, + tag: entry.tag, + timestampMillis: entry.when, + sha256: createHash('sha256') + .update(readFileSync(resolve(root, `${entry.tag}.sql`))) + .digest('hex'), + })), + ); + expect(schemaDigest).toBe('bef2a1cfaad1dcad4aca2a4932ef9aa9b9d8025dca495e3b1027902405da902a'); + expect( + compatibility.rollbackSafeFrom.filter((entry) => entry.schemaDigest === schemaDigest), + ).toEqual([{ platformVersion: '0.22.0', schemaDigest }]); + const db = new PGlite(); + try { + for (const entry of baseline) { + await db.exec(readFileSync(resolve(root, `${entry.tag}.sql`), 'utf8')); + } + expect( + ( + await db.query( + "SELECT column_default FROM information_schema.columns WHERE table_name='projects' AND column_name='mode'", + ) + ).rows, + ).toEqual([{ column_default: "'void'::text" }]); + expect( + (await db.query("SELECT to_regclass('public.deployment_operations') AS name")).rows, + ).toEqual([{ name: 'deployment_operations' }]); + await db.exec( + `INSERT INTO users (id, provider_id, login) VALUES ('owner', '1', 'owner'); INSERT INTO projects (id, user_id, slug) VALUES ('guarded', 'owner', 'guarded'), ('unrelated', 'owner', 'unrelated');`, + ); + const existingTables = ( + await db.query<{ tablename: string }>( + "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename", + ) + ).rows.map((row) => row.tablename); + const catalog = async () => + ( + await db.query( + "SELECT table_name, column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema='public' AND table_name=ANY($1::text[]) ORDER BY table_name, ordinal_position", + [existingTables], + ) + ).rows; + const before = await catalog(); + await db.exec(readFileSync(resolve(root, '0056_email_shared_recipients.sql'), 'utf8')); + expect(await catalog()).toEqual(before); + await db.exec( + `INSERT INTO email_shared_recipients (id, installation_id, project_id, route_id, recipient, rule_name) VALUES ('recipient', 'installation', 'guarded', 'route', 'guarded@mail.example.com', 'owned-rule');`, + ); + for (const status of ['pending', 'reconciling', 'ready', 'blocked', 'unknown']) { + await db.query('UPDATE email_shared_recipients SET status=$1 WHERE id=$2', [ + status, + 'recipient', + ]); + await expect(db.exec("DELETE FROM projects WHERE id='guarded'")).rejects.toMatchObject({ + code: '23503', + }); + await expect( + db.exec("UPDATE projects SET slug='reused' WHERE id='guarded'"), + ).rejects.toMatchObject({ code: '23503' }); + await expect(db.exec("DELETE FROM users WHERE id='owner'")).rejects.toThrow(); + } + // Status updates and unrelated project teardown continue to work on an old + // Worker; only unsafe ownership release is fenced. + await db.exec( + "UPDATE projects SET status='deleting', slug=slug WHERE id='guarded'; DELETE FROM projects WHERE id='unrelated';", + ); + expect((await db.query("SELECT id, slug FROM projects WHERE id='guarded'")).rows).toEqual([ + { id: 'guarded', slug: 'guarded' }, + ]); + await db.exec( + "UPDATE email_shared_recipients SET status='removed' WHERE id='recipient'; DELETE FROM projects WHERE id='guarded'; INSERT INTO projects (id, user_id, slug) VALUES ('replacement', 'owner', 'guarded');", + ); + expect((await db.query('SELECT project_id,status FROM email_shared_recipients')).rows).toEqual([ + { project_id: 'guarded', status: 'removed' }, + ]); + } finally { + await db.close(); + } +}, 15_000); diff --git a/platform/packages/api/test/unit/kitchen-sink-acceptance-auth.test.ts b/platform/packages/api/test/unit/kitchen-sink-acceptance-auth.test.ts new file mode 100644 index 000000000..8cc49451a --- /dev/null +++ b/platform/packages/api/test/unit/kitchen-sink-acceptance-auth.test.ts @@ -0,0 +1,141 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Hono } from 'hono'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { CloudEnv } from 'void/handler'; +import { prepareKitchenSinkPlatformFixture } from '../../../../scripts/kitchen-sink-platform-fixture.ts'; + +const mocks = vi.hoisted(() => ({ + env: {} as { SECRET_KEY?: string }, + sendEmail: vi.fn(), + getSandbox: vi.fn(), + kvGet: vi.fn(), + queueSend: vi.fn(), + aiRun: vi.fn(), + aiStream: vi.fn(), +})); +vi.mock('void', async () => import('void/handler')); +vi.mock('void/env', () => ({ env: mocks.env })); +vi.mock('void/email', () => ({ sendEmail: mocks.sendEmail })); +vi.mock('void/kv', () => ({ kv: { get: mocks.kvGet } })); +vi.mock('void/queues', () => ({ queues: { 'acceptance-queue': { send: mocks.queueSend } } })); +vi.mock('void/ai', () => ({ ai: { run: mocks.aiRun, stream: mocks.aiStream } })); +vi.mock('void/sandbox', () => ({ + getSandbox: mocks.getSandbox, + SandboxLimitError: class extends Error {}, +})); + +let fixture: ReturnType<typeof prepareKitchenSinkPlatformFixture>; +let app: Hono<CloudEnv>; +beforeAll(async () => { + fixture = prepareKitchenSinkPlatformFixture({ email: true, sandbox: true, runtime: true }); + // These handler tests use mocked bindings before `void init` has generated + // .void/tsconfig.json. The separate fixture build test checks generated types. + writeFileSync(join(fixture.root, 'tsconfig.json'), '{}'); + const email = await import(join(fixture.root, 'routes/api/acceptance/email.ts')); + const sandbox = await import(join(fixture.root, 'routes/api/acceptance/sandbox.ts')); + const queue = await import(join(fixture.root, 'routes/api/acceptance/queue.ts')); + const runtime = await import(join(fixture.root, 'routes/api/acceptance/runtime.ts')); + const websocket = (await import(join(fixture.root, 'routes/ws/acceptance.ws.ts'))).default; + app = new Hono<CloudEnv>() + .get('/email', (c) => email.GET(c) as Promise<Response>) + .post('/email', (c) => email.POST(c) as Promise<Response>) + .post('/sandbox', (c) => sandbox.POST(c) as Promise<Response>) + .get('/queue', (c) => queue.GET(c) as Promise<Response>) + .post('/queue', (c) => queue.POST(c) as Promise<Response>) + .get('/runtime', (c) => runtime.GET(c) as Promise<Response>) + .post('/runtime', (c) => runtime.POST(c) as Promise<Response>) + .get('/socket-auth', async (c) => { + const denied = await websocket.onBeforeConnect({ + env: mocks.env, + request: c.req.raw, + } as never); + return denied ?? c.json({ ok: true }); + }); +}); +afterAll(() => fixture?.cleanup()); + +beforeEach(() => { + delete mocks.env.SECRET_KEY; + mocks.sendEmail.mockReset(); + mocks.getSandbox.mockReset(); + mocks.kvGet.mockReset(); + mocks.queueSend.mockReset(); + mocks.aiRun.mockReset(); + mocks.aiStream.mockReset(); +}); + +describe.each([undefined, ''])('acceptance auth with secret %j', (secret) => { + it.each([ + ['GET', '/email?subject=test'], + ['POST', '/email'], + ['POST', '/sandbox'], + ['GET', '/queue?run=ab12cd34ab12cd34'], + ['POST', '/queue'], + ['GET', '/runtime'], + ['POST', '/runtime'], + ['GET', '/socket-auth'], + ])('rejects anonymous %s %s before any provider or data access', async (method, path) => { + mocks.env.SECRET_KEY = secret; + const response = await app.request(path, { method }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: 'unauthorized' }); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(mocks.getSandbox).not.toHaveBeenCalled(); + expect(mocks.kvGet).not.toHaveBeenCalled(); + expect(mocks.queueSend).not.toHaveBeenCalled(); + expect(mocks.aiRun).not.toHaveBeenCalled(); + expect(mocks.aiStream).not.toHaveBeenCalled(); + }); +}); + +describe('acceptance auth with a configured secret', () => { + it('rejects a wrong token without sending mail', async () => { + mocks.env.SECRET_KEY = 'configured-secret'; + const response = await app.request('/email', { + method: 'POST', + headers: { 'x-void-e2e-token': 'wrong-token' }, + }); + expect(response.status).toBe(401); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + }); + + it('allows the matching token to submit a message', async () => { + mocks.env.SECRET_KEY = 'configured-secret'; + mocks.sendEmail.mockResolvedValue({ ok: true, ids: [] }); + const message = { to: 'test@example.com', subject: 'test', text: 'acceptance' }; + const response = await app.request('/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-void-e2e-token': 'configured-secret' }, + body: JSON.stringify(message), + }); + expect(response.status).toBe(200); + expect(mocks.sendEmail).toHaveBeenCalledWith(message); + }); + + it('rejects an invalid queue run before submitting any work', async () => { + mocks.env.SECRET_KEY = 'configured-secret'; + const response = await app.request('/queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-void-e2e-token': 'configured-secret' }, + body: JSON.stringify({ run: 'stale/arbitrary' }), + }); + expect(response.status).toBe(400); + expect(mocks.queueSend).not.toHaveBeenCalled(); + }); + + it('uses a fixed small AI prompt rather than accepting arbitrary model inputs', async () => { + mocks.env.SECRET_KEY = 'configured-secret'; + mocks.aiRun.mockResolvedValue({ response: 'Hello' }); + const response = await app.request('/runtime', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-void-e2e-token': 'configured-secret' }, + body: JSON.stringify({ action: 'ai', model: 'arbitrary', max_tokens: 10000 }), + }); + expect(response.status).toBe(200); + expect(mocks.aiRun).toHaveBeenCalledWith('@cf/meta/llama-3.2-1b-instruct', { + messages: [{ role: 'user', content: 'Say hello in one word.' }], + max_tokens: 8, + }); + }); +}); diff --git a/platform/packages/api/test/unit/kitchen-sink-observations.test.ts b/platform/packages/api/test/unit/kitchen-sink-observations.test.ts new file mode 100644 index 000000000..3e9143b13 --- /dev/null +++ b/platform/packages/api/test/unit/kitchen-sink-observations.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; +import { + resetKitchenSink, + waitForChangedRender, + waitForFreshHeartbeat, + waitForIsrCacheHit, +} from '../../../../scripts/kitchen-sink-observations.ts'; + +function clock() { + let time = Date.parse('2026-10-01T00:00:00Z'); + return { + now: () => time, + wait: async (milliseconds: number) => { + time += milliseconds; + }, + timeoutMs: 10_000, + intervalMs: 3_000, + }; +} + +describe('kitchen-sink acceptance observations', () => { + it('stops acceptance when resetting the fixture returns a provider error', async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValue(new Response('failure', { status: 503 })); + await expect(resetKitchenSink('https://fixture.test', {}, fetcher)).rejects.toThrow( + 'Fixture reset failed (HTTP 503)', + ); + }); + + it.each([Response.json({ ok: false }), new Response('<html>wrong app</html>')])( + 'rejects a successful HTTP response without a confirmed fixture reset', + async (response) => { + const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response); + await expect(resetKitchenSink('https://fixture.test', {}, fetcher)).rejects.toThrow( + 'verify that SMOKE_URL selects the disposable kitchen-sink app', + ); + }, + ); + + it('starts the freshness window only after the reset confirms success', async () => { + const fetcher = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ ok: true })); + const now = vi.fn(() => 12345); + await expect(resetKitchenSink('https://fixture.test', {}, fetcher, now)).resolves.toBe(12345); + expect(fetcher).toHaveBeenCalledWith( + 'https://fixture.test/api/reset', + expect.objectContaining({ method: 'POST' }), + ); + expect(now).toHaveBeenCalledOnce(); + }); + + it('fails with provider and fixture diagnostics when a heartbeat never arrives', async () => { + const options = clock(); + const started = options.now(); + const read = vi.fn().mockResolvedValue(null); + await expect(waitForFreshHeartbeat(read, options)).rejects.toThrow( + 'check cron registration and dispatch logs, then the fixture KV binding', + ); + expect(options.now() - started).toBe(10_000); + expect(read).toHaveBeenCalledTimes(5); + }); + + it('waits through missing, malformed, stale, and future values before accepting a fresh heartbeat', async () => { + const options = { ...clock(), timeoutMs: 20_000 }; + const read = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('invalid') + .mockResolvedValueOnce('2026-09-30T23:54:00Z') + .mockResolvedValueOnce('2026-10-01T00:10:00Z') + .mockResolvedValueOnce('2026-10-01T00:00:10Z'); + await expect(waitForFreshHeartbeat(read, options)).resolves.toBe('2026-10-01T00:00:10Z'); + expect(read).toHaveBeenCalledTimes(5); + }); + + it('rejects a recent heartbeat from before the fixture reset', async () => { + const options = clock(); + const read = vi + .fn() + .mockResolvedValueOnce('2026-09-30T23:59:59Z') + .mockResolvedValueOnce('2026-10-01T00:00:01Z'); + await expect( + waitForFreshHeartbeat(read, { ...options, notBefore: options.now() }), + ).resolves.toBe('2026-10-01T00:00:01Z'); + expect(read).toHaveBeenCalledTimes(2); + }); + + it('does not accept an unchanged render or a non-date label as proof of a purge', async () => { + const options = clock(); + const previous = '2026-10-01T00:00:00Z'; + const read = vi + .fn() + .mockResolvedValueOnce(previous) + .mockResolvedValueOnce('void/isr') + .mockResolvedValueOnce('2026-10-01T00:00:02Z'); + await expect(waitForChangedRender(read, previous, options)).resolves.toBe( + '2026-10-01T00:00:02Z', + ); + expect(read).toHaveBeenCalledTimes(3); + }); + + it('fails when the cached render remains unchanged after an accepted purge', async () => { + const options = clock(); + const previous = '2026-10-01T00:00:00Z'; + await expect(waitForChangedRender(async () => previous, previous, options)).rejects.toThrow( + 'check the purge outcome, ISR cache configuration, and fixture loader', + ); + }); + + it('requires an actual cache hit before testing a purge on a public target', async () => { + const options = clock(); + const read = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('MISS') + .mockResolvedValueOnce('HIT'); + await expect(waitForIsrCacheHit(read, options)).resolves.toBe('HIT'); + expect(read).toHaveBeenCalledTimes(3); + }); +}); diff --git a/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts b/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts new file mode 100644 index 000000000..15ece4bcc --- /dev/null +++ b/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts @@ -0,0 +1,42 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { CloudEnv } from 'void/handler'; + +const mocks = vi.hoisted(() => ({ revalidate: vi.fn(), put: vi.fn() })); +vi.mock('void', async () => import('void/handler')); +vi.mock('void/isr', () => ({ revalidate: mocks.revalidate })); +vi.mock('void/kv', () => ({ kv: { put: mocks.put, get: vi.fn() } })); + +import { action } from '../../../../../playground/kitchen-sink/pages/revalidate.server.ts'; + +const app = new Hono<CloudEnv>() + .post('/', async (c) => { + const response = await action(c); + return response instanceof Response ? response : c.body(null, 204); + }) + .onError((error) => Response.json({ error: error.message }, { status: 502 })); + +beforeEach(() => { + mocks.revalidate.mockReset(); + mocks.put.mockReset(); +}); + +describe('kitchen-sink ISR revalidation', () => { + it('reports a rejected purge instead of storing a successful revalidation timestamp', async () => { + mocks.revalidate.mockRejectedValue(new Error('provider purge failed')); + const response = await app.request('/', { method: 'POST' }); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'provider purge failed' }); + expect(mocks.put).not.toHaveBeenCalled(); + }); + + it('records success after the purge completes', async () => { + const response = await app.request('/', { method: 'POST' }); + expect(response.status).toBe(204); + expect(mocks.revalidate).toHaveBeenCalledWith({ paths: ['/revalidate'] }); + expect(mocks.put).toHaveBeenCalledWith('revalidate:last', expect.any(String)); + expect(mocks.put.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.revalidate.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts b/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts new file mode 100644 index 000000000..9d1703a49 --- /dev/null +++ b/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts @@ -0,0 +1,100 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import type { CloudEnv } from 'void/handler'; + +const mocks = vi.hoisted(() => ({ getSandbox: vi.fn() })); +vi.mock('void', async () => import('void/handler')); +vi.mock('void/sandbox', () => ({ + getSandbox: mocks.getSandbox, + SandboxLimitError: class extends Error {}, +})); + +import { POST } from '../../../../../playground/kitchen-sink/routes/api/sandbox.ts'; + +const app = new Hono<CloudEnv>().post('/', (c) => POST(c) as Promise<Response>); +const request = (marker: string) => + app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: marker }), + }); + +beforeEach(() => mocks.getSandbox.mockReset()); + +describe('kitchen-sink Sandbox requests', () => { + it('isolates simultaneous programs and destroys both containers after reading their results', async () => { + let release!: () => void; + const bothStarted = new Promise<void>((resolve) => { + release = resolve; + }); + let started = 0; + const containers = new Map<string, ReturnType<typeof createContainer>>(); + function createContainer() { + const files = new Map<string, string>(); + return { + files: { + mkdir: vi.fn(), + writeFile: async (path: string, value: string) => { + files.set(path, value); + }, + readFile: async (path: string) => new Response(files.get(path)), + }, + exec: async () => { + if (++started === 2) { + release(); + } + await bothStarted; + // The command reads its program only after both requests have + // written files, reproducing the race when they share a container. + const marker = files.get('/workspace/void-kitchen-sink-code-lab/user-code.mjs'); + const output = JSON.stringify({ value: marker }); + files.set('/workspace/void-kitchen-sink-code-lab/output.json', output); + return { + output: async () => ({ + exitCode: 0, + stdout: new TextEncoder().encode(output), + stderr: new ArrayBuffer(0), + }), + }; + }, + destroy: vi.fn(), + }; + } + mocks.getSandbox.mockImplementation(async (id: string) => { + let container = containers.get(id); + if (!container) { + container = createContainer(); + containers.set(id, container); + } + return container; + }); + + const [first, second] = await Promise.all([ + request('first program'), + request('second program'), + ]); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + const firstResult = (await first.json()) as { output: { value: string } }; + const secondResult = (await second.json()) as { output: { value: string } }; + expect(firstResult.output.value).toBe('first program'); + expect(secondResult.output.value).toBe('second program'); + expect(containers.size).toBe(2); + for (const container of containers.values()) { + expect(container.destroy).toHaveBeenCalledOnce(); + } + }); + + it('destroys a container when execution fails and preserves the reported error', async () => { + const destroy = vi.fn(); + mocks.getSandbox.mockResolvedValue({ + files: { mkdir: vi.fn(), writeFile: vi.fn() }, + exec: vi.fn().mockRejectedValue(new Error('command failed')), + destroy, + }); + const response = await request('invalid program'); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ ok: false, error: 'command failed' }); + expect(destroy).toHaveBeenCalledOnce(); + }); +}); diff --git a/platform/packages/api/test/unit/redirect-validation.test.ts b/platform/packages/api/test/unit/redirect-validation.test.ts index 9b3b0103d..17db0f5d0 100644 --- a/platform/packages/api/test/unit/redirect-validation.test.ts +++ b/platform/packages/api/test/unit/redirect-validation.test.ts @@ -37,6 +37,25 @@ describe('isAllowedRedirect', () => { ).toBe(true); }); + it('admits only the configured self-hosted dashboard origin including its port', () => { + const dashboard = 'https://void-e2e-dashboard.readwise.dev:8443'; + expect( + isAllowedRedirect( + `${dashboard}/auth/callback?login_nonce=real-browser-nonce`, + dashboard, + 'isolated-installation', + ), + ).toBe(true); + for (const callback of [ + 'https://void-e2e-dashboard.readwise.dev/auth/callback', + 'https://void-e2e-dashboard.readwise.dev.attacker.example:8443/auth/callback', + 'https://other.readwise.dev:8443/auth/callback', + 'http://void-e2e-dashboard.readwise.dev:8443/auth/callback', + ]) { + expect(isAllowedRedirect(callback, dashboard, 'isolated-installation')).toBe(false); + } + }); + // Subdomain attack vectors it('rejects subdomain prefix attack', () => { expect(isAllowedRedirect('https://dash.void.cloud.attacker.com/steal')).toBe(false); diff --git a/platform/packages/dashboard/pages/account.server.ts b/platform/packages/dashboard/pages/account.server.ts index b595ff256..0acbbb83e 100644 --- a/platform/packages/dashboard/pages/account.server.ts +++ b/platform/packages/dashboard/pages/account.server.ts @@ -12,22 +12,26 @@ export const head = defineHead<Props>((_context, props) => ({ title: platformPageTitle('Login methods', props.platformName), })); export const loader = defineHandler<Props>(async (context) => { - const response = await fetchApi(context, '/.well-known/void-platform'); - if (!response.ok) { + try { + const response = await fetchApi(context, '/.well-known/void-platform'); + if (!response.ok) { + throw new Error('Login methods unavailable'); + } + const discovery = (await response.json()) as { + displayName?: unknown; + authentication?: { methods?: Props['methods'] }; + }; + return { + methods: discovery.authentication?.methods ?? [], + platformName: discoveryDisplayName(discovery), + }; + } catch { return { methods: [], platformName: 'Void Cloud', error: 'Login methods are unavailable. Try again shortly.', }; } - const discovery = (await response.json()) as { - displayName?: unknown; - authentication?: { methods?: Props['methods'] }; - }; - return { - methods: discovery.authentication?.methods ?? [], - platformName: discoveryDisplayName(discovery), - }; }); export const action = defineHandler(async (context) => { // Cookie-authenticated mutations must originate in this dashboard. @@ -41,10 +45,15 @@ export const action = defineHandler(async (context) => { if (typeof body.connectionId !== 'string') { return context.text('Choose a login method.', 400); } - const response = await fetchApi(context, '/auth/link', { - method: 'POST', - body: JSON.stringify({ connectionId: body.connectionId }), - }); + let response: Response; + try { + response = await fetchApi(context, '/auth/link', { + method: 'POST', + body: JSON.stringify({ connectionId: body.connectionId }), + }); + } catch { + return context.text('The service is temporarily unavailable. Try again shortly.', 503); + } if (!response.ok) { return context.text(await getApiErrorMessage(response), response.status as 400); } diff --git a/platform/packages/dashboard/pages/auth/callback.server.ts b/platform/packages/dashboard/pages/auth/callback.server.ts index 10263913f..65038381a 100644 --- a/platform/packages/dashboard/pages/auth/callback.server.ts +++ b/platform/packages/dashboard/pages/auth/callback.server.ts @@ -46,12 +46,20 @@ export const loader = defineHandler(async (context) => { return context.redirect(loginUrl(context)); } const exchangePath = isLocal ? '/auth/cli/token' : '/auth/exchange'; - const resp = await fetchApi(context, exchangePath, { - method: 'POST', - body: JSON.stringify( - isLocal ? { code, state: pkceState, code_verifier: pkceVerifier } : { code }, - ), - }); + let resp: Response; + try { + resp = await fetchApi(context, exchangePath, { + method: 'POST', + body: JSON.stringify( + isLocal ? { code, state: pkceState, code_verifier: pkceVerifier } : { code }, + ), + }); + } catch (error) { + console.warn( + `[dashboard] ${exchangePath} request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return context.redirect(loginUrl(context)); + } if (!resp.ok) { console.warn( `[dashboard] ${exchangePath} returned ${resp.status}: ${await getApiErrorMessage(resp)}`, diff --git a/platform/packages/dashboard/test/unit/account.test.ts b/platform/packages/dashboard/test/unit/account.test.ts index a5f7cc7b1..7850406fa 100644 --- a/platform/packages/dashboard/test/unit/account.test.ts +++ b/platform/packages/dashboard/test/unit/account.test.ts @@ -39,6 +39,31 @@ describe('account linking error responses', () => { expect(head({} as never, props)?.title).toBe('Login methods · Example Platform · Void'); }); + it('keeps the account page available when discovery times out', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('API request timed out'))); + + const response = await accountPage.request('https://dashboard.example.com/account', undefined, { + API_URL: 'https://api.example.com', + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + methods: [], + error: 'Login methods are unavailable. Try again shortly.', + }); + }); + + it('gives a retry action when a linking request cannot reach the API', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('API request timed out'))); + + const response = await submit(); + + expect(response.status).toBe(503); + expect(await response.text()).toBe( + 'The service is temporarily unavailable. Try again shortly.', + ); + }); + it('renders the API recovery message while preserving its rejection status', async () => { vi.stubGlobal( 'fetch', diff --git a/platform/packages/dashboard/test/unit/auth-callback.test.ts b/platform/packages/dashboard/test/unit/auth-callback.test.ts index 17d68c951..ab21bd49a 100644 --- a/platform/packages/dashboard/test/unit/auth-callback.test.ts +++ b/platform/packages/dashboard/test/unit/auth-callback.test.ts @@ -75,6 +75,26 @@ describe('dashboard auth callback', () => { ); }); + it('returns to login when the code exchange cannot reach the API', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createApp(vi.fn().mockRejectedValue(new Error('API request timed out'))); + + const response = await app.request( + 'https://dashboard.example.com/auth/callback?code=auth-code&login_nonce=nonce', + { + headers: { Cookie: 'void_auth_nonce=nonce', Host: 'dashboard.example.com' }, + redirect: 'manual', + }, + { API_URL: 'https://api.example.com' }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('https://dashboard.example.com/login'); + expect(response.headers.getSetCookie().some((cookie) => cookie.startsWith('void_token='))).toBe( + false, + ); + }); + it('redeems localhost managed-login codes with the cookie-bound PKCE verifier', async () => { const request = vi.fn(async () => Response.json({ token: 'managed-local-jwt' })); const app = createApp(request as typeof fetch); diff --git a/platform/scripts/fixtures/kitchen-sink-email/email/_default.ts b/platform/scripts/fixtures/kitchen-sink-email/email/_default.ts new file mode 100644 index 000000000..0f007693a --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-email/email/_default.ts @@ -0,0 +1,3 @@ +import { acceptEmail } from '../lib/email-acceptance.ts'; + +export default acceptEmail('default'); diff --git a/platform/scripts/fixtures/kitchen-sink-email/email/support+[ticket].ts b/platform/scripts/fixtures/kitchen-sink-email/email/support+[ticket].ts new file mode 100644 index 000000000..91e2c834b --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-email/email/support+[ticket].ts @@ -0,0 +1,3 @@ +import { acceptEmail } from '../lib/email-acceptance.ts'; + +export default acceptEmail('support-ticket'); diff --git a/platform/scripts/fixtures/kitchen-sink-email/email/support+vip.ts b/platform/scripts/fixtures/kitchen-sink-email/email/support+vip.ts new file mode 100644 index 000000000..8a1a451ac --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-email/email/support+vip.ts @@ -0,0 +1,3 @@ +import { acceptEmail } from '../lib/email-acceptance.ts'; + +export default acceptEmail('support-vip'); diff --git a/platform/scripts/fixtures/kitchen-sink-email/email/support.ts b/platform/scripts/fixtures/kitchen-sink-email/email/support.ts new file mode 100644 index 000000000..f0aedcc15 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-email/email/support.ts @@ -0,0 +1,3 @@ +import { acceptEmail } from '../lib/email-acceptance.ts'; + +export default acceptEmail('support'); diff --git a/platform/scripts/fixtures/kitchen-sink-email/lib/email-acceptance.ts b/platform/scripts/fixtures/kitchen-sink-email/lib/email-acceptance.ts new file mode 100644 index 000000000..2b533c727 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-email/lib/email-acceptance.ts @@ -0,0 +1,38 @@ +import { defineEmail, parseEmail, replyEmail } from 'void/email'; +import { kv } from 'void/kv'; + +export function acceptEmail(route: string) { + return defineEmail(async (message, _env, _ctx, info) => { + const parsed = await parseEmail(message); + const subject = parsed.subject ?? '(no subject)'; + await kv.put( + `email-e2e:${subject}`, + JSON.stringify({ + route, + params: info?.params ?? {}, + from: message.from, + to: message.to, + subject, + text: parsed.text, + html: parsed.html, + attachments: parsed.attachments.map(({ filename, mimeType }) => ({ filename, mimeType })), + }), + { ttl: 3_600 }, + ); + if (subject.startsWith('REJECT ')) { + message.setReject('Void email acceptance reject'); + return; + } + if (subject.startsWith('REPLY ')) { + await replyEmail(message, { text: `Void email acceptance reply: ${subject}` }); + } + if (subject.startsWith('FORWARD ')) { + const destination = parsed.headers.find(({ key }) => key === 'x-void-e2e-forward')?.value; + if (!destination) { + message.setReject('x-void-e2e-forward is required for this test'); + return; + } + await message.forward(destination); + } + }); +} diff --git a/platform/scripts/fixtures/kitchen-sink-email/routes/api/acceptance/email.ts b/platform/scripts/fixtures/kitchen-sink-email/routes/api/acceptance/email.ts new file mode 100644 index 000000000..43cee4194 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-email/routes/api/acceptance/email.ts @@ -0,0 +1,25 @@ +import { defineHandler } from 'void'; +import { sendEmail, type SendEmailOptions } from 'void/email'; +import { env } from 'void/env'; +import { kv } from 'void/kv'; + +export const POST = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + const body = await c.req.json<SendEmailOptions>(); + return c.json(await sendEmail(body)); +}); + +export const GET = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + const subject = c.req.query('subject'); + if (!subject || subject.length > 180) { + return c.json({ error: 'subject must contain 1–180 characters' }, 400); + } + return c.json(await kv.get(`email-e2e:${subject}`)); +}); diff --git a/platform/scripts/fixtures/kitchen-sink-runtime/lib/queue-acceptance.ts b/platform/scripts/fixtures/kitchen-sink-runtime/lib/queue-acceptance.ts new file mode 100644 index 000000000..e3fa744fc --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-runtime/lib/queue-acceptance.ts @@ -0,0 +1,17 @@ +export const queueCases = [ + 'ack', + 'retry-once', + 'throw-once', + 'ack-then-retry', + 'retry-then-ack', + 'batch-ack-then-retry', + 'batch-retry-then-ack', +] as const; + +export type QueueCase = (typeof queueCases)[number]; +export type AcceptanceMessage = { run: string; scenario: QueueCase }; +export const queueResultKey = (run: string) => `runtime-queue:${run}`; + +export function validQueueRun(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{16}$/.test(value); +} diff --git a/platform/scripts/fixtures/kitchen-sink-runtime/queues/acceptance-queue.ts b/platform/scripts/fixtures/kitchen-sink-runtime/queues/acceptance-queue.ts new file mode 100644 index 000000000..9d6d32221 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-runtime/queues/acceptance-queue.ts @@ -0,0 +1,64 @@ +import { defineQueue } from 'void'; +import { + queueCases, + queueResultKey, + validQueueRun, + type AcceptanceMessage, +} from '../lib/queue-acceptance.ts'; + +// A single-message batch makes thrown retries independent of the other cases. +export const maxBatchSize = 1; +export const maxBatchTimeout = 1; +export const maxRetries = 1; +export const retryDelay = 1; + +export default defineQueue<AcceptanceMessage>(async (batch, env) => { + for (const message of batch.messages) { + const { run, scenario } = message.body; + if (!validQueueRun(run) || !queueCases.includes(scenario)) { + message.ack(); + continue; + } + await env.DB.prepare('INSERT INTO queue_results (key, value) VALUES (?, ?)') + .bind( + queueResultKey(run), + JSON.stringify({ + scenario, + id: message.id, + attempts: message.attempts, + timestamp: message.timestamp instanceof Date ? message.timestamp.toISOString() : null, + }), + ) + .run(); + if (message.attempts > 1) { + message.ack(); + continue; + } + switch (scenario) { + case 'ack': + message.ack(); + break; + case 'retry-once': + message.retry({ delaySeconds: 1 }); + break; + case 'throw-once': + throw new Error('Void queue acceptance deliberate first-attempt failure'); + case 'ack-then-retry': + message.ack(); + message.retry({ delaySeconds: 1 }); + break; + case 'retry-then-ack': + message.retry({ delaySeconds: 1 }); + message.ack(); + break; + case 'batch-ack-then-retry': + batch.ackAll(); + batch.retryAll({ delaySeconds: 1 }); + break; + case 'batch-retry-then-ack': + batch.retryAll({ delaySeconds: 1 }); + batch.ackAll(); + break; + } + } +}); diff --git a/platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/queue.ts b/platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/queue.ts new file mode 100644 index 000000000..36f1e896f --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/queue.ts @@ -0,0 +1,37 @@ +import { defineHandler } from 'void'; +import { env } from 'void/env'; +import { queues } from 'void/queues'; +import { queueCases, queueResultKey, validQueueRun } from '../../../lib/queue-acceptance.ts'; + +export const POST = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + const body = await c.req.json<{ run?: unknown }>().catch(() => null); + const run = body?.run; + if (!validQueueRun(run)) { + return c.json({ error: 'run must contain 16 hexadecimal characters' }, 400); + } + for (const scenario of queueCases) { + await queues['acceptance-queue'].send({ run, scenario }); + } + return c.json({ ok: true, run, cases: queueCases }); +}); + +export const GET = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + const run = c.req.query('run'); + if (!validQueueRun(run)) { + return c.json({ error: 'run must contain 16 hexadecimal characters' }, 400); + } + const rows = await c.env.DB.prepare( + 'SELECT value FROM queue_results WHERE key = ? ORDER BY id LIMIT 100', + ) + .bind(queueResultKey(run)) + .all<{ value: string }>(); + return c.json({ run, observations: rows.results.map(({ value }) => JSON.parse(value)) }); +}); diff --git a/platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/runtime.ts b/platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/runtime.ts new file mode 100644 index 000000000..9f276ad50 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-runtime/routes/api/acceptance/runtime.ts @@ -0,0 +1,44 @@ +import { defineHandler } from 'void'; +import { ai } from 'void/ai'; +import { env } from 'void/env'; + +// Root can change this literal between two disposable deployments for rollback proof. +export const acceptanceVersion = 'void-runtime-acceptance-v1'; + +export const GET = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + return c.json({ ok: true, version: acceptanceVersion }); +}); + +export const POST = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + const body = await c.req.json<{ action?: unknown; marker?: unknown }>().catch(() => null); + if (body?.action === 'log') { + if (typeof body.marker !== 'string' || !/^void-e2e-[a-f0-9]{16}$/.test(body.marker)) { + return c.json({ error: 'invalid isolated log marker' }, 400); + } + console.log(body.marker); + console.error(body.marker); + // This fixed 503 also exercises dispatch request metering without logging user input. + return c.json({ ok: true, marker: body.marker }, 503); + } + if (body?.action === 'ai' || body?.action === 'ai-stream') { + const inputs = { + messages: [{ role: 'user' as const, content: 'Say hello in one word.' }], + max_tokens: 8, + }; + const model = '@cf/meta/llama-3.2-1b-instruct'; + if (body.action === 'ai-stream') { + return ai.stream(model, inputs); + } + const result = await ai.run(model, inputs); + return c.json({ ok: true, result }); + } + return c.json({ error: 'invalid acceptance action' }, 400); +}); diff --git a/platform/scripts/fixtures/kitchen-sink-runtime/routes/ws/acceptance.ws.ts b/platform/scripts/fixtures/kitchen-sink-runtime/routes/ws/acceptance.ws.ts new file mode 100644 index 000000000..a025d08a2 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-runtime/routes/ws/acceptance.ws.ts @@ -0,0 +1,24 @@ +import * as v from 'valibot'; +import { defineWebSocket } from 'void/ws'; + +const marker = v.pipe(v.string(), v.regex(/^void-e2e-[a-f0-9]{16}$/)); + +export default defineWebSocket({ + messages: { + client: v.object({ marker }), + server: v.object({ kind: v.literal('echo'), marker }), + }, + onBeforeConnect(ctx) { + const secret = ctx.env.SECRET_KEY; + if ( + typeof secret !== 'string' || + !secret || + ctx.request.headers.get('x-void-e2e-token') !== secret + ) { + return Response.json({ error: 'unauthorized' }, { status: 401 }); + } + }, + async onMessage(ctx, event) { + await ctx.socket.send({ kind: 'echo', marker: event.marker }); + }, +}); diff --git a/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts b/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts new file mode 100644 index 000000000..f750563b1 --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts @@ -0,0 +1,215 @@ +import { defineHandler } from 'void'; +import { env } from 'void/env'; +import { getSandbox, SandboxLimitError, type SandboxClient } from 'void/sandbox'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +export const POST = defineHandler(async (c) => { + const secret = env.SECRET_KEY; + if (typeof secret !== 'string' || !secret || c.req.header('x-void-e2e-token') !== secret) { + return c.json({ error: 'unauthorized' }, 401); + } + const started = Date.now(); + const id = `acceptance-${crypto.randomUUID()}`; + const root = '/workspace/void-acceptance'; + const clients: Array<SandboxClient> = []; + const checks: Array<string> = []; + try { + const sandbox = await getSandbox(id, { + inactivityTimeoutMs: 30_000, + enableInternet: false, + labels: { purpose: 'void-acceptance' }, + }); + clients.push(sandbox); + assert(!(await sandbox.running()), 'Resolving a fresh Sandbox must not start it'); + checks.push('lazy start'); + + await sandbox.files.mkdir(root, { recursive: true }); + await sandbox.files.writeFile(`${root}/text.txt`, 'text content'); + await sandbox.files.writeFile(`${root}/bytes.bin`, new Uint8Array([0, 1, 127, 255])); + await sandbox.files.writeFile(`${root}/blob.txt`, new Blob(['blob content'])); + await sandbox.files.writeFile(`${root}/stream.txt`, new Blob(['stream content']).stream()); + await sandbox.files.writeFile('relative.txt', 'relative content', { cwd: root }); + assert(await sandbox.running(), 'File operations must start the container'); + assert( + (await (await sandbox.files.readFile(`${root}/text.txt`)).text()) === 'text content', + 'Text round trip failed', + ); + const bytes = new Uint8Array( + await (await sandbox.files.readFile(`${root}/bytes.bin`)).arrayBuffer(), + ); + assert(bytes.join(',') === '0,1,127,255', 'Binary round trip failed'); + assert( + (await (await sandbox.files.readFile(`${root}/blob.txt`)).text()) === 'blob content', + 'Blob round trip failed', + ); + assert( + (await (await sandbox.files.readFile(`${root}/stream.txt`)).text()) === 'stream content', + 'Stream round trip failed', + ); + assert( + (await (await sandbox.files.readFile('relative.txt', { cwd: root })).text()) === + 'relative content', + 'Relative path round trip failed', + ); + checks.push('text, binary, Blob, stream, relative files'); + + const stat = await sandbox.files.stat(`${root}/text.txt`); + assert( + stat.type === 'file' && stat.size === 12n && stat.modifiedAt instanceof Date, + 'File stat metadata failed', + ); + await sandbox.files.rename(`${root}/text.txt`, `${root}/renamed.txt`); + const entries = await sandbox.files.readDirectory(root); + assert( + entries.some(({ name }) => name === 'renamed.txt') && + !entries.some(({ name }) => name === 'text.txt'), + 'Directory listing or rename failed', + ); + await sandbox.files.remove(`${root}/renamed.txt`); + assert( + !(await sandbox.files.readDirectory(root)).some(({ name }) => name === 'renamed.txt'), + 'File removal failed', + ); + checks.push('mkdir, stat, rename, list, remove'); + + const command = await sandbox.exec( + [ + 'node', + '-e', + 'console.log(process.cwd()); console.log(process.env.VOID_ACCEPTANCE); console.error("stderr marker");', + ], + { + cwd: root, + env: { VOID_ACCEPTANCE: id }, + signal: AbortSignal.timeout(10_000), + }, + ); + const output = await command.output(); + const stdout = new TextDecoder().decode(output.stdout); + assert( + output.exitCode === 0 && stdout.includes(root) && stdout.includes(id), + 'Execution cwd, env, or stdout failed', + ); + assert( + new TextDecoder().decode(output.stderr).includes('stderr marker'), + 'Execution stderr failed', + ); + checks.push('exec, cwd, env, stdout, stderr, exitCode'); + + const pipe = await sandbox.exec(['cat'], { + stdin: 'pipe', + signal: AbortSignal.timeout(10_000), + }); + assert(pipe.stdin, 'Piped stdin is missing'); + const writer = pipe.stdin.getWriter(); + const collected = pipe.output(); + await writer.write(new TextEncoder().encode('stdin marker')); + await writer.close(); + const pipedOutput = await collected; + assert( + pipedOutput.exitCode === 0 && new TextDecoder().decode(pipedOutput.stdout) === 'stdin marker', + 'Piped stdin failed', + ); + checks.push('stdin pipe'); + + const long = await sandbox.exec(['node', '-e', 'setTimeout(() => {}, 60000)'], { + pty: { cols: 80, rows: 24 }, + }); + assert(long.isPty, 'PTY was not created'); + await long.resize(100, 30); + await long.kill(9); + assert((await long.exitCode) !== 0, 'Killed process returned success'); + checks.push('PTY, resize, kill'); + + const abort = new AbortController(); + const cancellable = await sandbox.exec(['node', '-e', 'setTimeout(() => {}, 60000)'], { + signal: abort.signal, + }); + abort.abort(); + assert((await cancellable.exitCode) !== 0, 'Aborted process returned success'); + checks.push('AbortSignal cancellation'); + + const server = await sandbox.exec( + [ + 'node', + '-e', + 'require("node:http").createServer((req,res) => { res.setHeader("content-type", "text/plain"); res.end(req.url); }).listen(8080, "0.0.0.0", () => console.log("ready"));', + ], + { signal: AbortSignal.timeout(15_000) }, + ); + assert(server.stdout, 'Server stdout is missing'); + const ready = server.stdout.getReader(); + await ready.read(); + ready.releaseLock(); + const response = await sandbox.fetch(8080, new Request('http://sandbox/acceptance?test=1')); + assert( + response.status === 200 && (await response.text()) === '/acceptance?test=1', + 'Sandbox port fetch failed', + ); + await server.kill(9); + await server.exitCode; + checks.push('HTTP port fetch'); + + const blocked = await sandbox.exec( + [ + 'node', + '-e', + 'fetch("https://www.cloudflare.com/cdn-cgi/trace", {signal: AbortSignal.timeout(3000)}).then(() => process.exit(1), () => process.exit(0));', + ], + { signal: AbortSignal.timeout(5_000) }, + ); + assert((await blocked.output()).exitCode === 0, 'Internet access was allowed while disabled'); + checks.push('Internet disabled'); + + const isolated = await getSandbox(`${id}-other`, { + inactivityTimeoutMs: 30_000, + enableInternet: true, + }); + clients.push(isolated); + const isolation = await isolated.exec(['sh', '-c', `test ! -e ${root}/bytes.bin`]); + assert( + (await isolation.output()).exitCode === 0, + 'A second Sandbox can see the first Sandbox files', + ); + checks.push('separate ID isolation'); + const internet = await isolated.exec( + [ + 'node', + '-e', + 'fetch("https://www.cloudflare.com/cdn-cgi/trace", {signal: AbortSignal.timeout(10000)}).then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1));', + ], + { signal: AbortSignal.timeout(15_000) }, + ); + assert((await internet.output()).exitCode === 0, 'Internet access failed while enabled'); + checks.push('Internet enabled'); + await isolated.destroy(); + + await sandbox.destroy(); + assert(!(await sandbox.running()), 'Destroy did not stop the container'); + const restarted = await sandbox.exec(['sh', '-c', `test ! -e ${root}/bytes.bin`]); + assert( + (await restarted.output()).exitCode === 0, + 'Container files survived a destroy and restart', + ); + checks.push('destroy, restart, ephemeral files'); + return c.json({ ok: true, checks, durationMs: Date.now() - started }); + } catch (error) { + return c.json( + { ok: false, checks, error: error instanceof Error ? error.message : String(error) }, + error instanceof SandboxLimitError ? 429 : 500, + ); + } finally { + await Promise.all( + clients.map((client) => + client + .destroy() + .catch((error) => console.error('[sandbox acceptance] cleanup failed', error)), + ), + ); + } +}); diff --git a/platform/scripts/kitchen-sink-observations.ts b/platform/scripts/kitchen-sink-observations.ts new file mode 100644 index 000000000..dd8ee1ba7 --- /dev/null +++ b/platform/scripts/kitchen-sink-observations.ts @@ -0,0 +1,103 @@ +type PollOptions = { + timeoutMs: number; + intervalMs?: number; + now?: () => number; + wait?: (milliseconds: number) => Promise<void>; +}; + +export async function resetKitchenSink( + origin: string, + headers: Record<string, string>, + fetcher: typeof fetch = fetch, + now: () => number = Date.now, +): Promise<number> { + const response = await fetcher(`${origin}/api/reset`, { + method: 'POST', + headers, + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) { + throw new Error( + `Fixture reset failed (HTTP ${response.status}); check the disposable app's database, KV, and storage bindings before testing.`, + ); + } + const body: unknown = await response.json().catch(() => null); + if (!body || typeof body !== 'object' || !('ok' in body) || body.ok !== true) { + throw new Error( + 'Fixture reset did not confirm success with ok: true; verify that SMOKE_URL selects the disposable kitchen-sink app.', + ); + } + return now(); +} + +async function pollObservation( + read: () => Promise<string | null>, + accepts: (value: string | null, now: number) => boolean, + description: string, + options: PollOptions, +): Promise<string> { + const now = options.now ?? Date.now; + const wait = + options.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const deadline = now() + options.timeoutMs; + let latest: string | null = null; + for (;;) { + latest = await read(); + if (accepts(latest, now())) { + return latest!; + } + const remaining = deadline - now(); + if (remaining <= 0) { + throw new Error( + `${description} after ${options.timeoutMs}ms (last value: ${JSON.stringify(latest)}).`, + ); + } + await wait(Math.min(options.intervalMs ?? 3_000, remaining)); + } +} + +export function waitForFreshHeartbeat( + read: () => Promise<string | null>, + options: PollOptions & { notBefore?: number }, +): Promise<string> { + return pollObservation( + read, + (value, now) => { + const timestamp = value === null ? NaN : Date.parse(value); + const age = now - timestamp; + return ( + Number.isFinite(timestamp) && + timestamp >= (options.notBefore ?? -Infinity) && + age >= -1_000 && + age < 5 * 60_000 + ); + }, + 'No fresh cron heartbeat was observed; check cron registration and dispatch logs, then the fixture KV binding and cron:last-heartbeat key', + options, + ); +} + +export function waitForChangedRender( + read: () => Promise<string | null>, + previous: string, + options: PollOptions, +): Promise<string> { + return pollObservation( + read, + (value) => value !== null && value !== previous && Number.isFinite(Date.parse(value)), + 'The rendered timestamp did not change after revalidation; check the purge outcome, ISR cache configuration, and fixture loader', + options, + ); +} + +export function waitForIsrCacheHit( + read: () => Promise<string | null>, + options: PollOptions, +): Promise<string> { + return pollObservation( + read, + (value) => value === 'HIT', + 'No ISR cache hit was observed; check the deployment ISR manifest and dispatch cache configuration', + options, + ); +} diff --git a/platform/scripts/kitchen-sink-platform-fixture.ts b/platform/scripts/kitchen-sink-platform-fixture.ts index 2f9835970..08de18cd9 100644 --- a/platform/scripts/kitchen-sink-platform-fixture.ts +++ b/platform/scripts/kitchen-sink-platform-fixture.ts @@ -1,33 +1,50 @@ -import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, cpSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createIsolatedPlayground } from '../../test/integration-helpers.ts'; const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); -/** The hosted smoke app exercises enabled platform features during the managed Sandbox pause. */ -export function prepareKitchenSinkPlatformFixture() { +/** Provider-dependent features are opt-in for the disposable hosted acceptance app. */ +export function prepareKitchenSinkPlatformFixture( + options: { sandbox?: boolean; email?: boolean; runtime?: boolean } = {}, +) { const fixture = createIsolatedPlayground(workspaceRoot, 'playground/kitchen-sink'); try { - for (const path of [ - 'routes/api/sandbox.ts', - 'pages/sandbox.vue', - 'pages/sandbox.server.ts', - 'lib/sandbox-demo.ts', - '.env', - ]) { - rmSync(join(fixture.root, path), { force: true }); - } - for (const [path, pattern] of [ - ['pages/layout.vue', /<Link href="\/sandbox">Sandbox<\/Link>\s*\|\s*/], - ['pages/index.vue', /\s*<li><Link href="\/sandbox">Sandbox<\/Link>[^<]*<\/li>/], - ] as const) { - const file = join(fixture.root, path); - const source = readFileSync(file, 'utf8'); - if (!pattern.test(source)) { - throw new Error(`Update the hosted kitchen-sink fixture navigation for ${path}.`); + rmSync(join(fixture.root, '.env'), { force: true }); + if (!options.sandbox) { + for (const path of [ + 'routes/api/sandbox.ts', + 'pages/sandbox.vue', + 'pages/sandbox.server.ts', + 'lib/sandbox-demo.ts', + ]) { + rmSync(join(fixture.root, path), { force: true }); + } + for (const [path, pattern] of [ + ['pages/layout.vue', /<Link href="\/sandbox">Sandbox<\/Link>\s*\|\s*/], + ['pages/index.vue', /\s*<li><Link href="\/sandbox">Sandbox<\/Link>[^<]*<\/li>/], + ] as const) { + const file = join(fixture.root, path); + const source = readFileSync(file, 'utf8'); + if (!pattern.test(source)) { + throw new Error(`Update the hosted kitchen-sink fixture navigation for ${path}.`); + } + writeFileSync(file, source.replace(pattern, '')); } - writeFileSync(file, source.replace(pattern, '')); + } else { + cpSync(join(workspaceRoot, 'platform/scripts/fixtures/kitchen-sink-sandbox'), fixture.root, { + recursive: true, + }); + } + if (options.email) { + const source = join(workspaceRoot, 'platform/scripts/fixtures/kitchen-sink-email'); + cpSync(source, fixture.root, { recursive: true }); + } + if (options.runtime) { + cpSync(join(workspaceRoot, 'platform/scripts/fixtures/kitchen-sink-runtime'), fixture.root, { + recursive: true, + }); } return fixture; } catch (error) { @@ -37,7 +54,11 @@ export function prepareKitchenSinkPlatformFixture() { } if (import.meta.main) { - const fixture = prepareKitchenSinkPlatformFixture(); + const fixture = prepareKitchenSinkPlatformFixture({ + sandbox: process.argv.includes('--sandbox'), + email: process.argv.includes('--email'), + runtime: process.argv.includes('--runtime'), + }); if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, `path=${fixture.root}\n`); } diff --git a/platform/scripts/kitchen-sink-smoke-test.md b/platform/scripts/kitchen-sink-smoke-test.md index 136e3eda7..f0bd5ba3c 100644 --- a/platform/scripts/kitchen-sink-smoke-test.md +++ b/platform/scripts/kitchen-sink-smoke-test.md @@ -1,12 +1,23 @@ # Kitchen Sink Smoke Tests Use `playground/kitchen-sink` to verify a platform with a disposable app. The automated suite resets the app's database, KV, and storage, then exercises navigation, mutations, authentication, queues, and caching. +The reset must return a successful response with `ok: true`; otherwise the suite stops before acceptance checks. It deletes the heartbeat and revalidation keys with the rest of the fixture KV state. -Managed Sandboxes are paused. Prepare the hosted fixture with `node platform/scripts/kitchen-sink-platform-fixture.ts`, then deploy the isolated app directory it prints to the platform you want to test. This copy omits the unsupported Sandbox routes and navigation; the original playground and native Cloudflare Sandbox build coverage are preserved. Pass the deployed URL explicitly from the repository root: +Queue submission must also produce its consumer result within 60 seconds. A +consumer timeout fails acceptance with queue/dispatch and D1 diagnostics. + +Prepare with `--runtime` to add an isolated queue retry consumer, authenticated +version/log/AI routes, and typed WebSocket echo. Set `SMOKE_RUNTIME=1` and supply +the app's existing secret privately through `SMOKE_E2E_TOKEN` to enable browser +test 14, which requires a fresh message round trip within 30 seconds. The +WebSocket handshake carries the secret as a header. These fixtures require a +configured nonempty secret and remain separate from the default hosted CI app. + +Prepare the hosted fixture with `oxnode platform/scripts/kitchen-sink-platform-fixture.ts`, then deploy the isolated app directory it prints to the platform you want to test. The basic fixture omits Sandbox so it works on platforms without Containers access. For Sandbox acceptance, pass `--sandbox` when preparing the fixture and set `SMOKE_SANDBOX=1` when running the tests. The platform must enable Sandbox and have Workers Paid, Containers access, and the required runtime-token permissions described in the [Sandbox guide](../../docs/guide/sandboxes.md). Pass the deployed URL explicitly from the repository root: ```sh vp exec playwright install chromium -SMOKE_URL=https://kitchen-sink.apps.example.com node platform/scripts/kitchen-sink-smoke-test.ts +SMOKE_URL=https://kitchen-sink.apps.example.com oxnode platform/scripts/kitchen-sink-smoke-test.ts ``` Use an app whose data can be deleted. The script has no default destination. If Cloudflare Access protects the app, supply that application's `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` from your secret manager. @@ -18,6 +29,25 @@ If the API confirms a value was stored but updating the old worker failed, setup The checklist below can also be run manually or with browser automation. Use unique values for created records, wait for each mutation to appear, and remove test records afterward. KV listings are eventually consistent; poll them instead of relying on a fixed delay. Record each result and report failures after completing the remaining cases. +## Email acceptance fixture + +Pass `--email` when preparing the fixture to add inbound handlers and `/api/acceptance/email`. Deploy it only to a disposable project on an email-enabled platform. Both `GET` and `POST` require `x-void-e2e-token` to match the app's `SECRET_KEY`; the fixture never copies local `.env` values. Set the required `APP_NAME`, `API_URL`, and random `SECRET_KEY` through the linked project's secret commands before deployment. + +- `POST /api/acceptance/email` accepts `sendEmail()` options as JSON. Send a unique subject to a verified test mailbox and confirm both provider acceptance and actual mailbox delivery. Repeat the identical body and `idempotencyKey` to confirm one delivery; change the body with the same key to confirm `IDEMPOTENCY_CONFLICT`. Check partial recipients, sender restrictions, and administrator-configured quotas separately. +- Send inbound mail to `<slug>+support@<shared-mail-domain>` and confirm `GET /api/acceptance/email?subject=<encoded-subject>` returns the `support` route, original text/HTML, and attachment metadata. For a registered custom domain, use `support@<domain>` instead. The KV receipt expires after one hour; poll because KV reads can be eventually consistent. +- Verify tag precedence with `<slug>+support+ticket-42@<shared-mail-domain>` (`support-ticket`, `params.ticket= ticket-42`) and `<slug>+support+vip@<shared-mail-domain>` (`support-vip`). An unmatched recipient reaches `default`. +- Subjects beginning with `REPLY ` send a threaded reply. Verify its arrival and `In-Reply-To`/`References`, and inspect `void email logs` for the action outcome. +- Subjects beginning with `FORWARD ` forward to the verified address in the original message's `x-void-e2e-forward` header. Shared-domain native events support forwarding; customer-zone relayed mail records `UNSUPPORTED_ACTION` without sending. Confirm that recorded distinction. +- Subjects beginning with `REJECT ` reject with `Void email acceptance reject`. Verify the sender's non-delivery outcome and the platform's inbound log. + +Use `void email usage`, `destinations`, and `logs` to compare API outcomes with recorded provider outcomes. Test admin email policy/caps and custom-domain readiness, secret rotation, and removal through the operator UI or CLI. A provider acceptance result alone does not prove mailbox delivery. + +## Sandbox runtime acceptance fixture + +`--sandbox` also adds `POST /api/acceptance/sandbox`, authenticated by `x-void-e2e-token` matching the app's `SECRET_KEY`. The endpoint runs through lazy startup; text, binary, Blob, streaming and relative file operations; metadata, rename, listing and removal; process cwd/env/stdout/stderr; piped stdin; PTY/resize/kill; cancellation; HTTP port access; disabled and enabled Internet access; isolation between IDs; and destroy/restart with ephemeral files. It uses unique IDs, limits idle time to 30 seconds, and stops its containers after the checks. + +Set `SMOKE_SANDBOX=1` and `SMOKE_E2E_TOKEN` to the fixture's secret when running the smoke script to include this test. Select a test user plan with enough runtime and at least two concurrent Sandbox instances, such as `solo`; the platform's `free` user tier has only one runtime second per month. The result includes the checks completed before any failure. The test allows up to three minutes for cold starts, and makes one permitted GET to Cloudflare's trace endpoint while testing Internet access. + ## Test Cases ### 1. Home page SSR + asset caching @@ -130,15 +160,24 @@ The checklist below can also be run manually or with browser automation. Use uni ### 10. Cron — heartbeat is running 1. Visit `/cron`. -2. Check if a heartbeat timestamp is displayed (a `<code>` element with an ISO timestamp). -3. If present, assert it is a valid ISO date and was recorded within the last 5 minutes. -4. If "No heartbeat recorded yet" is shown, note this as a warning (cron may not have fired yet) — not a hard failure. +2. Poll for up to 150 seconds for the heartbeat timestamp, allowing the next scheduled invocation and KV propagation. +3. Assert it is a valid date recorded after the fixture reset, within the last 5 minutes, and no more than one second in the future. An old heartbeat cannot satisfy the check. +4. If no fresh heartbeat arrives, fail the test. Check cron registration and dispatch logs, then verify the fixture's KV binding and `cron:last-heartbeat` key. ### 11. ISR Revalidation 1. Visit `/revalidate`. -2. Note the "Rendered at" timestamp. -3. Click the "Revalidate /revalidate" button. -4. Wait for the button to return to its non-disabled state. -5. Reload the page (navigate to `/revalidate` again). -6. Check the "Last revalidation" field — assert it is no longer "never" (shows a timestamp). +2. For a public target, poll for up to 15 seconds for an actual `x-isr-cache: HIT` response before continuing. Access-authenticated targets bypass the shared cache. +3. Note the valid date in the **Rendered at** field. +4. Click **Revalidate /revalidate** and assert the POST response succeeds. The app displays a failed purge as an error and does not record a success timestamp. +5. Wait for the button to return to its non-disabled state. +6. Poll reloads for up to 15 seconds until **Rendered at** is a different valid date. A nonempty **Last revalidation** field alone does not establish that the cached page was purged. + +### 12. Sandbox — files, command execution, and output + +Prepare and deploy the fixture with `--sandbox`, then run with `SMOKE_SANDBOX=1`. + +1. Visit `/sandbox` and replace the analyzer with a function that returns a unique marker and the request count, and writes a marker to stderr. +2. Click **Run in Sandbox** and allow up to 90 seconds for the first container start. +3. Assert the API returns `ok: true`, exit code `0`, the unique marker and a positive request count. This exercises writing input and executable files, running Node, and reading the output file. +4. Assert stdout contains the result and stderr contains the marker, then confirm the result appears in the page. diff --git a/platform/scripts/kitchen-sink-smoke-test.ts b/platform/scripts/kitchen-sink-smoke-test.ts index a507f405e..859468ea6 100644 --- a/platform/scripts/kitchen-sink-smoke-test.ts +++ b/platform/scripts/kitchen-sink-smoke-test.ts @@ -1,7 +1,7 @@ /** * Kitchen Sink Playwright Smoke Test * - * Standalone script that runs 11 smoke tests against a deployed kitchen-sink app. + * Standalone smoke tests against a deployed kitchen-sink app. * Uses node:test + node:assert — no vitest needed. * * Usage: @@ -12,8 +12,15 @@ */ import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; import { describe, it, before, after } from 'node:test'; import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import { + resetKitchenSink, + waitForChangedRender, + waitForFreshHeartbeat, + waitForIsrCacheHit, +} from './kitchen-sink-observations.ts'; if (!process.env.SMOKE_URL) { throw new Error( @@ -36,6 +43,9 @@ if ( const BASE = target.origin; const TIMEOUT = 15_000; const uid = Date.now().toString(36); +const testSandbox = process.env.SMOKE_SANDBOX === '1'; +const acceptanceToken = process.env.SMOKE_E2E_TOKEN; +const testRuntime = process.env.SMOKE_RUNTIME === '1'; // Supply a Cloudflare Access service-token pair if the selected test app is // protected by Access. These credentials belong to the explicit SMOKE_URL target. @@ -51,6 +61,7 @@ type MarkerWindow = Window & { __SPA_MARKER?: boolean }; type StampedNav = HTMLElement & { __stamp?: number }; let browser: Browser; +let resetCompletedAt = 0; // --------------------------------------------------------------------------- // Helpers @@ -61,8 +72,23 @@ async function waitForHydration(page: Page) { } async function goto(page: Page, path: string) { - await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded' }); + const response = await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded' }); await waitForHydration(page); + return response; +} + +function readTimestamp(page: Page, label: string): Promise<string | null> { + return page.evaluate((field) => { + const heading = Array.from(document.querySelectorAll('h2')).find( + (element) => element.textContent === field, + ); + const paragraph = + heading?.nextElementSibling ?? + Array.from(document.querySelectorAll('p')).find((element) => + element.textContent?.trim().startsWith(field), + ); + return paragraph?.querySelector('code')?.textContent || null; + }, label); } /** Wait for body text to include `text`, polling with page reloads. */ @@ -124,13 +150,8 @@ async function deleteRowContaining(page: Page, needle: string) { void describe(`kitchen-sink smoke (${BASE})`, { concurrency: true }, () => { before(async () => { + resetCompletedAt = await resetKitchenSink(BASE, CF_ACCESS); browser = await chromium.launch(); - - // Reset all app state for a clean slate - const res = await fetch(`${BASE}/api/reset`, { method: 'POST', headers: CF_ACCESS }); - if (!res.ok) { - console.warn(` ⚠ Reset endpoint returned ${res.status} — tests may see stale data`); - } }); after(async () => { @@ -414,24 +435,14 @@ void describe(`kitchen-sink smoke (${BASE})`, { concurrency: true }, () => { page.click('button:has-text("Send to Queue")'), ]); - // Poll for consumer processing — consumer invocation timing is CF - // infrastructure, not our code. Soft-pass on timeout (like cron test) - // since we've already verified the queue binding and send work. - try { - await pollForText(page, '/queues', key, 60_000); - - // Cleanup - try { - await deleteRowContaining(page, key); - await waitForTextGone(page, key); - } catch { - // best-effort - } - } catch { - console.log( - ` ⚠ Queue consumer did not process "${key}" within 60s (warning, not a failure)`, + // Submission alone does not establish end-to-end queue delivery. + await pollForText(page, '/queues', key, 60_000).catch(() => { + throw new Error( + 'Queue consumer did not process the submitted message within 60s; check queue registration and dispatch logs, then the fixture D1 queue_results table.', ); - } + }); + await deleteRowContaining(page, key); + await waitForTextGone(page, key); } finally { await ctx.close(); } @@ -509,30 +520,13 @@ void describe(`kitchen-sink smoke (${BASE})`, { concurrency: true }, () => { void it('10 — Cron heartbeat', async () => { const { ctx, page } = await freshPage(); try { - await goto(page, '/cron'); - - const body = await page.textContent('body'); - - if (body?.includes('No heartbeat recorded yet')) { - console.log(' ⚠ Cron heartbeat not recorded yet (warning, not a failure)'); - return; // soft pass - } - - // Find the heartbeat timestamp (code element after "Last Heartbeat" heading) - const timestamp = await page.evaluate(() => { - const h2 = Array.from(document.querySelectorAll('h2')).find( - (heading) => heading.textContent === 'Last Heartbeat', - ); - const nextP = h2?.nextElementSibling; - return nextP?.querySelector('code')?.textContent || null; - }); - assert.ok(timestamp, 'heartbeat timestamp found'); - - const date = new Date(timestamp!); - assert.ok(!isNaN(date.getTime()), `timestamp is a valid date (got: ${timestamp})`); - - const ageMs = Date.now() - date.getTime(); - assert.ok(ageMs < 5 * 60 * 1000, `heartbeat is recent (${Math.round(ageMs / 1000)}s ago)`); + await waitForFreshHeartbeat( + async () => { + await goto(page, '/cron'); + return readTimestamp(page, 'Last Heartbeat'); + }, + { timeoutMs: 150_000, notBefore: resetCompletedAt }, + ); } finally { await ctx.close(); } @@ -544,14 +538,36 @@ void describe(`kitchen-sink smoke (${BASE})`, { concurrency: true }, () => { void it('11 — ISR revalidation', async () => { const { ctx, page } = await freshPage(); try { - await goto(page, '/revalidate'); - - // Note the initial "Rendered at" timestamp - const renderedAt = await page.textContent('p:has(code) code'); - assert.ok(renderedAt, "initial 'Rendered at' timestamp found"); + if (Object.keys(CF_ACCESS).length === 0) { + await waitForIsrCacheHit( + async () => { + const response = await goto(page, '/revalidate'); + return response?.headers()['x-isr-cache'] ?? null; + }, + { timeoutMs: 15_000, intervalMs: 500 }, + ); + } else { + await goto(page, '/revalidate'); + } + const renderedAt = await readTimestamp(page, 'Rendered at:'); + assert.ok( + renderedAt && Number.isFinite(Date.parse(renderedAt)), + 'initial rendered timestamp is valid', + ); // Click "Revalidate /revalidate" + const completed = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/revalidate' && + response.request().method() === 'POST', + { timeout: TIMEOUT }, + ); await page.click('button:has-text("Revalidate /revalidate")'); + const actionResponse = await completed; + assert.ok( + actionResponse.ok(), + `Revalidation action failed (HTTP ${actionResponse.status()}); inspect the app and provider purge logs.`, + ); // Wait for button to return to non-disabled state await page.waitForFunction( @@ -564,24 +580,106 @@ void describe(`kitchen-sink smoke (${BASE})`, { concurrency: true }, () => { { timeout: TIMEOUT }, ); - // Reload and check "Last revalidation" is no longer "never" - await goto(page, '/revalidate'); - const body = await page.textContent('body'); - assert.ok( - !body?.includes('never') || body?.includes('Last revalidation'), - "last revalidation is no longer 'never'", + await waitForChangedRender( + async () => { + await goto(page, '/revalidate'); + return readTimestamp(page, 'Rendered at:'); + }, + renderedAt, + { timeoutMs: 15_000, intervalMs: 500 }, ); + } finally { + await ctx.close(); + } + }); - // More precise: check the last revalidation code element - const lastReval = await page.evaluate(() => { - const paragraphs = Array.from(document.querySelectorAll('p')); - const revalidationParagraph = paragraphs.find((paragraph) => - paragraph.textContent?.includes('Last revalidation'), + void describe('Sandbox', { concurrency: false, skip: !testSandbox }, () => { + void it('12 — Sandbox files, command execution, and output', async () => { + const { ctx, page } = await freshPage(); + try { + await goto(page, '/sandbox'); + await page.fill( + 'textarea', + `export default function(requests) { console.error('sandbox-smoke-${uid}'); return { marker: '${uid}', count: requests.length }; }`, + ); + const completed = page.waitForResponse( + (response) => + response.url() === `${BASE}/api/sandbox` && response.request().method() === 'POST', + { timeout: 90_000 }, ); - const code = revalidationParagraph?.querySelector('code'); - return code?.textContent || null; + await page.click('button:has-text("Run in Sandbox")'); + const response = await completed; + assert.equal(response.status(), 200, `Sandbox request succeeds (got ${response.status()})`); + const body = await response.json(); + assert.equal(body.ok, true, JSON.stringify(body)); + assert.equal(body.exitCode, 0); + assert.equal(body.output.value.marker, uid); + assert.ok(body.output.value.count > 0, 'Sandbox reads the input file'); + assert.ok(body.stdout.includes(uid), 'Sandbox stdout includes its written result'); + assert.ok(body.stderr.includes(`sandbox-smoke-${uid}`), 'Sandbox stderr is returned'); + await waitForText(page, uid); + } finally { + await ctx.close(); + } + }); + + void it('13 — Sandbox runtime acceptance', { skip: !acceptanceToken }, async () => { + const response = await fetch(`${BASE}/api/acceptance/sandbox`, { + method: 'POST', + headers: { ...CF_ACCESS, 'x-void-e2e-token': acceptanceToken! }, + signal: AbortSignal.timeout(180_000), }); - assert.ok(lastReval, `last revalidation has a timestamp (got: ${lastReval})`); + const body = await response.json(); + assert.equal(response.status, 200, JSON.stringify(body)); + assert.equal(body.ok, true, JSON.stringify(body)); + assert.equal(body.checks.length, 12, 'All Sandbox runtime acceptance checks completed'); + }); + }); + + void it('14 — WebSocket message round trip', { skip: !testRuntime }, async () => { + assert.ok(acceptanceToken, 'Runtime acceptance requires the configured app secret'); + const { ctx, page } = await freshPage(); + try { + await ctx.setExtraHTTPHeaders({ ...CF_ACCESS, 'x-void-e2e-token': acceptanceToken }); + await goto(page, '/'); + const marker = `void-e2e-${randomBytes(8).toString('hex')}`; + const echoed = await page.evaluate( + ({ origin, marker }) => + new Promise<unknown>((resolve, reject) => { + const url = new URL('/ws/acceptance', origin); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(url); + const timer = setTimeout(() => { + socket.close(); + reject(new Error('WebSocket echo timed out')); + }, 30_000); + socket.addEventListener('open', () => socket.send(JSON.stringify({ marker }))); + socket.addEventListener( + 'message', + (event) => { + clearTimeout(timer); + socket.close(); + try { + resolve(JSON.parse(String(event.data))); + } catch { + reject(new Error('WebSocket echo was not JSON')); + } + }, + { once: true }, + ); + socket.addEventListener( + 'error', + () => { + clearTimeout(timer); + socket.close(); + reject(new Error('WebSocket connection failed')); + }, + { once: true }, + ); + }), + { origin: BASE, marker }, + ); + assert.deepEqual(echoed, { kind: 'echo', marker }); } finally { await ctx.close(); } diff --git a/playground/kitchen-sink/pages/revalidate.server.ts b/playground/kitchen-sink/pages/revalidate.server.ts index c6e4117b2..c520138e1 100644 --- a/playground/kitchen-sink/pages/revalidate.server.ts +++ b/playground/kitchen-sink/pages/revalidate.server.ts @@ -15,10 +15,6 @@ export const loader = defineHandler(async () => { }); export const action = defineHandler(async () => { - try { - await reval({ paths: ['/revalidate'] }); - } catch (err) { - console.error('revalidate() failed:', err); - } + await reval({ paths: ['/revalidate'] }); await kv.put('revalidate:last', new Date().toISOString()); }); diff --git a/playground/kitchen-sink/pages/revalidate.vue b/playground/kitchen-sink/pages/revalidate.vue index 8ca248f8a..66450ab2a 100644 --- a/playground/kitchen-sink/pages/revalidate.vue +++ b/playground/kitchen-sink/pages/revalidate.vue @@ -31,5 +31,6 @@ const form = useForm('/revalidate', {}); {{ form.pending ? 'Revalidating...' : 'Revalidate /revalidate' }} </button> </form> + <p v-if="form.error" role="alert" style="color: red">{{ form.error.message }}</p> </div> </template> diff --git a/playground/kitchen-sink/routes/api/sandbox.ts b/playground/kitchen-sink/routes/api/sandbox.ts index a222b1232..0caa2a74b 100644 --- a/playground/kitchen-sink/routes/api/sandbox.ts +++ b/playground/kitchen-sink/routes/api/sandbox.ts @@ -1,5 +1,5 @@ import { defineHandler } from 'void'; -import { getSandbox, SandboxLimitError } from 'void/sandbox'; +import { getSandbox, SandboxLimitError, type SandboxClient } from 'void/sandbox'; import { sampleRequests } from '../../lib/sandbox-demo.ts'; const MAX_CODE_LENGTH = 8_000; @@ -45,8 +45,12 @@ export const POST = defineHandler(async (c) => { ); } + let sandbox: SandboxClient | undefined; try { - const sandbox = await getSandbox('kitchen-sink-code-lab'); + // Programs from concurrent callers must never share files or processes. + sandbox = await getSandbox(`kitchen-sink-${crypto.randomUUID()}`, { + inactivityTimeoutMs: 30_000, + }); await sandbox.files.mkdir(WORKDIR, { recursive: true }); await sandbox.files.writeFile( @@ -89,5 +93,15 @@ export const POST = defineHandler(async (c) => { } const message = err instanceof Error ? err.message : String(err); return c.json({ ok: false, error: message }, 500); + } finally { + if (sandbox) { + try { + await sandbox.destroy(); + } catch (error) { + // Preserve the execution result. The bounded idle timeout still stops + // the container if a transient provider error prevents explicit cleanup. + console.error('[sandbox] cleanup failed', error); + } + } } }); diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index 2040b149a..fd37e67ef 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -29,6 +29,7 @@ const heldServers = new Map<number, Server>(); const GENERATED_PLAYGROUND_ENTRIES = new Set([ '.astro', + '.next', '.nuxt', '.output', '.react-router', From 9530be0e0cca1308a1755f76119b32b5ec6d1ae9 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 01:41:37 +0800 Subject: [PATCH 02/10] test: isolate and update Cloudflare acceptance fixtures --- .../void/test/unit/build-logs-follow.test.ts | 5 +- .../void/test/unit/cloudflare-deploy.test.ts | 57 +++++++++++++------ .../test/unit/plugin-version-metadata.test.ts | 56 ++++++++++++++++-- 3 files changed, 94 insertions(+), 24 deletions(-) diff --git a/packages/void/test/unit/build-logs-follow.test.ts b/packages/void/test/unit/build-logs-follow.test.ts index d46cc28a2..6cfd97b11 100644 --- a/packages/void/test/unit/build-logs-follow.test.ts +++ b/packages/void/test/unit/build-logs-follow.test.ts @@ -253,8 +253,9 @@ describe('followBuildLogs', () => { it('follows an initially terminal build whose producer has not drained', async () => { vi.useFakeTimers(); - vi.stubEnv('VOID_TOKEN', 'token'); - vi.stubEnv('VOID_API_URL', ''); + // Bind this fixture token explicitly so a saved platform cannot select the destination. + vi.stubEnv('VOID_TOKEN', 'test-build-logs-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); vi.stubEnv('VOID_PROJECT', ''); vi.spyOn(PlatformClient.prototype, 'listProjects').mockResolvedValue([ { id: 'project', slug: 'project' } as Awaited< diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 928af2a34..9e07b11dd 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -5,6 +5,7 @@ import { log } from '@clack/prompts'; import { CloudflareWorkerInspectionError, parseCloudflareVersionMessage, + type CloudflareOperation, } from '@void/deploy-cloudflare'; import { toSlash } from 'pathslash'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -3428,22 +3429,22 @@ describe('runCloudflareDeploy — secrets gate', () => { const current = '11111111-1111-4111-8111-111111111111'; const next = '22222222-2222-4222-8222-222222222222'; let active = current; - const commands: Array<Array<string>> = []; - const runWrangler: DeployWranglerRun = operationFixture((args) => { - commands.push(args); - if (args[0] === 'whoami') { - return { ...cleanExit, stdout: '{"loggedIn":true,"accounts":[]}' }; + const operations: Array<CloudflareOperation> = []; + const runWrangler: DeployWranglerRun = (operation) => { + operations.push(operation); + if (operation.type === 'auth.whoami') { + return { ...cleanExit, data: { loggedIn: true, accounts: [] } }; } - if (args[0] === 'deployments') { + if (operation.type === 'deployments.status') { return { ...cleanExit, - stdout: JSON.stringify({ versions: [{ version_id: active, percentage: 100 }] }), + data: { versions: [{ version_id: active, percentage: 100 }] }, }; } - if (args[0] === 'versions' && args[1] === 'list') { + if (operation.type === 'versions.list') { return { ...cleanExit, - stdout: JSON.stringify([ + data: [ { id: current, number: 1, @@ -3458,25 +3459,34 @@ describe('runCloudflareDeploy — secrets gate', () => { script_runtime: { usage_model: 'standard', limits: {} }, }, }, - ]), + ], }; } - if (args[0] === 'versions' && args[1] === 'upload') { + if (operation.type === 'versions.upload') { return { ...cleanExit, status: 1, + failure: 'durable-migration', + errorCode: 10211, stderr: 'Durable Object migrations require deploy. [code: 10211]', }; } - if (args[0] === 'deploy') { + if (operation.type === 'worker.deploy') { active = next; return { ...cleanExit, - stdout: `Current Version ID: ${next}\nhttps://secret-do-app.example.workers.dev`, + data: { + versionId: next, + workersDevUrl: 'https://secret-do-app.example.workers.dev', + }, }; } - return cleanExit; - }); + if (operation.type === 'triggers.deploy' || operation.type === 'triggers.clear-crons') { + expect(active).toBe(next); + return cleanExit; + } + throw new Error(`Unexpected Cloudflare operation: ${operation.type}`); + }; let explicitBootstrapProof: boolean | undefined; const { deps } = harness({ runWrangler, @@ -3494,7 +3504,11 @@ describe('runCloudflareDeploy — secrets gate', () => { explicitBootstrapProof = options.allowFirstDeploymentBootstrap; return activateCloudflareVersionPrimitive({ ...options, - fetch: async () => Response.json({ ready: true, versionId: next }), + fetch: async () => { + expect(operations.at(-2)?.type).toBe('triggers.clear-crons'); + expect(operations.at(-1)?.type).toBe('deployments.status'); + return Response.json({ ready: true, versionId: active }); + }, }); }, }); @@ -3502,8 +3516,15 @@ describe('runCloudflareDeploy — secrets gate', () => { await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); expect(explicitBootstrapProof).not.toBe(true); - expect(commands.some((args) => args[0] === 'versions' && args[1] === 'upload')).toBe(true); - expect(commands.some((args) => args[0] === 'deploy')).toBe(true); + expect( + operations + .map((operation) => operation.type) + .filter( + (type) => + type !== 'auth.whoami' && type !== 'deployments.status' && type !== 'versions.list', + ), + ).toEqual(['versions.upload', 'worker.deploy', 'triggers.deploy', 'triggers.clear-crons']); + expect(active).toBe(next); }); }); diff --git a/packages/void/test/unit/plugin-version-metadata.test.ts b/packages/void/test/unit/plugin-version-metadata.test.ts index 535a9b679..e29b24728 100644 --- a/packages/void/test/unit/plugin-version-metadata.test.ts +++ b/packages/void/test/unit/plugin-version-metadata.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ConfigEnv, Plugin, UserConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { createWorkerFetch } from '../../src/runtime/routing-worker.ts'; import { CLOUDFLARE_DEPLOY_BUILD_ENV } from '../../src/shared/env.ts'; import { CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, @@ -65,6 +66,38 @@ async function generate(resolved: Record<string, unknown>, command: 'build' | 's return { patch, entry: readFileSync(join(root, '.void/entry.ts'), 'utf8') }; } +async function readiness(entry: string, env: Record<string, unknown>): Promise<Response> { + const source = entry.match(/const __voidFetch = (__createWorkerFetch\([\s\S]*?\n {2}\}\));/)?.[1]; + expect(source).toBeDefined(); + const fetch = new Function( + '__createWorkerFetch', + 'app', + 'serveWithAssets', + '__withRawEnv', + 'withRuntimeEnv', + '__filterInternalBindings', + '__voidHandleInternalRoute', + '__voidCollectPrerenderPaths', + `return (${source});`, + )( + createWorkerFetch, + { fetch: () => new Response('application') }, + (_request: Request, _env: object, response: Response) => response, + (_env: object, callback: () => Promise<Response>) => callback(), + (_env: object, callback: () => Promise<Response>) => callback(), + (bindings: object) => bindings, + async () => null, + async () => [], + ) as ReturnType<typeof createWorkerFetch>; + return fetch( + new Request('https://app.test/__void/ready', { + headers: { 'Cloudflare-Workers-Version-Overrides': 'app="requested-candidate"' }, + }), + env, + { waitUntil() {}, passThroughOnException() {}, props: {} }, + ); +} + describe('native metadata configuration and generated runtime', () => { it('adds Free-compatible self-metadata and threads its name to readiness without paid bindings', async () => { vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, '1'); @@ -73,7 +106,16 @@ describe('native metadata configuration and generated runtime', () => { .mockRejectedValue(new Error('Unexpected provider probe')); const { patch, entry } = await generate({}); expect(patch.version_metadata).toEqual({ binding: DEFAULT_VERSION_METADATA_BINDING }); - expect(entry).toContain(`versionId: env["${DEFAULT_VERSION_METADATA_BINDING}"]?.id`); + expect(entry).toContain(`versionMetadataBinding: "${DEFAULT_VERSION_METADATA_BINDING}"`); + const response = await readiness(entry, { + [DEFAULT_VERSION_METADATA_BINDING]: { + id: 'actual-served-version', + tag: 'private-tag', + timestamp: 'private-time', + }, + }); + expect(await response.json()).toEqual({ ready: true, versionId: 'actual-served-version' }); + expect(response.headers.get('cache-control')).toBe('no-store'); expect(patch.containers).toBeUndefined(); expect(patch.ai).toBeUndefined(); expect(patch.hyperdrive).toBeUndefined(); @@ -86,7 +128,13 @@ describe('native metadata configuration and generated runtime', () => { const { patch, entry } = await generate(resolved); expect(patch.version_metadata).toBeUndefined(); expect(resolved.version_metadata).toEqual({ binding: 'AUTHORED_VERSION' }); - expect(entry).toContain('versionId: env["AUTHORED_VERSION"]?.id'); + expect(entry).toContain('versionMetadataBinding: "AUTHORED_VERSION"'); + const response = await readiness(entry, { + AUTHORED_VERSION: { id: 'authored-served-version' }, + [DEFAULT_VERSION_METADATA_BINDING]: { id: 'wrong-default-version' }, + }); + expect(await response.json()).toEqual({ ready: true, versionId: 'authored-served-version' }); + expect(await (await readiness(entry, {})).json()).toEqual({ ready: true }); }); it('leaves handoff bindings and its legacy runtime unchanged when no metadata exists', async () => { @@ -94,7 +142,7 @@ describe('native metadata configuration and generated runtime', () => { vi.stubEnv(CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV, '1'); const { patch, entry } = await generate({}); expect(patch.version_metadata).toBeUndefined(); - expect(entry).not.toContain('versionId: env['); + expect(entry).not.toContain('versionMetadataBinding:'); }); it.each(['build', 'serve'] as const)( @@ -103,7 +151,7 @@ describe('native metadata configuration and generated runtime', () => { vi.stubEnv(CLOUDFLARE_DEPLOY_BUILD_ENV, ''); const { patch, entry } = await generate({}, command); expect(patch.version_metadata).toBeUndefined(); - expect(entry).not.toContain('versionId: env['); + expect(entry).not.toContain('versionMetadataBinding:'); }, ); From 2c467f33c74f63653257d830c95306013d548d5c Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 01:55:51 +0800 Subject: [PATCH 03/10] ci: validate canonical schema upgrades and native forms --- .github/scripts/schema-fixture.mjs | 150 +++++++++++------- .github/workflows/platform-ci.yml | 12 ++ .../void/test/unit/schema-fixture.test.ts | 104 ++++++++++++ .../unit/kitchen-sink-revalidation.test.ts | 25 +-- .../test/unit/kitchen-sink-sandbox.test.ts | 15 +- .../test/unit/kitchen-sink-source-fixture.ts | 28 ++++ 6 files changed, 268 insertions(+), 66 deletions(-) create mode 100644 packages/void/test/unit/schema-fixture.test.ts create mode 100644 platform/packages/api/test/unit/kitchen-sink-source-fixture.ts diff --git a/.github/scripts/schema-fixture.mjs b/.github/scripts/schema-fixture.mjs index 35fb35c20..3984542c5 100644 --- a/.github/scripts/schema-fixture.mjs +++ b/.github/scripts/schema-fixture.mjs @@ -4,62 +4,106 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -// Resolve the driver from the checkout being tested, even when this script was -// saved in RUNNER_TEMP before Actions checked out another immutable revision. -const { Pool } = createRequire( - pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`), -)('pg'); -const pool = new Pool({ connectionString: process.env.COMPAT_DATABASE_URL }); -const snapshot = - process.env.COMPAT_FIXTURE_PATH ?? - (process.env.RUNNER_TEMP ? `${process.env.RUNNER_TEMP}/void-schema-fixture.json` : undefined); -if (!snapshot || !process.env.COMPAT_DATABASE_URL) { - throw new Error('A disposable compatibility database and fixture path are required.'); +const PROJECT_MODE_MIGRATION = { + hash: '8f4452ea39f4fb6504fd6384cdc73634b1f38b4abfde604a366f83a38163d252', + timestamp: 1790841384175, +}; + +export function isProjectModeMigrationApplied(rows) { + return ( + rows.length === 1 && + rows[0].hash === PROJECT_MODE_MIGRATION.hash && + String(rows[0].created_at) === String(PROJECT_MODE_MIGRATION.timestamp) + ); } -try { - if (process.argv[2] === 'seed') { - await pool.query( - "INSERT INTO users (id, provider, provider_id, login, email, role, plan) VALUES ('user_compat', 'github', 'compat', 'compat', 'compat@example.invalid', 'admin', 'custom')", - ); - await pool.query( - "INSERT INTO projects (id, user_id, slug) VALUES ('proj_compat', 'user_compat', 'schema-compat-fixture')", - ); - // Test-only key and nonce; match the platform's AES-GCM storage format. - const iv = Buffer.alloc(12, 1); - const cipher = createCipheriv('aes-256-gcm', Buffer.alloc(32, 7), iv); - const ciphertext = Buffer.concat([ - cipher.update('schema compatibility fixture'), - cipher.final(), - cipher.getAuthTag(), - ]).toString('base64'); - await pool.query( - "INSERT INTO project_secrets (id, project_id, name, ciphertext, iv, key_version) VALUES ('psc_compat', 'proj_compat', 'COMPAT_SECRET', $1, $2, '1')", - [ciphertext, iv.toString('base64')], - ); - } else if (process.argv[2] !== 'verify') { - throw new Error('Use seed or verify.'); + +export function assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied = false) { + for (const [table, row] of Object.entries(original)) { + for (const [key, value] of Object.entries(row)) { + // 0054 deliberately normalizes the obsolete Worker mode to the public + // Void mode. Permit only this exact conversion after its immutable SQL + // was applied; every other pre-existing value must remain identical. + const expected = + projectModeMigrationApplied && + table === 'projects' && + row.id === 'proj_compat' && + key === 'mode' && + value === 'worker' + ? 'void' + : value; + assert.deepEqual( + rows[table]?.[key], + expected, + `${table}.${key}: upgrade changed existing data`, + ); + } + } +} + +export async function runSchemaFixture(command, env = process.env) { + const snapshot = + env.COMPAT_FIXTURE_PATH ?? + (env.RUNNER_TEMP ? `${env.RUNNER_TEMP}/void-schema-fixture.json` : undefined); + if (!snapshot || !env.COMPAT_DATABASE_URL) { + throw new Error('A disposable compatibility database and fixture path are required.'); } - const rows = {}; - for (const [table, id] of [ - ['users', 'user_compat'], - ['projects', 'proj_compat'], - ['project_secrets', 'psc_compat'], - ]) { - const result = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]); - assert.equal(result.rows.length, 1, `${table}: upgrade lost the compatibility fixture`); - rows[table] = JSON.parse(JSON.stringify(result.rows[0])); + if (command !== 'seed' && command !== 'verify') { + throw new Error('Use seed or verify.'); } - if (process.argv[2] === 'seed') { - writeFileSync(snapshot, JSON.stringify(rows), { mode: 0o600 }); - } else { - const original = JSON.parse(readFileSync(snapshot, 'utf8')); - for (const [table, row] of Object.entries(original)) { - for (const [key, value] of Object.entries(row)) { - assert.deepEqual(rows[table][key], value, `${table}.${key}: upgrade changed existing data`); - } + // Resolve the driver from the checkout being tested, even when this script + // was saved in RUNNER_TEMP before checking out another immutable revision. + const { Pool } = createRequire( + pathToFileURL(`${process.cwd()}/platform/packages/api/package.json`), + )('pg'); + const pool = new Pool({ connectionString: env.COMPAT_DATABASE_URL }); + try { + if (command === 'seed') { + await pool.query( + "INSERT INTO users (id, provider, provider_id, login, email, role, plan) VALUES ('user_compat', 'github', 'compat', 'compat', 'compat@example.invalid', 'admin', 'custom')", + ); + await pool.query( + "INSERT INTO projects (id, user_id, slug) VALUES ('proj_compat', 'user_compat', 'schema-compat-fixture')", + ); + // Test-only key and nonce; match the platform's AES-GCM storage format. + const iv = Buffer.alloc(12, 1); + const cipher = createCipheriv('aes-256-gcm', Buffer.alloc(32, 7), iv); + const ciphertext = Buffer.concat([ + cipher.update('schema compatibility fixture'), + cipher.final(), + cipher.getAuthTag(), + ]).toString('base64'); + await pool.query( + "INSERT INTO project_secrets (id, project_id, name, ciphertext, iv, key_version) VALUES ('psc_compat', 'proj_compat', 'COMPAT_SECRET', $1, $2, '1')", + [ciphertext, iv.toString('base64')], + ); + } + const rows = {}; + for (const [table, id] of [ + ['users', 'user_compat'], + ['projects', 'proj_compat'], + ['project_secrets', 'psc_compat'], + ]) { + const result = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [id]); + assert.equal(result.rows.length, 1, `${table}: upgrade lost the compatibility fixture`); + rows[table] = JSON.parse(JSON.stringify(result.rows[0])); } - console.log('Upgrade preserved the previous runtime user, project, and encrypted secret.'); + if (command === 'seed') { + writeFileSync(snapshot, JSON.stringify(rows), { mode: 0o600 }); + } else { + const original = JSON.parse(readFileSync(snapshot, 'utf8')); + const migration = await pool.query( + 'SELECT "hash", "created_at" FROM "drizzle"."__drizzle_migrations" WHERE "created_at" = $1', + [PROJECT_MODE_MIGRATION.timestamp], + ); + const projectModeMigrationApplied = isProjectModeMigrationApplied(migration.rows); + assertSchemaFixturePreserved(original, rows, projectModeMigrationApplied); + console.log('Upgrade preserved the previous runtime user, project, and encrypted secret.'); + } + } finally { + await pool.end(); } -} finally { - await pool.end(); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await runSchemaFixture(process.argv[2]); } diff --git a/.github/workflows/platform-ci.yml b/.github/workflows/platform-ci.yml index 1892f0e84..8cb5a9af4 100644 --- a/.github/workflows/platform-ci.yml +++ b/.github/workflows/platform-ci.yml @@ -78,6 +78,12 @@ jobs: with: cache: true node-version-file: .node-version + - name: Install Chromium for native API forms + if: ${{ startsWith(matrix.project, 'platform-api-integration') && github.repository == 'voidzero-dev/void' }} + run: vp exec playwright install chromium + - name: Install Chromium and system dependencies for native API forms + if: ${{ startsWith(matrix.project, 'platform-api-integration') && github.repository != 'voidzero-dev/void' }} + run: vp exec playwright install --with-deps chromium - run: vp run build - run: vp run --filter './platform/packages/**' build - run: vp test run --project '${{ matrix.project }}*' @@ -168,6 +174,12 @@ jobs: with: cache: true node-version-file: .node-version + - name: Install Chromium for deployed runtime API forms + if: ${{ github.repository == 'voidzero-dev/void' }} + run: vp exec playwright install chromium + - name: Install Chromium and system dependencies for deployed runtime API forms + if: ${{ github.repository != 'voidzero-dev/void' }} + run: vp exec playwright install --with-deps chromium - name: Run the deployed runtime against the upgraded schema run: | vp run build diff --git a/packages/void/test/unit/schema-fixture.test.ts b/packages/void/test/unit/schema-fixture.test.ts new file mode 100644 index 000000000..4bbf2d14d --- /dev/null +++ b/packages/void/test/unit/schema-fixture.test.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { beforeAll, describe, expect, it } from 'vite-plus/test'; + +type Rows = Record<string, Record<string, unknown>>; +let assertPreserved: (original: Rows, rows: Rows, projectModeMigrationApplied?: boolean) => void; +let migrationApplied: (rows: Array<{ hash?: string; created_at?: number | string }>) => boolean; +const migration = { + hash: createHash('sha256') + .update( + readFileSync( + new URL('../../../../platform/packages/api/drizzle/0054_project_mode.sql', import.meta.url), + ), + ) + .digest('hex'), + created_at: 1790841384175, +}; + +beforeAll(async () => { + const module = await import( + new URL('../../../../.github/scripts/schema-fixture.mjs', import.meta.url).href + ); + assertPreserved = module.assertSchemaFixturePreserved; + migrationApplied = module.isProjectModeMigrationApplied; +}); + +function original(mode = 'worker'): Rows { + return { + users: { id: 'user_compat', role: 'admin', email: 'compat@example.invalid' }, + projects: { id: 'proj_compat', user_id: 'user_compat', slug: 'schema-compat-fixture', mode }, + project_secrets: { id: 'psc_compat', ciphertext: 'fixture-ciphertext', iv: 'fixture-iv' }, + }; +} + +describe('populated schema compatibility fixture', () => { + it('accepts only the exact applied project-mode migration and intentional Worker normalization', () => { + expect(migrationApplied([migration])).toBe(true); + expect(migrationApplied([{ ...migration, created_at: String(migration.created_at) }])).toBe( + true, + ); + const before = original(); + const after = structuredClone(before); + after.projects.mode = 'void'; + expect(() => assertPreserved(before, after, migrationApplied([migration]))).not.toThrow(); + expect(after.project_secrets).toEqual(before.project_secrets); + }); + + it.each([ + { rows: [] }, + { rows: [{ created_at: migration.created_at }] }, + { rows: [{ ...migration, hash: '0'.repeat(64) }] }, + { rows: [{ ...migration, created_at: migration.created_at + 1 }] }, + { rows: [migration, migration] }, + ])('refuses normalization without the exact unique applied migration $rows', ({ rows }) => { + expect(migrationApplied(rows)).toBe(false); + const before = original(); + const after = structuredClone(before); + after.projects.mode = 'void'; + expect(() => assertPreserved(before, after, migrationApplied(rows))).toThrow('projects.mode'); + }); + + it.each(['spa', 'static', 'framework', 'unexpected'])( + 'refuses a Worker conversion to %s', + (mode) => { + const before = original(); + const after = structuredClone(before); + after.projects.mode = mode; + expect(() => assertPreserved(before, after, true)).toThrow('projects.mode'); + }, + ); + + it.each(['void', 'spa', 'static', 'framework'])( + 'preserves existing %s projects exactly', + (mode) => { + const before = original(mode); + const after = structuredClone(before); + expect(() => assertPreserved(before, after, true)).not.toThrow(); + after.projects.mode = mode === 'void' ? 'worker' : 'void'; + expect(() => assertPreserved(before, after, true)).toThrow('projects.mode'); + }, + ); + + it.each([ + ['users', 'email'], + ['projects', 'slug'], + ['projects', 'user_id'], + ['project_secrets', 'ciphertext'], + ['project_secrets', 'iv'], + ])('still rejects unrelated mutation of %s.%s', (table, column) => { + const before = original(); + const after = structuredClone(before); + after.projects.mode = 'void'; + after[table][column] = 'changed'; + expect(() => assertPreserved(before, after, true)).toThrow(`${table}.${column}`); + }); + + it('does not allow another project identity to use the fixture normalization exception', () => { + const before = original(); + before.projects.id = 'unrelated'; + const after = structuredClone(before); + after.projects.mode = 'void'; + expect(() => assertPreserved(before, after, true)).toThrow('projects.mode'); + }); +}); diff --git a/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts b/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts index 15ece4bcc..1f8d81ec3 100644 --- a/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts +++ b/platform/packages/api/test/unit/kitchen-sink-revalidation.test.ts @@ -1,20 +1,27 @@ +import { join } from 'node:path'; import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import type { CloudEnv } from 'void/handler'; +import { kitchenSinkSourceFixture } from './kitchen-sink-source-fixture.ts'; const mocks = vi.hoisted(() => ({ revalidate: vi.fn(), put: vi.fn() })); vi.mock('void', async () => import('void/handler')); vi.mock('void/isr', () => ({ revalidate: mocks.revalidate })); vi.mock('void/kv', () => ({ kv: { put: mocks.put, get: vi.fn() } })); -import { action } from '../../../../../playground/kitchen-sink/pages/revalidate.server.ts'; - -const app = new Hono<CloudEnv>() - .post('/', async (c) => { - const response = await action(c); - return response instanceof Response ? response : c.body(null, 204); - }) - .onError((error) => Response.json({ error: error.message }, { status: 502 })); +let fixture: ReturnType<typeof kitchenSinkSourceFixture>; +let app: Hono<CloudEnv>; +beforeAll(async () => { + fixture = kitchenSinkSourceFixture(['pages/revalidate.server.ts']); + const { action } = await import(join(fixture.root, 'pages/revalidate.server.ts')); + app = new Hono<CloudEnv>() + .post('/', async (c) => { + const response = await action(c); + return response instanceof Response ? response : c.body(null, 204); + }) + .onError((error) => Response.json({ error: error.message }, { status: 502 })); +}); +afterAll(() => fixture?.cleanup()); beforeEach(() => { mocks.revalidate.mockReset(); diff --git a/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts b/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts index 9d1703a49..4b249f3d9 100644 --- a/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts +++ b/platform/packages/api/test/unit/kitchen-sink-sandbox.test.ts @@ -1,6 +1,8 @@ +import { join } from 'node:path'; import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import type { CloudEnv } from 'void/handler'; +import { kitchenSinkSourceFixture } from './kitchen-sink-source-fixture.ts'; const mocks = vi.hoisted(() => ({ getSandbox: vi.fn() })); vi.mock('void', async () => import('void/handler')); @@ -9,9 +11,14 @@ vi.mock('void/sandbox', () => ({ SandboxLimitError: class extends Error {}, })); -import { POST } from '../../../../../playground/kitchen-sink/routes/api/sandbox.ts'; - -const app = new Hono<CloudEnv>().post('/', (c) => POST(c) as Promise<Response>); +let fixture: ReturnType<typeof kitchenSinkSourceFixture>; +let app: Hono<CloudEnv>; +beforeAll(async () => { + fixture = kitchenSinkSourceFixture(['routes/api/sandbox.ts', 'lib/sandbox-demo.ts']); + const { POST } = await import(join(fixture.root, 'routes/api/sandbox.ts')); + app = new Hono<CloudEnv>().post('/', (c) => POST(c) as Promise<Response>); +}); +afterAll(() => fixture?.cleanup()); const request = (marker: string) => app.request('/', { method: 'POST', diff --git a/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts b/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts new file mode 100644 index 000000000..c2f536712 --- /dev/null +++ b/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts @@ -0,0 +1,28 @@ +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Transform actual playground source without needing generated playground types. */ +export function kitchenSinkSourceFixture(files: Array<string>) { + const workspace = fileURLToPath(new URL('../../../../../', import.meta.url)); + // Stay under the API package so the public Void imports resolve through its + // installed workspace dependency, including on a package-only CI build. + const root = mkdtempSync(join(import.meta.dirname, '.tmp-kitchen-sink-source-')); + try { + writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + // Keep runtime tests independent of the app's build-generated .void config. + writeFileSync(join(root, 'tsconfig.json'), '{}'); + for (const file of files) { + const target = join(root, file); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(join(workspace, 'playground/kitchen-sink', file), target); + } + return { + root, + cleanup: () => rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }), + }; + } catch (error) { + rmSync(root, { recursive: true, force: true }); + throw error; + } +} From 80dec5ad564e3c72e09cc79698e3918ed3a8c527 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 02:07:33 +0800 Subject: [PATCH 04/10] test: keep route fixtures manifest-free --- platform/packages/api/test/unit/kitchen-sink-source-fixture.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts b/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts index c2f536712..ea96631e7 100644 --- a/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts +++ b/platform/packages/api/test/unit/kitchen-sink-source-fixture.ts @@ -9,7 +9,6 @@ export function kitchenSinkSourceFixture(files: Array<string>) { // installed workspace dependency, including on a package-only CI build. const root = mkdtempSync(join(import.meta.dirname, '.tmp-kitchen-sink-source-')); try { - writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true, type: 'module' })); // Keep runtime tests independent of the app's build-generated .void config. writeFileSync(join(root, 'tsconfig.json'), '{}'); for (const file of files) { From 712af41b902b397c698b944bef0b16a493362b0e Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 02:17:45 +0800 Subject: [PATCH 05/10] test: keep temporary apps outside workspace package scans --- .../integration/cloudflare-isr-policy.test.ts | 11 ++-- .../void/test/integration/node-target.test.ts | 58 ++++++++++--------- .../integration/pages-target-assets.test.ts | 3 +- .../integration/spa-middleware-auth.test.ts | 34 ++++------- .../test/unit/integration-helpers.test.ts | 49 +++++++++++++++- test/integration-helpers.ts | 8 +-- 6 files changed, 102 insertions(+), 61 deletions(-) diff --git a/packages/void/test/integration/cloudflare-isr-policy.test.ts b/packages/void/test/integration/cloudflare-isr-policy.test.ts index e2c355bff..8e385eae8 100644 --- a/packages/void/test/integration/cloudflare-isr-policy.test.ts +++ b/packages/void/test/integration/cloudflare-isr-policy.test.ts @@ -27,11 +27,12 @@ describe('explicit ISR policy in real builds', () => { }, }), ); - const build = spawn.sync( - 'vp', - ['run', '--filter', `./${playground.workspacePath}`, 'build'], - { cwd: ROOT, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 60_000 }, - ); + const build = spawn.sync('vp', ['build'], { + cwd: playground.root, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + timeout: 60_000, + }); expect(build.status, `${build.stdout ?? ''}${build.stderr ?? ''}`).toBe(0); const entry = readFileSync(join(playground.root, '.void/entry.ts'), 'utf8'); expect(entry).not.toContain('handleIsr'); diff --git a/packages/void/test/integration/node-target.test.ts b/packages/void/test/integration/node-target.test.ts index 552cf7907..fa86c945e 100644 --- a/packages/void/test/integration/node-target.test.ts +++ b/packages/void/test/integration/node-target.test.ts @@ -1,9 +1,14 @@ -import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; -import { getPort, waitForServer, killChild } from '../../../../test/integration-helpers.ts'; +import { + createIsolatedPlayground, + getPort, + waitForServer, + killChild, + type IsolatedPlayground, +} from '../../../../test/integration-helpers.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); let DEV_PORT: number; @@ -12,34 +17,24 @@ let PROD_PORT: number; let PROD_BASE: string; describe('playground/node-app dev', () => { + let playground: IsolatedPlayground; let child: ReturnType<typeof spawn>; let stderr = ''; beforeAll(async () => { + playground = createIsolatedPlayground(ROOT, 'playground/node-app'); DEV_PORT = await getPort(); DEV_BASE = `http://127.0.0.1:${DEV_PORT}`; - child = spawn( - 'vp', - [ - 'run', - '--filter', - './playground/node-app', - 'dev', - '--host', - '127.0.0.1', - '--port', - String(DEV_PORT), - ], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(DEV_PORT)], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + }); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); }); + child.stdout!.resume(); await waitForServer(DEV_BASE, 20_000); }); @@ -48,6 +43,7 @@ describe('playground/node-app dev', () => { if (child) { await killChild(child); } + playground?.cleanup(); if (stderr.includes('error when starting dev server')) { throw new Error(stderr); } @@ -92,17 +88,24 @@ describe('playground/node-app dev', () => { }); describe('playground/node-app build + production', () => { + let playground: IsolatedPlayground; let child: ReturnType<typeof spawn>; beforeAll(async () => { - rmSync(join(ROOT, 'playground/node-app/dist'), { recursive: true, force: true }); - execSync('vp run --filter ./playground/node-app build', { cwd: ROOT, stdio: 'pipe' }); + playground = createIsolatedPlayground(ROOT, 'playground/node-app'); + const build = spawn.sync('vp', ['build'], { + cwd: playground.root, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 10 * 1024 * 1024, + }); + expect(build.status, `${build.stdout ?? ''}${build.stderr ?? ''}`).toBe(0); PROD_PORT = await getPort(); PROD_BASE = `http://127.0.0.1:${PROD_PORT}`; child = spawn('node', ['dist/ssr/index.js'], { - cwd: join(ROOT, 'playground/node-app'), + cwd: playground.root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PORT: String(PROD_PORT) }, }); @@ -116,14 +119,15 @@ describe('playground/node-app build + production', () => { if (child) { await killChild(child); } + playground?.cleanup(); }); it('emits app.js, index.js, and client assets', () => { - expect(existsSync(join(ROOT, 'playground/node-app/dist/ssr/app.js'))).toBe(true); - expect(existsSync(join(ROOT, 'playground/node-app/dist/ssr/index.js'))).toBe(true); - expect(existsSync(join(ROOT, 'playground/node-app/dist/client'))).toBe(true); + expect(existsSync(join(playground.root, 'dist/ssr/app.js'))).toBe(true); + expect(existsSync(join(playground.root, 'dist/ssr/index.js'))).toBe(true); + expect(existsSync(join(playground.root, 'dist/client'))).toBe(true); - const index = readFileSync(join(ROOT, 'playground/node-app/dist/ssr/index.js'), 'utf-8'); + const index = readFileSync(join(playground.root, 'dist/ssr/index.js'), 'utf-8'); expect(index).toContain('@hono/node-server'); }); diff --git a/packages/void/test/integration/pages-target-assets.test.ts b/packages/void/test/integration/pages-target-assets.test.ts index 10d4433a4..c67f651c1 100644 --- a/packages/void/test/integration/pages-target-assets.test.ts +++ b/packages/void/test/integration/pages-target-assets.test.ts @@ -51,8 +51,7 @@ for (const { target, command, args } of targets) { let errors = ''; beforeAll(async () => { - // Keep relative workspace dependency links at the same depth and on the - // same drive. A junction into the Windows system temp directory breaks them. + // Keep the copy on the same drive as the installed dependency links. playground = createIsolatedPlayground(repository, 'playground/node-app'); root = playground.root; const config = readConfig(root); diff --git a/packages/void/test/integration/spa-middleware-auth.test.ts b/packages/void/test/integration/spa-middleware-auth.test.ts index c01c76784..27d7e7267 100644 --- a/packages/void/test/integration/spa-middleware-auth.test.ts +++ b/packages/void/test/integration/spa-middleware-auth.test.ts @@ -16,7 +16,6 @@ import { readConfig } from '../../src/config.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const SOURCE_PLAYGROUND = 'playground/spa'; let playground: IsolatedPlayground; -let playgroundFilter = ''; let authMiddleware = ''; let documentRoute = ''; let middlewareDir = ''; @@ -115,18 +114,14 @@ async function startSpaDevServer(): Promise<string> { const port = await getPort(); const base = `http://127.0.0.1:${port}`; stderr = ''; - child = spawn( - 'vp', - ['run', '--filter', playgroundFilter, 'dev', '--host', '127.0.0.1', '--port', String(port)], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - VOID_INSPECTOR_PORT: 'false', - }, + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port)], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VOID_INSPECTOR_PORT: 'false', }, - ); + }); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); @@ -142,15 +137,11 @@ async function startSpaPreviewServer(): Promise<string> { const port = await getPort(); const base = `http://127.0.0.1:${port}`; stderr = ''; - child = spawn( - 'vp', - ['run', '--filter', playgroundFilter, 'preview', '--host', '127.0.0.1', '--port', String(port)], - { - cwd: ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, - }, - ); + child = spawn('vp', ['preview', '--host', '127.0.0.1', '--port', String(port)], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }); child.stderr!.on('data', (chunk: Buffer) => { stderr += String(chunk); }); @@ -175,7 +166,6 @@ function readBuiltAssetConfig(): { beforeAll(() => { playground = createIsolatedPlayground(ROOT, SOURCE_PLAYGROUND); - playgroundFilter = `./${playground.workspacePath}`; authMiddleware = join(playground.root, 'middleware/00.basic-auth-regression.ts'); documentRoute = join(playground.root, 'routes/dashboard.ts'); middlewareDir = join(playground.root, 'middleware'); diff --git a/packages/void/test/unit/integration-helpers.test.ts b/packages/void/test/unit/integration-helpers.test.ts index 77916643a..741a1190c 100644 --- a/packages/void/test/unit/integration-helpers.test.ts +++ b/packages/void/test/unit/integration-helpers.test.ts @@ -1,6 +1,20 @@ import { spawnSync } from 'node:child_process'; +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vite-plus/test'; -import { shouldCopyPlaygroundPath } from '../../../../test/integration-helpers.ts'; +import { + createIsolatedPlayground, + shouldCopyPlaygroundPath, +} from '../../../../test/integration-helpers.ts'; describe('shouldCopyPlaygroundPath', () => { it('excludes generated top-level entries from native Windows paths', () => { @@ -28,6 +42,39 @@ describe('shouldCopyPlaygroundPath', () => { }); }); +it('keeps copied manifests outside the actual workspace package glob during cleanup', () => { + const workspace = mkdtempSync(join(tmpdir(), 'void-isolated-playground-')); + const source = join(workspace, 'playground', 'app'); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, 'package.json'), JSON.stringify({ name: 'original-app' })); + writeFileSync(join(source, 'entry.ts'), 'export const marker = "original";'); + const patterns = readFileSync(new URL('../../../../pnpm-workspace.yaml', import.meta.url), 'utf8') + .split('\n') + .flatMap((line) => line.match(/^ {2}- (playground-local\/\S+)$/)?.[1] ?? []); + expect(patterns).toEqual(['playground-local/*']); + const first = createIsolatedPlayground(workspace, 'playground/app'); + const second = createIsolatedPlayground(workspace, 'playground/app'); + try { + const manifests = () => + patterns.flatMap((pattern) => globSync(`${pattern}/package.json`, { cwd: workspace })); + expect(manifests()).toEqual([]); + expect(readFileSync(join(first.root, 'entry.ts'), 'utf8')).toContain('original'); + expect(JSON.parse(readFileSync(join(first.root, 'package.json'), 'utf8')).name).not.toBe( + JSON.parse(readFileSync(join(second.root, 'package.json'), 'utf8')).name, + ); + first.cleanup(); + expect(manifests()).toEqual([]); + expect(existsSync(join(second.root, 'package.json'))).toBe(true); + expect(JSON.parse(readFileSync(join(source, 'package.json'), 'utf8')).name).toBe( + 'original-app', + ); + } finally { + first.cleanup(); + second.cleanup(); + rmSync(workspace, { recursive: true, force: true }); + } +}); + it('clears the shutdown deadline after a real child exits', () => { const helper = new URL('../../../../test/integration-helpers.ts', import.meta.url).href; const result = spawnSync( diff --git a/test/integration-helpers.ts b/test/integration-helpers.ts index fd37e67ef..de85a5160 100644 --- a/test/integration-helpers.ts +++ b/test/integration-helpers.ts @@ -58,9 +58,9 @@ export function shouldCopyPlaygroundPath(sourceRoot: string, path: string): bool } /** - * Copies a playground into the gitignored playground-local workspace so tests that rewrite configs, - * source, or build output can run beside tests using the checked-in playground. Dependencies stay - * linked to the installed source tree; generated state is deliberately not copied. + * Copies a playground outside the workspace package globs so concurrent task-graph scans never + * discover a manifest while the fixture is created or removed. Run Vite+ directly from the copy. + * Dependencies stay linked to the installed source tree; generated state is deliberately not copied. */ export function createIsolatedPlayground( workspaceRoot: string, @@ -71,7 +71,7 @@ export function createIsolatedPlayground( ? join(process.env.VOID_TEST_SOURCE_SNAPSHOTS, sourceWorkspacePath) : undefined; const copySource = snapshot && existsSync(snapshot) ? snapshot : sourceRoot; - const playgroundLocal = join(workspaceRoot, 'playground-local'); + const playgroundLocal = join(workspaceRoot, 'playground-local', '.test-fixtures'); mkdirSync(playgroundLocal, { recursive: true }); const root = mkdtempSync(join(playgroundLocal, `test-${basename(sourceWorkspacePath)}-`)); From ee32f5be3aeb8465484d378c5974f500b32dd542 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 03:04:45 +0800 Subject: [PATCH 06/10] fix: preserve native Durable Object migration history --- packages/void/src/cli/deploy.ts | 19 +- packages/void/src/cli/wrangler.ts | 7 +- packages/void/src/cloudflare-durable-patch.ts | 122 ++++++++++ packages/void/src/index.ts | 26 +- packages/void/src/sandbox/config.ts | 69 +++++- .../cloudflare-durable-build.test.ts | 175 ++++++++++++++ .../void/test/unit/cloudflare-deploy.test.ts | 27 +++ .../unit/cloudflare-durable-patch.test.ts | 224 ++++++++++++++++++ packages/void/test/unit/wrangler.test.ts | 32 +++ 9 files changed, 680 insertions(+), 21 deletions(-) create mode 100644 packages/void/src/cloudflare-durable-patch.ts create mode 100644 packages/void/test/integration/cloudflare-durable-build.test.ts create mode 100644 packages/void/test/unit/cloudflare-durable-patch.test.ts diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index e9e72d841..95d79ecb8 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -102,10 +102,12 @@ import { import { readProjectPaths } from '../project-paths.ts'; import { hasEmailHandlers, type EmailHandlersScan } from '../router/email.ts'; import { + assertSandboxNamespaceConfig, requiresSandboxPlatformImage, resolveSandboxConfig, sandboxContainerConfig, SANDBOX_MIGRATION_TAG, + hasSandboxSqliteMigration, } from '../sandbox/config.ts'; import { trustedCloudflareWorkerAccessHeaders } from '../shared/cf-access.ts'; import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; @@ -1205,14 +1207,7 @@ export function reconstructExpectedDurableObjectState( if (!bindings.some((binding) => binding.name === sandbox.binding)) { bindings.push({ name: sandbox.binding, class_name: sandbox.className }); } - if ( - !migrations.some( - (migration) => - migration.tag === SANDBOX_MIGRATION_TAG || - (Array.isArray(migration.new_sqlite_classes) && - migration.new_sqlite_classes.includes(sandbox.className)), - ) - ) { + if (!hasSandboxSqliteMigration(migrations, sandbox.className)) { migrations.push({ tag: SANDBOX_MIGRATION_TAG, new_sqlite_classes: [sandbox.className], @@ -4833,6 +4828,14 @@ export async function runCloudflareDeploy( // re-parses are the account discovery's (3b) and the email step's (3b-ii), both of which write // BEFORE any project code runs. let rootConfig = parseRootWranglerConfig(configPath); + if (needsSandbox) { + try { + assertSandboxNamespaceConfig(rootConfig, resolveSandboxConfig(voidConfig, root)); + } catch (error) { + log.error(`deploy: ${getErrorMessage(error)}`); + return exit(1); + } + } // 3a. Resolve + pin the ONE transport for the WHOLE deploy (build + whoami + migrate + // deploy), reusing the provisioner's resolver + dotenv fence. Fail closed on any diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 14de2fdf9..19e3225e7 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -28,6 +28,7 @@ import { DEFAULT_SANDBOX_BINDING, SANDBOX_MIGRATION_TAG, assertSandboxNamespaceConfig, + hasSandboxSqliteMigration, resolveSandboxConfig, sandboxContainerConfig, type ResolvedSandboxConfig, @@ -759,11 +760,7 @@ export function syncWranglerBindings( new_sqlite_classes?: Array<string>; }>) : []; - const hasMigration = migrations.some( - (migration) => - migration.tag === SANDBOX_MIGRATION_TAG || - migration.new_sqlite_classes?.includes(className), - ); + const hasMigration = hasSandboxSqliteMigration(migrations, className); if (!hasMigration) { parsed.migrations = [ ...migrations, diff --git a/packages/void/src/cloudflare-durable-patch.ts b/packages/void/src/cloudflare-durable-patch.ts new file mode 100644 index 000000000..52330fcad --- /dev/null +++ b/packages/void/src/cloudflare-durable-patch.ts @@ -0,0 +1,122 @@ +import { isDeepStrictEqual } from 'node:util'; + +type Config = Record<string, unknown>; +type Entry = Record<string, unknown>; + +function entries(value: unknown, label: string): Array<Entry> { + if (value === undefined) { + return []; + } + if ( + !Array.isArray(value) || + value.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry)) + ) { + throw new Error(`Cloudflare ${label} configuration must be an array of objects.`); + } + return value as Array<Entry>; +} + +function byIdentity( + rows: Array<Entry>, + field: string, + label: string, + unique = false, +): Map<string, Entry> { + const map = new Map<string, Entry>(); + for (const row of rows) { + const id = row[field]; + if (typeof id !== 'string' || !id) { + throw new Error(`Cloudflare ${label} is missing its ${field}.`); + } + const previous = map.get(id); + if (previous && unique) { + throw new Error(`Cloudflare ${label} ${JSON.stringify(id)} is configured more than once.`); + } + if (previous && !isDeepStrictEqual(previous, row)) { + throw new Error(`Cloudflare ${label} ${JSON.stringify(id)} has conflicting configuration.`); + } + if (!previous) { + map.set(id, row); + } + } + return map; +} + +/** Finish cumulative feature planning without repeating the Cloudflare plugin's array fallback. */ +export function finalizeDurableObjectPatch( + resolved: Config, + patch: Config, + generatedLocalBindings: Array<{ name: string; class_name: string }>, +): void { + const baselineObjects = resolved.durable_objects as { bindings?: unknown } | undefined; + const pendingObjects = patch.durable_objects as { bindings?: unknown } | undefined; + const baselineBindings = byIdentity( + entries(baselineObjects?.bindings, 'Durable Object bindings'), + 'name', + 'Durable Object binding', + true, + ); + const pendingBindings = byIdentity( + entries(pendingObjects?.bindings, 'Durable Object bindings'), + 'name', + 'Durable Object binding', + ); + const history = entries(resolved.migrations, 'Durable Object migrations'); + const baselineMigrations = byIdentity(history, 'tag', 'Durable Object migration', true); + for (const local of generatedLocalBindings) { + for (const map of [baselineBindings, pendingBindings]) { + const existing = map.get(local.name); + // SDK classes are exported by this Worker. A same-class remote binding would run another + // Worker's code; retain unrelated authored remote bindings, but never silently adopt it. + if ( + existing && + (existing.class_name !== local.class_name || + existing.script_name !== undefined || + existing.environment !== undefined) + ) { + throw new Error( + `Cloudflare generated Durable Object binding ${JSON.stringify(local.name)} conflicts with its configured identity.`, + ); + } + } + } + if (pendingObjects?.bindings !== undefined) { + const additions: Array<Entry> = []; + for (const [name, row] of pendingBindings) { + const existing = baselineBindings.get(name); + if (existing && !isDeepStrictEqual(existing, row)) { + throw new Error( + `Cloudflare Durable Object binding ${JSON.stringify(name)} has conflicting configuration.`, + ); + } + if (!existing) { + additions.push(row); + } + } + patch.durable_objects = { ...pendingObjects, bindings: additions }; + } + if (patch.migrations !== undefined) { + const pending = byIdentity( + entries(patch.migrations, 'Durable Object migrations'), + 'tag', + 'Durable Object migration', + ); + const additions: Array<Entry> = []; + for (const [tag, row] of pending) { + const previous = baselineMigrations.get(tag); + if (previous && !isDeepStrictEqual(previous, row)) { + throw new Error( + `Cloudflare Durable Object migration ${JSON.stringify(tag)} has conflicting configuration.`, + ); + } + if (!previous) { + additions.push(row); + } + } + // defu prepends the patch to the fallback. Migrations need the original ordered history + // followed by additions, so emit that complete sequence once. Only the in-memory fallback + // is cleared; authored configuration and the lock retain their original history. + patch.migrations = [...history, ...additions]; + resolved.migrations = []; + } +} diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 1b9ddb25f..cead738be 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -50,6 +50,7 @@ import { restoreCloudflarePreviewPointer, stashCloudflareDeployPointer, } from './cloudflare-config.ts'; +import { finalizeDurableObjectPatch } from './cloudflare-durable-patch.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { readConfig, isNodeTarget, type NotFoundHandling } from './config.ts'; import { inferBindingsFromSource } from './inference/walk.ts'; @@ -84,6 +85,7 @@ import { isRouteEnabledForEnvironment, type RouteEnvironment } from './router/pa import { DEV_INBOX_ENV_VAR, DEV_INBOX_TOKEN_ENV_VAR } from './runtime/email/dev-inbox.ts'; import { SANDBOX_MIGRATION_TAG, + hasSandboxSqliteMigration, resolveSandboxConfig, sandboxContainerConfig, assertSandboxNamespaceConfig, @@ -1700,13 +1702,7 @@ export function voidPlugin(options?: { new_sqlite_classes?: Array<string>; }>) : []; - if ( - !existingMigrations.some( - (migration) => - migration.tag === SANDBOX_MIGRATION_TAG || - migration.new_sqlite_classes?.includes(sandboxConfig.className), - ) - ) { + if (!hasSandboxSqliteMigration(existingMigrations, sandboxConfig.className)) { (result as Record<string, unknown>).migrations = [ ...existingMigrations, { @@ -1824,6 +1820,22 @@ export function voidPlugin(options?: { result.version_metadata = { binding: versionMetadataBinding }; } } + finalizeDurableObjectPatch(resolved as Record<string, unknown>, result, [ + ...environmentWebsocketRoutes.map((route) => ({ + name: route.bindingName, + class_name: route.className, + })), + ...durableObjects.map((module) => ({ + name: module.bindingName, + class_name: module.className, + })), + ...(bindings.needsSandbox && sandboxConfig + ? [{ name: sandboxConfig.binding, class_name: sandboxConfig.className }] + : []), + ...(shouldEmitLive() && liveConfig + ? [{ name: liveConfig.bindingName, class_name: liveConfig.className }] + : []), + ]); return result as Partial<WorkerConfig>; } diff --git a/packages/void/src/sandbox/config.ts b/packages/void/src/sandbox/config.ts index e082143fd..056aa177a 100644 --- a/packages/void/src/sandbox/config.ts +++ b/packages/void/src/sandbox/config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url'; import { isSandboxRegistryImage } from '@void/deploy-core'; +import type { DurableObjectMigrationStep } from '@void/deploy-core'; import { isAbsolute, join } from 'pathslash'; import type { VoidConfig } from '../config.ts'; @@ -134,6 +135,42 @@ export function sandboxContainerConfig(sandbox: ResolvedSandboxConfig): Record<s }; } +/** Resolve the final local storage kind without recreating a renamed or deleted class. */ +export function resolveSandboxMigrationStorage( + migrations: ReadonlyArray<DurableObjectMigrationStep>, + className: string, +): 'sqlite' | 'legacy-kv' | 'unknown' | undefined { + const storage = new Map<string, 'sqlite' | 'legacy-kv' | 'unknown'>(); + for (const migration of migrations) { + for (const name of migration.new_classes ?? []) { + storage.set(name, 'legacy-kv'); + } + for (const name of migration.new_sqlite_classes ?? []) { + storage.set(name, 'sqlite'); + } + for (const rename of migration.renamed_classes ?? []) { + const sqlite = storage.get(rename.from); + storage.delete(rename.from); + storage.set(rename.to, sqlite ?? 'unknown'); + } + // A transferred class's source storage is not established by this local history. + for (const transfer of migration.transferred_classes ?? []) { + storage.set(transfer.to, 'unknown'); + } + for (const name of migration.deleted_classes ?? []) { + storage.delete(name); + } + } + return storage.get(className); +} + +export function hasSandboxSqliteMigration( + migrations: ReadonlyArray<DurableObjectMigrationStep>, + className: string, +): boolean { + return resolveSandboxMigrationStorage(migrations, className) === 'sqlite'; +} + /** Refuse namespace reuse before either uploader can change a live Worker. */ export function assertSandboxNamespaceConfig( config: Record<string, unknown>, @@ -141,8 +178,38 @@ export function assertSandboxNamespaceConfig( ): void { const containers = Array.isArray(config.containers) ? config.containers : []; const durable = config.durable_objects as - | { bindings?: Array<{ name: string; class_name: string }> } + | { + bindings?: Array<{ + name: string; + class_name: string; + script_name?: string; + environment?: string; + }>; + } | undefined; + const binding = durable?.bindings?.find((entry) => entry.name === sandbox.binding); + if (binding && (binding.script_name !== undefined || binding.environment !== undefined)) { + throw new Error( + `sandbox: binding '${sandbox.binding}' must reference the local '${sandbox.className}' class. Choose another sandbox.binding name to preserve the existing external Durable Object.`, + ); + } + const migrations = Array.isArray(config.migrations) + ? (config.migrations as Array<DurableObjectMigrationStep>) + : []; + const storage = resolveSandboxMigrationStorage(migrations, sandbox.className); + if (storage === 'legacy-kv' || storage === 'unknown') { + throw new Error( + `sandbox: class '${sandbox.className}' needs SQLite storage, but its existing namespace has ${storage} storage. Preserve the existing migration history and choose a fresh Worker name or a new sandbox.className and sandbox.binding.`, + ); + } + if ( + storage !== 'sqlite' && + migrations.some((migration) => migration.tag === SANDBOX_MIGRATION_TAG) + ) { + throw new Error( + `sandbox: migration tag '${SANDBOX_MIGRATION_TAG}' is already configured for another class. Keep the existing history and choose a fresh Worker for Sandbox.`, + ); + } if ( containers.some( (container) => diff --git a/packages/void/test/integration/cloudflare-durable-build.test.ts b/packages/void/test/integration/cloudflare-durable-build.test.ts new file mode 100644 index 000000000..ce9fcc3e3 --- /dev/null +++ b/packages/void/test/integration/cloudflare-durable-build.test.ts @@ -0,0 +1,175 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import spawn from 'cross-spawn'; +import { describe, expect, it } from 'vite-plus/test'; +import { createIsolatedPlayground } from '../../../../test/integration-helpers.ts'; +import { analyzeApplicationSync } from '../../src/application-analysis.ts'; +import { + computeDesiredCloudflareResources, + parseRootWranglerConfig, + reconstructExpectedContainers, + reconstructExpectedDurableObjectState, + snapshotBindingIdentities, + snapshotPassthroughIdentities, + snapshotPassthroughRawFields, + verifyGeneratedDeployConfig, +} from '../../src/cli/deploy.ts'; +import { syncWranglerBindings } from '../../src/cli/wrangler.ts'; +import { materializeCloudflareConfig } from '../../src/cloudflare-config.ts'; +import { renderVoidConfig } from '../../src/config-write.ts'; +import { readConfig } from '../../src/config.ts'; +import { CLOUDFLARE_DEPLOY_BUILD_ENV } from '../../src/shared/env.ts'; + +const repository = join(import.meta.dirname, '..', '..', '..', '..'); +const accountId = '0123456789abcdef0123456789abcdef'; +const external = { + name: 'FOREIGN', + class_name: 'Remote', + script_name: 'external-owner', + environment: 'production', +}; +const history = [{ tag: 'custom-v1', new_sqlite_classes: ['HistoryDurableObject'] }]; + +describe('real Cloudflare Durable Object configuration merge', () => { + it.each([ + { name: 'combined', websocket: true, live: true, sandbox: true, authoredHistory: true }, + { name: 'sandbox-only', websocket: false, live: false, sandbox: true, authoredHistory: false }, + { name: 'free', websocket: true, live: true, sandbox: false, authoredHistory: false }, + ])( + 'emits $name identities and ordered migrations exactly once through the actual provider plugin', + async (options) => { + const fixture = createIsolatedPlayground(repository, 'playground/ssr'); + const root = fixture.root; + const write = (path: string, source: string) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), source); + }; + try { + if (options.websocket) { + write( + 'routes/chat.ws.ts', + `import { defineWebSocket } from 'void/ws'; +export default defineWebSocket({ onConnect({ connection }) { connection.send('connected'); } });\n`, + ); + } + if (options.live) { + write( + 'routes/api/live.ts', + `import { defineHandler } from 'void'; +import { defineLiveStream } from 'void/live'; +const stream = defineLiveStream({ id: 'build-regression' }); +export const GET = defineHandler(c => stream.connect(c, { owner: 'local', user: null }));\n`, + ); + } + if (options.authoredHistory) { + write( + 'durable-objects/history.ts', + `import { defineDurableState } from 'void/durable'; +export default defineDurableState({ initialState: { count: 0 }, methods: { increment(c) { return ++c.state.count; } } });\n`, + ); + } + write( + 'void.config.ts', + renderVoidConfig({ + ...readConfig(root), + ...(options.sandbox ? { sandbox: true } : {}), + cloudflare: { + name: `durable-merge-${options.name}`, + account_id: accountId, + ...(options.authoredHistory + ? { + durable_objects: { + bindings: [{ name: 'HISTORY', class_name: 'HistoryDurableObject' }, external], + }, + migrations: history, + } + : {}), + }, + }), + ); + const authored = readFileSync(join(root, 'void.config.ts'), 'utf8'); + const config = readConfig(root); + const analysis = analyzeApplicationSync(root, { config }); + const configPath = materializeCloudflareConfig(root); + // Native classification retains pre-existing WebSocket/class history; the build appends + // Sandbox and Live. This exercises the actual fallback arrays that used to be repeated. + syncWranglerBindings( + root, + { ...analysis.bindings, needsSandbox: false, needsLive: false }, + { + websockets: analysis.allWebsockets, + }, + ); + const rootConfig = parseRootWranglerConfig(configPath); + const signals = await computeDesiredCloudflareResources(root, config, configPath); + const durable = reconstructExpectedDurableObjectState(config, root, rootConfig, { + needsSandbox: options.sandbox, + needsLive: options.live, + analysis, + }); + const expected = { + accountId, + workerName: `durable-merge-${options.name}`, + desired: signals.desired, + bindingIdentitySnapshot: snapshotBindingIdentities(rootConfig), + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + needsAI: false, + expectedContainers: reconstructExpectedContainers( + config, + root, + rootConfig, + options.sandbox, + ), + expectedCrons: signals.expectedCrons, + expectedDurableObjectBindings: durable.bindings, + expectedMigrations: durable.migrations, + }; + const build = () => { + const result = spawn.sync('vp', ['build'], { + cwd: root, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 10 * 1024 * 1024, + env: { + ...process.env, + [CLOUDFLARE_DEPLOY_BUILD_ENV]: '1', + CLOUDFLARE_API_TOKEN: '', + CLOUDFLARE_API_KEY: '', + }, + }); + expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); + return JSON.parse(readFileSync(join(root, 'dist/ssr/wrangler.json'), 'utf8')); + }; + const emitted = build(); + expect(emitted.durable_objects.bindings).toHaveLength( + (options.websocket ? 1 : 0) + + (options.live ? 1 : 0) + + (options.sandbox ? 1 : 0) + + (options.authoredHistory ? 2 : 0), + ); + expect( + new Set(emitted.durable_objects.bindings.map((binding: { name: string }) => binding.name)) + .size, + ).toBe(emitted.durable_objects.bindings.length); + expect(emitted.migrations).toEqual(durable.migrations); + expect(verifyGeneratedDeployConfig(root, expected)).toEqual({ ok: true }); + expect(readFileSync(join(root, 'void.config.ts'), 'utf8')).toBe(authored); + if (options.authoredHistory) { + expect(emitted.durable_objects.bindings).toContainEqual(external); + expect(emitted.migrations.slice(0, history.length)).toEqual(history); + const repeated = build(); + expect(repeated.durable_objects).toEqual(emitted.durable_objects); + expect(repeated.migrations).toEqual(emitted.migrations); + } + if (!options.sandbox) { + expect(emitted.containers ?? []).toEqual([]); + expect(emitted.ai).toBeUndefined(); + expect(emitted.hyperdrive ?? []).toEqual([]); + } + } finally { + fixture.cleanup(); + } + }, + ); +}); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 9e07b11dd..42f0a22a9 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -3094,6 +3094,33 @@ describe('runCloudflareDeploy — preflight', () => { expect(wranglerArgs.some((args) => args[0] === 'containers')).toBe(false); }); + it.each([ + { tag: 'occupied', new_classes: ['SandboxV1'] }, + { + tag: 'transfer', + transferred_classes: [{ from_script: 'foreign', from: 'Original', to: 'SandboxV1' }], + }, + { tag: 'void-sandbox-v2', new_sqlite_classes: ['Unrelated'] }, + ])( + 'rejects incompatible Sandbox history before account probes, resource provisioning or build', + async (migration) => { + const root = sandboxProject(); + const configPath = join(root, 'wrangler.jsonc'); + const original = JSON.stringify({ + ...parseRootWranglerConfig(configPath), + migrations: [migration], + }); + writeFileSync(configPath, original); + const { deps, trace, exitCalls } = harness(); + await expect( + runCloudflareDeploy(root, { platform: 'cloudflare', provision: true }, silentCliLog, deps), + ).rejects.toBeInstanceOf(ExitError); + expect(exitCalls).toEqual([1]); + expect(trace).toEqual([]); + expect(readFileSync(configPath, 'utf8')).toBe(original); + }, + ); + it('translates a Sandbox upload entitlement error into Workers Paid guidance', async () => { const root = sandboxProject(); const errors: Array<string> = []; diff --git a/packages/void/test/unit/cloudflare-durable-patch.test.ts b/packages/void/test/unit/cloudflare-durable-patch.test.ts new file mode 100644 index 000000000..5723b1fc3 --- /dev/null +++ b/packages/void/test/unit/cloudflare-durable-patch.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { finalizeDurableObjectPatch } from '../../src/cloudflare-durable-patch.ts'; +import { + assertSandboxNamespaceConfig, + hasSandboxSqliteMigration, + resolveSandboxConfig, + SANDBOX_MIGRATION_TAG, +} from '../../src/sandbox/config.ts'; + +describe('Cloudflare durable feature patch', () => { + const ws = { name: 'CHAT', class_name: 'Chat' }; + const sandbox = { name: 'SANDBOX', class_name: 'SandboxV1' }; + const live = { name: 'LIVE', class_name: 'Live' }; + const history = [ + { tag: 'custom-v1', new_sqlite_classes: ['History'] }, + { tag: 'custom-v2', renamed_classes: [{ from: 'History', to: 'Archived' }] }, + { tag: 'ws-v1', new_sqlite_classes: ['Chat'] }, + ]; + const additions = [ + { tag: 'sandbox-v2', new_sqlite_classes: ['SandboxV1'] }, + { tag: 'live-v1', new_sqlite_classes: ['Live'] }, + ]; + + it('returns binding additions and retains the ordered authored migration prefix exactly once', () => { + const foreign = { + name: 'FOREIGN', + class_name: 'Remote', + script_name: 'another-worker', + environment: 'production', + }; + const original = { durable_objects: { bindings: [foreign, ws] }, migrations: history }; + const serialized = JSON.stringify(original); + const resolved = structuredClone(original); + const patch = { + durable_objects: { bindings: [foreign, ws, sandbox, live, sandbox] }, + migrations: [...history, ...additions, additions[0]], + }; + finalizeDurableObjectPatch(resolved, patch, [ws, sandbox, live]); + expect(patch.durable_objects.bindings).toEqual([sandbox, live]); + expect(resolved.durable_objects).toEqual(original.durable_objects); + expect(patch.migrations).toEqual([...history, ...additions]); + expect(resolved.migrations).toEqual([]); + expect(JSON.stringify(original)).toBe(serialized); + }); + + it('keeps an identical repeat build from duplicating history or configured bindings', () => { + const resolved = { + durable_objects: { bindings: [ws, sandbox, live] }, + migrations: [...history, ...additions], + }; + const patch = structuredClone(resolved); + finalizeDurableObjectPatch(resolved, patch, [ws, sandbox, live]); + expect(patch.durable_objects.bindings).toEqual([]); + expect(patch.migrations).toEqual([...history, ...additions]); + }); + + it.each(['script_name', 'environment'])( + 'rejects silent generated local-class reuse of an external %s identity', + (field) => { + const resolved = { durable_objects: { bindings: [{ ...ws, [field]: 'foreign' }] } }; + expect(() => finalizeDurableObjectPatch(resolved, {}, [ws])).toThrow('configured identity'); + expect(resolved.durable_objects.bindings[0]).toHaveProperty(field, 'foreign'); + }, + ); + + it('preserves optional undefined identity fields and authored development options on a local binding', () => { + const binding = { ...ws, script_name: undefined, environment: undefined, remote: false }; + const resolved = { durable_objects: { bindings: [binding] } }; + const patch = structuredClone(resolved); + finalizeDurableObjectPatch(resolved, patch, [ws]); + expect(resolved.durable_objects.bindings).toEqual([binding]); + expect(patch.durable_objects.bindings).toEqual([]); + }); + + it('refuses duplicate authored identities without silently rewriting their history', () => { + const resolved = { durable_objects: { bindings: [ws, ws] }, migrations: history }; + expect(() => finalizeDurableObjectPatch(resolved, {}, [ws])).toThrow( + 'configured more than once', + ); + expect(resolved.durable_objects.bindings).toEqual([ws, ws]); + const migrations = [...history, history[0]]; + expect(() => finalizeDurableObjectPatch({ migrations }, {}, [])).toThrow( + 'configured more than once', + ); + expect(migrations).toEqual([...history, history[0]]); + }); + + it('rejects conflicting binding fields instead of stripping the authored entry', () => { + const resolved = { durable_objects: { bindings: [{ ...ws, class_name: 'Previous' }] } }; + expect(() => + finalizeDurableObjectPatch(resolved, { durable_objects: { bindings: [ws] } }, []), + ).toThrow('conflicting configuration'); + }); + + it('rejects conflicting migration payloads without clearing incoming history', () => { + const resolved = { migrations: history }; + expect(() => + finalizeDurableObjectPatch( + resolved, + { migrations: [{ tag: 'custom-v1', new_classes: ['History'] }] }, + [], + ), + ).toThrow('conflicting configuration'); + expect(resolved.migrations).toBe(history); + }); + + it('rejects conflicts within cumulative pending entries', () => { + expect(() => + finalizeDurableObjectPatch( + {}, + { durable_objects: { bindings: [ws, { ...ws, script_name: 'foreign' }] } }, + [], + ), + ).toThrow('conflicting configuration'); + expect(() => + finalizeDurableObjectPatch( + {}, + { + migrations: [ + { tag: 'v1', new_classes: ['First'] }, + { tag: 'v1', new_classes: ['Second'] }, + ], + }, + [], + ), + ).toThrow('conflicting configuration'); + }); + + it('emits no configuration for an app with no durable features', () => { + const resolved = {}, + patch = {}; + finalizeDurableObjectPatch(resolved, patch, []); + expect(resolved).toEqual({}); + expect(patch).toEqual({}); + }); +}); + +describe('Sandbox migration storage identity', () => { + const sandbox = resolveSandboxConfig({ sandbox: true }); + const occupied = { tag: SANDBOX_MIGRATION_TAG, new_sqlite_classes: ['Unrelated'] }; + it('rejects an occupied reserved tag only when the required final SQLite class is missing', () => { + expect(() => assertSandboxNamespaceConfig({ migrations: [occupied] }, sandbox)).toThrow( + 'already configured for another class', + ); + expect(() => + assertSandboxNamespaceConfig( + { migrations: [{ tag: 'custom', new_sqlite_classes: ['SandboxV1'] }, occupied] }, + sandbox, + ), + ).not.toThrow(); + }); + it('preserves a custom renamed SQLite class with an independently occupied reserved tag', () => { + const history = [ + { tag: 'custom-v1', new_sqlite_classes: ['OriginalSandbox'] }, + { tag: 'custom-v2', renamed_classes: [{ from: 'OriginalSandbox', to: 'SandboxV1' }] }, + occupied, + ]; + expect(hasSandboxSqliteMigration(history, 'SandboxV1')).toBe(true); + expect(() => assertSandboxNamespaceConfig({ migrations: history }, sandbox)).not.toThrow(); + }); + it('distinguishes legacy storage and classes renamed away or deleted', () => { + expect(hasSandboxSqliteMigration([{ new_classes: ['SandboxV1'] }], 'SandboxV1')).toBe(false); + expect( + hasSandboxSqliteMigration( + [ + { new_sqlite_classes: ['SandboxV1'] }, + { renamed_classes: [{ from: 'SandboxV1', to: 'Archived' }] }, + ], + 'SandboxV1', + ), + ).toBe(false); + expect( + hasSandboxSqliteMigration( + [{ new_sqlite_classes: ['SandboxV1'] }, { deleted_classes: ['SandboxV1'] }], + 'SandboxV1', + ), + ).toBe(false); + expect(() => + assertSandboxNamespaceConfig( + { migrations: [{ tag: 'custom', new_classes: ['SandboxV1'] }, occupied] }, + sandbox, + ), + ).toThrow('needs SQLite storage'); + }); + it.each([ + [{ tag: 'legacy', new_classes: ['SandboxV1'] }], + [ + { + tag: 'transfer', + transferred_classes: [{ from_script: 'foreign', from: 'Original', to: 'SandboxV1' }], + }, + ], + [{ tag: 'rename', renamed_classes: [{ from: 'Unknown', to: 'SandboxV1' }] }], + ])( + 'rejects an existing non-SQLite or unknown storage namespace before appending a duplicate class', + (migrations) => { + expect(() => assertSandboxNamespaceConfig({ migrations: [migrations] }, sandbox)).toThrow( + 'needs SQLite storage', + ); + }, + ); + it('rejects external SDK namespace reuse while retaining unrelated external bindings', () => { + expect(() => + assertSandboxNamespaceConfig( + { + durable_objects: { + bindings: [{ name: 'SANDBOX', class_name: 'SandboxV1', script_name: 'foreign' }], + }, + }, + sandbox, + ), + ).toThrow('local'); + expect(() => + assertSandboxNamespaceConfig( + { + durable_objects: { + bindings: [{ name: 'OTHER', class_name: 'Foreign', script_name: 'foreign' }], + }, + }, + sandbox, + ), + ).not.toThrow(); + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index da9b5475c..bea46be69 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -643,6 +643,38 @@ describe('syncWranglerBindings', () => { expect(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf8')).toBe(original); }); + it('preserves custom Sandbox SQLite rename history even when the reserved tag belongs to another class', () => { + const migrations = [ + { tag: 'custom-v1', new_sqlite_classes: ['OriginalSandbox'] }, + { tag: 'custom-v2', renamed_classes: [{ from: 'OriginalSandbox', to: 'SandboxV1' }] }, + { tag: 'void-sandbox-v2', new_sqlite_classes: ['Unrelated'] }, + ]; + setup('wrangler.jsonc', JSON.stringify({ name: 'custom', migrations })); + expect(syncWranglerBindings(tmpDir, { ...noBindings, needsSandbox: true })).toBe(true); + const parsed = JSON.parse(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf8')); + expect(parsed.migrations).toEqual(migrations); + expect(parsed.durable_objects.bindings).toEqual([{ name: 'SANDBOX', class_name: 'SandboxV1' }]); + }); + + it.each([ + { tag: 'void-sandbox-v2', new_sqlite_classes: ['Unrelated'] }, + { tag: 'legacy', new_classes: ['SandboxV1'] }, + { + tag: 'transfer', + transferred_classes: [{ from_script: 'foreign', from: 'Original', to: 'SandboxV1' }], + }, + ])( + 'refuses an incompatible Sandbox migration before writing any generated config', + (migration) => { + const original = JSON.stringify({ name: 'incompatible', migrations: [migration] }); + setup('wrangler.jsonc', original); + expect(() => syncWranglerBindings(tmpDir, { ...noBindings, needsSandbox: true })).toThrow( + /another class|needs SQLite storage/, + ); + expect(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf8')).toBe(original); + }, + ); + it('appends typed Durable Object migrations when new files sort earlier', () => { setup('wrangler.jsonc', '{ "name": "test" }'); const zeta = { From b3f810a9877c1996518d2bc827d02df8943119c7 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 04:20:52 +0800 Subject: [PATCH 07/10] fix: preserve pinned Cloudflare bindings and Sandbox uploads --- packages/void/src/cli/cf-version-api.ts | 644 ++++++++++++++- packages/void/src/cli/cf-version-runner.ts | 83 +- .../src/cli/cloudflare-operation-process.ts | 36 +- .../void/test/unit/cf-version-runner.test.ts | 770 +++++++++++++++++- .../unit/cloudflare-operation-process.test.ts | 151 ++++ 5 files changed, 1664 insertions(+), 20 deletions(-) diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index da013eff9..1b1a2284a 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -1,16 +1,34 @@ +import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; +import { setTimeout as delay } from 'node:timers/promises'; +import { isDeepStrictEqual } from 'node:util'; import { getWorkerAssetsDir, getWorkerBundleDir, readBuildOutput, } from '@cloudflare/build-output-utils'; +import type { CloudflareCommandResult } from '@void/deploy-cloudflare'; import ignore from 'ignore'; import mime from 'mime'; import { extname, join, relative } from 'pathslash'; import type { CfBuildOutput } from './cf-build-output.ts'; type Json = Record<string, unknown>; +type ContainerCommand = ( + args: Array<string>, + options: { cwd: string; capture: boolean; timeoutMs?: number; maxOutputBytes?: number }, +) => CloudflareCommandResult | Promise<CloudflareCommandResult>; + +class CloudflareApiError extends Error { + readonly status: number; + readonly code?: number; + constructor(message: string, status: number, code?: number) { + super(message); + this.status = status; + this.code = code; + } +} function object(value: unknown): Json | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : null; @@ -108,6 +126,101 @@ function bindingsFromOutput( : {}), }); break; + case 'unsafe:durable_object_namespace': + // The config translator uses this exact local form for Container DOs. + // Do not turn arbitrary unsafe bindings into upload metadata. + if (Object.keys(value).some((key) => !['type', 'class_name', 'dev'].includes(key))) { + throw new Error(`Cloudflare binding ${name} is not a verified local Durable Object.`); + } + bindings.push({ + name, + type: 'durable_object_namespace', + class_name: requiredString(value.class_name, name), + }); + break; + case 'queue': + bindings.push({ + name, + type: 'queue', + queue_name: requiredString(value.name, name), + ...(value.deliveryDelay === undefined ? {} : { delivery_delay: value.deliveryDelay }), + }); + break; + case 'ai': + case 'browser': + case 'images': + bindings.push({ name, type: value.type }); + break; + case 'hyperdrive': + bindings.push({ name, type: 'hyperdrive', id: requiredString(value.id, name) }); + break; + case 'worker': + bindings.push({ + name, + type: 'service', + service: requiredString(value.worker, name), + ...(value.exportName === undefined ? {} : { entrypoint: value.exportName }), + ...(value.props === undefined ? {} : { props: value.props }), + }); + break; + case 'workflow': + bindings.push({ + name, + type: 'workflow', + workflow_name: requiredString(value.name, name), + ...(value.worker === workerName + ? { class_name: requiredString(value.exportName, name) } + : { script_name: requiredString(value.worker, name) }), + }); + break; + case 'send-email': + bindings.push({ + name, + type: 'send_email', + ...(value.destinationAddress === undefined + ? {} + : { destination_address: value.destinationAddress }), + ...(value.allowedDestinationAddresses === undefined + ? {} + : { allowed_destination_addresses: value.allowedDestinationAddresses }), + ...(value.allowedSenderAddresses === undefined + ? {} + : { allowed_sender_addresses: value.allowedSenderAddresses }), + }); + break; + case 'analytics-engine-dataset': + bindings.push({ + name, + type: 'analytics_engine', + ...(value.name === undefined ? {} : { dataset: value.name }), + }); + break; + case 'vectorize': + bindings.push({ + name, + type: 'vectorize', + index_name: requiredString(value.name, name), + }); + break; + case 'dispatch-namespace': { + const outbound = object(value.outbound); + bindings.push({ + name, + type: 'dispatch_namespace', + namespace: requiredString(value.namespace, name), + ...(outbound + ? { + outbound: { + worker: { service: requiredString(outbound.worker, name) }, + ...(Array.isArray(outbound.parameters) + ? { params: outbound.parameters.map((parameter) => ({ name: parameter })) } + : {}), + }, + } + : {}), + }); + break; + } case 'assets': bindings.push({ name, type: 'assets' }); break; @@ -132,13 +245,46 @@ function exportsFromOutput(exports: unknown): Json | undefined { const result: Json = {}; for (const [name, raw] of Object.entries(entries)) { const value = object(raw); + if (value?.type === 'worker') { + result[name] = { + type: 'worker', + ...(value.cache === undefined ? {} : { cache: value.cache }), + }; + continue; + } if (!value || value.type !== 'durable-object') { throw new Error(`Cloudflare export ${name} cannot be uploaded with pinned secrets.`); } if (value.state === 'deleted') { result[name] = { type: 'durable-object', state: 'deleted' }; + } else if (value.state === 'renamed' || value.state === 'transferred') { + const renamed = value.state === 'renamed'; + result[name] = { + type: 'durable-object', + state: value.state, + [renamed ? 'renamed_to' : 'transferred_to']: requiredString( + value[renamed ? 'renamedTo' : 'transferredTo'], + name, + ), + }; } else if (value.storage === 'sqlite' || value.storage === 'legacy-kv') { - result[name] = { type: 'durable-object', storage: value.storage, state: 'created' }; + if ( + value.state !== undefined && + !['created', 'expecting-transfer'].includes(String(value.state)) + ) { + throw new Error(`Cloudflare Durable Object export ${name} has an unsupported state.`); + } + result[name] = { + type: 'durable-object', + storage: value.storage, + state: value.state ?? 'created', + ...(value.container === undefined + ? {} + : { container: requiredString(value.container, name) }), + ...(value.state === 'expecting-transfer' + ? { transfer_from: requiredString(value.transferFrom, name) } + : {}), + }; } else { throw new Error(`Cloudflare Durable Object export ${name} has no storage type.`); } @@ -195,12 +341,456 @@ async function apiResult( if (!response.ok || parsed?.success !== true || !result) { const error = Array.isArray(parsed?.errors) ? object(parsed.errors[0]) : null; const code = Number.isSafeInteger(error?.code) ? ` [code: ${error!.code}]` : ''; - const message = typeof error?.message === 'string' ? ` ${error.message}` : ''; - throw new Error(`Cloudflare version upload failed (${response.status})${code}.${message}`); + throw new CloudflareApiError( + `Cloudflare version upload failed (${response.status})${code}.`, + response.status, + typeof error?.code === 'number' ? error.code : undefined, + ); } return result; } +type ContainerPlan = { name: string; className: string; images: Json; settings: Json }; + +function assertContainerApplicationIdentity( + application: Json, + plan: ContainerPlan, + namespace: string, +): void { + if ( + application.id !== namespace || + application.name !== plan.name || + application.scheduling_policy !== 'durable_object' || + object(application.durable_objects)?.namespace_id !== namespace + ) { + throw new Error( + `Cloudflare Container ${plan.name} application identity does not match the uploaded namespace.`, + ); + } +} + +async function preflightContainerApplications( + plans: Array<ContainerPlan>, + bindings: Array<unknown>, + accountUrl: string, + workerName: string, + token: string, + verifyUploader: () => boolean, +): Promise<void> { + for (const plan of plans) { + const matches = bindings + .map(object) + .filter( + (binding) => + binding?.type === 'durable_object_namespace' && + binding.class_name === plan.className && + (binding.script_name === undefined || binding.script_name === workerName) && + binding.environment === undefined && + binding.dispatch_namespace === undefined && + binding.namespace_id !== undefined, + ); + const namespaces = new Set(matches.map((binding) => binding!.namespace_id)); + if (namespaces.size > 1) { + throw new Error(`Cloudflare Container ${plan.name} active namespace was ambiguous.`); + } + const namespace = [...namespaces][0]; + if (namespace === undefined) { + if (Object.keys(plan.images).length === 0) { + throw new Error( + `Cloudflare Container ${plan.name} has no images and no verified existing namespace application.`, + ); + } + continue; + } + if (typeof namespace !== 'string' || !/^[a-f0-9]{32}$/.test(namespace)) { + throw new Error('The active Container namespace ID was invalid.'); + } + try { + const application = await apiResult( + `${accountUrl}/containers/applications/${encodeURIComponent(namespace)}`, + token, + 'GET', + undefined, + verifyUploader, + ); + assertContainerApplicationIdentity(application, plan, namespace); + } catch (cause) { + if ( + !(cause instanceof CloudflareApiError) || + cause.status !== 404 || + Object.keys(plan.images).length === 0 + ) { + throw cause; + } + } + } +} + +function containerPlansFromOutput( + containers: Awaited<ReturnType<typeof readBuildOutput>>['containers'], + exports: Json | undefined, + bindings: Array<Json>, +): Array<ContainerPlan> { + const plans: Array<ContainerPlan> = []; + for (const { config } of containers) { + if (config.schedulingPolicy !== 'durable-object') { + throw new Error( + 'Scheduler-managed Cloudflare Containers cannot be uploaded with pinned secrets.', + ); + } + const classes = Object.entries(exports ?? {}).filter( + ([, value]) => object(value)?.container === config.name, + ); + if (classes.length !== 1) { + throw new Error(`Cloudflare Container ${config.name} needs exactly one local SQLite export.`); + } + const [className, raw] = classes[0]; + const declaration = object(raw)!; + const localBindings = bindings.filter( + (binding) => + binding.type === 'durable_object_namespace' && + binding.class_name === className && + binding.script_name === undefined, + ); + if (declaration.storage !== 'sqlite' || localBindings.length === 0) { + throw new Error( + `Cloudflare Container ${config.name} needs a local SQLite Durable Object binding.`, + ); + } + const unsafe = object(config.unsafe); + const configuration = object(unsafe?.configuration); + if ( + (unsafe && Object.keys(unsafe).some((key) => key !== 'configuration')) || + (configuration && Object.keys(configuration).some((key) => key !== 'experimental_flags')) + ) { + throw new Error(`Cloudflare Container ${config.name} has an unsupported setting.`); + } + const settings: Json = { + ...(configuration || config.ssh || config.authorizedKeys + ? { + configuration: { + ...(configuration ?? {}), + ...(config.ssh ? { wrangler_ssh: config.ssh } : {}), + ...(config.authorizedKeys + ? { + authorized_keys: config.authorizedKeys.map(({ name, publicKey }) => ({ + name, + public_key: publicKey, + })), + } + : {}), + }, + } + : {}), + ...(config.observability + ? { + observability: { + logs: { + enabled: + config.observability.enabled === true || + config.observability.logs?.enabled === true, + }, + }, + } + : {}), + }; + plans.push({ name: config.name, className, images: config.images ?? {}, settings }); + } + for (const [name, raw] of Object.entries(exports ?? {})) { + const container = object(raw)?.container; + if ( + container !== undefined && + !plans.some((plan) => plan.name === container && plan.className === name) + ) { + throw new Error( + `Cloudflare export ${name} references a Container outside the verified output.`, + ); + } + } + return plans; +} + +async function dockerImageDigest( + image: string, + timeout: number, + verifyUploader: () => boolean, +): Promise<string> { + if (!verifyUploader()) { + throw new Error('The Cloudflare uploader changed during deployment.'); + } + const stdout = await new Promise<string>((resolve, reject) => { + execFile( + process.env.WRANGLER_DOCKER_BIN ?? 'docker', + ['image', 'inspect', '--format', '{{json .RepoDigests}}', image], + { + encoding: 'utf8', + timeout, + maxBuffer: 1024 * 1024, + windowsHide: true, + }, + (error, stdout) => + error + ? reject(new Error('Cloudflare Container image digest could not be verified after push.')) + : resolve(stdout), + ); + }); + const digests: unknown = JSON.parse(stdout); + const repository = image.replace(/:[^/:]+$/, ''); + const matching = Array.isArray(digests) + ? digests.filter( + (digest) => + typeof digest === 'string' && + digest.startsWith(`${repository}@sha256:`) && + /^[a-f0-9]{64}$/.test(digest.slice(digest.lastIndexOf(':') + 1)), + ) + : []; + if (matching.length !== 1) { + throw new Error('Cloudflare Container image digest could not be verified after push.'); + } + return matching[0] as string; +} + +async function prepareContainerMetadata(options: { + plans: Array<ContainerPlan>; + directory: string; + accountUrl: string; + accountId: string; + token: string; + verifyUploader: () => boolean; + runCf?: ContainerCommand; +}): Promise<Array<Json>> { + if (options.plans.length === 0) { + return []; + } + const deadline = Date.now() + 15 * 60_000; + const remaining = () => { + const duration = deadline - Date.now(); + if (duration <= 0) { + throw new Error('Cloudflare Container image preparation timed out.'); + } + return duration; + }; + const prepared = new Map<string, string>(); + const result: Array<Json> = []; + for (const plan of options.plans) { + const images: Json = {}; + for (const [name, raw] of Object.entries(plan.images)) { + const source = object(raw)!; + const reference = requiredString( + source.localReference ?? source.reference, + `Container ${plan.name} image`, + ); + let image = prepared.get(reference); + if (!image) { + image = reference; + if (source.localReference !== undefined) { + if (!options.runCf || !options.verifyUploader()) { + throw new Error('The verified Cloudflare Container image uploader is unavailable.'); + } + const pushed = await options.runCf(['containers', 'push', '--tag', reference], { + cwd: options.directory, + capture: true, + timeoutMs: remaining(), + maxOutputBytes: 16 * 1024 * 1024, + }); + if (pushed.status !== 0 || pushed.signal || pushed.error) { + throw new Error('Cloudflare Container image push failed.'); + } + const matches = [...pushed.stdout.matchAll(/Pushed image:\s*(\S+)/g)]; + const remote = matches.length === 1 ? matches[0][1] : ''; + if ( + !remote.startsWith(`registry.cloudflare.com/${options.accountId}/`) || + [...remote].some( + (character) => character.charCodeAt(0) <= 32 || character.charCodeAt(0) === 127, + ) + ) { + throw new Error( + 'Cloudflare Container image push did not return the verified account registry.', + ); + } + image = await dockerImageDigest( + remote, + Math.min(120_000, remaining()), + options.verifyUploader, + ); + } + for (;;) { + const ready = await apiResult( + `${options.accountUrl}/containers/image-preparations`, + options.token, + 'POST', + { image }, + options.verifyUploader, + Math.min(120_000, remaining()), + ); + if (ready.image !== image) { + throw new Error('Cloudflare Container image preparation returned another image.'); + } + if (ready.status === 'ready') { + break; + } + if (ready.status !== 'pending') { + throw new Error( + 'Cloudflare Container image preparation failed or returned an unsupported state.', + ); + } + await delay(Math.min(2_000, remaining())); + } + prepared.set(reference, image); + } + images[name] = image; + } + result.push({ + name: plan.name, + class_name: plan.className, + ...(Object.keys(images).length ? { images } : {}), + }); + } + return result; +} + +async function ensureContainerApplications(options: { + plans: Array<ContainerPlan>; + containers: Array<Json>; + accountUrl: string; + workerName: string; + versionId: string; + token: string; + verifyUploader: () => boolean; +}): Promise<void> { + if (options.plans.length === 0) { + return; + } + const deadline = Date.now() + 60_000; + let version: Json; + for (;;) { + try { + version = await apiResult( + `${options.accountUrl}/workers/scripts/${encodeURIComponent(options.workerName)}/versions/${encodeURIComponent(options.versionId)}`, + options.token, + 'GET', + undefined, + options.verifyUploader, + Math.max(1, Math.min(30_000, deadline - Date.now())), + ); + break; + } catch (cause) { + if ( + !(cause instanceof CloudflareApiError) || + cause.code !== 100146 || + Date.now() >= deadline + ) { + throw cause; + } + await delay(Math.min(2_000, deadline - Date.now())); + } + } + const versionBindings = object(version.resources)?.bindings; + if (version.id !== options.versionId || !Array.isArray(versionBindings)) { + throw new Error('The uploaded Container namespace could not be verified.'); + } + const metadata = object(object(version.resources)?.script_runtime)?.containers; + if ( + !Array.isArray(metadata) || + metadata.length !== options.containers.length || + options.containers.some((expected) => { + const matching = metadata + .map(object) + .filter( + (entry) => + entry !== null && + entry.name === expected.name && + entry.class_name === expected.class_name, + ); + return ( + matching.length !== 1 || + !isDeepStrictEqual(matching[0]!.images ?? {}, expected.images ?? {}) + ); + }) + ) { + throw new Error( + 'The uploaded Worker version did not retain the verified Container image metadata.', + ); + } + for (const plan of options.plans) { + const matches = versionBindings + .map(object) + .filter( + (binding) => + binding?.type === 'durable_object_namespace' && + binding.class_name === plan.className && + (binding.script_name === undefined || binding.script_name === options.workerName) && + binding.environment === undefined && + binding.dispatch_namespace === undefined, + ); + const namespaces = new Set(matches.map((binding) => binding!.namespace_id)); + if (matches.length === 0 || namespaces.size !== 1) { + throw new Error( + `Cloudflare Container ${plan.name} has no unambiguous uploaded local namespace.`, + ); + } + const namespace = requiredString([...namespaces][0], `Container ${plan.name} namespace`); + if (!/^[a-f0-9]{32}$/.test(namespace)) { + throw new Error('The uploaded Container namespace ID was invalid.'); + } + const url = `${options.accountUrl}/containers/applications/${encodeURIComponent(namespace)}`; + let application: Json; + try { + application = await apiResult(url, options.token, 'GET', undefined, options.verifyUploader); + } catch (cause) { + if (!(cause instanceof CloudflareApiError) || cause.status !== 404) { + throw cause; + } + application = await apiResult( + `${options.accountUrl}/containers/applications`, + options.token, + 'POST', + { + name: plan.name, + scheduling_policy: 'durable_object', + durable_objects: { namespace_id: namespace }, + ...plan.settings, + }, + options.verifyUploader, + ); + } + assertContainerApplicationIdentity(application, plan, namespace); + const changed = Object.entries(plan.settings).some( + ([key, value]) => !isDeepStrictEqual(application[key], value), + ); + if (changed) { + const updated = await apiResult( + url, + options.token, + 'PATCH', + plan.settings, + options.verifyUploader, + ); + assertContainerApplicationIdentity(updated, plan, namespace); + if ( + Object.entries(plan.settings).some( + ([key, value]) => !isDeepStrictEqual(updated[key], value), + ) + ) { + throw new Error( + `Cloudflare Container ${plan.name} application changed during configuration.`, + ); + } + } + const confirmed = await apiResult(url, options.token, 'GET', undefined, options.verifyUploader); + assertContainerApplicationIdentity(confirmed, plan, namespace); + if ( + Object.entries(plan.settings).some( + ([key, value]) => !isDeepStrictEqual(confirmed[key], value), + ) + ) { + throw new Error( + `Cloudflare Container ${plan.name} settings were not confirmed after deployment.`, + ); + } + } +} + async function uploadAssets(options: { output: CfBuildOutput; accountUrl: string; @@ -313,6 +903,7 @@ export async function uploadPinnedCloudflareVersion(options: { secretsFilePath?: string; atomic: boolean; verifyUploader: () => boolean; + runCf?: ContainerCommand; }): Promise<{ id: string; urls: Array<string> }> { const activeSecrets = options.output.activeSecrets; if (!activeSecrets) { @@ -373,6 +964,8 @@ export async function uploadPinnedCloudflareVersion(options: { activeSecrets, explicitSecrets, ); + const exports = exportsFromOutput(config.exports); + const containerPlans = containerPlansFromOutput(built.containers, exports, bindings); // The scripts uploader normally carries forward remote vars. Preserve only // those implicit bindings from the live version, never the inactive upload. const active = await apiResult( @@ -410,7 +1003,6 @@ export async function uploadPinnedCloudflareVersion(options: { if (activeSecrets.bindings.some(({ name }) => !activeBindingNames.has(name))) { throw new Error('The active Cloudflare Worker secrets changed before upload.'); } - const exports = exportsFromOutput(config.exports); const assetConfig = assetConfigFromOutput(config.assets); if (bindings.some((binding) => binding.type === 'assets') && !options.output.assetsDirectory) { throw new Error('The Cloudflare Worker has an assets binding without verified assets.'); @@ -430,6 +1022,23 @@ export async function uploadPinnedCloudflareVersion(options: { }); } const accountUrl = `${options.apiBaseUrl.replace(/\/$/, '')}/accounts/${encodeURIComponent(options.accountId)}`; + await preflightContainerApplications( + containerPlans, + active.bindings, + accountUrl, + options.workerName, + options.token, + options.verifyUploader, + ); + const containers = await prepareContainerMetadata({ + plans: containerPlans, + directory: options.output.directory, + accountUrl, + accountId: options.accountId, + token: options.token, + verifyUploader: options.verifyUploader, + runCf: options.runCf, + }); const jwt = await uploadAssets({ output: options.output, accountUrl, @@ -448,13 +1057,38 @@ export async function uploadPinnedCloudflareVersion(options: { compatibility_flags: config.compatibilityFlags, bindings, ...(exports ? { exports } : {}), + ...(containers.length ? { containers } : {}), ...(jwt ? { assets: { jwt, ...(assetConfig ? { config: assetConfig } : {}) } } : {}), annotations: { 'workers/message': options.message ?? '' }, }, options.verifyUploader, ); + const versionId = uploaded.id; + if ( + typeof versionId !== 'string' || + !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(versionId) + ) { + throw new Error( + 'Cloudflare accepted the Worker upload but returned an invalid version identity. Read back the Worker deployment before retrying.', + ); + } + try { + await ensureContainerApplications({ + plans: containerPlans, + containers, + accountUrl, + workerName: options.workerName, + versionId, + token: options.token, + verifyUploader: options.verifyUploader, + }); + } catch { + throw new Error( + `Cloudflare Worker version ${versionId} was uploaded${options.atomic ? ' and may already be published' : ''}, but its Container applications could not be verified. Read back the Worker deployment and Container identities before retrying.`, + ); + } return { - id: requiredString(uploaded.id, 'Worker Version ID'), + id: versionId, urls: Array.isArray(uploaded.urls) ? uploaded.urls.filter((url): url is string => typeof url === 'string') : [], diff --git a/packages/void/src/cli/cf-version-runner.ts b/packages/void/src/cli/cf-version-runner.ts index 46f59eb57..122b5e376 100644 --- a/packages/void/src/cli/cf-version-runner.ts +++ b/packages/void/src/cli/cf-version-runner.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -16,7 +16,7 @@ import { uploadPinnedCloudflareVersion } from './cf-version-api.ts'; type CfCommand = ( args: Array<string>, - options: { cwd: string; capture: boolean }, + options: { cwd: string; capture: boolean; timeoutMs?: number; maxOutputBytes?: number }, ) => CloudflareCommandResult | Promise<CloudflareCommandResult>; function failed(message: string): CloudflareCommandResult { @@ -279,24 +279,66 @@ export function createCfCommand(options: { env.CLOUDFLARE_API_TOKEN = options.token; } return await new Promise<CloudflareCommandResult>((resolve) => { + const bounded = run.timeoutMs !== undefined; + const processGroup = bounded && process.platform !== 'win32'; const child = spawn(process.execPath, [cf, ...args], { cwd: run.cwd, env, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + detached: processGroup, }); const stdout: Array<Buffer> = []; const stderr: Array<Buffer> = []; let bytes = 0; let error: string | null = null; + const stop = () => { + if (processGroup && child.pid) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== 'ESRCH') { + child.kill('SIGKILL'); + } + } + } else if (bounded && process.platform === 'win32' && child.pid) { + execFile( + 'taskkill', + ['/PID', String(child.pid), '/T', '/F'], + { windowsHide: true, timeout: 10_000 }, + (cause) => { + if (cause) { + child.kill('SIGKILL'); + } + }, + ); + } else { + child.kill('SIGKILL'); + } + }; + const interrupted = () => { + error = 'Cloudflare command interrupted.'; + stop(); + }; + if (bounded) { + process.on('SIGINT', interrupted); + process.on('SIGTERM', interrupted); + } + const timer = + run.timeoutMs === undefined + ? undefined + : setTimeout(() => { + error = 'Cloudflare command timed out.'; + stop(); + }, run.timeoutMs); // Version lists contain full Worker records. Preserve the previous 256 MiB // limit while letting Void's spinner animate during the network operation. const collect = (chunks: Array<Buffer>, chunk: Buffer) => { bytes += chunk.length; - if (bytes > 256 * 1024 * 1024) { - error = 'Cloudflare command output exceeded 256 MiB.'; - child.kill(); + if (bytes > (run.maxOutputBytes ?? 256 * 1024 * 1024)) { + error = 'Cloudflare command output exceeded its verified limit.'; + stop(); } else { chunks.push(chunk); } @@ -307,6 +349,11 @@ export function createCfCommand(options: { error = cause.message; }); child.on('close', (status, signal) => { + clearTimeout(timer); + if (bounded) { + process.off('SIGINT', interrupted); + process.off('SIGTERM', interrupted); + } const output = Buffer.concat(stdout).toString('utf8'); const diagnostic = Buffer.concat(stderr).toString('utf8'); const completed = typeof status === 'number' && signal == null && error == null; @@ -421,6 +468,7 @@ export function createCfVersionRunner(options: { ...(operation.secretsFilePath ? { secretsFilePath: operation.secretsFilePath } : {}), atomic, verifyUploader: options.pinnedUpload.verifyUploader, + runCf, }); const preview = version.urls.find((url) => /^https:\/\//.test(url)); return { @@ -777,14 +825,33 @@ export function createCfVersionRunner(options: { if (!Array.isArray(allConsumers)) { return failed('The Cloudflare Queue consumers were not readable.'); } - const matching = allConsumers.filter((item) => record(item) && item.script === workerName); + const matching: Array<Record<string, unknown>> = []; + for (const item of allConsumers) { + if (!record(item)) { + return failed('The Cloudflare Queue consumers were not readable.'); + } + const name = item.script_name ?? item.script; + if ( + (item.script_name !== undefined && !safePositional(item.script_name)) || + (item.script !== undefined && !safePositional(item.script)) || + (item.script_name !== undefined && + item.script !== undefined && + item.script_name !== item.script) || + (item.type === 'worker' && name === undefined) + ) { + return failed('The Cloudflare Queue consumer Worker identity was ambiguous.'); + } + if (name === workerName) { + matching.push(item); + } + } if (matching.length === 0) { return { status: 0, stdout: '', stderr: '' }; } if ( matching.length !== 1 || - !record(matching[0]) || - typeof matching[0].consumer_id !== 'string' + !safePositional(matching[0].consumer_id) || + (matching[0].type !== undefined && matching[0].type !== 'worker') ) { return failed('The Cloudflare Queue consumer identity was ambiguous.'); } diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 58c02a37c..8633330e6 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -987,11 +987,37 @@ async function workerChanges(): Promise<Result> { if (!Array.isArray(members)) { throw new Error('Queue consumer list was malformed.'); } - for (const item of members - .map(object) - .filter((entry) => entry.script === operation.workerName)) { - if (typeof item.consumer_id !== 'string') { - throw new Error('Queue consumer ID is missing.'); + const matching = members.map(object).filter((entry) => { + const name = entry.script_name ?? entry.script; + if ( + (entry.script_name !== undefined && + (typeof entry.script_name !== 'string' || !entry.script_name)) || + (entry.script !== undefined && (typeof entry.script !== 'string' || !entry.script)) || + (entry.script_name !== undefined && + entry.script !== undefined && + entry.script_name !== entry.script) || + (entry.type === 'worker' && name === undefined) + ) { + throw new Error('Queue consumer Worker identity is ambiguous.'); + } + return name === operation.workerName; + }); + if (matching.length > 1) { + throw new Error('Queue consumer Worker identity is ambiguous.'); + } + for (const item of matching) { + if ( + typeof item.consumer_id !== 'string' || + !item.consumer_id || + item.consumer_id.startsWith('-') || + /\s/.test(item.consumer_id) || + [...item.consumer_id].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }) || + (item.type !== undefined && item.type !== 'worker') + ) { + throw new Error('Queue consumer ID or type is invalid.'); } const deleted = runCf([ 'queues', diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 2e360c008..9b604e1f8 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -7,7 +7,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Duplex } from 'node:stream'; import { fileURLToPath } from 'node:url'; -import { getWorkerConfigPath } from '@cloudflare/build-output-utils'; +import { + getWorkerConfigPath, + readBuildOutput, + writeContainerConfig, +} from '@cloudflare/build-output-utils'; +import { OutputContainerSchema } from '@cloudflare/config'; import { afterEach, expect, it, vi } from 'vite-plus/test'; import { activateCloudflareVersion, @@ -33,6 +38,8 @@ const pinnedCfVersion = JSON.parse( readFileSync(fileURLToPath(import.meta.resolve('cf/package.json')), 'utf8'), ).version as string; afterEach(() => { + vi.restoreAllMocks(); + syncBuiltinESMExports(); for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }); } @@ -232,6 +239,82 @@ it('captures a version list larger than Node’s default child-process buffer', } }); +it.runIf(process.platform !== 'win32')( + 'bounds a Container command by terminating its actual child and descendant before returning', + async () => { + const originalSpawn = childProcess.spawn; + let group: number | undefined; + const spawn = vi.spyOn(childProcess, 'spawn').mockImplementation((_file, args, options) => { + expect(args!.slice(1)).toEqual(['containers', 'push', '--tag', 'fixture:local']); + expect(options).toMatchObject({ detached: true }); + const child = originalSpawn( + process.execPath, + [ + '-e', + ` + const {spawn}=require('node:child_process'); + const descendant=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'inherit'}); + console.log(JSON.stringify({pid:process.pid,descendant:descendant.pid})); + setInterval(()=>{},1000); + `, + ], + options, + ); + group = child.pid; + return child; + }); + syncBuiltinESMExports(); + try { + const command = createCfCommand({ + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:1', + env: () => ({ PATH: process.env.PATH }), + verifyUploader: () => true, + }); + const started = Date.now(); + const result = await command(['containers', 'push', '--tag', 'fixture:local'], { + cwd: tmpdir(), + capture: true, + timeoutMs: 500, + maxOutputBytes: 1024, + }); + expect(result).toMatchObject({ + status: 1, + signal: 'SIGKILL', + error: 'Cloudflare command timed out.', + }); + expect(Date.now() - started).toBeLessThan(5_000); + const identity = JSON.parse(result.stdout.trim()) as { pid: number; descendant: number }; + expect(identity.pid).toBe(group); + for (const pid of [identity.pid, identity.descendant]) { + const deadline = Date.now() + 2_000; + for (;;) { + try { + process.kill(pid, 0); + } catch (cause) { + expect((cause as NodeJS.ErrnoException).code).toBe('ESRCH'); + break; + } + if (Date.now() >= deadline) { + throw new Error('Container command descendant survived termination.'); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + } finally { + spawn.mockRestore(); + syncBuiltinESMExports(); + if (group) { + try { + process.kill(-group, 'SIGKILL'); + } catch { + /* Already terminated. */ + } + } + } + }, +); + it('treats a verified empty deployment list as missing but rejects malformed status', async () => { const prepared = await output(); try { @@ -308,6 +391,43 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { keep_secrets: true, keep_vars: true, assets: { directory: '../client', binding: 'ASSETS', not_found_handling: 'none' }, + queues: { producers: [{ binding: 'JOBS', queue: 'jobs', delivery_delay: 7 }] }, + ai: { binding: 'AI' }, + hyperdrive: [{ binding: 'POSTGRES', id: 'a'.repeat(32) }], + services: [ + { + binding: 'SERVICE', + service: 'utility-worker', + entrypoint: 'Rpc', + props: { enabled: true }, + }, + ], + send_email: [ + { + name: 'MAIL', + allowed_destination_addresses: ['fixture@example.test'], + allowed_sender_addresses: ['sender@example.test'], + }, + ], + analytics_engine_datasets: [{ binding: 'METRICS', dataset: 'metrics' }], + vectorize: [{ binding: 'VECTORS', index_name: 'vectors' }], + browser: { binding: 'BROWSER' }, + images: { binding: 'IMAGES' }, + dispatch_namespaces: [ + { + binding: 'DISPATCH', + namespace: 'dispatch', + outbound: { service: 'outbound-worker', parameters: ['tenant'] }, + }, + ], + workflows: [ + { + name: 'external-workflow', + binding: 'WORKFLOW', + class_name: 'ExternalJob', + script_name: 'external-worker', + }, + ], }, true, ); @@ -327,6 +447,17 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { ).toBe(true); const worker = JSON.parse(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')); expect(worker.env.AUTH_SECRET).toEqual({ type: 'secret' }); + worker.exports = { + Rpc: { type: 'worker', cache: { enabled: false } }, + Departed: { type: 'durable-object', state: 'transferred', transferredTo: 'target-worker' }, + Arriving: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'sqlite', + transferFrom: 'source-worker', + }, + }; + writeFileSync(getWorkerConfigPath(prepared.directory), JSON.stringify(worker)); expect(prepared.activeSecrets).toEqual({ versionId: activeVersionId, bindings: [ @@ -400,14 +531,58 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { { name: 'KEY', type: 'inherit', version_id: activeVersionId }, { name: 'DASHBOARD_VAR', type: 'inherit', version_id: activeVersionId }, { name: 'APP_VERSION', type: 'version_metadata' }, + { name: 'JOBS', type: 'queue', queue_name: 'jobs', delivery_delay: 7 }, + { name: 'AI', type: 'ai' }, + { name: 'POSTGRES', type: 'hyperdrive', id: 'a'.repeat(32) }, + { + name: 'SERVICE', + type: 'service', + service: 'utility-worker', + entrypoint: 'Rpc', + props: { enabled: true }, + }, + { + name: 'MAIL', + type: 'send_email', + allowed_destination_addresses: ['fixture@example.test'], + allowed_sender_addresses: ['sender@example.test'], + }, + { name: 'METRICS', type: 'analytics_engine', dataset: 'metrics' }, + { name: 'VECTORS', type: 'vectorize', index_name: 'vectors' }, + { name: 'BROWSER', type: 'browser' }, + { name: 'IMAGES', type: 'images' }, + { + name: 'DISPATCH', + type: 'dispatch_namespace', + namespace: 'dispatch', + outbound: { worker: { service: 'outbound-worker' }, params: [{ name: 'tenant' }] }, + }, + { + name: 'WORKFLOW', + type: 'workflow', + workflow_name: 'external-workflow', + script_name: 'external-worker', + }, ]), ); expect(body.modules).toEqual([expect.objectContaining({ name: 'index.js' })]); + expect(body.exports).toEqual({ + Rpc: { type: 'worker', cache: { enabled: false } }, + Departed: { type: 'durable-object', state: 'transferred', transferred_to: 'target-worker' }, + Arriving: { + type: 'durable-object', + state: 'expecting-transfer', + storage: 'sqlite', + transfer_from: 'source-worker', + }, + }); expect(body.assets).toEqual({ jwt: 'completion-token', config: { not_found_handling: 'none' }, }); expect(body.bindings).toContainEqual({ name: 'ASSETS', type: 'assets' }); + expect(body.containers).toBeUndefined(); + expect(requests.some(({ url }) => url.includes('/containers/'))).toBe(false); const session = requests.find(({ url }) => url.endsWith('/assets-upload-session')); const manifest = JSON.parse(session!.body).manifest as Record<string, { hash: string }>; expect(Object.keys(manifest)).toEqual( @@ -453,6 +628,503 @@ it('uploads live secrets and multiple asset batches when the latest upload is in it('uploads live secrets and multiple asset batches for atomic publication', () => verifyPinnedUpload(true)); +it.each([ + 'unsafe binding', + 'local Workflow export', + 'scheduler Container', + 'explicit secret collision', +] as const)( + 'refuses unsupported pinned output before provider or image mutations: %s', + async (mode) => { + const prepared = + mode === 'local Workflow export' + ? await output({ + secrets: { required: ['AUTH_SECRET'] }, + workflows: [{ name: 'jobs', binding: 'JOBS', class_name: 'Job' }], + }) + : await output({ secrets: { required: ['AUTH_SECRET'] } }); + try { + expect( + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }), + ).toBe(true); + const workerPath = getWorkerConfigPath(prepared.directory); + const worker = JSON.parse(readFileSync(workerPath, 'utf8')); + if (mode === 'unsafe binding') { + worker.env.BAD = { type: 'unsafe:unknown_product', value: 'unapproved' }; + } + if (mode === 'explicit secret collision') { + worker.env.AUTH_SECRET = { type: 'ai' }; + } + writeFileSync(workerPath, JSON.stringify(worker)); + if (mode === 'scheduler Container') { + await writeContainerConfig({ + root: prepared.directory, + directoryName: 'scheduler', + config: OutputContainerSchema.parse({ + name: 'scheduler', + image: { reference: 'example.test/image:tag' }, + }), + }); + } + const secretsFilePath = + mode === 'explicit secret collision' ? join(roots.at(-1)!, 'secrets.json') : undefined; + if (secretsFilePath) { + writeFileSync(secretsFilePath, JSON.stringify({ AUTH_SECRET: 'private-secret' })); + } + const fetch = vi.spyOn(globalThis, 'fetch'); + const runCf = vi.fn(); + await expect( + uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:1', + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + secretsFilePath, + runCf, + }), + ).rejects.toThrow( + mode === 'local Workflow export' + ? 'export Job' + : mode === 'scheduler Container' + ? 'Scheduler-managed' + : mode === 'explicit secret collision' + ? 'invalid explicit value or binding' + : 'binding BAD', + ); + expect(fetch).not.toHaveBeenCalled(); + expect(runCf).not.toHaveBeenCalled(); + } finally { + prepared.dispose(); + } + }, +); + +it('refuses a new imageless Container before version publication', async () => { + const prepared = await containerOutput(); + try { + const built = await readBuildOutput(prepared.directory); + await writeContainerConfig({ + root: prepared.directory, + directoryName: 'sandbox', + config: OutputContainerSchema.parse({ ...built.containers[0].config, images: {} }), + }); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + success: true, + result: { + id: activePinnedVersion, + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + }, + }), + ); + const runCf = vi.fn(); + await expect( + uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:1', + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + runCf, + }), + ).rejects.toThrow('no images and no verified existing namespace application'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1]?.method).toBe('GET'); + expect(runCf).not.toHaveBeenCalled(); + } finally { + prepared.dispose(); + } +}); + +it('bounds pending Container image preparation before uploading a Worker version', async () => { + const prepared = await containerOutput(); + try { + let pending = false; + vi.spyOn(Date, 'now').mockImplementation(() => (pending ? 900_001 : 0)); + const requests: Array<string> = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + requests.push(String(url)); + if (String(url).endsWith('/image-preparations')) { + pending = true; + return Response.json({ + success: true, + result: { image: containerImage, status: 'pending' }, + }); + } + return Response.json({ + success: true, + result: { + id: activePinnedVersion, + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + }, + }); + }); + await expect( + uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:1', + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + }), + ).rejects.toThrow('image preparation timed out'); + expect(requests).toHaveLength(2); + expect(requests.at(-1)).toContain('/image-preparations'); + } finally { + prepared.dispose(); + } +}); + +const activePinnedVersion = '11111111-1111-4111-8111-111111111111'; +const uploadedPinnedVersion = '22222222-2222-4222-8222-222222222222'; +const containerNamespace = 'c'.repeat(32); +const containerImage = `registry.cloudflare.com/account-id/sandbox@sha256:${'d'.repeat(64)}`; + +async function containerOutput() { + const prepared = await output({ + secrets: { required: ['AUTH_SECRET'] }, + durable_objects: { bindings: [{ name: 'SANDBOX', class_name: 'Sandbox' }] }, + migrations: [ + { tag: 'history', new_sqlite_classes: ['OldRoom', 'Removed'] }, + { + tag: 'current', + new_sqlite_classes: ['Sandbox'], + renamed_classes: [{ from: 'OldRoom', to: 'Room' }], + deleted_classes: ['Removed'], + }, + ], + containers: [ + { + name: 'sandbox', + class_name: 'Sandbox', + scheduling_policy: 'durable_object', + images: { default: { image: containerImage } }, + }, + ], + }); + expect( + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: activePinnedVersion, + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }), + ).toBe(true); + return prepared; +} + +async function verifyContainerUpload( + mode: + | 'create' + | 'existing' + | 'pending' + | 'image-error' + | 'foreign' + | 'lost-readback' + | 'local' + | 'lost-image-metadata' + | 'foreign-namespace' + | 'key-order', +) { + const prepared = await containerOutput(); + const built = await readBuildOutput(prepared.directory); + const existing = ['existing', 'foreign', 'lost-readback', 'key-order'].includes(mode); + const expectedImages = + mode === 'key-order' + ? { first: containerImage, second: containerImage } + : { default: containerImage }; + await writeContainerConfig({ + root: prepared.directory, + directoryName: 'sandbox', + config: OutputContainerSchema.parse({ + ...built.containers[0].config, + observability: { logs: { enabled: true } }, + ...(mode === 'key-order' + ? { + ssh: { enabled: true, port: 2222 }, + images: { first: { reference: containerImage }, second: { reference: containerImage } }, + } + : {}), + ...(mode === 'local' + ? { images: { default: { localReference: 'void-sandbox-build:fixture' } } } + : {}), + }), + }); + const requests: Array<{ method: string; url: string; body: Record<string, unknown> }> = []; + let application: Record<string, unknown> | undefined = existing + ? { + id: containerNamespace, + name: mode === 'foreign' ? 'foreign-app' : 'sandbox', + scheduling_policy: 'durable_object', + durable_objects: { namespace_id: containerNamespace }, + observability: { logs: { enabled: mode === 'key-order' } }, + ...(mode === 'key-order' + ? { configuration: { wrangler_ssh: { port: 2222, enabled: true } } } + : {}), + } + : undefined; + let imageAttempts = 0; + let versionVisibleAttempts = 0; + let patched = false; + const server = createServer(async (request, response) => { + const chunks: Array<Buffer> = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString('utf8'); + const row = { method: request.method!, url: request.url!, body: text ? JSON.parse(text) : {} }; + requests.push(row); + let status = 200; + let result: unknown; + let code = 0; + const binding = { + name: 'SANDBOX', + type: 'durable_object_namespace', + class_name: 'Sandbox', + namespace_id: containerNamespace, + }; + if (row.url.endsWith(`/versions/${activePinnedVersion}`)) { + result = { + id: activePinnedVersion, + bindings: [ + { name: 'AUTH_SECRET', type: 'secret_text' }, + ...(existing ? [{ ...binding, script_name: 'void-test' }] : []), + ], + }; + } else if (row.url.endsWith('/image-preparations')) { + result = { + image: row.body.image, + status: + mode === 'image-error' + ? 'error' + : mode === 'pending' && ++imageAttempts === 1 + ? 'pending' + : 'ready', + }; + } else if (row.method === 'POST' && row.url.includes('/workers/workers/void-test/versions')) { + result = { id: uploadedPinnedVersion }; + } else if (row.url.endsWith(`/scripts/void-test/versions/${uploadedPinnedVersion}`)) { + if (mode === 'pending' && ++versionVisibleAttempts === 1) { + status = 404; + code = 100146; + } else { + result = { + id: uploadedPinnedVersion, + resources: { + bindings: + mode === 'foreign-namespace' + ? [{ ...binding, script_name: 'foreign-worker' }] + : [binding], + script_runtime: { + containers: + mode === 'lost-image-metadata' + ? [{ class_name: 'Sandbox' }] + : [ + { + name: 'sandbox', + class_name: 'Sandbox', + images: + mode === 'key-order' + ? { second: containerImage, first: containerImage } + : expectedImages, + }, + ], + }, + }, + }; + } + } else if (row.url.endsWith(`/applications/${containerNamespace}`)) { + if (!application) { + status = 404; + } else if (row.method === 'PATCH') { + application = { ...application, ...row.body }; + patched = true; + result = application; + } else { + result = + mode === 'lost-readback' && patched + ? { ...application, durable_objects: { namespace_id: 'f'.repeat(32) } } + : application; + } + } else if (row.url.endsWith('/applications') && row.method === 'POST') { + application = { ...row.body, id: containerNamespace }; + result = application; + } else { + status = 500; + } + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: status === 200, + result: result ?? null, + errors: status === 200 ? [] : [{ code, message: 'private-provider-detail' }], + }), + ); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const runCf = vi.fn(() => ({ + status: 0, + stdout: 'Pushed image: registry.cloudflare.com/account-id/sandbox:fixture\n', + stderr: '', + })); + const inspect = + mode === 'local' + ? vi + .spyOn(childProcess, 'execFile') + .mockImplementation((_file, _args, _options, callback) => { + (callback as (error: null, stdout: string, stderr: string) => void)( + null, + JSON.stringify([containerImage]), + '', + ); + return {} as ReturnType<typeof execFile>; + }) + : undefined; + syncBuiltinESMExports(); + try { + const upload = uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`, + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + runCf, + }); + if (mode === 'image-error' || mode === 'foreign') { + await expect(upload).rejects.toThrow( + mode === 'image-error' ? 'image preparation failed' : 'application identity', + ); + expect( + requests.some( + (row) => row.method === 'POST' && row.url.includes('/workers/workers/void-test/versions'), + ), + ).toBe(false); + } else if (['lost-readback', 'lost-image-metadata', 'foreign-namespace'].includes(mode)) { + await expect(upload).rejects.toThrow( + `version ${uploadedPinnedVersion} was uploaded and may already be published`, + ); + expect(requests.filter((row) => row.method === 'PATCH')).toHaveLength( + mode === 'lost-readback' ? 1 : 0, + ); + if (mode !== 'lost-readback') { + expect(requests.some((row) => row.url.includes('/applications'))).toBe(false); + } + } else { + expect((await upload).id).toBe(uploadedPinnedVersion); + const post = requests.find( + (row) => row.method === 'POST' && row.url.includes('/workers/workers/void-test/versions'), + )!; + expect(post.body.bindings).toContainEqual({ + name: 'AUTH_SECRET', + type: 'inherit', + version_id: activePinnedVersion, + }); + expect(post.body.bindings).toContainEqual({ + name: 'SANDBOX', + type: 'durable_object_namespace', + class_name: 'Sandbox', + }); + expect(post.body.containers).toEqual([ + { name: 'sandbox', class_name: 'Sandbox', images: expectedImages }, + ]); + expect(post.body.exports).toEqual({ + OldRoom: { type: 'durable-object', state: 'renamed', renamed_to: 'Room' }, + Room: { type: 'durable-object', storage: 'sqlite', state: 'created' }, + Removed: { type: 'durable-object', state: 'deleted' }, + Sandbox: { + type: 'durable-object', + storage: 'sqlite', + state: 'created', + container: 'sandbox', + }, + }); + expect(requests.findIndex((row) => row.url.endsWith('/image-preparations'))).toBeLessThan( + requests.indexOf(post), + ); + const creates = requests.filter( + (row) => row.method === 'POST' && row.url.endsWith('/applications'), + ); + expect(creates).toHaveLength(existing ? 0 : 1); + if (!existing) { + expect(creates[0].body).toEqual({ + name: 'sandbox', + scheduling_policy: 'durable_object', + durable_objects: { namespace_id: containerNamespace }, + observability: { logs: { enabled: true } }, + }); + } else { + expect(requests.filter((row) => row.method === 'PATCH')).toHaveLength( + mode === 'key-order' ? 0 : 1, + ); + } + expect(requests.at(-1)?.method).toBe('GET'); + if (mode === 'pending') { + expect(imageAttempts).toBe(2); + expect(versionVisibleAttempts).toBe(2); + } + if (mode === 'local') { + expect(runCf).toHaveBeenCalledWith( + ['containers', 'push', '--tag', 'void-sandbox-build:fixture'], + expect.objectContaining({ + cwd: prepared.directory, + capture: true, + timeoutMs: expect.any(Number), + maxOutputBytes: 16 * 1024 * 1024, + }), + ); + expect(inspect).toHaveBeenCalledWith( + expect.any(String), + [ + 'image', + 'inspect', + '--format', + '{{json .RepoDigests}}', + 'registry.cloudflare.com/account-id/sandbox:fixture', + ], + expect.objectContaining({ timeout: expect.any(Number) }), + expect.any(Function), + ); + } else { + expect(runCf).not.toHaveBeenCalled(); + } + } + } finally { + inspect?.mockRestore(); + syncBuiltinESMExports(); + prepared.dispose(); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +} + +it.each([ + 'create', + 'existing', + 'pending', + 'image-error', + 'foreign', + 'lost-readback', + 'local', + 'lost-image-metadata', + 'foreign-namespace', + 'key-order', +] as const)('preserves pinned Sandbox image and application lifecycle: %s', verifyContainerUpload); + it.each([false, true])( 'publishes the intended custom domain before pinned-secret bootstrap readiness (atomic=%s)', async (atomic) => { @@ -1093,6 +1765,98 @@ it.each(['restore', 'remove'] as const)( }, ); +it.each([ + { + rows: [{ type: 'worker', script_name: 'void-test', consumer_id: 'owned-id' }], + status: 0, + remove: true, + }, + { rows: [{ script: 'void-test', consumer_id: 'owned-id' }], status: 0, remove: true }, + { + rows: [ + { type: 'worker', script_name: 'void-test', script: 'foreign', consumer_id: 'owned-id' }, + ], + status: 1, + remove: false, + }, + { + rows: [{ type: 'worker', script_name: null, script: 'void-test', consumer_id: 'owned-id' }], + status: 1, + remove: false, + }, + { + rows: [{ type: 'worker', script_name: '', consumer_id: 'owned-id' }], + status: 1, + remove: false, + }, + { + rows: [{ type: 'worker', script_name: 'void-test', consumer_id: '' }], + status: 1, + remove: false, + }, + { + rows: [{ type: 'http_pull', script_name: 'void-test', consumer_id: 'owned-id' }], + status: 1, + remove: false, + }, + { rows: [{ type: 'worker', consumer_id: 'owned-id' }], status: 1, remove: false }, + { + rows: [ + { type: 'worker', script_name: 'void-test', consumer_id: 'one' }, + { type: 'worker', script_name: 'void-test', consumer_id: 'two' }, + ], + status: 1, + remove: false, + }, + { + rows: [ + { type: 'worker', script_name: 'foreign', consumer_id: 'foreign-id' }, + { type: 'http_pull', consumer_id: 'pull-id' }, + ], + status: 0, + remove: false, + }, +])( + 'removes only an unambiguous Queue consumer Worker identity: $rows', + async ({ rows, status, remove }) => { + const prepared = await output(); + const calls: Array<Array<string>> = []; + try { + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + runCf: (args) => { + calls.push(args); + return { + status: 0, + stdout: JSON.stringify( + args[1] === 'list' + ? [{ queue_name: 'jobs', queue_id: 'queue-id' }] + : args[2] === 'list' + ? rows + : {}, + ), + stderr: '', + }; + }, + }); + const result = await runner( + { type: 'queues.consumer.remove', queueName: 'jobs', workerName: 'void-test' }, + { capture: true }, + ); + expect(result.status).toBe(status); + expect(calls.filter((args) => args[2] === 'delete')).toEqual( + remove + ? [['queues', 'consumers', 'delete', 'owned-id', '--queue-id', 'queue-id', '--force']] + : [], + ); + } finally { + prepared.dispose(); + } + }, +); + it('uses cf for Queue triggers, consumer cleanup, and version binding reads', async () => { const prepared = await output(); const triggerConfig = join(roots.at(-1)!, 'legacy-trigger.json'); @@ -1118,7 +1882,9 @@ it('uses cf for Queue triggers, consumer cleanup, and version binding reads', as if (args[0] === 'queues' && args[1] === 'consumers' && args[2] === 'list') { return { status: 0, - stdout: JSON.stringify([{ script: 'void-test', consumer_id: 'consumer-id' }]), + stdout: JSON.stringify([ + { type: 'worker', script_name: 'void-test', consumer_id: 'consumer-id' }, + ]), stderr: '', }; } diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index e6763c048..39b76149e 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -471,6 +471,157 @@ it('finds existing Queues beyond the first cf API page', async () => { } }, 30_000); +it.each([ + { + label: 'Cloudflare script_name', + consumers: [{ script_name: 'test-worker', consumer_id: 'owned-consumer', type: 'worker' }], + deleted: ['owned-consumer'], + status: 0, + }, + { + label: 'matching legacy alias', + consumers: [ + { script_name: 'test-worker', script: 'test-worker', consumer_id: 'owned-consumer' }, + ], + deleted: ['owned-consumer'], + status: 0, + }, + { + label: 'foreign Worker and HTTP pull consumers', + consumers: [ + { script_name: 'other-worker', consumer_id: 'foreign-consumer', type: 'worker' }, + { consumer_id: 'pull-consumer', type: 'http_pull' }, + ], + deleted: [], + status: 0, + }, + { + label: 'conflicting aliases', + consumers: [ + { script_name: 'other-worker', script: 'test-worker', consumer_id: 'foreign-consumer' }, + ], + deleted: [], + status: 1, + }, + { + label: 'missing Worker identity', + consumers: [{ consumer_id: 'unknown-consumer', type: 'worker' }], + deleted: [], + status: 1, + }, + { + label: 'null Worker identity', + consumers: [{ script_name: null, consumer_id: 'unknown-consumer' }], + deleted: [], + status: 1, + }, + { + label: 'missing consumer ID', + consumers: [{ script_name: 'test-worker', type: 'worker' }], + deleted: [], + status: 1, + }, + { + label: 'duplicate owned consumers', + consumers: [ + { script_name: 'test-worker', consumer_id: 'first-consumer', type: 'worker' }, + { script_name: 'test-worker', consumer_id: 'second-consumer', type: 'worker' }, + ], + deleted: [], + status: 1, + }, + { + label: 'option-shaped consumer ID', + consumers: [{ script_name: 'test-worker', consumer_id: '--help', type: 'worker' }], + deleted: [], + status: 1, + }, +])( + 'cleans up Queue consumers using verified identities: $label', + async ({ consumers, deleted, status }) => { + const directory = mkdtempSync(join(tmpdir(), 'void-cf-queue-cleanup-')); + const deletes: Array<string> = []; + const server = createServer((request, response) => { + const url = new URL(request.url ?? '', 'http://127.0.0.1'); + request.resume(); + response.setHeader('Content-Type', 'application/json'); + const queuePath = '/client/v4/accounts/test-account/queues'; + if (request.method === 'GET' && url.pathname === queuePath) { + response.end( + JSON.stringify({ + success: true, + result: [{ queue_id: 'queue-id', queue_name: 'existing-queue' }], + result_info: { page: 1, total_pages: 1 }, + }), + ); + } else if (request.method === 'GET' && url.pathname === `${queuePath}/queue-id/consumers`) { + response.end(JSON.stringify({ success: true, result: consumers })); + } else if ( + request.method === 'DELETE' && + url.pathname.startsWith(`${queuePath}/queue-id/consumers/`) + ) { + deletes.push(url.pathname.split('/').at(-1)!); + response.end(JSON.stringify({ success: true, result: {} })); + } else { + response.statusCode = 400; + response.end( + JSON.stringify({ success: false, errors: [{ message: 'Unexpected request' }] }), + ); + } + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const configPath = join(directory, 'wrangler.json'); + writeFileSync( + configPath, + JSON.stringify({ account_id: 'test-account', name: 'test-worker' }), + ); + const [command, ...prefix] = resolveCloudflareOperationArgv(directory); + const output = await new Promise<{ status: number; stdout: string; stderr: string }>( + (resolve) => { + execFile( + command!, + [ + ...prefix, + ...encodeCloudflareOperation({ + type: 'queues.consumer.remove', + queueName: 'existing-queue', + workerName: 'test-worker', + configPath, + }), + ], + { + cwd: directory, + encoding: 'utf8', + timeout: 20_000, + env: { + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + CI: 'true', + CF_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'test-account', + CLOUDFLARE_API_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`, + }, + }, + (error, stdout, stderr) => resolve({ status: error ? 1 : 0, stdout, stderr }), + ); + }, + ); + expect(output.status, output.stderr).toBe(status); + expect(deletes).toEqual(deleted); + expect(output.stderr).not.toContain('synthetic-token'); + } finally { + await new Promise<void>((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + rmSync(directory, { recursive: true, force: true }); + } + }, + 30_000, +); + it('uses pinned cf despite a project-local CLI and preserves D1 results and the CF token alias', async () => { const directory = mkdtempSync(join(tmpdir(), 'void-cf-d1-contract-')); const requests: Array<{ method: string; path: string; authorization: string | undefined }> = []; From 42dd4bd4376b76165dcc6f0015b1d8887302e4cd Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 05:09:21 +0800 Subject: [PATCH 08/10] Fix live native auth caching, WebSocket HTTP hooks, and binary logs --- packages/void/src/cli/cf-version-api.ts | 78 ++++- .../src/cli/cloudflare-operation-process.ts | 75 +++-- packages/void/src/plugin-routing.ts | 1 + packages/void/src/router/compile.ts | 29 +- .../void/src/runtime/better-auth-shared.ts | 30 +- packages/void/src/runtime/routing-pages.ts | 10 +- packages/void/src/runtime/routing-worker.ts | 57 +++- .../integration/auth-isr-cache-policy.test.ts | 101 ++++++ .../native-asset-cache-policy.test.ts | 168 ++++++++++ .../integration/typed-routing-runtime.test.ts | 22 +- .../integration/websocket-middleware.test.ts | 120 ++++++- .../void/test/unit/auth-middleware.test.ts | 81 ++++- .../void/test/unit/cf-version-runner.test.ts | 294 ++++++++++++++++-- .../unit/cloudflare-operation-process.test.ts | 158 ++++++++++ packages/void/test/unit/compile.test.ts | 48 ++- .../void/test/unit/routing-runtime.test.ts | 66 +++- .../operator-asset-migration.test.ts | 7 +- 17 files changed, 1258 insertions(+), 87 deletions(-) create mode 100644 packages/void/test/integration/auth-isr-cache-policy.test.ts create mode 100644 packages/void/test/integration/native-asset-cache-policy.test.ts diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index 1b1a2284a..997294c94 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -234,7 +234,7 @@ function bindingsFromOutput( return bindings; } -function exportsFromOutput(exports: unknown): Json | undefined { +function exportsFromOutput(exports: unknown, bindings: Array<Json>): Json | undefined { if (exports === undefined) { return undefined; } @@ -243,8 +243,20 @@ function exportsFromOutput(exports: unknown): Json | undefined { throw new Error('Cloudflare Durable Object exports were not readable.'); } const result: Json = {}; + const workflows = new Map<string, string>(); for (const [name, raw] of Object.entries(entries)) { const value = object(raw); + if (value?.type === 'workflow') { + const workflowName = requiredString(value.name, `Workflow export ${name} name`); + if (workflows.has(workflowName)) { + throw new Error(`Cloudflare Workflow ${workflowName} has multiple local exports.`); + } + workflows.set(workflowName, name); + // cf uploads identity only. Execution settings stay in the immutable + // Build Output and are applied by its separate typed trigger deployment. + result[name] = { type: 'workflow', name: workflowName }; + continue; + } if (value?.type === 'worker') { result[name] = { type: 'worker', @@ -289,9 +301,64 @@ function exportsFromOutput(exports: unknown): Json | undefined { throw new Error(`Cloudflare Durable Object export ${name} has no storage type.`); } } + for (const binding of bindings) { + if (binding.type !== 'workflow') { + continue; + } + const localExport = workflows.get(String(binding.workflow_name)); + if ( + localExport !== undefined && + (binding.script_name !== undefined || binding.class_name !== localExport) + ) { + throw new Error( + `Cloudflare Workflow binding ${binding.name} conflicts with its local export.`, + ); + } + } return result; } +async function preflightWorkflowExports(options: { + exports: Json | undefined; + accountUrl: string; + workerName: string; + token: string; + verifyUploader: () => boolean; +}): Promise<void> { + for (const raw of Object.values(options.exports ?? {})) { + const entry = object(raw); + if (entry?.type !== 'workflow') { + continue; + } + const name = requiredString(entry.name, 'Workflow name'); + let workflow: Json; + try { + workflow = await apiResult( + `${options.accountUrl}/workflows/${encodeURIComponent(name)}`, + options.token, + 'GET', + undefined, + options.verifyUploader, + ); + } catch (cause) { + if (cause instanceof CloudflareApiError && cause.status === 404 && cause.code === 10200) { + continue; + } + throw cause; + } + // The canonical uploader prompts before reassigning an account-wide name. + // The pinned path must not silently adopt another Worker's Workflow. + if ( + workflow.name !== name || + workflow.script_name !== options.workerName || + typeof workflow.class_name !== 'string' || + !workflow.class_name + ) { + throw new Error(`Cloudflare Workflow ${name} is not owned by this Worker.`); + } + } +} + function assetConfigFromOutput(value: unknown): Json | undefined { if (value === undefined) { return undefined; @@ -964,7 +1031,7 @@ export async function uploadPinnedCloudflareVersion(options: { activeSecrets, explicitSecrets, ); - const exports = exportsFromOutput(config.exports); + const exports = exportsFromOutput(config.exports, bindings); const containerPlans = containerPlansFromOutput(built.containers, exports, bindings); // The scripts uploader normally carries forward remote vars. Preserve only // those implicit bindings from the live version, never the inactive upload. @@ -1022,6 +1089,13 @@ export async function uploadPinnedCloudflareVersion(options: { }); } const accountUrl = `${options.apiBaseUrl.replace(/\/$/, '')}/accounts/${encodeURIComponent(options.accountId)}`; + await preflightWorkflowExports({ + exports, + accountUrl, + workerName: options.workerName, + token: options.token, + verifyUploader: options.verifyUploader, + }); await preflightContainerApplications( containerPlans, active.bindings, diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 8633330e6..f540c5cb7 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -1164,46 +1164,63 @@ async function tail(): Promise<Result> { } await new Promise<void>((resolve, reject) => { finish = resolve; + let pendingMessages = Promise.resolve(); socket = new WebSocket(tailUrl, 'trace-v1'); socket.addEventListener('open', () => { socket!.send(JSON.stringify({ debug: false })); }); socket.addEventListener('message', (event) => { - try { - const log = object(parsedJson(String(event.data))); - const detail = object(log.event ?? {}); - const request = object(detail.request ?? {}); - const prefix = - typeof request.url === 'string' - ? `${request.method ?? 'GET'} ${request.url}` - : typeof detail.queue === 'string' - ? `Queue ${detail.queue}` - : typeof detail.mailFrom === 'string' - ? `Email from ${detail.mailFrom}` - : 'Worker event'; - process.stdout.write( - `${prefix} - ${log.outcome ?? 'unknown'} @ ${new Date(Number(log.eventTimestamp)).toLocaleString()}\n`, - ); - if (Array.isArray(log.logs)) { - for (const line of log.logs.map(object)) { - process.stdout.write( - ` (${line.level ?? 'log'}) ${Array.isArray(line.message) ? line.message.join(' ') : ''}\n`, - ); + pendingMessages = pendingMessages + .then(async () => { + const data: unknown = event.data; + const text = + typeof data === 'string' + ? data + : data instanceof Blob + ? await data.text() + : data instanceof ArrayBuffer + ? new TextDecoder().decode(data) + : null; + if (text === null) { + throw new Error('Unsupported Cloudflare tail frame.'); } - } - if (Array.isArray(log.exceptions)) { - for (const exception of log.exceptions.map(object)) { - process.stderr.write(` ${exception.name ?? 'Error'}: ${exception.message ?? ''}\n`); + const log = object(parsedJson(text)); + const detail = object(log.event ?? {}); + const request = object(detail.request ?? {}); + const prefix = + typeof request.url === 'string' + ? `${request.method ?? 'GET'} ${request.url}` + : typeof detail.queue === 'string' + ? `Queue ${detail.queue}` + : typeof detail.mailFrom === 'string' + ? `Email from ${detail.mailFrom}` + : 'Worker event'; + process.stdout.write( + `${prefix} - ${log.outcome ?? 'unknown'} @ ${new Date(Number(log.eventTimestamp)).toLocaleString()}\n`, + ); + if (Array.isArray(log.logs)) { + for (const line of log.logs.map(object)) { + process.stdout.write( + ` (${line.level ?? 'log'}) ${Array.isArray(line.message) ? line.message.join(' ') : ''}\n`, + ); + } } - } - } catch { - process.stderr.write('Cloudflare returned a malformed tail event.\n'); - } + if (Array.isArray(log.exceptions)) { + for (const exception of log.exceptions.map(object)) { + process.stderr.write( + ` ${exception.name ?? 'Error'}: ${exception.message ?? ''}\n`, + ); + } + } + }) + .catch(() => { + process.stderr.write('Cloudflare returned a malformed tail event.\n'); + }); }); socket.addEventListener('error', () => reject(new Error('Cloudflare Worker tail connection failed.')), ); - socket.addEventListener('close', () => resolve()); + socket.addEventListener('close', () => void pendingMessages.then(resolve)); if (stopped.signal.aborted) { resolve(); } diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 2cf23fea6..1d384fcb1 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -487,6 +487,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug resolvedConfig = config; authClientPath = resolveAuthClientPath(config); compileOptions.dev = config.command === 'serve'; + compileOptions.assetsDir = config.build.assetsDir; compileOptions.reactRefresh = config.plugins.some( (plugin) => plugin.name === 'vite:react-babel' || diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 0efe36b81..c1c494274 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -85,6 +85,8 @@ export type CompileOptions = { * skips the fallback middleware entirely. */ fallbackRules?: Array<DevRedirectRule>; + /** Resolved Vite build directory for hashed client assets. */ + assetsDir?: string; /** Explicit prerender paths from void.json routing.prerender */ explicitPrerenderPaths?: Array<string>; /** Absolute path to the internal WebSocket runtime module */ @@ -434,6 +436,23 @@ export function compileRouteTable( compilePageRoutes(lines, scan, pages, options); } + // Ordinary HTTP hooks are fallbacks for WebSocket paths. Explicit API and + // Pages handlers win for their methods; upgrades were forwarded above. + for (let i = 0; i < webSockets.length; i++) { + lines.push( + `app.all(${JSON.stringify(webSockets[i].pattern)}, (c) => __forwardWebSocketRequest(c.req.raw, c.env, __voidWebSocketRoutes[${i}]));`, + ); + } + if (webSockets.length > 0) { + lines.push(''); + } + + if (pages && scan.pages.pages.length > 0) { + lines.push( + `app.notFound(__createPagesFallback({ routing: __routingRuntime, errors: __errorRenderer, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${JSON.stringify(emitEdgeRules && options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)} }));`, + ); + } + compilePrerenderCollector(lines, scan, ssr, options); if (ssr) { @@ -441,7 +460,7 @@ export function compileRouteTable( `const getClientAssetTags = __createClientAssetTags(${JSON.stringify({ entryId: ssr.clientEntryRelativePath, entryUrl: '/' + ssr.clientEntryRelativePath, kind: 'ssr', dev: options.dev === true, reactRefresh: options.reactRefresh })});`, ); lines.push( - `app.notFound(__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, fallbackRules: ${JSON.stringify(emitEdgeRules && options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags }));`, + `app.notFound(__createSsrFallback({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${JSON.stringify(emitEdgeRules && options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)}, load: () => import(${JSON.stringify(ssr.serverEntryPath)}), assets: getClientAssetTags }));`, ); } else if ( emitEdgeRules && @@ -449,7 +468,7 @@ export function compileRouteTable( !(pages && scan.pages.pages.length > 0) ) { lines.push( - `app.notFound(__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, fallbackRules: ${JSON.stringify(options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)} }));`, + `app.notFound(__createAssetNotFound({ routing: __routingRuntime, nodeTarget: ${JSON.stringify(options.nodeTarget === true)}, assetsDir: ${JSON.stringify(options.assetsDir)}, fallbackRules: ${JSON.stringify(options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)} }));`, ); } @@ -1115,12 +1134,6 @@ function compilePageRoutes( } } lines.push(''); - - const managedDispatch = - options.nodeTarget !== true && options.dev !== true && options.managedDispatch === true; - lines.push( - `app.notFound(__createPagesFallback({ routing: __routingRuntime, errors: __errorRenderer, fallbackRules: ${JSON.stringify(!managedDispatch && options.fallbackRules?.length ? (options.dev ? options.fallbackRules : stripOriginForProd(options.fallbackRules)) : undefined)} }));`, - ); } /** diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 9cafb8eac..ee5dc654c 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -213,16 +213,35 @@ function resolveSocialProviders( return Object.keys(providers).length > 0 ? providers : undefined; } -function appendAuthHeaders(c: Context, source?: Headers): void { +function appendAuthHeaders( + c: Context, + source: Headers | undefined, + includeCachePolicy: boolean, +): void { if (!source) { return; } source.forEach((value, key) => { + // getSession's response policy belongs to the session endpoint. An anonymous + // lookup must not make the application's public page or asset uncacheable. + if (!includeCachePolicy && ['cache-control', 'pragma'].includes(key.toLowerCase())) { + return; + } c.header(key, value, { append: key.toLowerCase() === 'set-cookie', }); }); + if ( + source.has('set-cookie') && + !(source.get('cache-control') ?? '') + .split(',') + .some((directive) => + ['private', 'no-store', 'no-cache'].includes(directive.trim().toLowerCase()), + ) + ) { + c.header('Cache-Control', 'private, no-store'); + } } function resolveUserConfig( @@ -391,7 +410,14 @@ export function createVoidAuthRuntime( await next(); }); - appendAuthHeaders(c, result.headers); + const credentialed = ['cookie', 'authorization', 'cf-access-jwt-assertion'].some((header) => + c.req.raw.headers.has(header), + ); + appendAuthHeaders( + c, + result.headers, + state !== null || credentialed || result.headers?.has('set-cookie') === true, + ); } finally { await dispose(); } diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 5f6b88e78..43567988e 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { handlePageGet, handlePageMutation } from '../pages/protocol.ts'; import type { ClientAssetTags } from './routing-assets.ts'; import type { ErrorRenderer } from './routing-errors.ts'; +import { applyAssetCachePolicy } from './routing-worker.ts'; import type { RoutingRule, RoutingRuntime } from './routing.ts'; type PageGetArguments = Parameters<typeof handlePageGet>; @@ -68,6 +69,7 @@ export function createPagesFallback(options: { routing: RoutingRuntime; errors: ErrorRenderer; fallbackRules?: Array<RoutingRule>; + assetsDir?: string; }): (c: Context) => Promise<Response> { return async (c) => { const path = c.req.path; @@ -84,7 +86,7 @@ export function createPagesFallback(options: { ? await assets.fetch(c.req.raw) : options.routing.markNoMatch(c.req.raw, new Response('Not Found', { status: 404 })); if (assetResponse.status !== 404) { - return assetResponse; + return applyAssetCachePolicy(c.req.raw, assetResponse, options.assetsDir); } const fallback = options.fallbackRules ? await options.routing.applyFallback(c, options.fallbackRules) @@ -117,6 +119,7 @@ export function createSsrFallback(options: { fallbackRules?: Array<RoutingRule>; load: () => Promise<SsrModule>; assets: (request: Request, env: Context['env']) => Promise<ClientAssetTags>; + assetsDir?: string; }): (c: Context) => Promise<Response> { const fetchAssets = async (c: Context): Promise<Response> => { const binding = c.env?.ASSETS; @@ -126,7 +129,7 @@ export function createSsrFallback(options: { const response: Response = await binding.fetch(c.req.raw); return response.status === 404 ? options.routing.markNoMatch(c.req.raw, new Response('Not Found', { status: 404 })) - : response; + : applyAssetCachePolicy(c.req.raw, response, options.assetsDir); }; return async (c) => { if (options.nodeTarget && options.fallbackRules) { @@ -164,6 +167,7 @@ export function createAssetNotFound(options: { routing: RoutingRuntime; nodeTarget?: boolean; fallbackRules?: Array<RoutingRule>; + assetsDir?: string; }): (c: Context) => Promise<Response> { return async (c) => { if (!options.nodeTarget) { @@ -171,7 +175,7 @@ export function createAssetNotFound(options: { if (assets && typeof assets.fetch === 'function') { const response: Response = await assets.fetch(c.req.raw); if (response.status !== 404) { - return response; + return applyAssetCachePolicy(c.req.raw, response, options.assetsDir); } } } diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index c4754ae6a..5ddad0c04 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -26,6 +26,45 @@ const ASSET_CONTENT_TYPES: Readonly<Record<string, string>> = { '.woff': 'font/woff', '.woff2': 'font/woff2', }; + +/** Apply native static policy before application middleware adds authored headers. */ +export function applyAssetCachePolicy( + request: Request, + asset: Response, + assetsDir = 'assets', +): Response { + if (!['GET', 'HEAD'].includes(request.method) || ![200, 206, 304].includes(asset.status)) { + return asset; + } + const pathname = new URL(request.url).pathname; + const filename = pathname.slice(pathname.lastIndexOf('/') + 1); + const extension = filename.slice(filename.lastIndexOf('.')).toLowerCase(); + const headers = new Headers(asset.headers); + if (!headers.has('Content-Type') && ASSET_CONTENT_TYPES[extension]) { + headers.set('Content-Type', ASSET_CONTENT_TYPES[extension]); + } + const policy = headers.get('Cache-Control'); + const directives = (policy ?? '') + .toLowerCase() + .split(',') + .map((part) => part.trim()) + .sort(); + const providerDefault = directives.join(',') === 'max-age=0,must-revalidate,public'; + const hashed = + pathname.startsWith('/' + assetsDir.replace(/^\/+|\/+$/g, '') + '/') && + /[-._][A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(filename) && + extension !== '.html' && + !(headers.get('Content-Type') ?? '').toLowerCase().includes('text/html'); + if (!headers.has('Set-Cookie') && hashed && (!policy || providerDefault)) { + headers.set('Cache-Control', 'public, max-age=31536000, immutable'); + } else if (!policy) { + headers.set('Cache-Control', 'no-store'); + } + // Cache API and fetch responses can have immutable headers. Preserve the + // original response and change only this application-owned wrapper. + return new Response(asset.body, { status: asset.status, statusText: asset.statusText, headers }); +} + export function createAssetFallback( options: AssetFallbackOptions = {}, ): (request: Request, env: RuntimeEnvironment, response: Response) => Promise<Response> { @@ -41,23 +80,7 @@ export function createAssetFallback( new Request(request.url, { method: request.method, headers: request.headers }), ); if (asset.status !== 404) { - const res = new Response(asset.body, asset); - const pathname = new URL(request.url).pathname; - if (!res.headers.get('Content-Type')) { - const filename = pathname.slice(pathname.lastIndexOf('/') + 1); - const contentType = - ASSET_CONTENT_TYPES[filename.slice(filename.lastIndexOf('.')).toLowerCase()]; - if (contentType) { - res.headers.set('Content-Type', contentType); - } - } - res.headers.set( - 'Cache-Control', - pathname.startsWith('/' + (options.assetsDir ?? 'assets') + '/') - ? 'public, max-age=31536000, immutable' - : 'no-store', - ); - return res; + return applyAssetCachePolicy(request, asset, options.assetsDir); } const accept = request.headers.get('accept') ?? ''; if (options.serveSpaFallback && accept.includes('text/html')) { diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts new file mode 100644 index 000000000..e80dcbc6f --- /dev/null +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -0,0 +1,101 @@ +import { handleIsr, type IsrCacheEnv } from '@void/isr/runtime'; +import { memoryAdapter } from 'better-auth/adapters/memory'; +import { Hono } from 'hono'; +import { describe, expect, it } from 'vite-plus/test'; +import { createVoidAuthRuntime } from '../../src/runtime/better-auth-shared.ts'; + +const env = { BETTER_AUTH_SECRET: 'local-test-secret-with-at-least-32-characters' }; +const options = { dialect: 'sqlite' as const, providers: null }; + +function fixture() { + const database = memoryAdapter({}); + const runtime = createVoidAuthRuntime( + 'sqlite', + async () => ({ database, dispose: async () => {} }), + async () => {}, + ); + const app = new Hono(); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options)); + return { app, runtime, database }; +} + +describe('real anonymous Better Auth lookup cache policy', () => { + it('does not copy the null-session lookup policy onto an ordinary response', async () => { + const { app, runtime } = fixture(); + const request = new Request('https://app.test/assets/client-abcdefgh.js'); + const { auth, dispose } = await runtime.createVoidAuth(request, env, options); + try { + const lookup = await auth.api.getSession({ + headers: request.headers, + asResponse: false, + returnHeaders: true, + }); + expect(lookup.response).toBeNull(); + expect(lookup.headers.get('cache-control')).toBe('no-store'); + expect(lookup.headers.get('pragma')).toBe('no-cache'); + expect(lookup.headers.has('set-cookie')).toBe(false); + } finally { + await dispose(); + } + app.get( + '/assets/client-abcdefgh.js', + () => + new Response('export const value = 1;', { + headers: { 'cache-control': 'public, max-age=31536000, immutable' }, + }), + ); + const response = await app.fetch(request, env); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.headers.has('pragma')).toBe(false); + await expect(response.text()).resolves.toBe('export const value = 1;'); + }); + + it('allows anonymous ISR to populate KV and serve a hit on the next request', async () => { + const { app } = fixture(); + let renders = 0; + app.get('/revalidate', (c) => c.html(`<html>render ${++renders}</html>`)); + const records = new Map<string, { value: ArrayBuffer; metadata: unknown }>(); + const cacheEnv: IsrCacheEnv = { + ISR_CACHE: { + async getWithMetadata(key: string) { + return records.get(key) ?? { value: null, metadata: null }; + }, + async put(key: string, value: ArrayBuffer, config: { metadata: unknown }) { + records.set(key, { value, metadata: config.metadata }); + }, + async list() { + return { keys: [...records.keys()].map((name) => ({ name })), list_complete: true }; + }, + } as unknown as KVNamespace, + }; + const request = new Request('https://app.test/revalidate', { + headers: { 'x-fixture-guard': 'allowed' }, + }); + const tasks: Array<Promise<unknown>> = []; + const context = { + waitUntil: (task: Promise<unknown>) => tasks.push(task), + passThroughOnException() {}, + props: {}, + } as unknown as ExecutionContext; + const render = async (input: Request): Promise<Response> => app.fetch(input, env); + const policy = { deploymentId: 'test-deployment', revalidate: { '/revalidate': 60 } }; + const first = await handleIsr(request, cacheEnv, context, policy, render); + expect(first?.headers.get('x-isr-cache')).toBe('MISS'); + await Promise.all(tasks); + expect(records.size).toBeGreaterThan(0); + const second = await handleIsr(request, cacheEnv, context, policy, render); + expect(second?.headers.get('x-isr-cache')).toBe('HIT'); + expect(renders).toBe(1); + await expect(second?.text()).resolves.toBe('<html>render 1</html>'); + }); + + it('retains the actual auth endpoint no-store and no-cache headers', async () => { + const { app, runtime } = fixture(); + app.get('/api/auth/get-session', (c) => runtime.handleVoidAuthRequest(c, options)); + const response = await app.request('https://app.test/api/auth/get-session', undefined, env); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + await expect(response.json()).resolves.toBeNull(); + }); +}); diff --git a/packages/void/test/integration/native-asset-cache-policy.test.ts b/packages/void/test/integration/native-asset-cache-policy.test.ts new file mode 100644 index 000000000..25cd0693a --- /dev/null +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -0,0 +1,168 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { build } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +const runtimeSource = (file: string) => join(import.meta.dirname, '../../src/runtime', file); +let directory: string; +let runtime: Miniflare | undefined; + +beforeAll(async () => { + directory = mkdtempSync(join(tmpdir(), 'void-native-asset-policy-')); + const assets = join(directory, 'client'); + const output = join(directory, 'output'); + mkdirSync(join(assets, 'static'), { recursive: true }); + for (const file of [ + 'client-abcdefgh.js', + 'private-abcdefgh.js', + 'client.js', + 'page-abcdefgh.html', + ]) { + writeFileSync( + join(assets, 'static', file), + file.endsWith('.html') ? '<html>asset</html>' : 'export const value = 1;', + ); + } + writeFileSync( + join(assets, '_headers'), + '/static/private-abcdefgh.js\n Cache-Control: private, no-store\n', + ); + const entry = join(directory, 'worker.ts'); + writeFileSync( + entry, + ` + import { Hono } from ${JSON.stringify(fileURLToPath(import.meta.resolve('hono')))}; + import { createPagesFallback, createSsrFallback, createAssetNotFound } from ${JSON.stringify(runtimeSource('routing-pages.ts'))}; + import { applyAssetCachePolicy, createAssetFallback } from ${JSON.stringify(runtimeSource('routing-worker.ts'))}; + import { createRoutingRuntime } from ${JSON.stringify(runtimeSource('routing.ts'))}; + import { createErrorRenderer } from ${JSON.stringify(runtimeSource('routing-errors.ts'))}; + const apps = {}; + for (const mode of ['pages', 'ssr', 'static']) { + const app = new Hono(); + const routing = createRoutingRuntime(app, {}); + app.use(routing.contextMiddleware); + app.notFound(mode === 'pages' + ? createPagesFallback({ routing, assetsDir: 'static', errors: createErrorRenderer({ projectRoot: '/fixture', homeDir: '/home' }) }) + : mode === 'ssr' + ? createSsrFallback({ routing, assetsDir: 'static', load: async () => ({ render: () => new Response('document') }), assets: async () => ({ css: '', body: '', preloads: '' }) }) + : createAssetNotFound({ routing, assetsDir: 'static' })); + apps[mode] = app; + } + const outer = createAssetFallback({ assetsDir: 'static' }); + export default { async fetch(request, env, context) { + const url = new URL(request.url); + if (url.pathname === '/cache-proof') { + const key = new Request('https://cache.test/static/client-abcdefgh.js'); + await caches.default.put(key, new Response(new Uint8Array([1, 2, 3]), { headers: { 'cache-control': 'public, max-age=600' } })); + const original = await caches.default.match(key); + let immutable = false; + try { original.headers.set('x-immutable-probe', '1'); } catch { immutable = true; } + const result = applyAssetCachePolicy(key, original, 'static'); + return Response.json({ immutable, originalContentType: original.headers.get('content-type'), resultContentType: result.headers.get('content-type'), originalPolicy: original.headers.get('cache-control'), resultPolicy: result.headers.get('cache-control'), body: [...new Uint8Array(await result.arrayBuffer())] }); + } + const mode = url.searchParams.get('mode') ?? 'pages'; + return mode === 'outer' + ? outer(request, env, new Response('not found', { status: 404 })) + : apps[mode].fetch(request, env, context); + }}; + `, + ); + await build({ + configFile: false, + logLevel: 'silent', + ssr: { noExternal: true }, + build: { + outDir: output, + emptyOutDir: true, + ssr: entry, + target: 'esnext', + rollupOptions: { + external: [/^cloudflare:/, /^node:/], + output: { entryFileNames: 'worker.mjs', codeSplitting: false }, + }, + }, + }); + runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: '2026-07-01', + compatibilityFlags: ['nodejs_compat'], + modules: true, + modulesRoot: output, + scriptPath: join(output, 'worker.mjs'), + assets: { + directory: assets, + binding: 'ASSETS', + run_worker_first: true, + routerConfig: { has_user_worker: true }, + assetConfig: { + html_handling: 'none', + not_found_handling: 'none', + }, + }, + }), + ); +}); + +afterAll(async () => { + await runtime?.dispose(); + if (directory) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('native asset cache policy in real Workers', () => { + it.each(['pages', 'ssr', 'static', 'outer'])( + 'normalizes successful native ASSETS responses in %s', + async (mode) => { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await runtime!.dispatchFetch( + `https://app.test/static/client-abcdefgh.js?mode=${mode}`, + ); + expect(response.status, response.status === 200 ? undefined : await response.text()).toBe( + 200, + ); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.headers.get('content-type')).toContain('javascript'); + await expect(response.text()).resolves.toBe('export const value = 1;'); + } + }, + ); + + it.each(['pages', 'ssr', 'static', 'outer'])( + 'preserves authored privacy, unhashed/HTML defaults and missing files in %s', + async (mode) => { + for (const [file, policy] of [ + ['private-abcdefgh.js', 'private, no-store'], + ['client.js', 'public, max-age=0, must-revalidate'], + ['page-abcdefgh.html', 'public, max-age=0, must-revalidate'], + ]) { + const response = await runtime!.dispatchFetch( + `https://app.test/static/${file}?mode=${mode}`, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe(policy); + } + const missing = await runtime!.dispatchFetch( + `https://app.test/static/missing-abcdefgh.js?mode=${mode}`, + ); + expect(missing.status).toBe(404); + expect(missing.headers.get('cache-control') ?? '').not.toContain('immutable'); + }, + ); + + it('wraps an immutable Cache API response without mutating its headers or consuming its body', async () => { + const response = await runtime!.dispatchFetch('https://app.test/cache-proof'); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + immutable: true, + originalContentType: null, + resultContentType: 'text/javascript; charset=utf-8', + originalPolicy: 'public, max-age=600', + resultPolicy: 'public, max-age=600', + body: [1, 2, 3], + }); + }); +}); diff --git a/packages/void/test/integration/typed-routing-runtime.test.ts b/packages/void/test/integration/typed-routing-runtime.test.ts index 056f7e7db..9e0cf199f 100644 --- a/packages/void/test/integration/typed-routing-runtime.test.ts +++ b/packages/void/test/integration/typed-routing-runtime.test.ts @@ -29,9 +29,12 @@ describe('typed routing in generated Workers', () => { const middleware = join(directory, 'middleware'); const pages = join(directory, 'pages'); const output = join(directory, 'output'); - for (const path of [routes, middleware, pages]) { + const assets = join(directory, 'client'); + for (const path of [routes, middleware, pages, assets]) { mkdirSync(path); } + mkdirSync(join(assets, 'static')); + writeFileSync(join(assets, 'static/client-abcdefgh.js'), 'export const value = 1;'); writeFileSync( join(routes, 'hello.ts'), `export function GET(c) { return { path: c.req.path, original: c.originalUrl()?.pathname, rewritten: c.isRewritten(), public: c.env.PUBLIC, hidden: c.env.__VOID_PROXY_TOKEN }; }\n`, @@ -84,6 +87,7 @@ describe('typed routing in generated Workers', () => { reactRefresh: false, auth: null, dev, + assetsDir: 'static', redirectRules: [ { source: '/rewrite', destination: '/hello', status: 200, force: true }, ], @@ -105,7 +109,11 @@ describe('typed routing in generated Workers', () => { plugins: [ { name: 'typed-routing-fixture', + enforce: 'pre', resolveId(id) { + if (id.endsWith('/dist/runtime/routing.mjs')) { + return source('routing.ts'); + } if (id === 'virtual:void-routes') { return routeTable; } @@ -145,12 +153,24 @@ describe('typed routing in generated Workers', () => { modulesRoot: output, scriptPath: join(output, 'worker.mjs'), bindings: { PUBLIC: 'public', __VOID_PROXY_TOKEN: 'secret' }, + assets: { + directory: assets, + binding: 'ASSETS', + run_worker_first: true, + routerConfig: { has_user_worker: true }, + assetConfig: { not_found_handling: 'none' }, + }, }), ); const hello = await runtime.dispatchFetch('https://app.test/hello'); expect(await hello.json()).toEqual({ path: '/hello', rewritten: false, public: 'public' }); expect(hello.headers.get('x-user-middleware')).toBe('ran'); expect(hello.headers.get('x-edge-rule')).toBe('ran'); + const asset = await runtime.dispatchFetch('https://app.test/static/client-abcdefgh.js'); + expect(asset.headers.get('x-user-middleware')).toBe('ran'); + expect(asset.headers.get('x-edge-rule')).toBe('ran'); + expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + await expect(asset.text()).resolves.toBe('export const value = 1;'); for (const [path, trace] of [ ['/rewrite', 'rewrite[/rewrite]'], ['/move', 'c.rewrite'], diff --git a/packages/void/test/integration/websocket-middleware.test.ts b/packages/void/test/integration/websocket-middleware.test.ts index 1b005ae64..3cab9c190 100644 --- a/packages/void/test/integration/websocket-middleware.test.ts +++ b/packages/void/test/integration/websocket-middleware.test.ts @@ -87,7 +87,30 @@ export default { }, };\n`, ); - writeFileSync(httpRoutePath, 'export function GET(c) { return c.text("HTTP route"); }\n'); + writeFileSync( + httpRoutePath, + 'export function GET(c) { c.header("x-http-handler", "GET"); return c.text("HTTP route"); }\n', + ); + for (const mode of ['socket', 'room'] as const) { + writeFileSync( + join(routesDir, `${mode}.ws.ts`), + `const schema = { "~standard": { version: 1, vendor: "test", validate: value => ({ value }) } }; +export default { + __kind: ${JSON.stringify(mode)}, + messages: { client: schema, server: schema }, + async onRequest(ctx) { + const requests = ((await ctx.storage.get("requests")) ?? 0) + 1; + await ctx.storage.put("requests", requests); + return Response.json({ kind: ${JSON.stringify(mode)}, id: ctx.id, params: ctx.params, method: ctx.request.method, requests${mode === 'room' ? ', active: Array.from(ctx.room.getConnections()).length' : ''} }, { headers: { "x-ws-on-request": ${JSON.stringify(mode)} } }); + }, +};\n`, + ); + } + writeFileSync( + join(routesDir, 'upgrade-only.ws.ts'), + `const schema = { "~standard": { version: 1, vendor: "test", validate: value => ({ value }) } }; +export default { __kind: "socket", messages: { client: schema, server: schema } };\n`, + ); writeFileSync( middlewarePath, `export default async function auth(c, next) { @@ -123,6 +146,19 @@ export default { mode: 'socket', hasAuthGate: false, }, + ...(['socket', 'room', 'upgrade-only'] as const).map((mode) => ({ + pattern: `/${mode}/:id`, + methods: [], + params: ['id'], + catchAll: false, + env: null, + filePath: `${mode}.ws.ts`, + className: mode === 'room' ? 'RoomWs' : mode === 'socket' ? 'SocketWs' : 'UpgradeOnlyWs', + bindingName: + mode === 'room' ? 'ROOM_WS' : mode === 'socket' ? 'SOCKET_WS' : 'UPGRADE_ONLY_WS', + mode: mode === 'room' ? ('room' as const) : ('socket' as const), + hasAuthGate: false, + })), ], middleware: [{ order: 1, filePath: '01.auth.ts' }], jobs: [], @@ -142,7 +178,13 @@ export default { auth: null, }), ); - writeFileSync(workerPath, compileWorkerEntry({ webSocketClasses: ['ChatWs'], runtimeEnvPath })); + writeFileSync( + workerPath, + compileWorkerEntry({ + webSocketClasses: ['ChatWs', 'SocketWs', 'RoomWs', 'UpgradeOnlyWs'], + runtimeEnvPath, + }), + ); await build({ configFile: false, @@ -172,7 +214,12 @@ export default { convertV4MiniflareOptions({ compatibilityDate: '2026-07-01', compatibilityFlags: ['nodejs_compat', 'web_socket_manual_reply_to_close'], - durableObjects: { CHAT_WS: 'ChatWs' }, + durableObjects: { + CHAT_WS: 'ChatWs', + SOCKET_WS: 'SocketWs', + ROOM_WS: 'RoomWs', + UPGRADE_ONLY_WS: 'UpgradeOnlyWs', + }, modules: true, modulesRoot: output, scriptPath: join(output, 'worker.mjs'), @@ -225,6 +272,73 @@ export default { }); expect(response.status).toBe(200); + expect(response.headers.get('x-http-handler')).toBe('GET'); expect(await response.text()).toBe('HTTP route'); }); + + it.each(['socket', 'room'] as const)( + 'forwards ordinary HTTP to the %s onRequest hook', + async (mode) => { + const response = await miniflare.dispatchFetch(`http://localhost/${mode}/ordinary`, { + headers: { Authorization: 'Bearer allowed' }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('x-ws-on-request')).toBe(mode); + expect(await response.json()).toEqual({ + kind: mode, + id: 'id=ordinary', + params: { id: 'ordinary' }, + method: 'GET', + requests: 1, + ...(mode === 'room' ? { active: 0 } : {}), + }); + }, + ); + + it('returns 426 for a WebSocket route without an ordinary HTTP hook', async () => { + const response = await miniflare.dispatchFetch('http://localhost/upgrade-only/ordinary', { + headers: { Authorization: 'Bearer allowed' }, + }); + expect(response.status).toBe(426); + expect(await response.text()).toBe('Upgrade Required'); + }); + + it.each(['socket', 'room', 'upgrade-only'] as const)( + 'authorizes ordinary %s requests before DO dispatch', + async (mode) => { + const response = await miniflare.dispatchFetch(`http://localhost/${mode}/restricted`); + expect(response.status).toBe(401); + expect(await response.text()).toBe('Unauthorized'); + const allowed = await miniflare.dispatchFetch(`http://localhost/${mode}/restricted`, { + headers: { Authorization: 'Bearer allowed' }, + }); + expect(allowed.status).toBe(mode === 'upgrade-only' ? 426 : 200); + if (mode !== 'upgrade-only') { + expect(((await allowed.json()) as { requests: number }).requests).toBe(1); + } + }, + ); + + it('keeps GET and HEAD HTTP precedence while other methods use onRequest', async () => { + const head = await miniflare.dispatchFetch('http://localhost/chat', { + method: 'HEAD', + headers: { Authorization: 'Bearer allowed' }, + }); + expect(head.status).toBe(200); + expect(head.headers.get('x-http-handler')).toBe('GET'); + expect(await head.text()).toBe(''); + const post = await miniflare.dispatchFetch('http://localhost/chat', { + method: 'POST', + headers: { Authorization: 'Bearer allowed' }, + }); + expect(post.status).toBe(200); + expect(await post.json()).toEqual({ dispatches: 1 }); + const wsHead = await miniflare.dispatchFetch('http://localhost/socket/head', { + method: 'HEAD', + headers: { Authorization: 'Bearer allowed' }, + }); + expect(wsHead.status).toBe(200); + expect(wsHead.headers.get('x-ws-on-request')).toBe('socket'); + expect(await wsHead.text()).toBe(''); + }); }); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 6508c8a83..7ab39b301 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -10,6 +10,7 @@ vi.mock('better-auth', () => ({ betterAuth: mocks.betterAuth, })); +import type { AuthState } from '../../src/runtime/auth.ts'; import { createVoidAuthRuntime, type VoidAuthRuntimeOptions, @@ -28,7 +29,7 @@ const runtime = createVoidAuthRuntime( async () => {}, ); -function createApp(sessionResult: { response: null; headers: Headers }): Hono { +function createApp(sessionResult: { response: AuthState | null; headers: Headers }): Hono { mocks.getSession.mockResolvedValue(sessionResult); mocks.betterAuth.mockReturnValue({ api: { getSession: mocks.getSession }, @@ -99,4 +100,82 @@ describe('runVoidAuthMiddleware response headers', () => { ]); await expect(response.text()).resolves.toBe('ok'); }); + + it.each(['public, max-age=60', 'private', 'no-store', 'no-cache'])( + 'preserves an anonymous application cache policy: %s', + async (policy) => { + const app = createApp({ + response: null, + headers: new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' }), + }); + app.get('/public', (c) => c.text('content', 200, { 'cache-control': policy })); + const response = await app.request('http://localhost/public', undefined, { + BETTER_AUTH_SECRET: 'test-secret', + }); + expect(response.headers.get('cache-control')).toBe(policy); + expect(response.headers.has('pragma')).toBe(false); + }, + ); + + it.each(['cookie', 'authorization', 'cf-access-jwt-assertion'])( + 'keeps the session lookup private for requests with %s', + async (header) => { + const app = createApp({ + response: null, + headers: new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' }), + }); + app.get('/private', (c) => c.text('content', 200, { 'cache-control': 'public, max-age=60' })); + const response = await app.request( + 'http://localhost/private', + { headers: { [header]: 'synthetic-credential' } }, + { BETTER_AUTH_SECRET: 'test-secret' }, + ); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }, + ); + + it('keeps a resolved authenticated session private even without a request credential header', async () => { + const now = new Date(); + const app = createApp({ + response: { + user: { + id: 'local-user', + name: 'Local user', + email: 'user@example.test', + emailVerified: true, + createdAt: now, + updatedAt: now, + }, + session: { + id: 'local-session', + userId: 'local-user', + token: 'synthetic-token', + expiresAt: now, + createdAt: now, + updatedAt: now, + }, + }, + headers: new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' }), + }); + app.get('/private', (c) => c.text('content', 200, { 'cache-control': 'public, max-age=60' })); + const response = await app.request('http://localhost/private', undefined, { + BETTER_AUTH_SECRET: 'test-secret', + }); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + }); + + it('does not allow a cookie rotation without its own cache policy to retain a public response', async () => { + const app = createApp({ + response: null, + headers: new Headers({ 'set-cookie': 'session=rotated; Path=/; HttpOnly' }), + }); + app.get('/refresh', (c) => c.text('content', 200, { 'cache-control': 'public, max-age=60' })); + const response = await app.request('http://localhost/refresh', undefined, { + BETTER_AUTH_SECRET: 'test-secret', + }); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); + }); }); diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 9b604e1f8..d606a3b84 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -421,6 +421,15 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { }, ], workflows: [ + { + name: 'local-workflow', + binding: 'LOCAL_WORKFLOW', + class_name: 'LocalJob', + limits: { steps: 7 }, + concurrency: { limit: 2 }, + schedules: ['0 * * * *'], + default_retention: { success_retention: '1 day', error_retention: '3 days' }, + }, { name: 'external-workflow', binding: 'WORKFLOW', @@ -448,6 +457,8 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { const worker = JSON.parse(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')); expect(worker.env.AUTH_SECRET).toEqual({ type: 'secret' }); worker.exports = { + ...worker.exports, + ExportOnlyJob: { type: 'workflow', name: 'export-only-workflow', limits: { steps: 3 } }, Rpc: { type: 'worker', cache: { enabled: false } }, Departed: { type: 'durable-object', state: 'transferred', transferredTo: 'target-worker' }, Arriving: { @@ -458,6 +469,7 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { }, }; writeFileSync(getWorkerConfigPath(prepared.directory), JSON.stringify(worker)); + const originalWorkerConfig = readFileSync(getWorkerConfigPath(prepared.directory), 'utf8'); expect(prepared.activeSecrets).toEqual({ versionId: activeVersionId, bindings: [ @@ -490,16 +502,22 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { ? ++uploadedBatches === 5 ? { jwt: 'completion-token' } : {} - : request.method === 'GET' + : path.includes('/workflows/') ? { - id: activeVersionId, - bindings: [ - { name: 'AUTH_SECRET', type: 'secret_text' }, - { name: 'KEY', type: 'secret_key' }, - { name: 'DASHBOARD_VAR', type: 'plain_text' }, - ], + name: path.endsWith('/local-workflow') ? 'local-workflow' : 'export-only-workflow', + script_name: 'void-test', + class_name: path.endsWith('/local-workflow') ? 'LocalJob' : 'ExportOnlyJob', } - : { id: '22222222-2222-4222-8222-222222222222', urls: [] }; + : request.method === 'GET' + ? { + id: activeVersionId, + bindings: [ + { name: 'AUTH_SECRET', type: 'secret_text' }, + { name: 'KEY', type: 'secret_key' }, + { name: 'DASHBOARD_VAR', type: 'plain_text' }, + ], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }; response.end( JSON.stringify({ success: true, @@ -563,10 +581,18 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { workflow_name: 'external-workflow', script_name: 'external-worker', }, + { + name: 'LOCAL_WORKFLOW', + type: 'workflow', + workflow_name: 'local-workflow', + class_name: 'LocalJob', + }, ]), ); expect(body.modules).toEqual([expect.objectContaining({ name: 'index.js' })]); expect(body.exports).toEqual({ + LocalJob: { type: 'workflow', name: 'local-workflow' }, + ExportOnlyJob: { type: 'workflow', name: 'export-only-workflow' }, Rpc: { type: 'worker', cache: { enabled: false } }, Departed: { type: 'durable-object', state: 'transferred', transferred_to: 'target-worker' }, Arriving: { @@ -576,6 +602,17 @@ async function verifyPinnedUpload(atomic: boolean): Promise<void> { transfer_from: 'source-worker', }, }); + expect(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')).toBe( + originalWorkerConfig, + ); + expect(worker.exports.LocalJob).toEqual({ + type: 'workflow', + name: 'local-workflow', + limits: { steps: 7 }, + concurrency: { limit: 2 }, + schedules: ['0 * * * *'], + defaultRetention: { successRetention: '1 day', errorRetention: '3 days' }, + }); expect(body.assets).toEqual({ jwt: 'completion-token', config: { not_found_handling: 'none' }, @@ -628,21 +665,141 @@ it('uploads live secrets and multiple asset batches when the latest upload is in it('uploads live secrets and multiple asset batches for atomic publication', () => verifyPinnedUpload(true)); +it.each(['missing name', 'duplicate name', 'external collision', 'class collision'] as const)( + 'rejects an invalid local Workflow declaration before any provider request: %s', + async (mode) => { + const prepared = await output({ secrets: { required: ['AUTH_SECRET'] } }); + try { + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const path = getWorkerConfigPath(prepared.directory); + const worker = JSON.parse(readFileSync(path, 'utf8')); + worker.exports = { Job: { type: 'workflow', name: mode === 'missing name' ? '' : 'jobs' } }; + if (mode === 'duplicate name') { + worker.exports.OtherJob = { type: 'workflow', name: 'jobs' }; + } + if (mode === 'external collision' || mode === 'class collision') { + worker.env.JOB = { + type: 'workflow', + name: 'jobs', + worker: mode === 'external collision' ? 'foreign-worker' : 'void-test', + exportName: mode === 'external collision' ? 'Job' : 'OtherJob', + }; + } + writeFileSync(path, JSON.stringify(worker)); + const fetch = vi.spyOn(globalThis, 'fetch'); + await expect( + uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: 'http://127.0.0.1:1', + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + }), + ).rejects.toThrow( + mode === 'missing name' + ? 'Workflow export Job name' + : mode === 'duplicate name' + ? 'multiple local exports' + : 'conflicts with its local export', + ); + expect(fetch).not.toHaveBeenCalled(); + } finally { + prepared.dispose(); + } + }, +); + +it.each(['missing', 'owned', 'foreign', 'malformed', 'unrelated 404'] as const)( + 'checks local Workflow ownership before uploading assets or a version: %s', + async (mode) => { + const prepared = await output({ + secrets: { required: ['AUTH_SECRET'] }, + assets: { directory: '../client', binding: 'ASSETS' }, + workflows: [{ name: 'jobs', binding: 'JOB', class_name: 'Job' }], + }); + await pinActiveWorkerSecrets({ + output: prepared, + activeVersionId: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + requiredNames: ['AUTH_SECRET'], + }); + const requests: Array<{ method: string; path: string }> = []; + const server = createServer((request, response) => { + request.resume(); + const path = request.url ?? ''; + requests.push({ method: request.method ?? '', path }); + const missing = + path.endsWith('/workflows/jobs') && ['missing', 'unrelated 404'].includes(mode); + response.writeHead(missing ? 404 : 200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: !missing, + errors: missing ? [{ code: mode === 'missing' ? 10200 : 10000 }] : [], + result: path.endsWith('/workflows/jobs') + ? { + name: mode === 'malformed' ? 'another-workflow' : 'jobs', + script_name: mode === 'foreign' ? 'foreign-worker' : 'void-test', + class_name: 'PreviousJob', + } + : path.endsWith('/assets-upload-session') + ? { jwt: 'asset-completion', buckets: [] } + : request.method === 'GET' + ? { + id: '11111111-1111-4111-8111-111111111111', + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + } + : { id: '22222222-2222-4222-8222-222222222222', urls: [] }, + }), + ); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const upload = uploadPinnedCloudflareVersion({ + output: prepared, + accountId: 'account-id', + apiBaseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`, + workerName: 'void-test', + token: 'synthetic-token', + atomic: true, + verifyUploader: () => true, + }); + if (mode === 'missing' || mode === 'owned') { + await expect(upload).resolves.toMatchObject({ id: '22222222-2222-4222-8222-222222222222' }); + expect(requests.map(({ method }) => method)).toEqual(['GET', 'GET', 'POST', 'POST']); + } else { + await expect(upload).rejects.toThrow( + mode === 'unrelated 404' + ? 'Cloudflare version upload failed' + : 'not owned by this Worker', + ); + expect(requests.map(({ method }) => method)).toEqual(['GET', 'GET']); + } + expect(requests[1]?.path).toBe('/client/v4/accounts/account-id/workflows/jobs'); + } finally { + prepared.dispose(); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, +); + it.each([ 'unsafe binding', - 'local Workflow export', + 'unknown export', 'scheduler Container', 'explicit secret collision', ] as const)( 'refuses unsupported pinned output before provider or image mutations: %s', async (mode) => { - const prepared = - mode === 'local Workflow export' - ? await output({ - secrets: { required: ['AUTH_SECRET'] }, - workflows: [{ name: 'jobs', binding: 'JOBS', class_name: 'Job' }], - }) - : await output({ secrets: { required: ['AUTH_SECRET'] } }); + const prepared = await output({ secrets: { required: ['AUTH_SECRET'] } }); try { expect( await pinActiveWorkerSecrets({ @@ -657,6 +814,9 @@ it.each([ if (mode === 'unsafe binding') { worker.env.BAD = { type: 'unsafe:unknown_product', value: 'unapproved' }; } + if (mode === 'unknown export') { + worker.exports = { Job: { type: 'unapproved', name: 'jobs' } }; + } if (mode === 'explicit secret collision') { worker.env.AUTH_SECRET = { type: 'ai' }; } @@ -691,8 +851,8 @@ it.each([ runCf, }), ).rejects.toThrow( - mode === 'local Workflow export' - ? 'export Job' + mode === 'unknown export' + ? 'invalid Worker config' : mode === 'scheduler Container' ? 'Scheduler-managed' : mode === 'explicit secret collision' @@ -1765,6 +1925,104 @@ it.each(['restore', 'remove'] as const)( }, ); +it('applies all local Workflow settings through the real pinned cf trigger transport', async () => { + const workflow = { + binding: 'JOB', + name: 'void-job', + class_name: 'Job', + limits: { steps: 7 }, + concurrency: { limit: 2 }, + schedules: ['0 * * * *'], + default_retention: { success_retention: '1 day', error_retention: '3 days' }, + }; + const prepared = await output({ workers_dev: false, preview_urls: false, workflows: [workflow] }); + const original = readFileSync(getWorkerConfigPath(prepared.directory), 'utf8'); + const requests: Array<{ method: string; path: string; body: unknown }> = []; + const server = createServer(async (request, response) => { + const chunks: Array<Buffer> = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const body = Buffer.concat(chunks).toString('utf8'); + requests.push({ + method: request.method ?? '', + path: request.url ?? '', + body: body ? JSON.parse(body) : undefined, + }); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + result: request.url?.endsWith('/workers/subdomain') + ? { subdomain: 'fixture' } + : { subdomain: { enabled: false, previews_enabled: false } }, + }), + ); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const apiBaseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`; + const command = createCfCommand({ + accountId: 'account-id', + apiBaseUrl, + verifyUploader: () => true, + env: () => ({ + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + CI: 'true', + CLOUDFLARE_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'account-id', + CLOUDFLARE_API_BASE_URL: apiBaseUrl, + }), + }); + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + runCf: command, + verifyResources: async () => ({ status: 0, stdout: '', stderr: '' }), + }); + const result = await deployCloudflareVersionTriggers({ + workerName: 'void-test', + configPath: join(roots.at(-1)!, 'dist', 'ssr', 'wrangler.json'), + runWrangler: runner, + triggerConfig: { + workers_dev: false, + preview_urls: false, + routes: [], + crons: [], + queueConsumers: [], + workflows: [workflow], + }, + }); + expect(result, result.stdout + result.stderr).toMatchObject({ status: 0 }); + expect(requests.filter(({ path }) => path.includes('/workflows/'))).toEqual([ + { + method: 'PUT', + path: '/client/v4/accounts/account-id/workflows/void-job', + body: { + script_name: 'void-test', + class_name: 'Job', + limits: { steps: 7 }, + concurrency: { limit: 2 }, + schedules: [{ cron: '0 * * * *' }], + default_retention: { success_retention: '1 day', error_retention: '3 days' }, + }, + }, + ]); + expect(requests.some(({ path }) => path.includes('/versions'))).toBe(false); + expect(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')).toBe(original); + } finally { + prepared.dispose(); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); + it.each([ { rows: [{ type: 'worker', script_name: 'void-test', consumer_id: 'owned-id' }], diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index 39b76149e..28bd4f94d 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -1,10 +1,12 @@ import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer, request as httpRequest } from 'node:http'; import type { AddressInfo } from 'node:net'; import { connect } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { Duplex } from 'node:stream'; import { pathToFileURL } from 'node:url'; import { expect, it } from 'vite-plus/test'; import { @@ -1425,3 +1427,159 @@ it('rejects a changed tail target before any provider request', async () => { rmSync(directory, { recursive: true, force: true }); } }); + +it('renders binary and text frames from a real Node WebSocket in order and removes the captured tail', async () => { + const directory = mkdtempSync(join(tmpdir(), 'void-tail-binary-')); + const sockets = new Set<Duplex>(); + const requests: Array<{ method: string | undefined; url: string | undefined; body: string }> = []; + let initialization: string | undefined; + const server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + requests.push({ method: request.method, url: request.url, body }); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + success: true, + ...(request.method === 'POST' + ? { + result: { + id: 'owned-tail', + url: `ws://127.0.0.1:${(server.address() as AddressInfo).port}/stream`, + }, + } + : {}), + }), + ); + }); + server.on('upgrade', (request, socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + const key = request.headers['sec-websocket-key']; + const accept = createHash('sha1') + .update(String(key) + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + .digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\nSec-WebSocket-Protocol: trace-v1\r\n\r\n`, + ); + let input = Buffer.alloc(0); + const started = (chunk: Buffer) => { + input = Buffer.concat([input, chunk]); + if (input.length < 6) { + return; + } + const length = input[1]! & 127; + if (length >= 126 || input.length < 6 + length) { + return; + } + initialization = Buffer.from( + input.subarray(6, 6 + length).map((value, index) => value ^ input[2 + (index % 4)]!), + ).toString(); + socket.off('data', started); + const frame = (value: unknown, opcode: number) => { + const data = Buffer.from(JSON.stringify(value)); + const header = + data.length < 126 ? Buffer.from([0x80 | opcode, data.length]) : Buffer.alloc(4); + if (data.length >= 126) { + header[0] = 0x80 | opcode; + header[1] = 126; + header.writeUInt16BE(data.length, 2); + } + return Buffer.concat([header, data]); + }; + socket.write( + Buffer.concat([ + frame( + { + event: { request: { method: 'POST', url: 'https://isolated.invalid/runtime' } }, + eventTimestamp: 1790000000000, + outcome: 'ok', + logs: [ + { level: 'log', message: ['binary marker'] }, + { level: 'error', message: ['error marker'] }, + ], + }, + 2, + ), + frame( + { + event: { queue: 'owned-queue' }, + eventTimestamp: 1790000000001, + outcome: 'ok', + logs: [{ level: 'log', message: ['text marker'] }], + }, + 1, + ), + Buffer.from([0x88, 0]), + ]), + ); + socket.once('data', () => socket.end()); + }; + socket.on('data', started); + }); + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const configPath = join(directory, 'wrangler.json'); + writeFileSync(configPath, JSON.stringify({ name: 'test-worker', account_id: 'test-account' })); + const [command, ...prefix] = resolveCloudflareOperationArgv(directory); + const output = await new Promise<{ status: number; stdout: string; stderr: string }>( + (resolve) => { + execFile( + command!, + [ + ...prefix, + ...encodeCloudflareOperation({ + type: 'tail', + workerName: 'test-worker', + configPath, + search: 'marker', + versionId: '11111111-2222-3333-4444-555555555555', + }), + ], + { + cwd: directory, + encoding: 'utf8', + timeout: 20_000, + env: { + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + CLOUDFLARE_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'test-account', + CLOUDFLARE_API_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`, + }, + }, + (error, stdout, stderr) => resolve({ status: error ? 1 : 0, stdout, stderr }), + ); + }, + ); + expect(output.status, output.stderr).toBe(0); + expect(initialization).toBe('{"debug":false}'); + expect(output.stderr).not.toContain('malformed tail'); + expect(output.stdout).toContain('POST https://isolated.invalid/runtime - ok'); + expect(output.stdout).toContain(' (log) binary marker'); + expect(output.stdout).toContain(' (error) error marker'); + expect(output.stdout).toContain('Queue owned-queue - ok'); + expect(output.stdout.indexOf('binary marker')).toBeLessThan( + output.stdout.indexOf('text marker'), + ); + expect(requests).toHaveLength(2); + expect(JSON.parse(requests[0]!.body)).toEqual({ + filters: [{ query: 'marker' }, { scriptVersion: '11111111-2222-3333-4444-555555555555' }], + }); + expect(requests[1]).toEqual({ + method: 'DELETE', + url: '/client/v4/accounts/test-account/workers/scripts/test-worker/tails/owned-tail', + body: '', + }); + } finally { + for (const socket of sockets) { + socket.destroy(); + } + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + rmSync(directory, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 47fafd480..b8f14e407 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -365,7 +365,16 @@ describe('compileRouteTable', () => { it('generates websocket durable object exports and proxy routes', () => { const scan: ScanResult = { - routes: [], + routes: [ + { + pattern: '/chat/:room', + methods: ['GET'], + params: ['room'], + catchAll: false, + env: null, + filePath: 'chat/[room].ts', + }, + ], websockets: [ { pattern: '/chat/:room', @@ -408,6 +417,43 @@ describe('compileRouteTable', () => { expect(code.indexOf('app.use(middleware0)')).toBeLessThan( code.indexOf('app.all("/chat/:room"'), ); + const fallback = 'app.all("/chat/:room", (c) => __forwardWebSocketRequest'; + expect(code.indexOf('app.get("/chat/:room"')).toBeGreaterThan( + code.indexOf('app.all("/chat/:room"'), + ); + expect(code.indexOf(fallback)).toBeGreaterThan(code.indexOf('app.get("/chat/:room"')); + + scan.pages.pages = [ + { + pattern: '/chat/:room', + params: ['room'], + catchAll: false, + componentPath: 'chat/[room].vue', + componentId: 'chat/[room]', + serverPath: null, + methods: [], + ssrMode: 'server', + prerenderMode: 'disabled', + renderMode: 'server', + shouldPrerender: false, + }, + ]; + const withPages = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + { pagesDir: '/pages', pagesDirRelative: 'pages', clientEntryId: 'virtual:void-pages-client' }, + ); + expect(withPages.indexOf(fallback)).toBeGreaterThan( + withPages.indexOf('app.get("/chat/:room", __createPageGetHandler'), + ); + expect(withPages.indexOf(fallback)).toBeLessThan( + withPages.indexOf('app.notFound(__createPagesFallback'), + ); }); it('exports tombstones for removed websocket Durable Object classes', () => { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 374605dae..bf351985e 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -10,6 +10,7 @@ import { createSsrFallback, } from '../../src/runtime/routing-pages.ts'; import { + applyAssetCachePolicy, createAssetFallback, createWorkerFetch, filterInternalBindings, @@ -270,6 +271,69 @@ describe('typed Worker fetch execution', () => { }); describe('typed asset fallthrough', () => { + it.each([['private, max-age=60'], ['no-store'], ['no-cache'], ['public, max-age=120']])( + 'preserves the authored %s policy for hashed assets', + (policy) => { + const source = new Response('asset', { headers: { 'cache-control': policy } }); + const response = applyAssetCachePolicy( + new Request('https://app.test/assets/client-abcdefgh.js'), + source, + ); + expect(response.headers.get('cache-control')).toBe(policy); + expect(source.headers.get('content-type')).toBe('text/plain;charset=UTF-8'); + }, + ); + it.each([ + ['/assets/client.js', 'text/javascript'], + ['/assets/page-abcdefgh.html', 'text/html'], + ['/assets/client-abcdefgh.js', 'text/html'], + ['/public/client-abcdefgh.js', 'text/javascript'], + ])('does not make %s (%s) immutable', (path, contentType) => { + const source = new Response('asset', { + headers: { + 'cache-control': 'public, max-age=0, must-revalidate', + 'content-type': contentType, + }, + }); + expect( + applyAssetCachePolicy(new Request('https://app.test' + path), source).headers.get( + 'cache-control', + ), + ).toBe('public, max-age=0, must-revalidate'); + }); + it('recognizes the configured directory, hash, query and HEAD without overriding cookies', () => { + const request = new Request('https://app.test/static/chunks/client-EmOhWjz4.js?cache=1', { + method: 'HEAD', + }); + const source = new Response(null, { + headers: { 'cache-control': 'must-revalidate, PUBLIC, max-age=0' }, + }); + expect(applyAssetCachePolicy(request, source, '/static/').headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(source.headers.get('cache-control')).toBe('must-revalidate, PUBLIC, max-age=0'); + const cookie = new Response('asset', { headers: { 'set-cookie': 'local=value; Path=/' } }); + expect(applyAssetCachePolicy(request, cookie, 'static').headers.get('cache-control')).toBe( + 'no-store', + ); + }); + it('preserves non-success, redirects and mutation responses', () => { + for (const source of [ + new Response('missing', { status: 404 }), + Response.redirect('https://app.test/new', 302), + ]) { + expect( + applyAssetCachePolicy(new Request('https://app.test/assets/client-abcdefgh.js'), source), + ).toBe(source); + } + const source = new Response('post'); + expect( + applyAssetCachePolicy( + new Request('https://app.test/assets/client-abcdefgh.js', { method: 'POST' }), + source, + ), + ).toBe(source); + }); it('uses fresh requests, skips mutations and preserves intentional responses', async () => { const binding = vi.fn(async (request: Request) => new Response(new URL(request.url).pathname)); const serve = createAssetFallback(); @@ -282,7 +346,7 @@ describe('typed asset fallthrough', () => { await serve(new Request('https://app.test/page'), { ASSETS: { fetch: binding } }, success), ).toBe(success); expect(binding).not.toHaveBeenCalled(); - const request = new Request('https://app.test/assets/file.js'); + const request = new Request('https://app.test/assets/file-abcdefgh.js'); const asset = await serve(request, { ASSETS: { fetch: binding } }, intentional); expect(binding.mock.calls[0][0]).not.toBe(request); expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); diff --git a/platform/packages/api/test/integration/operator-asset-migration.test.ts b/platform/packages/api/test/integration/operator-asset-migration.test.ts index d1153e6a0..f1e688105 100644 --- a/platform/packages/api/test/integration/operator-asset-migration.test.ts +++ b/platform/packages/api/test/integration/operator-asset-migration.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import { eq, sql } from 'drizzle-orm'; import { afterAll, @@ -274,7 +275,11 @@ async function assertMigrated(id: string, projectId: string, entries: Array<Asse }); expect(serving[entry.path]).toBe(`!${entry.hash}`); const object = await env.STATIC_ASSETS_R2.get(`assets/${projectId}/${entry.hash}`); - expect(new Uint8Array(await object!.arrayBuffer())).toEqual(new Uint8Array(entry.bytes)); + // Compare every byte without Vitest walking millions of typed-array keys + // in the historical 2 MiB boundary case on a shared CI runner. + expect(Buffer.compare(Buffer.from(await object!.arrayBuffer()), Buffer.from(entry.bytes))).toBe( + 0, + ); } } From 2106f796223875381ddc81cd7dc161ab113e061d Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 05:32:29 +0800 Subject: [PATCH 09/10] Observe rendered UI rather than bootstrap JSON in smoke tests --- .../kitchen-sink-observations-browser.test.ts | 69 +++++++++++++++++++ platform/scripts/kitchen-sink-observations.ts | 19 +++++ platform/scripts/kitchen-sink-smoke-test.ts | 25 ++----- 3 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 packages/void/test/integration/kitchen-sink-observations-browser.test.ts diff --git a/packages/void/test/integration/kitchen-sink-observations-browser.test.ts b/packages/void/test/integration/kitchen-sink-observations-browser.test.ts new file mode 100644 index 000000000..0e0ac4fcf --- /dev/null +++ b/packages/void/test/integration/kitchen-sink-observations-browser.test.ts @@ -0,0 +1,69 @@ +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { waitForRenderedText } from '../../../../platform/scripts/kitchen-sink-observations.ts'; +import { serializePageData } from '../../src/pages/serialize.ts'; + +describe('kitchen-sink rendered app observations', () => { + let browser: Browser; + const key = 'smoke-q-browser-observation'; + + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser?.close(); + }); + + it('observes a deleted row while its original SSR hydration data remains in the document', async () => { + const page = await browser.newPage(); + try { + const payload = serializePageData({ props: { results: [{ id: 1, key }] } }); + await page.setContent(`<!doctype html><html><body><script id="__VOID_PAGE_DATA__" type="application/json">${payload}</script> + <div id="app"><table><tbody><tr><td>${key}</td><td><button>Delete</button></td></tr></tbody></table></div></body></html>`); + await waitForRenderedText(page, key, true, 1_000); + await page.evaluate(() => { + const button = document.querySelector('button')!; + button.addEventListener('click', () => button.closest('tr')!.remove()); + }); + await page.getByRole('button', { name: 'Delete' }).click(); + + expect(await page.locator('tr').count()).toBe(0); + expect(await page.locator('body').textContent()).toContain(key); + await waitForRenderedText(page, key, false, 1_000); + expect(await page.locator('#__VOID_PAGE_DATA__').textContent()).toBe(payload); + } finally { + await page.close(); + } + }); + + it('does not count hydration-only or hidden text as a visible consumer result', async () => { + const page = await browser.newPage(); + try { + await page.setContent(`<!doctype html><html><body><script id="__VOID_PAGE_DATA__" type="application/json">${serializePageData({ props: { key } })}</script> + <div id="app"><span hidden>${key}</span><p>No results</p></div></body></html>`); + await expect(waitForRenderedText(page, key, true, 100)).rejects.toThrow('Timeout'); + } finally { + await page.close(); + } + }); + + it('fails if the rendered row remains after a delete request', async () => { + const page = await browser.newPage(); + try { + await page.setContent(`<div id="app"><table><tr><td>${key}</td></tr></table></div>`); + await expect(waitForRenderedText(page, key, false, 100)).rejects.toThrow('Timeout'); + } finally { + await page.close(); + } + }); + + it('requires the app to exist before accepting rendered-text absence', async () => { + const page = await browser.newPage(); + try { + await page.setContent('<p>Wrong destination</p>'); + await expect(waitForRenderedText(page, key, false, 100)).rejects.toThrow('Timeout'); + } finally { + await page.close(); + } + }); +}); diff --git a/platform/scripts/kitchen-sink-observations.ts b/platform/scripts/kitchen-sink-observations.ts index dd8ee1ba7..ce6c44ec7 100644 --- a/platform/scripts/kitchen-sink-observations.ts +++ b/platform/scripts/kitchen-sink-observations.ts @@ -1,3 +1,22 @@ +import type { Page } from 'playwright'; + +/** Observe the rendered app, excluding the unchanged SSR hydration payload. */ +export async function waitForRenderedText( + page: Page, + text: string, + present: boolean, + timeoutMs: number, +): Promise<void> { + await page.waitForFunction( + ({ text, present }) => { + const app = document.getElementById('app'); + return app !== null && app.innerText.includes(text) === present; + }, + { text, present }, + { timeout: timeoutMs }, + ); +} + type PollOptions = { timeoutMs: number; intervalMs?: number; diff --git a/platform/scripts/kitchen-sink-smoke-test.ts b/platform/scripts/kitchen-sink-smoke-test.ts index 859468ea6..7bfe36aa0 100644 --- a/platform/scripts/kitchen-sink-smoke-test.ts +++ b/platform/scripts/kitchen-sink-smoke-test.ts @@ -20,6 +20,7 @@ import { waitForChangedRender, waitForFreshHeartbeat, waitForIsrCacheHit, + waitForRenderedText, } from './kitchen-sink-observations.ts'; if (!process.env.SMOKE_URL) { @@ -91,12 +92,12 @@ function readTimestamp(page: Page, label: string): Promise<string | null> { }, label); } -/** Wait for body text to include `text`, polling with page reloads. */ +/** Wait for rendered app text to include `text`, polling with page reloads. */ async function pollForText(page: Page, path: string, text: string, timeoutMs = 30_000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { await goto(page, path); - const body = await page.textContent('body'); + const body = await page.locator('#app').innerText(); if (body?.includes(text)) { return; } @@ -105,26 +106,14 @@ async function pollForText(page: Page, path: string, text: string, timeoutMs = 3 throw new Error(`Timed out after ${timeoutMs}ms waiting for "${text}" on ${path}`); } -/** Wait for body text to stop including `text` (up to timeoutMs, no reloads). */ +/** Wait for rendered app text to stop including `text`, without reloading. */ async function waitForTextGone(page: Page, text: string, timeoutMs = TIMEOUT) { - await page.waitForFunction( - (expectedText) => !document.body.textContent?.includes(expectedText), - text, - { - timeout: timeoutMs, - }, - ); + await waitForRenderedText(page, text, false, timeoutMs); } -/** Wait for body text to include `text` (no reload, just DOM polling). */ +/** Wait for rendered app text to include `text`, without reloading. */ async function waitForText(page: Page, text: string, timeoutMs = TIMEOUT) { - await page.waitForFunction( - (expectedText) => document.body.textContent?.includes(expectedText), - text, - { - timeout: timeoutMs, - }, - ); + await waitForRenderedText(page, text, true, timeoutMs); } /** Click the Delete button in the table row containing `needle`. */ From b86db46caec3ad87e52f41acd6057261d786fef6 Mon Sep 17 00:00:00 2001 From: LongYinan <lynweklm@gmail.com> Date: Fri, 2 Oct 2026 06:10:15 +0800 Subject: [PATCH 10/10] Isolate script checks from generated dashboard configuration --- platform/scripts/kitchen-sink-smoke-test.ts | 8 ++++++++ platform/scripts/tsconfig.json | 13 +++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 platform/scripts/tsconfig.json diff --git a/platform/scripts/kitchen-sink-smoke-test.ts b/platform/scripts/kitchen-sink-smoke-test.ts index 7bfe36aa0..4eb8a8dc3 100644 --- a/platform/scripts/kitchen-sink-smoke-test.ts +++ b/platform/scripts/kitchen-sink-smoke-test.ts @@ -620,6 +620,14 @@ void describe(`kitchen-sink smoke (${BASE})`, { concurrency: true }, () => { }); const body = await response.json(); assert.equal(response.status, 200, JSON.stringify(body)); + assert.ok( + body && + typeof body === 'object' && + 'ok' in body && + 'checks' in body && + Array.isArray(body.checks), + 'Sandbox runtime response must contain an ok flag and checks array', + ); assert.equal(body.ok, true, JSON.stringify(body)); assert.equal(body.checks.length, 12, 'All Sandbox runtime acceptance checks completed'); }); diff --git a/platform/scripts/tsconfig.json b/platform/scripts/tsconfig.json new file mode 100644 index 000000000..344595bf8 --- /dev/null +++ b/platform/scripts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["*.ts"] +}