diff --git a/docs/guide/app-types.md b/docs/guide/app-types.md index 455c754b6..24e91204c 100644 --- a/docs/guide/app-types.md +++ b/docs/guide/app-types.md @@ -45,7 +45,7 @@ Add `voidPlugin()` to the framework's Vite config to get binding inference, type Any project that produces static files, whether that is an SPA, a static site, or a plain directory. Void serves the assets without requiring application code. -**SPAs** (client-side single-page apps) fall back all non-file paths to `index.html` with a 200 status, so client-side routing works out of the box. Detected when `vite` is a dependency and no backend files exist. +**SPAs** (client-side single-page apps) serve `index.html` with a 200 status for paths that match no file, so client-side routing works out of the box. On a Void platform, missing files under the hashed assets folder (`/assets/`) return 404 instead. Detected when `vite` is a dependency and no backend files exist. **Static sites** (static site generators like VitePress, Docusaurus, or any pre-built directory) produce per-page HTML files. Detected when a known SSG is a dependency, or when using the `--dir` flag. diff --git a/docs/guide/live.md b/docs/guide/live.md index e778e9068..f89301623 100644 --- a/docs/guide/live.md +++ b/docs/guide/live.md @@ -22,6 +22,8 @@ export const live = defineLiveStream({ }); ``` +Void enables live streams when a [scanned file](../reference/resource-inference.md#scanned-directories) imports `void/live`, so keep this module in `src/` or another scanned directory. To keep it in an unscanned directory such as `lib/`, add that directory to [`inference.scanDirs`](../reference/resource-inference.md#custom-scan-directories) and list the default directories you still use, because `scanDirs` replaces them. + Expose it from a normal route: ```ts diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index 0c48d797b..54f1c0e6c 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -8,7 +8,7 @@ Keep one password-manager entry for this platform. Paste the saved values into t ## Cloudflare API Tokens {#runtime-token-permissions} -For a domain installation or Cloudflare Access setup, the installer guides you through creating separate management and runtime tokens using Cloudflare's [API token setup](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/). Its links preselect the permissions and suggest names based on your platform's display name. Scope the tokens to your selected account and, when using a domain, its zone, save them in your password manager, and paste them into the masked CLI prompts. +For a domain installation or Cloudflare Access setup, the installer guides you through creating separate management and runtime tokens using Cloudflare's [API token setup](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/). Its links preselect the permissions and suggest names based on your platform's display name, but Cloudflare's form cannot preselect a zone: for a domain installation, change the zone policy from **All Domains** to **Specified Domains** and select your zone. If Void will create the zone, keep **All Domains** on both tokens: the zone does not exist yet, and the runtime token needs it to purge cache. Save the tokens in your password manager and paste them into the masked CLI prompts. For workers.dev testing with the default API hostname, browser login can authorize infrastructure setup. Void also checks account-wide Access requirements; if the @@ -21,7 +21,7 @@ Browser login does not grant AI Gateway access. Include **Account → AI Gateway The management token lets your CLI install and maintain the platform. The runtime token is stored as a Worker secret so the platform can deploy apps after you close your terminal. They are separate credentials. -Compare each token's form with the permission checklist below, and select the indicated account and zone for a domain installation. The installer saves a management token entered at the prompt in this installation’s encrypted local checkpoint and reuses it on resume. Upgrades, repairs, and other lifecycle maintenance also use that saved management token. During installation, a rejected token can be replaced at the prompt. An explicit `CLOUDFLARE_API_TOKEN` or `CF_API_TOKEN` takes precedence. Keep your password-manager copy for maintenance or another machine; scripts supply it through `CLOUDFLARE_API_TOKEN`. +Compare each token's form with the permission checklist below. The installer saves a management token entered at the prompt in this installation’s encrypted local checkpoint and reuses it on resume. Upgrades, repairs, and other lifecycle maintenance also use that saved management token. During installation, a rejected token can be replaced at the prompt. An explicit `CLOUDFLARE_API_TOKEN` or `CF_API_TOKEN` takes precedence. Keep your password-manager copy for maintenance or another machine; scripts supply it through `CLOUDFLARE_API_TOKEN`. ::: details Permissions to select for each token diff --git a/docs/guide/platform/installation/first-deployment.md b/docs/guide/platform/installation/first-deployment.md index 8d43e8135..c6a6a1196 100644 --- a/docs/guide/platform/installation/first-deployment.md +++ b/docs/guide/platform/installation/first-deployment.md @@ -34,7 +34,11 @@ void project token create --name ci --expires-in 30 Store the printed `VOID_TOKEN` and `VOID_API_URL` in the CI secret manager, then run `void connect "$VOID_API_URL" --no-login` before deploying in each fresh CI -environment. Project files do not replace this connection step. Rotate with `void project token renew ` and revoke with +environment. Project files do not replace this connection step. CI cannot answer +the project prompt, so choose the project before `void deploy`: set `VOID_PROJECT`, +pass `--project `, or commit `.void/project.json` from `void project link`. +New apps ignore `.void` in `.gitignore`; to commit the link, replace that line +with `.void/*` and `!.void/project.json`. Rotate with `void project token renew ` and revoke with `void project token revoke `. A human login token is not a CI credential. If Cloudflare Access protects the platform, Access proof and the project diff --git a/docs/guide/platform/installation/setup.md b/docs/guide/platform/installation/setup.md index 98b1e5a96..719f279b3 100644 --- a/docs/guide/platform/installation/setup.md +++ b/docs/guide/platform/installation/setup.md @@ -12,7 +12,7 @@ For workers.dev testing, start the interactive install and choose your login met void platform install --workers-dev ``` -For a domain installation or Access setup, Void guides you to create a management API token and paste it into a masked prompt. Browser login can preview DNS, but cannot change it or configure Access. The token-creation link preselects the permissions for your choices; scope the token to your account and, when using a domain, its zone. No shell environment variable is needed for interactive installation. +For a domain installation or Access setup, Void guides you to create a management API token and paste it into a masked prompt. Browser login can preview DNS, but cannot change it or configure Access. The token-creation link preselects the permissions for your choices. When using an existing domain, change the zone policy from **All Domains** to **Specified Domains** and select your zone, as the CLI prompt describes. No shell environment variable is needed for interactive installation. ::: details Use an existing management token from the shell diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index de11b74a8..d3f70d8cf 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -11,7 +11,9 @@ void platform disable [id] --plan void platform disable [id] ``` -Disabled platforms reject application traffic while retaining domains and routes. Repair and upgrade preserve that state. Restore traffic with: +Disabled platforms reject application traffic while retaining domains and routes. The platform API also rejects requests, so CLI commands that use it, including login and deploy, fail until you enable the platform again. Only the administrator status page at `/admin/status` and administrator sign-in stay available. Repair and upgrade preserve that state. + +Enabling redeploys the platform Workers from the selected runtime, verifies them, and then restores traffic. Pass `--runtime ` to enable with a custom platform build: ```sh void platform enable [id] --plan @@ -31,7 +33,9 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -Uninstall blocks platform traffic, removes Queues that no remaining platform Worker uses, and records the resources left for you to review. By default, Workers, the Queues they use, KV, R2, Hyperdrive, the dispatch namespace, and AI Gateway remain in your account. Adopted resources, external PostgreSQL, zones, DNS records, routes, custom domains, and Cloudflare Access resources are always retained. +Uninstall blocks platform traffic, removes Queues that no remaining platform Worker uses, and lists the resources left for you to review. `--plan` shows the same list before any change. By default, Workers, the Queues they use, KV, R2, Hyperdrive, the dispatch namespace, and AI Gateway remain in your account. Adopted resources, external PostgreSQL, and zones are always retained. Email Routing settings are retained; `--purge-resources` removes only the rules that send mail to the platform. DNS records, routes, custom domains, and Cloudflare Access resources are retained unless you use `--purge-resources`. + +When uninstall finishes, the record of what was kept stays at `~/.void/platforms//.json`. Without `--purge-data`, the encrypted credentials file `.recovery.enc` next to it is kept too. Delete both once you have finished the manual cleanup below. To also remove eligible data resources owned by the installer: @@ -39,20 +43,48 @@ To also remove eligible data resources owned by the installer: void platform uninstall [id] --purge-data ``` -Even with `--purge-data`, Void retains Workers, the Queues they use, R2, AI Gateway, DNS records, routes, custom domains, Access resources, adopted resources, external PostgreSQL, and zones. Review those in the Cloudflare dashboard if you want to remove them. +Even with `--purge-data`, Void retains Workers, the Queues they use, R2, AI Gateway, DNS records, routes, custom domains, Access resources, adopted resources, external PostgreSQL, and zones. Review those in the Cloudflare dashboard if you want to remove them, or use `--purge-resources`. + +### Remove everything Void created + +To delete every Cloudflare resource the installation created: + +```sh +void platform uninstall [id] --purge-resources --plan +void platform uninstall [id] --purge-resources +``` + +`--purge-resources` includes `--purge-data`. It also deletes the platform Workers, Queues, the application Workers left in the dispatch namespace, the R2 bucket and its objects, the AI Gateway, the platform DNS record, Worker routes and custom domains, and the Access applications and service tokens the installer created. The plan lists each resource in deletion order, and everything that stays. If Cloudflare shows that the AI Gateway was created after installation, for example because it was deleted and created again under the same name, it is kept and listed. + +If the platform sends email, it first deletes the Email Routing rules in the sender domain's zone that send mail to the platform email gateway Worker, and resets such a catch-all rule to drop mail. It leaves Email Routing enabled and keeps the sender domain's MX, SPF, and DKIM records. + +It never removes zones, adopted resources, external PostgreSQL, other Email Routing settings, Analytics Engine datasets, Access policies or identity providers you chose, or API tokens. Revoke the API tokens you created for the platform afterward. + +Before it changes anything, `--purge-resources` stops if: + +- projects remain. Delete each one with `void project delete ` first. +- the credential cannot make every planned deletion. Browser login cannot delete DNS records or Access resources, so set `CLOUDFLARE_API_TOKEN` to a token with the permissions the plan lists. If you connected Access from a separate identity account, the token also needs the Access permissions in that account. If the platform sends email, it also needs **Zone → Email Routing Rules → Edit** for the sender domain's zone. + +If a route, custom domain, DNS record, or Access resource no longer matches what the installation recorded, for example a route that now sends traffic to another Worker, it is kept and listed with the reason, and uninstall continues. Do not run `--purge-resources` while someone else is changing the platform's Workers, routes, or custom domains: a change made after Void checks a resource and before it deletes it is not detected. + +If Cloudflare refuses a deletion, for example because a Worker you created yourself still sends logs to the platform tail Worker, uninstall stops and names the resource. Fix it, then rerun the same command to continue. + +If a plain or `--purge-data` uninstall stopped before it finished, rerun it with `--purge-resources` to also delete what it kept. Once an uninstall finishes, use the manual steps below instead. ### Remove retained resources -Cloudflare refuses to delete a resource while a platform Worker still uses it. In the Cloudflare dashboard, remove what is left in this order, skipping anything already gone: +After an uninstall without `--purge-resources`, Cloudflare refuses to delete a resource while a platform Worker still uses it. In the Cloudflare dashboard, remove what is left in this order, skipping anything already gone: 1. Delete the platform DNS record, Worker routes, and custom domains. -2. Open each platform Queue and remove the platform API Worker from its consumers. -3. Delete the platform Workers. -4. Delete the platform Queues. -5. Delete the application Workers in the dispatch namespace, then delete the namespace. -6. Delete the platform KV namespaces and Hyperdrive configuration. -7. Empty the R2 bucket, then delete it. -8. Delete the AI Gateway. +2. If the platform sends email, open **Email Routing** for the sender domain's zone. Delete the routing rules and any catch-all rule that send mail to the platform email gateway Worker. Then remove the sender subdomain from Email Routing, or disable Email Routing if you enabled it only for the platform. +3. Open each platform Queue and remove the platform API Worker from its consumers. +4. Delete the platform Workers, except the tail Worker (its name ends in `-tail`). +5. Delete the platform Queues. +6. Delete the application Workers in the dispatch namespace, then delete the namespace. +7. Delete the tail Worker. Cloudflare refuses while other Workers still send logs to it. +8. Delete the platform KV namespaces and Hyperdrive configuration. +9. Empty the R2 bucket, then delete it. +10. Delete the AI Gateway. If installation configured Cloudflare Access, the uninstall plan lists its recorded applications and service tokens with their ownership and IDs. Review them in Zero Trust after uninstall. Remove installer-created resources only when nothing else uses them; resources connected from an existing company setup remain under their owner's control. diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 354122fb9..c125a03c6 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -101,7 +101,7 @@ void platform rollback [id] --runtime [--from-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] +void platform uninstall [id] [--plan] [--purge-data | --purge-resources] [--keep-zone] [--yes] ``` `discover --installation` limits recovery and endpoint verification to one installation in a shared Cloudflare account. @@ -115,15 +115,15 @@ dashboard origin before maintenance can proceed. See [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 | -| ----------- | --------------------------------------------------------------------------------------------- | -| `discover` | Verifies remote ownership and restores local installation records without downloading secrets | -| `repair` | Recreates missing resources owned by the installer | -| `upgrade` | Deploys the selected runtime and supported pending migrations | -| `rollback` | Restores a declared-compatible earlier runtime without reversing PostgreSQL migrations | -| `disable` | Blocks platform traffic through routing storage without removing data | -| `enable` | Restores traffic after checking the platform | -| `uninstall` | Blocks traffic and removes eligible resources, retaining data by default | +| Command | Behavior | +| ----------- | ----------------------------------------------------------------------------------------------------------- | +| `discover` | Verifies remote ownership and restores local installation records without downloading secrets | +| `repair` | Recreates missing resources owned by the installer | +| `upgrade` | Deploys the selected runtime and supported pending migrations | +| `rollback` | Restores a declared-compatible earlier runtime without reversing PostgreSQL migrations | +| `disable` | Blocks app traffic and the platform API without removing data; only admin status and sign-in stay available | +| `enable` | Redeploys and verifies the platform Workers, then restores traffic | +| `uninstall` | Blocks traffic and removes eligible resources, retaining data by default | Repair and upgrade preserve disabled state. New, resumed, and previously disabled installations block user traffic until all target Workers pass verification; the installer's health probes can still run. Routes and custom domains remain attached. @@ -137,6 +137,8 @@ An upgrade completes after the new Workers pass health checks. If rollout fails, `platform rollback` restores a compatible earlier runtime without reversing database migrations. Pass its files with `--runtime`; if the installed version is custom, also supply that version with `--from-runtime`. Void refuses incompatible targets. -Uninstall verifies remote ownership before removing anything. Data resources are retained unless you pass `--purge-data`. Workers, the Queues they use, R2, AI Gateway, DNS records, routes, custom domains, adopted resources, external PostgreSQL, and zones are always retained for manual review. +Uninstall verifies remote ownership before removing anything. Data resources are retained unless you pass `--purge-data`. Workers, the Queues they use, R2, AI Gateway, DNS records, routes, custom domains, adopted resources, external PostgreSQL, and zones are retained for manual review. + +`--purge-resources` includes `--purge-data` and also deletes every other resource the installation created, including Workers, Queues, R2, AI Gateway, the platform DNS record, routes, custom domains, and installer-created Access resources. It removes only the Email Routing rules that send mail to the platform email gateway, and keeps zones, adopted resources, external PostgreSQL, other Email Routing settings, and API tokens. A route, custom domain, DNS record, or Access resource that no longer matches the installation's record is kept and listed. It refuses before any change while projects remain or when the credential cannot make the planned deletions. Rerun the same command to continue after an interruption, or after a plain or `--purge-data` uninstall stopped. See [Disable and Uninstall](../../guide/platform/installation/uninstall.md#remove-retained-resources) for the full removal policy and the cleanup order. diff --git a/docs/reference/resource-inference.md b/docs/reference/resource-inference.md index 463e42ae5..a4d2cb78c 100644 --- a/docs/reference/resource-inference.md +++ b/docs/reference/resource-inference.md @@ -17,6 +17,7 @@ Void automatically detects which Cloudflare resources your project uses by scann | `SANDBOX` | Managed Sandbox capability | `import { getSandbox } from "void/sandbox"` | `getSandbox()`; native Cloudflare also exposes `env.SANDBOX` / `c.env.SANDBOX` | | Auth | none | `import { ... } from "void/auth"` or `import { auth } from "void/client"` / `void/client/react` / `void/client/vue` / `void/client/svelte` / `void/client/solid` | none | | `QUEUE_*` | `Queue` | `import { queues } from "void/queues"` | `env.QUEUE_*` | +| `VOID_LIVE` | `DurableObjectNamespace` | `import { defineLiveStream } from "void/live"` | n/a | | filename-derived | `DurableObjectNamespace` | A default-exported `defineDurableState()` module in `durable-objects/` | n/a | Auth detection also triggers when importing the `auth` specifier from `void/client` or a framework-specific client subpath such as `void/client/react` (but not when importing only `fetch`). diff --git a/packages/deploy-core/src/index.ts b/packages/deploy-core/src/index.ts index b7f3dd4fc..e7df7a761 100644 --- a/packages/deploy-core/src/index.ts +++ b/packages/deploy-core/src/index.ts @@ -352,6 +352,7 @@ export { hasStandalonePragma, isSingleStatement, MIGRATION_CLASSIFIER_VERSION, + splitSqlStatements, UNSPLIT_MULTI_STATEMENT_PRAGMA, type MigrationPlanHashInput, type SqlMigrationClassification, diff --git a/packages/deploy-core/src/migration-plan.ts b/packages/deploy-core/src/migration-plan.ts index 4c14a76c2..ddbedeae6 100644 --- a/packages/deploy-core/src/migration-plan.ts +++ b/packages/deploy-core/src/migration-plan.ts @@ -74,10 +74,20 @@ function hasStatementBreakpointOutsideLiterals(sql: string): boolean { return false; } -export function isSingleStatement(sql: string): boolean { +type StatementScan = { + normalized: string; + /** Statement ranges in `normalized`, each ending after its `;` when present. */ + ranges: Array<{ start: number; end: number }>; + /** Whether bare SQL (outside literals and comments) contains BEGIN or TRIGGER. */ + hasCompoundBody: boolean; +}; + +function scanStatements(sql: string): StatementScan { const normalized = canonicalSql(sql); - let statements = 0; + const ranges: Array<{ start: number; end: number }> = []; + let cursor = 0; let inStatement = false; + let hasCompoundBody = false; for (const segment of tokenizeSql(normalized)) { if (segment.kind === 'line-comment' || segment.kind === 'block-comment') { @@ -87,13 +97,17 @@ export function isSingleStatement(sql: string): boolean { inStatement = true; continue; } + if (/\b(?:BEGIN|TRIGGER)\b/i.test(normalized.slice(segment.start, segment.end))) { + hasCompoundBody = true; + } for (let index = segment.start; index < segment.end; index++) { const character = normalized[index]; if (character === ';') { if (inStatement) { - statements++; + ranges.push({ start: cursor, end: index + 1 }); inStatement = false; } + cursor = index + 1; continue; } if (character !== ' ' && character !== '\t' && character !== '\n' && character !== '\r') { @@ -103,9 +117,26 @@ export function isSingleStatement(sql: string): boolean { } if (inStatement) { - statements++; + ranges.push({ start: cursor, end: normalized.length }); + } + return { normalized, ranges, hasCompoundBody }; +} + +export function isSingleStatement(sql: string): boolean { + return scanStatements(sql).ranges.length === 1; +} + +/** + * Split SQL on top-level semicolons, ignoring literals and comments. Returns + * null when a statement may contain a compound `BEGIN ... END` body (such as + * CREATE TRIGGER), whose inner semicolons cannot be told apart lexically. + */ +export function splitSqlStatements(sql: string): Array | null { + const { normalized, ranges, hasCompoundBody } = scanStatements(sql); + if (hasCompoundBody) { + return null; } - return statements === 1; + return ranges.map((range) => normalized.slice(range.start, range.end).trim()); } /** Classify SQL without imposing the caller's execution policy or hashing implementation. */ diff --git a/packages/deploy-core/test/unit/migration-plan.test.ts b/packages/deploy-core/test/unit/migration-plan.test.ts index fb23a3be4..dd3e0cd01 100644 --- a/packages/deploy-core/test/unit/migration-plan.test.ts +++ b/packages/deploy-core/test/unit/migration-plan.test.ts @@ -4,6 +4,7 @@ import { classifySqlMigration, getMigrationPlanHashInput, isSingleStatement, + splitSqlStatements, tokenizeSql, } from '../../src/index.ts'; @@ -77,6 +78,52 @@ describe('SQL migration classification', () => { }); }); +describe('SQL statement splitting', () => { + it('splits a multi-line CREATE TABLE and following INSERTs into separate statements', () => { + const sql = [ + '-- seed', + 'CREATE TABLE `notes` (', + '\t`id` integer PRIMARY KEY NOT NULL,', + '\t`body` text NOT NULL', + ');', + "INSERT INTO `notes` (`body`) VALUES ('hello');", + "INSERT INTO `notes` (`body`) VALUES ('world');", + '/* trailing comment */', + '', + ].join('\r\n'); + expect(splitSqlStatements(sql)).toEqual([ + [ + '-- seed', + 'CREATE TABLE `notes` (', + '\t`id` integer PRIMARY KEY NOT NULL,', + '\t`body` text NOT NULL', + ');', + ].join('\n'), + "INSERT INTO `notes` (`body`) VALUES ('hello');", + "INSERT INTO `notes` (`body`) VALUES ('world');", + ]); + }); + + it('does not split on semicolons inside strings, identifiers, or comments', () => { + const sql = + 'CREATE TABLE notes (message TEXT DEFAULT \'a;b\', "c;d" TEXT); -- x;y\nINSERT INTO notes DEFAULT VALUES'; + expect(splitSqlStatements(sql)).toEqual([ + 'CREATE TABLE notes (message TEXT DEFAULT \'a;b\', "c;d" TEXT);', + '-- x;y\nINSERT INTO notes DEFAULT VALUES', + ]); + }); + + it('returns null for trigger bodies it cannot split safely', () => { + const sql = [ + 'CREATE TABLE notes (id INTEGER PRIMARY KEY, updated_at TEXT);', + 'CREATE TRIGGER notes_touch AFTER UPDATE ON notes BEGIN', + " UPDATE notes SET updated_at = datetime('now') WHERE id = NEW.id;", + 'END;', + ].join('\n'); + expect(splitSqlStatements(sql)).toBeNull(); + }); +}); + describe('SQLite tokenization', () => { it('keeps escaped quotes and unterminated comments in their lexical regions', () => { const sql = 'SELECT \'a\'\';b\', "a"";b", `a``;b`, [a;] /* unclosed'; diff --git a/packages/platform/src/worker-config.ts b/packages/platform/src/worker-config.ts index 471730fae..a4ecc27ad 100644 --- a/packages/platform/src/worker-config.ts +++ b/packages/platform/src/worker-config.ts @@ -261,6 +261,9 @@ export function createPlatformWorkerConfigs(input: { }, tail: { ...common, + // The tail worker only receives trace events from the dispatch worker. + workers_dev: false, + preview_urls: false, name: names.tailWorker, main: runtimeEntrypoints.tail, no_bundle: true, diff --git a/packages/platform/test/unit/worker-config.test.ts b/packages/platform/test/unit/worker-config.test.ts index 2e2f3012e..245fa0f5f 100644 --- a/packages/platform/test/unit/worker-config.test.ts +++ b/packages/platform/test/unit/worker-config.test.ts @@ -285,6 +285,45 @@ describe('platform usage queue bindings', () => { expect(configs.tail.queues).toBeUndefined(); }); + it('keeps the tail worker off workers.dev and preview URLs when endpoints are public', () => { + const deployment = createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', + }); + const configs = createPlatformWorkerConfigs({ + accountId: 'account', + apiUrl: 'https://api.account.workers.dev', + deployment, + healthProbeToken: 'probe', + installationId: 'installation', + ownershipNonce: 'ownership', + platformVersion: '1.0.0', + proxyUrl: 'https://proxy.account.workers.dev', + emailGatewayUrl: 'https://email-gateway.account.workers.dev', + publicEndpoints: true, + trafficState: 'enabled', + resources: { + hyperdrive: 'hyperdrive-id', + isrCacheKv: 'isr-id', + legacyAssetsKv: 'assets-id', + routingKv: 'routing-id', + zone: 'zone-id', + }, + runtimeEntrypoints: { + api: 'api.mjs', + dispatch: 'dispatch.mjs', + proxy: 'proxy.mjs', + emailGateway: 'email-gateway.mjs', + tail: 'tail.mjs', + }, + }); + + expect(configs.api.workers_dev).toBe(true); + expect(configs.tail.workers_dev).toBe(false); + expect(configs.tail.preview_urls).toBe(false); + }); + it('creates core Durable Objects with SQLite while preserving migration tags', () => { const deployment = createPlatformDeploymentManifest({ application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, diff --git a/packages/void/src/cli/delete.ts b/packages/void/src/cli/delete.ts index 113c49dde..2a15f7f65 100644 --- a/packages/void/src/cli/delete.ts +++ b/packages/void/src/cli/delete.ts @@ -1,7 +1,7 @@ -import { outro, note, log, text, isCancel, cancel } from '@voidzero-dev/vite-plus-prompts'; +import { outro, log, text, isCancel, cancel } from '@voidzero-dev/vite-plus-prompts'; import { getToken } from './auth.ts'; import { PlatformClient } from './client.ts'; -import { createSpinner } from './output.ts'; +import { createSpinner, note } from './output.ts'; import { readProjectConfig, removeProjectConfig } from './project.ts'; import { resolveProjectBySlug } from './resolve-project.ts'; @@ -74,7 +74,7 @@ export async function runDeleteCommand( const result = await client.deleteProject(projectId); - s.stop('Project deleted.'); + s.clear(); if (result.warnings && result.warnings.length > 0) { for (const warning of result.warnings) { diff --git a/packages/void/src/cli/deploy-cloudflare-publish.ts b/packages/void/src/cli/deploy-cloudflare-publish.ts index e06714c57..ad5771c0b 100644 --- a/packages/void/src/cli/deploy-cloudflare-publish.ts +++ b/packages/void/src/cli/deploy-cloudflare-publish.ts @@ -8,7 +8,6 @@ import { type ExistingCloudflareWorker, type ApprovedCloudflareKvBinding, } from '@void/deploy-cloudflare'; -import { note } from '@voidzero-dev/vite-plus-prompts'; import { collectMigrations } from '../migrations/collect.ts'; import { detectFramework } from '../plugin-inference.ts'; import { readVersionMetadataBinding } from '../shared/version-metadata.ts'; @@ -51,6 +50,7 @@ import { deployChildEnv } from './deploy-transport.ts'; import { type CloudflareDeployDeps } from './deploy-types.ts'; import { type SelfHostSchemaFacts } from './env-validation.ts'; import { type CliLogger } from './log-file.ts'; +import { note } from './output.ts'; import { acknowledgeWranglerManagedQueueRemovals, readWranglerManagedQueueCleanupCandidates, diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index b61b99f9e..67b98a281 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -14,7 +14,6 @@ import { assertExistingCloudflareConfig, assertCloudflareBindingsPreserved, } from '@void/deploy-cloudflare'; -import { note } from '@voidzero-dev/vite-plus-prompts'; import { dirname, join, relative } from 'pathslash'; import { analyzeApplicationSync } from '../application-analysis.ts'; import { analyzeApplication } from '../application-routing.ts'; @@ -98,6 +97,7 @@ import { import { type RunDeployOptions, type CloudflareDeployDeps } from './deploy-types.ts'; import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts'; import { type CliLogger } from './log-file.ts'; +import { note } from './output.ts'; import { buildStaticManifest } from './package.ts'; import { detectPreset, type StaticPreset, type FrameworkPreset } from './preset.ts'; import { diff --git a/packages/void/src/cli/deploy-drift.ts b/packages/void/src/cli/deploy-drift.ts index 87f524fd1..9ec0eb132 100644 --- a/packages/void/src/cli/deploy-drift.ts +++ b/packages/void/src/cli/deploy-drift.ts @@ -13,6 +13,8 @@ import { dirname, join, relative } from 'pathslash'; import { type DatabaseDialect } from '../config.ts'; import { writeDrizzleConfig } from '../drizzle/config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; +import { collectMigrations } from '../migrations/collect.ts'; +import { listDrizzleArtifacts } from '../migrations/validate.ts'; import { readProjectPaths } from '../project-paths.ts'; import { buildEnv } from '../shared/untrusted-env.ts'; import { log, getErrorMessage } from './deploy-helpers.ts'; @@ -32,7 +34,7 @@ export function assertDeploySchemaDriftFree(root: string, dialect: DatabaseDiale process.exit(1); } - log.error(getDeploySchemaDriftMessage()); + log.error(getDeploySchemaDriftMessage(root)); process.exit(1); } @@ -107,7 +109,29 @@ function runDrizzleKitGenerate(root: string, configPath: string): string { return output; } -export function getDeploySchemaDriftMessage(): string { +export function getDeploySchemaDriftMessage(root: string): string { + const migrationsDir = readProjectPaths(root).migrationsDir; + // Hand-written SQL with no Drizzle journal or snapshot: Drizzle diffs the schema + // against nothing, so `void db generate` would emit a migration that recreates + // tables the existing SQL already creates (and fails the fresh-database replay). + if ( + collectMigrations(migrationsDir).length > 0 && + !existsSync(join(migrationsDir, 'meta', '_journal.json')) && + listDrizzleArtifacts(migrationsDir).length === 0 + ) { + const dir = `${relative(root, migrationsDir)}/`; + return [ + `Schema drift detected: ${dir} has SQL migrations without Drizzle metadata (${dir}meta/_journal.json and a schema snapshot).`, + '`void db generate` cannot fix this: it would add a migration that creates your existing tables again.', + 'To add the metadata:', + ` 1. Make your Drizzle schema match the tables your migrations in ${dir} create.`, + ` 2. In a scratch copy of the project, delete ${dir} and run \`void db generate\`.`, + ` 3. Copy its meta/ folder into ${dir}. In meta/_journal.json, list each existing migration file in order (tag = file name without .sql). Rename the snapshot to _snapshot.json, where is the last tag up to its first "_".`, + `If no database has applied these migrations yet, you can instead delete them and run \`void db generate\`.`, + 'Then commit the changes and rerun `void deploy`.', + ].join('\n'); + } + return [ 'Schema drift detected between your Drizzle schema and db/migrations/.', 'Run `void db generate`, review the generated migration, commit it yourself, then rerun `void deploy`.', diff --git a/packages/void/src/cli/deploy-managed-events.ts b/packages/void/src/cli/deploy-managed-events.ts index 9b9493ad1..75b4d74e2 100644 --- a/packages/void/src/cli/deploy-managed-events.ts +++ b/packages/void/src/cli/deploy-managed-events.ts @@ -47,7 +47,14 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep s.message('Waiting for worker to become reachable...'); return { kind: 'continue' }; case 'migration_start': - s.message(`Running ${event.pending} migration${event.pending > 1 ? 's' : ''}...`); + // `pending` counts every migration bundled with the build; the server + // only learns which are unapplied while running them. + s.message(`Checking ${event.pending} bundled migration${event.pending === 1 ? '' : 's'}...`); + return { kind: 'continue' }; + case 'migration_warning': + // log.warn persists above the spinner; a spinner message would be + // overwritten by the next event. + log.warn(event.message); return { kind: 'continue' }; case 'migration': s.message(`Applied migration: ${event.name} (${event.durationMs}ms)`); diff --git a/packages/void/src/cli/deploy-managed-framework.ts b/packages/void/src/cli/deploy-managed-framework.ts index 015514cb8..09d8d3f0a 100644 --- a/packages/void/src/cli/deploy-managed-framework.ts +++ b/packages/void/src/cli/deploy-managed-framework.ts @@ -1,6 +1,5 @@ import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { note } from '@voidzero-dev/vite-plus-prompts'; import { join } from 'pathslash'; import { analyzeApplicationSync } from '../application-analysis.ts'; import { type VoidConfig } from '../config.ts'; @@ -30,7 +29,7 @@ import { type ResourceSummary } from './deploy-types.ts'; import { type GitDeployMetadata } from './git-metadata.ts'; import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts'; import { type CliLogger } from './log-file.ts'; -import { createSpinner, formatCount } from './output.ts'; +import { createSpinner, formatCount, note } from './output.ts'; import { collectAndHashAssets, buildFrameworkManifest, collectWorkerFiles } from './package.ts'; import { formatProjectCommand, resolveProjectCommand, type FrameworkPreset } from './preset.ts'; import { type ProjectConfig } from './project.ts'; diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index 9bb6e1d63..c1cb4b0d0 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -1,6 +1,5 @@ import { execSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; -import { note } from '@voidzero-dev/vite-plus-prompts'; import { join } from 'pathslash'; import { analyzeApplication } from '../application-routing.ts'; import { @@ -55,7 +54,7 @@ import { type ResourceSummary } from './deploy-types.ts'; import { type GitDeployMetadata } from './git-metadata.ts'; import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts'; import { type CliLogger } from './log-file.ts'; -import { createSpinner, formatCount } from './output.ts'; +import { createSpinner, formatCount, note } from './output.ts'; import { collectAndHashAssets, collectWorkerFiles, diff --git a/packages/void/src/cli/deploy-managed-static.ts b/packages/void/src/cli/deploy-managed-static.ts index c2fe4e4bc..ad200a0ca 100644 --- a/packages/void/src/cli/deploy-managed-static.ts +++ b/packages/void/src/cli/deploy-managed-static.ts @@ -1,6 +1,5 @@ import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { note } from '@voidzero-dev/vite-plus-prompts'; import { type NotFoundHandling, type RoutingConfig } from '../config.ts'; import { collectPages } from '../isr-config.ts'; import type { PageScanResult } from '../pages/types.ts'; @@ -17,7 +16,7 @@ import { MANAGED_DEPLOY_BUILD_ENV } from './deploy-transport.ts'; import { type GitDeployMetadata } from './git-metadata.ts'; import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts'; import { type CliLogger } from './log-file.ts'; -import { createSpinner, formatCount } from './output.ts'; +import { createSpinner, formatCount, note } from './output.ts'; import { collectAndHashAssets, buildVoidManifest, buildStaticManifest } from './package.ts'; import { type StaticPreset } from './preset.ts'; import { type ProjectConfig } from './project.ts'; diff --git a/packages/void/src/cli/deploy-migrations.ts b/packages/void/src/cli/deploy-migrations.ts index fabf05809..30b7fbc20 100644 --- a/packages/void/src/cli/deploy-migrations.ts +++ b/packages/void/src/cli/deploy-migrations.ts @@ -530,7 +530,7 @@ export function assertRemoteMigrationsDeployable( migrations: Array<{ name: string; sql: string }>, ): void { if (hasDeploySchemaDrift(root, dialect)) { - throw new Error(getDeploySchemaDriftMessage()); + throw new Error(getDeploySchemaDriftMessage(root)); } assertJournalCoherence(migrationsDir, migrations); if (migrations.length > 0) { diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index ac5333596..4ac8d575e 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -1,8 +1,8 @@ -import { log, note } from '@voidzero-dev/vite-plus-prompts'; +import { log } from '@voidzero-dev/vite-plus-prompts'; import { getToken } from './auth.ts'; import { type DomainStatusResponse, PlatformClient } from './client.ts'; import type { DomainArgs } from './domain-args.ts'; -import { pc } from './output.ts'; +import { note, pc } from './output.ts'; import { readDeploymentPlatform, readProjectConfig } from './project.ts'; export async function runDomainCommand(root: string, args: DomainArgs): Promise { diff --git a/packages/void/src/cli/email.ts b/packages/void/src/cli/email.ts index 32a53ffca..ba832c0bc 100644 --- a/packages/void/src/cli/email.ts +++ b/packages/void/src/cli/email.ts @@ -2,7 +2,7 @@ import { execSync } from 'node:child_process'; import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { parseRootWranglerConfig, readWranglerTopLevelString } from '@void/deploy-cloudflare'; -import { confirm, isCancel, log, note, password } from '@voidzero-dev/vite-plus-prompts'; +import { confirm, isCancel, log, password } from '@voidzero-dev/vite-plus-prompts'; import { join } from 'pathslash'; import { readConfig } from '../config.ts'; import { readProjectPaths } from '../project-paths.ts'; @@ -32,7 +32,7 @@ import { type EmailPreflight, type EmailWranglerRun, } from './email-cloudflare.ts'; -import { createSpinner, pc } from './output.ts'; +import { createSpinner, note, pc } from './output.ts'; import { readProjectConfig } from './project.ts'; import { writeProvisionedWranglerConfig } from './wrangler.ts'; diff --git a/packages/void/src/cli/env.ts b/packages/void/src/cli/env.ts index 351381d17..37b01e0d8 100644 --- a/packages/void/src/cli/env.ts +++ b/packages/void/src/cli/env.ts @@ -1,11 +1,12 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { outro, log, note } from '@voidzero-dev/vite-plus-prompts'; +import { outro, log } from '@voidzero-dev/vite-plus-prompts'; import { join } from 'pathslash'; import { findEnvFile, generateEnvTypes } from '../codegen/env-types.ts'; import { getToken } from './auth.ts'; import { PlatformClient } from './client.ts'; import type { EnvArgs } from './env-args.ts'; import { fetchRemoteSecretNames, formatEnvReport, validateProdEnv } from './env-validation.ts'; +import { note } from './output.ts'; import { readDeploymentPlatform, readProjectConfig } from './project.ts'; export async function runEnvCommand(root: string, args: EnvArgs): Promise { diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index b1206667e..f339da09d 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'node:fs'; -import { note, outro } from '@voidzero-dev/vite-plus-prompts'; +import { outro } from '@voidzero-dev/vite-plus-prompts'; import { join } from 'pathslash'; import { getPackageDir } from './agents.ts'; import { asciiArts } from './ascii.ts'; import { operatorCommands, operatorGroups } from './operator-args.ts'; -import { categoryTitle, helpLine, pc, sectionTitle } from './output.ts'; +import { categoryTitle, helpLine, note, pc, sectionTitle } from './output.ts'; type HelpCommand = { command: string; @@ -580,6 +580,11 @@ const HELP_TREE: HelpTree = { command: '--purge-data', description: 'Delete installer-owned data even when it contains apps', }, + { + command: '--purge-resources', + description: + 'Delete every resource the installation created (includes --purge-data); keeps zones, adopted resources, and PostgreSQL', + }, { command: '--keep-zone', description: 'Explicitly retain an installer-created zone', diff --git a/packages/void/src/cli/list.ts b/packages/void/src/cli/list.ts index 33ae32c0c..98c03b4de 100644 --- a/packages/void/src/cli/list.ts +++ b/packages/void/src/cli/list.ts @@ -181,6 +181,7 @@ function formatDeploymentRow( static?: string; framework?: string; revalidate?: number; + pathRevalidate?: Record; } | null = null; if (d.manifest) { try { @@ -191,7 +192,13 @@ function formatDeploymentRow( } const appType = manifest?.type ?? (manifest?.framework ? 'framework' : 'void'); - const isr = manifest?.revalidate && manifest.revalidate > 0 ? `${manifest.revalidate}s` : '-'; + const pathRuleCount = Object.keys(manifest?.pathRevalidate ?? {}).length; + const isr = + manifest?.revalidate && manifest.revalidate > 0 + ? `${manifest.revalidate}s` + : pathRuleCount > 0 + ? formatCount(pathRuleCount, 'rule') + : '-'; const source = d.source ?? '-'; const commit = d.commit ? d.commit.slice(0, 7) : '-'; diff --git a/packages/void/src/cli/log-file.ts b/packages/void/src/cli/log-file.ts index b8f48cba9..1750b2618 100644 --- a/packages/void/src/cli/log-file.ts +++ b/packages/void/src/cli/log-file.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { createWriteStream, mkdirSync, type WriteStream } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'pathslash'; @@ -174,8 +175,12 @@ export function openDeployLog(opts?: { mirrorToStderr?: boolean }): CliLogger { const dir = join(homedir(), '.void', 'logs'); mkdirSync(dir, { recursive: true }); const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const filePath = join(dir, `deploy-${ts}.jsonl`); - stream = createWriteStream(filePath, { flags: 'a', autoClose: true }); + // Deploys started in the same millisecond (parallel deploys from one + // machine) must not share a file, so add the pid and a random suffix and + // refuse to open a file that already exists. + const suffix = randomBytes(3).toString('hex'); + const filePath = join(dir, `deploy-${ts}-${process.pid}-${suffix}.jsonl`); + stream = createWriteStream(filePath, { flags: 'wx', autoClose: true }); stream.on('error', () => { // Disable subsequent writes silently. Stream errors should never // bubble up into the deploy pipeline. diff --git a/packages/void/src/cli/operator-client.ts b/packages/void/src/cli/operator-client.ts index ade837f95..1154e7be1 100644 --- a/packages/void/src/cli/operator-client.ts +++ b/packages/void/src/cli/operator-client.ts @@ -136,7 +136,11 @@ export class OperatorClient { throw new OperatorClientError( typeof result.message === 'string' ? result.message - : `Operator request failed (HTTP ${response.status}).`, + : typeof result.error === 'string' + ? result.error === 'Platform disabled' && result.reason === 'maintenance' + ? `Operator request failed (HTTP ${response.status}): the platform is paused for an upgrade or repair. Retry when it finishes.` + : `Operator request failed (HTTP ${response.status}): ${result.error}.${result.error === 'Platform disabled' ? ' Run `void platform enable`.' : ''}` + : `Operator request failed (HTTP ${response.status}).`, typeof result.error === 'string' ? result.error : 'request_failed', response.status, result.details, diff --git a/packages/void/src/cli/operator-cmd.ts b/packages/void/src/cli/operator-cmd.ts index ec7c60946..4f677fddf 100644 --- a/packages/void/src/cli/operator-cmd.ts +++ b/packages/void/src/cli/operator-cmd.ts @@ -89,7 +89,32 @@ function eventLines(events: Array, indent: string): Array { ]); } -function humanLines(value: unknown, indent = ''): Array { +const LABEL_WORDS: Record = { + api: 'API', + dns: 'DNS', + github: 'GitHub', + http: 'HTTP', + id: 'ID', + ids: 'IDs', + oauth: 'OAuth', + oidc: 'OIDC', + url: 'URL', + urls: 'URLs', +}; + +function fieldLabel(key: string): string { + const words = key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[\s_-]+/) + .filter(Boolean) + .map((word) => LABEL_WORDS[word.toLowerCase()] ?? word.toLowerCase()); + if (words[0]) { + words[0] = words[0][0]!.toUpperCase() + words[0].slice(1); + } + return words.join(' '); +} + +export function humanLines(value: unknown, indent = ''): Array { if (Array.isArray(value)) { return table(value, indent); } @@ -97,7 +122,7 @@ function humanLines(value: unknown, indent = ''): Array { return [`${indent}${printable(value)}`]; } return Object.entries(value).flatMap(([key, item]) => { - const label = key.replaceAll('_', ' ').replace(/([a-z])([A-Z])/g, '$1 $2'); + const label = fieldLabel(key); if (Array.isArray(item)) { return [ `${indent}${label}:`, @@ -663,7 +688,7 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { console.log( [ ...humanLines({ pagination: result.pagination }), - 'deployments:', + 'Deployments:', ...eventLines(result.deployments, ' '), ].join('\n'), ); diff --git a/packages/void/src/cli/output.ts b/packages/void/src/cli/output.ts index 5506351c1..267b78c7c 100644 --- a/packages/void/src/cli/output.ts +++ b/packages/void/src/cli/output.ts @@ -1,6 +1,8 @@ +import type { Writable } from 'node:stream'; import { isCI, log, + note as promptsNote, settings, spinner, type SpinnerOptions, @@ -74,6 +76,30 @@ export function createSpinner( }; } +/** Narrowest terminal the shared box renderer can draw a note in, guide bar included. */ +const MIN_NOTE_COLUMNS = 6; + +/** + * The shared box renderer throws when the terminal is narrower than its smallest box (a 0x0 pty + * reports 0 columns). Treat a non-positive width as unknown, like `stdout.columns || 80` elsewhere + * in the CLI, and draw tiny terminals at the narrowest width the box supports. + */ +export function note(...args: Parameters): void { + const [message, title, opts] = args; + const output = opts?.output ?? process.stdout; + const columns = (output as { columns?: unknown }).columns; + if (typeof columns !== 'number' || columns >= MIN_NOTE_COLUMNS) { + promptsNote(...args); + return; + } + // The box renderer only reads `columns` and calls `write()`. + const sized = { + columns: columns > 0 ? MIN_NOTE_COLUMNS : 80, + write: (chunk: string) => output.write(chunk), + } as unknown as Writable; + promptsNote(message, title, { ...opts, output: sized }); +} + export function sectionTitle(title: string): string { return pc.reset(pc.blue(title)); } diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index ca3b32bb5..8bd8f4dd2 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -115,6 +115,8 @@ export type PlatformArgs = selector: string; plan: boolean; purgeData: boolean; + /** Delete every resource the installation created; implies purgeData. */ + purgeResources: boolean; deleteZone: boolean | null; yes: boolean; }; @@ -333,7 +335,10 @@ export function parsePlatformArgs(args: Array): PlatformArgs { } if (subcommand === 'uninstall') { const parsed = parseSelector( - rest.filter((arg) => !['--purge-data', '--delete-zone', '--keep-zone'].includes(arg)), + rest.filter( + (arg) => + !['--purge-data', '--purge-resources', '--delete-zone', '--keep-zone'].includes(arg), + ), subcommand, { allowYes: true }, ); @@ -344,7 +349,8 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand, selector: parsed.selector, plan: parsed.plan, - purgeData: rest.includes('--purge-data'), + purgeData: rest.includes('--purge-data') || rest.includes('--purge-resources'), + purgeResources: rest.includes('--purge-resources'), yes: parsed.yes, deleteZone: rest.includes('--delete-zone') ? true diff --git a/packages/void/src/cli/platform-auth-config.ts b/packages/void/src/cli/platform-auth-config.ts index c4b52299a..9d052940c 100644 --- a/packages/void/src/cli/platform-auth-config.ts +++ b/packages/void/src/cli/platform-auth-config.ts @@ -21,6 +21,7 @@ import { import { openBrowser } from './auth.ts'; import { readOperatorToken, resolveOperatorOrigin } from './operator-auth.ts'; import { OperatorClient } from './operator-client.ts'; +import { humanLines } from './operator-cmd.ts'; import type { PlatformConfigArgs } from './platform-config-args.ts'; type Method = { @@ -46,8 +47,8 @@ function value(input: T | typeof CANCEL_SYMBOL): T { return input; } -function print(result: unknown): void { - console.log(JSON.stringify(result, null, 2)); +function print(result: unknown, json: boolean): void { + console.log(json ? JSON.stringify(result, null, 2) : humanLines(result).join('\n')); } async function apply( @@ -267,11 +268,11 @@ export async function runPlatformAuthConfig(args: PlatformConfigArgs): Promise; export type CloudflareNamedResource = Readonly<{ + /** When Cloudflare created it, where the API reports that. */ + createdAt?: string; fingerprint?: string; id: string; name: string; @@ -90,6 +92,28 @@ export type CloudflareWorkerServiceRoute = Readonly<{ pattern: string; zoneName: string; }>; +/** What still depends on a Worker, as `wrangler delete` reads it before forcing. */ +export type CloudflareWorkerDependents = Readonly<{ + /** + * Workers that use it through a service binding, a Durable Object namespace + * it implements, a dispatch namespace outbound, or as their tail Worker. + */ + workers: ReadonlyArray; + /** A Pages project binds to it; Cloudflare does not name the project. */ + pagesFunction: boolean; +}>; +export type CloudflareQueueConsumer = Readonly<{ + id: string; + /** Worker that consumes the Queue (`script_name`); null for an HTTP pull consumer. */ + script: string | null; +}>; +export type PlatformAccessResourceKind = 'application' | 'service-token'; +export type CloudflareEmailRoutingCatchAll = Readonly<{ + enabled: boolean; + /** The Worker that receives mail; null unless its only action is one Worker. */ + workerName: string | null; +}>; +export type CloudflareEmailRoutingRule = CloudflareEmailRoutingCatchAll & Readonly<{ id: string }>; export type PlatformManagedWorkerTraffic = Readonly<{ customDomains: ReadonlyArray>; routes: ReadonlyArray>; @@ -220,11 +244,7 @@ export class PlatformCloudflareClient { ); } if (!response.ok || envelope.success !== true || envelope.result === undefined) { - const details = - envelope.errors - ?.map((error) => error.message) - .filter(Boolean) - .join(', ') || `HTTP ${response.status}`; + const details = cloudflareErrorDetails(envelope, response.status); throw new PlatformCloudflareApiError( `Cloudflare API error: ${details} (HTTP ${response.status}; ${init?.method ?? 'GET'} ${path.split('?')[0]}).`, response.status, @@ -284,9 +304,9 @@ export class PlatformCloudflareClient { if (this.#aiGatewayClient) { return this.#aiGatewayClient.listAiGateways(accountId); } - const entries: Array<{ id?: unknown }> = []; + const entries: Array<{ created_at?: unknown; id?: unknown }> = []; for (let page = 1; ; page++) { - const result = await this.request>( + const result = await this.request>( `/accounts/${encodeURIComponent(accountId)}/ai-gateway/gateways?page=${page}&per_page=50`, ); entries.push(...result); @@ -295,6 +315,7 @@ export class PlatformCloudflareClient { } } return entries.map((entry) => ({ + ...withCreatedAt(entry.created_at), id: requiredString(entry.id, 'AI Gateway id'), name: requiredString(entry.id, 'AI Gateway id'), })); @@ -947,6 +968,8 @@ export class PlatformCloudflareClient { accountId: string, bucketName: string, preservedKeys: ReadonlySet = new Set(), + /** Runs before each page of deletes; throw to stop emptying the bucket. */ + beforeDelete?: () => Promise, ): Promise { let cursor = ''; const observedCursors = new Set(); @@ -985,6 +1008,9 @@ export class PlatformCloudflareClient { const keys = envelope.result .map((object) => requiredString(object.key, 'R2 object key')) .filter((key) => !preservedKeys.has(key)); + if (keys.length > 0) { + await beforeDelete?.(); + } for (let offset = 0; offset < keys.length; offset += 1000) { await this.deleteR2Objects(accountId, bucketName, keys.slice(offset, offset + 1000)); } @@ -1023,12 +1049,19 @@ export class PlatformCloudflareClient { async deleteResource( accountId: string, - kind: string, - id: string, + resource: Readonly<{ id: string; kind: string; name: string }>, zoneId?: string, ): Promise { + const { kind } = resource; + if (kind === 'ai-gateway' && this.#aiGatewayClient) { + return this.#aiGatewayClient.deleteResource(accountId, resource, zoneId); + } const account = encodeURIComponent(accountId); - const encoded = encodeURIComponent(id); + // Cloudflare addresses dispatch namespaces and Workers by name; every + // other kind by id. + const encoded = encodeURIComponent( + kind === 'dispatch-namespace' || kind === 'worker' ? resource.name : resource.id, + ); const path = kind === 'kv-namespace' ? `/accounts/${account}/storage/kv/namespaces/${encoded}` @@ -1054,16 +1087,232 @@ export class PlatformCloudflareClient { if (!path) { throw new Error(`platform: Unsupported Cloudflare deletion kind ${kind}.`); } + await this.#deleteIfPresent(path); + } + + /** + * DELETE one resource by the key Cloudflare addresses it with. Only + * `forceDeleteWorker` sends `force`; otherwise Cloudflare refuses a + * resource another Worker still uses. A 404 means it is already gone. + */ + async #deleteIfPresent(path: string): Promise { + const response = await this.#fetcher(`${this.apiBaseUrl}${path}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${this.#token}` }, + method: 'DELETE', + redirect: 'manual', + signal: AbortSignal.timeout(30_000), + }); + if (response.status === 404) { + return; + } + let envelope: CloudflareEnvelope = {}; try { - await this.request(path, { method: 'DELETE' }); + envelope = (await response.json()) as CloudflareEnvelope; + } catch { + // Judge an empty body by its status alone. + envelope = { success: response.ok }; + } + if (!response.ok || envelope.success !== true) { + throw new PlatformCloudflareApiError( + `Cloudflare API error: ${cloudflareErrorDetails(envelope, response.status)} (HTTP ${response.status}; DELETE ${path}).`, + response.status, + path, + ); + } + } + + async deleteWorkerRoute(zoneId: string, routeId: string): Promise { + await this.#deleteIfPresent( + `/zones/${encodeURIComponent(zoneId)}/workers/routes/${encodeURIComponent(routeId)}`, + ); + } + + async deleteWorkerDomain(accountId: string, domainId: string): Promise { + await this.#deleteIfPresent( + `/accounts/${encodeURIComponent(accountId)}/workers/domains/${encodeURIComponent(domainId)}`, + ); + } + + async listQueueConsumers( + accountId: string, + queueId: string, + ): Promise> { + const result = await this.request>>( + `/accounts/${encodeURIComponent(accountId)}/queues/${encodeURIComponent(queueId)}/consumers`, + ); + return result.map((entry) => ({ + id: requiredString(entry.consumer_id, 'Queue consumer id'), + // The API documents `script_name`; Wrangler still reads `script` and + // `service`. A Worker consumer without a name would keep its Worker + // and Queue from being deleted, so refuse it instead of skipping it. + script: + entry.type === 'http_pull' + ? null + : requiredString( + entry.script_name ?? entry.script ?? entry.service, + 'Queue consumer Worker name', + ), + })); + } + + async listWorkerDependents( + accountId: string, + workerName: string, + ): Promise { + const account = encodeURIComponent(accountId); + const script = encodeURIComponent(workerName); + type Reference = Readonly<{ script?: unknown; service?: unknown }>; + const references = await this.request<{ + dispatch_outbounds?: ReadonlyArray; + durable_objects?: ReadonlyArray; + services?: { incoming?: ReadonlyArray; pages_function?: unknown }; + }>(`/accounts/${account}/workers/scripts/${script}/references`); + const tails = await this.request>( + `/accounts/${account}/workers/tails/by-consumer/${script}`, + ); + const name = (reference: Reference | undefined) => + requiredString(reference?.script ?? reference?.service, 'Dependent Worker name'); + const workers = [ + ...(references.services?.incoming ?? []).map(name), + // A Worker lists the Durable Object namespaces it implements itself. + ...(references.durable_objects ?? []).map(name).filter((entry) => entry !== workerName), + ...(references.dispatch_outbounds ?? []).map(name), + ...tails.map((tail) => name(tail.producer)), + ]; + return { + pagesFunction: references.services?.pages_function === true, + workers: [...new Set(workers)], + }; + } + + /** + * DELETE a Worker even though other Workers still bind to it. Cloudflare + * leaves those bindings pointing at nothing, so callers first check that + * every dependent is going away too. + */ + async forceDeleteWorker(accountId: string, workerName: string): Promise { + await this.#deleteIfPresent( + `/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(workerName)}?force=true`, + ); + } + + async deleteQueueConsumer(accountId: string, queueId: string, consumerId: string): Promise { + await this.#deleteIfPresent( + `/accounts/${encodeURIComponent(accountId)}/queues/${encodeURIComponent(queueId)}/consumers/${encodeURIComponent(consumerId)}`, + ); + } + + async listDispatchNamespaceScripts( + accountId: string, + namespace: string, + ): Promise> { + const result = await this.request>( + `/accounts/${encodeURIComponent(accountId)}/workers/dispatch/namespaces/${encodeURIComponent(namespace)}/scripts`, + ); + return result.map((entry) => requiredString(entry.id, 'Dispatch namespace script name')); + } + + async deleteDispatchNamespaceScript( + accountId: string, + namespace: string, + scriptName: string, + ): Promise { + await this.#deleteIfPresent( + `/accounts/${encodeURIComponent(accountId)}/workers/dispatch/namespaces/${encodeURIComponent(namespace)}/scripts/${encodeURIComponent(scriptName)}`, + ); + } + + /** Read one Access application or service token; null when it is gone. */ + async getAccessResource( + accountId: string, + kind: PlatformAccessResourceKind, + id: string, + ): Promise { + try { + const result = await this.request<{ id?: unknown; name?: unknown }>( + accessResourcePath(accountId, kind, id), + ); + return { + id: requiredString(result.id, 'Access resource id'), + name: requiredString(result.name, 'Access resource name'), + }; } catch (error) { if (error instanceof PlatformCloudflareApiError && error.status === 404) { - return; + return null; } throw error; } } + async deleteAccessResource( + accountId: string, + kind: PlatformAccessResourceKind, + id: string, + ): Promise { + await this.#deleteIfPresent(accessResourcePath(accountId, kind, id)); + } + + /** Email Routing rules in a zone, without the catch-all, which has its own endpoint. */ + async listEmailRoutingRules(zoneId: string): Promise> { + const entries: Array> = []; + for (let page = 1; ; page++) { + const query = new URLSearchParams({ page: String(page), per_page: '50' }); + const result = await this.request>>( + `/zones/${encodeURIComponent(zoneId)}/email/routing/rules?${query}`, + ); + entries.push(...result); + if (result.length < 50) { + break; + } + } + return entries + .filter( + (entry) => + !(Array.isArray(entry.matchers) ? entry.matchers : []).some( + (matcher: unknown) => (matcher as { type?: unknown } | null)?.type === 'all', + ), + ) + .map((entry) => ({ + // `tag` is the deprecated name of `id`. + id: requiredString(entry.id ?? entry.tag, 'Email Routing rule id'), + ...parseEmailRoutingTarget(entry), + })); + } + + async deleteEmailRoutingRule(zoneId: string, ruleId: string): Promise { + await this.#deleteIfPresent( + `/zones/${encodeURIComponent(zoneId)}/email/routing/rules/${encodeURIComponent(ruleId)}`, + ); + } + + /** The zone's Email Routing catch-all rule; null when the zone has none. */ + async getEmailRoutingCatchAll(zoneId: string): Promise { + try { + return parseEmailRoutingTarget( + await this.request>( + `/zones/${encodeURIComponent(zoneId)}/email/routing/rules/catch_all`, + ), + ); + } catch (error) { + if (error instanceof PlatformCloudflareApiError && error.status === 404) { + return null; + } + throw error; + } + } + + /** Point the catch-all back at its disabled default, which drops mail. */ + async resetEmailRoutingCatchAll(zoneId: string): Promise { + await this.request(`/zones/${encodeURIComponent(zoneId)}/email/routing/rules/catch_all`, { + method: 'PUT', + body: JSON.stringify({ + matchers: [{ type: 'all' }], + actions: [{ type: 'drop' }], + enabled: false, + }), + }); + } + async removeRoutes( _accountId: string, _zoneId: string | null, @@ -1095,10 +1344,56 @@ export class PlatformCloudflareClient { } } +function accessResourcePath( + accountId: string, + kind: PlatformAccessResourceKind, + id: string, +): string { + return `/accounts/${encodeURIComponent(accountId)}/access/${kind === 'application' ? 'apps' : 'service_tokens'}/${encodeURIComponent(id)}`; +} + +function parseEmailRoutingTarget(rule: Record): CloudflareEmailRoutingCatchAll { + const actions = Array.isArray(rule.actions) ? (rule.actions as Array) : []; + const action = + actions.length === 1 ? (actions[0] as { type?: unknown; value?: unknown } | null) : null; + const value = Array.isArray(action?.value) ? (action.value as Array) : []; + return { + enabled: rule.enabled === true, + workerName: + action?.type === 'worker' && value.length === 1 && typeof value[0] === 'string' + ? value[0] + : null, + }; +} + +function cloudflareErrorDetails(envelope: CloudflareEnvelope, status: number): string { + return ( + envelope.errors + ?.filter((error) => error.message) + .map((error) => + error.code === undefined ? error.message : `${error.message} (code ${error.code})`, + ) + .join(', ') || `HTTP ${status}` + ); +} + function encodeR2ObjectKey(key: string): string { return key.split('/').map(encodeURIComponent).join('/'); } +/** + * An AI Gateway creation time as ISO 8601 UTC. Cloudflare reports it without a + * zone ("2026-10-05 18:29:14"), which JavaScript would read as local time. + */ +function withCreatedAt(value: unknown): { createdAt?: string } { + if (typeof value !== 'string') { + return {}; + } + const zoneless = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value); + const time = Date.parse(zoneless ? `${value.replace(' ', 'T')}Z` : value); + return Number.isNaN(time) ? {} : { createdAt: new Date(time).toISOString() }; +} + function requiredString(value: unknown, label: string): string { if (typeof value !== 'string' || value === '') { throw new Error(`platform: ${label} is missing from the Cloudflare response.`); diff --git a/packages/void/src/cli/platform-cmd.ts b/packages/void/src/cli/platform-cmd.ts index 2d6c310ca..df4794877 100644 --- a/packages/void/src/cli/platform-cmd.ts +++ b/packages/void/src/cli/platform-cmd.ts @@ -8,7 +8,7 @@ import { } from './cf-access.ts'; import { pc } from './output.ts'; import type { PlatformArgs } from './platform-args.ts'; -import { readPlatformDiscoveryResponse } from './platform-discovery.ts'; +import { PlatformDisabledError, readPlatformDiscoveryResponse } from './platform-discovery.ts'; import { getActivePlatformConnection, listPlatformConnections, @@ -195,11 +195,24 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro try { discovery = await discoverPlatform(apiUrl); } catch (error) { + if (error instanceof PlatformDisabledError && !error.maintenance) { + console.log('Platform: disabled (API and application traffic are paused).'); + if (!saved?.operation) { + console.log(`Enable it: void platform enable${saved ? ` ${saved.id}` : ''}`); + } + return; + } if (saved) { log.info( 'Live availability could not be confirmed. The saved checkpoint above may be out of date.', ); } + if (error instanceof PlatformDisabledError) { + throw new Error( + `platform: Traffic at ${apiUrl} is paused for an upgrade or repair. Check again when it finishes.`, + { cause: error }, + ); + } throw error; } log.success(`${discovery.displayName} is reachable.`); diff --git a/packages/void/src/cli/platform-discovery.ts b/packages/void/src/cli/platform-discovery.ts index 026e2c504..05084b1e3 100644 --- a/packages/void/src/cli/platform-discovery.ts +++ b/packages/void/src/cli/platform-discovery.ts @@ -1,14 +1,41 @@ import { parsePlatformDiscoveryDocument, type PlatformDiscoveryDocument } from '@void/platform'; import { normalizePlatformApiUrl } from './platform-registry.ts'; +/** Discovery was refused because the platform traffic gate is closed. */ +export class PlatformDisabledError extends Error { + /** True when an upgrade or repair paused traffic, not `void platform disable`. */ + readonly maintenance: boolean; + + constructor(message: string, maintenance = false) { + super(message); + this.name = 'PlatformDisabledError'; + this.maintenance = maintenance; + } +} + /** Validates a `GET /.well-known/void-platform` response from `apiUrl`. */ export async function readPlatformDiscoveryResponse( apiUrl: string, response: Response, ): Promise { if (!response.ok) { - await response.body?.cancel(); - throw new Error(`platform: Discovery failed at ${apiUrl} (HTTP ${response.status}).`); + const message = `platform: Discovery failed at ${apiUrl} (HTTP ${response.status}).`; + if (response.status === 503) { + const body: unknown = await response.json().catch(() => null); + if ( + body && + typeof body === 'object' && + (body as Record).error === 'Platform disabled' + ) { + throw new PlatformDisabledError( + message, + (body as Record).reason === 'maintenance', + ); + } + } else { + await response.body?.cancel(); + } + throw new Error(message); } let value: unknown; try { diff --git a/packages/void/src/cli/platform-domain.ts b/packages/void/src/cli/platform-domain.ts index f113f220f..860d1cc66 100644 --- a/packages/void/src/cli/platform-domain.ts +++ b/packages/void/src/cli/platform-domain.ts @@ -7,8 +7,9 @@ import { type PlatformApplication, type PlatformInstallationManifest, } from '@void/platform'; -import { confirm, isCancel, log, note, text } from '@voidzero-dev/vite-plus-prompts'; +import { confirm, isCancel, log, text } from '@voidzero-dev/vite-plus-prompts'; import { cfAccessHeaders } from '../shared/cf-access.ts'; +import { note } from './output.ts'; import type { PlatformArgs } from './platform-args.ts'; import { assertPlatformOperatorCredentialCanApplyPlan, diff --git a/packages/void/src/cli/platform-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index e383801e6..d8bb3a0e4 100644 --- a/packages/void/src/cli/platform-installer-database.ts +++ b/packages/void/src/cli/platform-installer-database.ts @@ -25,6 +25,7 @@ import { } from './platform-installer-types.ts'; import { type PlatformInstallPlan } from './platform-plan.ts'; import { writeInstallationManifest } from './platform-registry.ts'; +import { readPlatformProjectSlugsConnection } from './platform-teardown.ts'; import { fencePlatformProjectZeroTrustConnection, liftPlatformProjectZeroTrustFenceConnection, @@ -274,6 +275,26 @@ export async function readPlatformProjectZeroTrustState( } } +/** Read project slugs for a --purge-resources plan, which holds no lifecycle lock. */ +export async function readPlatformProjectSlugs( + databaseUrl: string, + installationId: string, + ownershipNonce: string, +): Promise> { + if (!databaseUrl) { + throw new Error('the installation database URL is not recorded on this machine'); + } + const { Pool } = await import('pg'); + const pool = new Pool({ connectionString: platformDatabaseClientUrl(databaseUrl), max: 1 }); + try { + // Throws unless the database is claimed by this installation. + await readPlatformDatabaseLifecycleManifestConnection(pool, installationId, ownershipNonce); + return await readPlatformProjectSlugsConnection(pool); + } finally { + await pool.end(); + } +} + type DatabaseClaimConnection = Readonly<{ query: = Record>( sql: string, @@ -564,6 +585,7 @@ export async function acquirePlatformDatabaseLifecycleLockConnection( : null; }; release.readProjectZeroTrust = () => readPlatformProjectZeroTrustStateConnection(client); + release.readProjects = () => readPlatformProjectSlugsConnection(client); release.lockProjectZeroTrust = () => lockPlatformProjectZeroTrustConnection(client); release.fenceProjectZeroTrust = () => fencePlatformProjectZeroTrustConnection(client); release.liftProjectZeroTrustFence = () => liftPlatformProjectZeroTrustFenceConnection(client); @@ -918,10 +940,13 @@ export async function claimPlatformDatabaseConnection( ); const claim = result.rows[0]; if (!claim) { + // Migration history lives outside search_path. Leftover history would be + // read as already-applied migrations only after the claim, so check it + // here. Other provider-owned schemas stay out of scope. const contents = await client.query<{ schemaname: string; tablename: string }>( `SELECT "schemaname", "tablename" FROM "pg_catalog"."pg_tables" - WHERE "schemaname" = ANY (current_schemas(false)) + WHERE ("schemaname" = ANY (current_schemas(false)) OR "schemaname" = 'drizzle') AND "tablename" <> '__void_platform_installation' ORDER BY "schemaname", "tablename" LIMIT 1`, diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index e3a846a42..0b1a43b7e 100644 --- a/packages/void/src/cli/platform-installer-types.ts +++ b/packages/void/src/cli/platform-installer-types.ts @@ -115,6 +115,8 @@ export type PlatformDatabaseLifecycleLock = (() => Promise) & { ) => Promise; /** Project Zero Trust state that decides whether uninstall may start. */ readProjectZeroTrust?: () => Promise; + /** Project slugs, which --purge-resources requires to be empty. */ + readProjects?: () => Promise>; /** Block Zero Trust writers in the API until the returned release runs. */ lockProjectZeroTrust?: () => Promise<() => Promise>; /** Make API Zero Trust writers refuse until the fence is lifted. */ diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index 6d910e4f8..aec1d11d1 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -1094,6 +1094,7 @@ export { claimPlatformDatabase, acquirePlatformDatabaseLifecycleLock, readPlatformDatabaseLifecycleManifest, + readPlatformProjectSlugs, readPlatformProjectZeroTrustState, inspectPlatformDatabaseStateConnection, preparePlatformDatabaseConnection, diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index df258299a..d8ac6e014 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -11,10 +11,11 @@ import { setInstallationDesiredState, transitionInstallation, type InstallationResource, + type InstallationResourceKind, type PlatformInstallationManifest, } from '@void/platform'; -import { confirm, isCancel, log, note, outro, select } from '@voidzero-dev/vite-plus-prompts'; -import { createSpinner } from './output.ts'; +import { confirm, isCancel, log, outro, select } from '@voidzero-dev/vite-plus-prompts'; +import { createSpinner, note } from './output.ts'; import type { PlatformArgs } from './platform-args.ts'; import { platformDatabaseNameFromOwnerUrl } from './platform-auth-database.ts'; import { @@ -33,6 +34,7 @@ import { createPlatformInstallationSecrets, installPlatform, loadPlatformRuntime, + readPlatformProjectSlugs, readPlatformProjectZeroTrustState, REMOTE_INSTALLATION_MANIFEST_KEY, resolveBootstrapGitHubId, @@ -49,6 +51,7 @@ import { } from './platform-plan.ts'; import { acquirePlatformLifecycleLock, + installationManifestPath, listInstallationManifests, readInstallationRecoverySecrets, removeInstallationRecoverySecrets, @@ -56,6 +59,25 @@ import { resolveInstallationManifest, writeInstallationManifest, } from './platform-registry.ts'; +import { + assertNoPlatformProjects, + checkPurgeResourcesCredentials, + describePurgeResourcesPlan, + isPlatformTailWorker, + isPurgeResourcesCandidate, + isPurgedAccessResource, + PlatformResourceChangedError, + purgeResourcesAlwaysKept, + purgeResourcesHandlesEmailRouting, + purgeResourcesKeptReason, + purgeResourcesKeptResources, + purgeResourcesPermissions, + purgeResourcesResumeCommand, + PURGE_RESOURCES_STEP_PREFIX, + runPurgeResourcesTeardown, + type PurgeResourcesClient, + type PurgeResourcesCredential, +} from './platform-teardown.ts'; import { trackedPlatformWorkerTraffic } from './platform-traffic.ts'; import { assertProjectZeroTrustAllowsUninstall, @@ -84,7 +106,8 @@ export type PlatformManagementClient = Pick< >; export type PlatformUninstallClient = PlatformManagementClient & - Pick; + Pick & + Partial; export type PlatformOwnershipClient = Pick< PlatformManagementClient, @@ -157,6 +180,10 @@ export type PlatformManagementDependencies = Readonly<{ fenceProjectZeroTrust?: () => Promise; /** Removes that fence when uninstall stops before it disables traffic. */ liftProjectZeroTrustFence?: () => Promise; + /** Project slugs; --purge-resources refuses to start while any remain or when this cannot read them. */ + readProjects?: () => Promise>; + /** Where the operator credential came from; --purge-resources checks it before any change. */ + credential?: PurgeResourcesCredential; }>; export function selectAuthoritativePlatformManifest( @@ -278,6 +305,10 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise readPlatformProjectSlugs(databaseUrl, manifest.id, manifest.ownershipNonce), + credential: { source: operatorCredential.source, aiGatewayToken: !!gatewayToken }, }); return; } @@ -780,7 +811,7 @@ export async function disablePlatform( dependencies.checkpoint, ); note( - 'Block platform traffic through installation-owned routing storage. Retain routes and custom domains for explicit dashboard cleanup because Cloudflare cannot condition their deletion on an immutable generation. Workers and all data remain installed.', + 'Block all platform traffic and the platform API. Routes, custom domains, Workers, and data stay installed.', 'Disable plan', ); if (planOnly) { @@ -870,9 +901,15 @@ export async function uninstallPlatform( dependencies.checkpoint, ); const retainData = !args.purgeData; - const hasRoutingCheckpoint = manifest.resources.some( - (resource) => resource.key === 'routing-kv' && resource.state === 'ready', - ); + const purgeResources = args.purgeResources; + const routingKv = manifest.resources.find((resource) => resource.key === 'routing-kv'); + // --purge-resources also uses routing KV that an uninstall which stopped before it + // finished recorded as retained. + const reopenedRouting = + purgeResources && + routingKv?.state === 'retained' && + isPurgeResourcesCandidate(manifest, routingKv); + const hasRoutingCheckpoint = routingKv?.state === 'ready' || reopenedRouting; const createdZone = manifest.resources.find( (resource) => resource.key === 'zone' && resource.ownership === 'created', ); @@ -884,19 +921,46 @@ export async function uninstallPlatform( ); } await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); + if (purgeResources) { + await assertNoPlatformProjects(dependencies.readProjects); + } const dataMessage = retainData ? 'Retain installer-owned Workers, storage, dispatch, AI logs, and Hyperdrive by default.' : args.purgeData ? 'Delete safely identifiable installer-owned data (--purge-data); retain Workers, R2, AI Gateway, and external PostgreSQL for manual cleanup.' : 'Retain installer-owned data; external PostgreSQL is never deleted.'; + const purgeResourcesClient = purgeResources ? requirePurgeResourcesClient(client) : null; note( - `Disable all platform traffic through installation-owned routing storage.\nRetain DNS records, routes, and custom domains for explicit dashboard cleanup.\nDelete installer-owned Queues unless a retained Worker still uses them.\n${dataMessage}\n${ - createdZone - ? `Keep zone ${createdZone.name} for explicit manual cleanup.` - : 'Never delete the adopted application zone.' - }${(manifest.authenticationResources ?? []).length ? '\nRetain Access resources for deliberate cleanup:\n' + manifest.authenticationResources!.map((resource) => ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})`).join('\n') : ''}`, + purgeResources + ? purgeResourcesPlanText(manifest) + : `Disable all platform traffic through installation-owned routing storage.\nRetain DNS records, routes, and custom domains for explicit dashboard cleanup.\nDelete installer-owned Queues unless a retained Worker still uses them.\n${dataMessage}\n${ + createdZone + ? `Keep zone ${createdZone.name} for explicit manual cleanup.` + : 'Never delete the adopted application zone.' + }${describeKeptResources( + manifest, + plannedKeptResources(manifest, retainData, hasRoutingCheckpoint), + 'Keep for manual cleanup:', + 'Retain Access resources for deliberate cleanup:', + ) + .map((line) => `\n${line}`) + .join('')}`, 'Uninstall plan', ); + if (purgeResourcesClient) { + const credentialCheck = checkPurgeResourcesCredentials( + purgeResourcesClient, + manifest, + dependencies.credential ?? { source: 'environment', aiGatewayToken: false }, + ); + if (args.plan) { + await credentialCheck.catch((error: unknown) => { + log.warn(error instanceof Error ? error.message : String(error)); + }); + } else { + await credentialCheck; + } + } if (args.plan) { outro('No Cloudflare or local installation state was changed.'); return; @@ -911,9 +975,11 @@ export async function uninstallPlatform( } if (interactive && !args.yes) { const proceed = await confirmAction( - args.purgeData - ? 'Permanently purge installer-owned platform data?' - : 'Uninstall this platform?', + purgeResources + ? 'Permanently delete every Cloudflare resource this installation created?' + : args.purgeData + ? 'Permanently purge installer-owned platform data?' + : 'Uninstall this platform?', false, ); if (!proceed) { @@ -922,11 +988,29 @@ export async function uninstallPlatform( } } + const purgeResourcesKept = new Set( + purgeResources ? purgeResourcesKeptResources(manifest).map((resource) => resource.key) : [], + ); const resourcesToDelete = new Set( manifest.resources .filter( (resource) => - shouldDeleteResource(resource, retainData) && + (shouldDeleteResource(resource, retainData) || + (purgeResources && + // Includes what an uninstall that stopped before it finished + // recorded as retained, so a replaced one stops the run here too. + (resource.state === 'ready' || isPurgeResourcesCandidate(manifest, resource)) && + !purgeResourcesKept.has(resource.key) && + isOwnershipVerifiableKind(resource.kind) && + // A changed DNS record is kept, not a reason to stop, so it is + // proven only right before its DELETE. + resource.kind !== 'dns-record' && + // After its ownership marker was removed, the bucket is proven + // by its creation fingerprint instead. + !hasLifecycleStep( + manifest, + `${PURGE_RESOURCES_STEP_PREFIX}r2-marker:${resource.key}`, + ))) && (hasRoutingCheckpoint || resource.kind === 'zone') && !( resource.kind === 'dns-record' && @@ -943,12 +1027,27 @@ export async function uninstallPlatform( .map((resource) => resource.key), ...resourcesToDelete, ]); + // Ownership checks only look at ready resources. Prove retained ones that + // --purge-resources reopens the same way, without recording them as ready again. + const proofManifest = { + ...manifest, + resources: manifest.resources.map((resource) => + resource.state === 'retained' && resourcesToDelete.has(resource.key) + ? { ...resource, state: 'ready' as const } + : resource, + ), + }; let missingResources: ReadonlySet; if (hasRoutingCheckpoint) { - missingResources = await verifyPlatformMutationOwnership(client, manifest, ownershipTargets, { - allowDeletedRoutingCheckpoint: args.purgeData, - databaseUrl: dependencies.databaseUrl, - }); + missingResources = await verifyPlatformMutationOwnership( + client, + proofManifest, + ownershipTargets, + { + allowDeletedRoutingCheckpoint: args.purgeData, + databaseUrl: dependencies.databaseUrl, + }, + ); } else { await verifyPlatformInstallationOwnership(client, manifest, { databaseUrl: dependencies.databaseUrl, @@ -956,12 +1055,12 @@ export async function uninstallPlatform( missingResources = new Set(); } - // Zero Trust writers in the API take this lock and never the lifecycle lock. - // Holding it from the final check until traffic is disabled keeps them from - // creating Access applications the check did not see. A request admitted - // before the disable may already be waiting on the lock, so the fence makes - // every later holder refuse. Only enabling the platform again lifts it once - // the disable write has started. + // Zero Trust writers and project creation in the API take this lock and + // never the lifecycle lock. Holding it from the final check until traffic is + // disabled keeps them from creating Access applications or projects the + // check did not see. A request admitted before the disable may already be + // waiting on the lock, so the fence makes every later holder refuse. Only + // enabling the platform again lifts it once the disable write has started. const { fenceProjectZeroTrust, liftProjectZeroTrustFence, lockProjectZeroTrust } = dependencies; if (!lockProjectZeroTrust || !fenceProjectZeroTrust || !liftProjectZeroTrustFence) { throw new Error( @@ -984,6 +1083,9 @@ export async function uninstallPlatform( let next = manifest; try { await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); + if (purgeResources) { + await assertNoPlatformProjects(dependencies.readProjects); + } fenced = true; await fenceProjectZeroTrust(); next = transitionInstallation(manifest, 'uninstalling', new Date().toISOString()); @@ -994,9 +1096,7 @@ export async function uninstallPlatform( } try { await writeRemoteManifestIfPresent(client, next); - const routing = next.resources.find( - (resource) => resource.key === 'routing-kv' && resource.state === 'ready', - ); + const routing = hasRoutingCheckpoint ? requireResource(next, 'routing-kv') : undefined; disablingTraffic = true; if (routing) { await client.putKvValue( @@ -1042,6 +1142,37 @@ export async function uninstallPlatform( } } } + if (purgeResourcesClient && hasRoutingCheckpoint) { + next = await runPurgeResourcesTeardown( + { + client: purgeResourcesClient, + checkpoint: async (checkpointManifest) => { + // Keep `next` current so a failure records the progress made. + next = checkpointManifest; + await writeLifecycleCheckpoint(client, checkpointManifest); + }, + verify: (checkpointManifest, keys) => + verifyPlatformMutationOwnership(client, checkpointManifest, keys, { + allowDeletedRoutingCheckpoint: true, + databaseUrl: dependencies.databaseUrl, + }), + bucketGeneration: async (checkpointManifest, bucket) => { + const live = (await client.listR2Buckets(checkpointManifest.account.id)).find( + (entry) => entry.id === bucket.id && entry.name === bucket.name, + ); + if (!live) { + return null; + } + return live.fingerprint === undefined + ? 'unknown' + : r2BucketConfigHash(bucket.name, live.fingerprint); + }, + // --purge-resources already refused unless the project list could be read. + readProjects: () => dependencies.readProjects!(), + }, + next, + ); + } const deletionPriority: Record = { worker: 0, queue: 1, @@ -1067,7 +1198,12 @@ export async function uninstallPlatform( (resource) => resource.kind === 'queue' && shouldDeleteResource(resource, retainData), ) && (await hasLiveInstallationWorker(client, next)); - for (const resource of resources) { + for (const listed of resources) { + // Routing KV that a stopped uninstall retained is deleted last, like a ready one. + const resource = + reopenedRouting && listed.key === 'routing-kv' + ? { ...listed, state: 'ready' as const } + : listed; if (resource.state !== 'ready') { continue; } @@ -1077,7 +1213,13 @@ export async function uninstallPlatform( continue; } if (!shouldDeleteResource(resource, retainData)) { - next = updateResourceState(next, resource.key, 'retained'); + // AI Gateway is never deleted, but one already removed in Cloudflare + // is not left behind, so record it like other missing resources. + const removed = + resource.kind === 'ai-gateway' && + resource.ownership === 'created' && + !hasNamedResource(await client.listAiGateways(next.account.id), resource); + next = updateResourceState(next, resource.key, removed ? 'deleted' : 'retained'); await writeLifecycleCheckpoint(client, next); continue; } @@ -1104,7 +1246,7 @@ export async function uninstallPlatform( await writeLifecycleCheckpoint(client, next); continue; } - await client.deleteResource(next.account.id, resource.kind, resource.id, zone?.id); + await client.deleteResource(next.account.id, resource, zone?.id); next = updateResourceState(next, resource.key, 'deleted'); await writeLifecycleCheckpoint(client, next); } @@ -1116,10 +1258,21 @@ export async function uninstallPlatform( removeInstallationRecoverySecrets(next); } removePlatformConnectionForInstallation(next.id); + const kept = describeKeptResources( + next, + next.resources.filter((resource) => resource.state === 'retained'), + 'Cloudflare resources:', + 'Access resources:', + purgeResources && purgeResourcesHandlesEmailRouting(next), + ); + if (purgeResources) { + kept.push('Always kept:', ...purgeResourcesAlwaysKept()); + } + if (kept.length) { + note(kept.join('\n'), 'Kept for manual cleanup'); + } log.success( - retainData - ? `${next.deployment.displayName} was uninstalled; data resources were retained and recorded in ${next.id}.` - : `${next.deployment.displayName} was uninstalled.`, + `${next.deployment.displayName} was uninstalled. The full record is in ${installationManifestPath(next.account.id, next.id)}.`, ); } catch (error) { await stopProjectZeroTrustHold().catch(() => {}); @@ -1212,6 +1365,171 @@ function isOwnershipVerifiableKind(kind: InstallationResource['kind']): boolean ); } +// Kinds in the order of the manual cleanup steps in the uninstall guide. The +// tail Worker is listed after the dispatch namespace (see keptResourceRank), +// because Cloudflare refuses to delete it while other Workers send it logs. +const KEPT_RESOURCE_ORDER: ReadonlyArray = [ + 'dns-record', + 'worker-route', + 'worker-custom-domain', + 'queue', + 'worker', + 'dispatch-namespace', + 'kv-namespace', + 'hyperdrive', + 'r2-bucket', + 'ai-gateway', + 'analytics-dataset', + 'zone', +]; + +/** Resources an uninstall with these options leaves in the account. */ +function plannedKeptResources( + manifest: PlatformInstallationManifest, + retainData: boolean, + hasRoutingCheckpoint: boolean, +): Array { + // Uninstall never deletes Workers, and Queues are deleted only once no + // installation Worker remains. + const workersRemain = manifest.resources.some( + (resource) => resource.kind === 'worker' && resource.state === 'ready', + ); + return manifest.resources.filter( + (resource) => + resource.state === 'retained' || + (resource.state === 'ready' && + ((!hasRoutingCheckpoint && resource.kind !== 'zone') || + !shouldDeleteResource(resource, retainData) || + (resource.kind === 'queue' && workersRemain))), + ); +} + +function keptResourceRank( + manifest: PlatformInstallationManifest, + resource: InstallationResource, +): number { + return isPlatformTailWorker(manifest, resource) + ? KEPT_RESOURCE_ORDER.indexOf('dispatch-namespace') + 0.5 + : KEPT_RESOURCE_ORDER.indexOf(resource.kind); +} + +function purgeResourcesPlanText(manifest: PlatformInstallationManifest): string { + const permissions = purgeResourcesPermissions(manifest); + // Created Access resources are deleted unless an earlier run kept them. + const keepManifest = { + ...manifest, + authenticationResources: (manifest.authenticationResources ?? []).filter( + (resource) => + resource.ownership !== 'created' || + purgeResourcesKeptReason(manifest, `access:${resource.key}`) !== null, + ), + }; + return [ + 'Disable all platform traffic through installation-owned routing storage.', + ...describePurgeResourcesPlan(manifest), + ...describeKeptResources( + keepManifest, + purgeResourcesKeptResources(manifest), + 'Keep for manual cleanup:', + 'Keep Access resources you connected:', + purgeResourcesHandlesEmailRouting(manifest), + ), + 'Always kept:', + ...purgeResourcesAlwaysKept(), + ...(permissions.length + ? [ + `API token needs: ${permissions.join(', ')}. Browser login cannot delete DNS records or Access resources.`, + ] + : []), + `If it stops, fix the reported problem and rerun: ${purgeResourcesResumeCommand(manifest)}`, + ].join('\n'); +} + +function requirePurgeResourcesClient(client: PlatformUninstallClient): PurgeResourcesClient { + const required: ReadonlyArray = [ + 'deleteAccessResource', + 'deleteDispatchNamespaceScript', + 'deleteEmailRoutingRule', + 'deleteQueueConsumer', + 'deleteR2Object', + 'deleteWorkerDomain', + 'deleteWorkerRoute', + 'emptyR2Bucket', + 'forceDeleteWorker', + 'getAccessResource', + 'getEmailRoutingCatchAll', + 'listDispatchNamespaceScripts', + 'listEmailRoutingRules', + 'listQueueConsumers', + 'listWorkerDependents', + 'listWorkerRoutes', + 'resetEmailRoutingCatchAll', + ]; + if (required.some((method) => typeof client[method] !== 'function')) { + throw new Error('platform: This Cloudflare client cannot run --purge-resources.'); + } + return client as PurgeResourcesClient; +} + +function describeKeptResources( + manifest: PlatformInstallationManifest, + resources: ReadonlyArray, + heading: string, + accessHeading: string, + /** --purge-resources removes the Email Routing rules that send mail to the gateway Worker. */ + emailRulesRemoved = false, +): Array { + const sorted = [...resources].sort( + (left, right) => keptResourceRank(manifest, left) - keptResourceRank(manifest, right), + ); + const isTraffic = (resource: InstallationResource) => + KEPT_RESOURCE_ORDER.indexOf(resource.kind) < KEPT_RESOURCE_ORDER.indexOf('queue'); + const why = (key: string) => { + const reason = purgeResourcesKeptReason(manifest, key); + return reason === null ? '' : `, kept because ${reason}`; + }; + const line = (resource: InstallationResource) => + ` ${resource.kind}: ${resource.name} (${resource.id})${why(resource.key)}`; + const { + capabilities, + emailSenderDomain, + emailSharedZoneId, + resources: names, + } = manifest.deployment; + // Email Routing is set up through the platform API and is not a manifest + // resource. Its rules point at the email gateway Worker, so it comes before + // the Workers. + const email = capabilities.email + ? [ + emailRulesRemoved && emailSenderDomain + ? ` Email Routing for ${emailSenderDomain} (zone ${emailSharedZoneId}): its settings and the sender domain's MX, SPF, and DKIM records.` + : emailSenderDomain + ? ` Email Routing for ${emailSenderDomain} (zone ${emailSharedZoneId}): its routing DNS records and any catch-all rule that sends mail to ${names.emailGatewayWorker}.` + : ` Email Routing: any catch-all rule that sends mail to ${names.emailGatewayWorker}.`, + ] + : []; + const remainingAccess = (manifest.authenticationResources ?? []).filter( + (resource) => !isPurgedAccessResource(manifest, resource), + ); + const accessLine = (resource: (typeof remainingAccess)[number]) => + ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})${why(`access:${resource.key}`)}`; + // Access resources --purge-resources kept because they changed are listed with the + // other kept resources, not as ones the operator connected. + const keptAccess = (resource: (typeof remainingAccess)[number]) => + purgeResourcesKeptReason(manifest, `access:${resource.key}`) !== null; + const kept = [ + ...sorted.filter(isTraffic).map(line), + ...remainingAccess.filter(keptAccess).map(accessLine), + ...email, + ...sorted.filter((resource) => !isTraffic(resource)).map(line), + ]; + const access = remainingAccess.filter((resource) => !keptAccess(resource)).map(accessLine); + return [ + ...(kept.length ? [heading, ...kept] : []), + ...(access.length ? [accessHeading, ...access] : []), + ]; +} + function shouldDeleteResource(resource: InstallationResource, retainData: boolean): boolean { if (resource.state !== 'ready' || resource.ownership !== 'created') { return false; @@ -1392,7 +1710,11 @@ async function verifyPlatformMutationOwnership( const zone = manifest.resources.find((resource) => resource.key === 'zone'); const inventory = await Promise.all([ client.listAiGateways(accountId), - client.listDispatchNamespaces(accountId), + // Workers for Platforms is a paid product; list namespaces only when one + // is being checked. + targetResources.some((resource) => resource.kind === 'dispatch-namespace') + ? client.listDispatchNamespaces(accountId) + : Promise.resolve([]), client.listHyperdriveConfigs(accountId), client.listKvNamespaces(accountId), client.listR2Buckets(accountId), @@ -1481,8 +1803,11 @@ async function verifyPlatformMutationOwnership( liveHash !== resource.configHash && !(resource.configHash === legacyHash && liveHash === expectedHash) ) { - throw ownershipError( - `dns-record ${resource.name} changed configuration after installation`, + throw new PlatformResourceChangedError( + resource.key, + 'its configuration changed after installation', + ownershipError(`dns-record ${resource.name} changed configuration after installation`) + .message, ); } } @@ -1601,7 +1926,9 @@ export function isPlatformManifestMonotonicExtension( next.name === resource.name && next.ownership === resource.ownership && (next.state === resource.state || - (resource.state === 'ready' && (next.state === 'deleted' || next.state === 'retained'))) + (resource.state === 'ready' && (next.state === 'deleted' || next.state === 'retained')) || + // --purge-resources deletes routes and custom domains recorded as retained. + (resource.state === 'retained' && next.state === 'deleted')) ); }); const stepsAdvance = earlier.steps.every((step) => diff --git a/packages/void/src/cli/platform-plan.ts b/packages/void/src/cli/platform-plan.ts index bde638db1..ec92718d2 100644 --- a/packages/void/src/cli/platform-plan.ts +++ b/packages/void/src/cli/platform-plan.ts @@ -357,7 +357,7 @@ export async function createPlatformInstallPlan( client.listHyperdriveConfigs(account.id), client.listWorkerNames(account.id), ]); - const dnsRecords = zone ? await client.listDnsRecords(zone.id, wildcardHostname) : []; + const dnsRecords = zone ? await listZoneDnsRecords(client, zone, wildcardHostname) : []; const remoteOwnership = existing ? await readRemoteOwnership(client, account.id, routingKv, existing) : null; @@ -469,7 +469,7 @@ export async function createPlatformInstallPlan( ); } const records = controlPlaneZone - ? await client.listDnsRecords(controlPlaneZone.id, apiHostname) + ? await listZoneDnsRecords(client, controlPlaneZone, apiHostname) : []; const ownedApiWorker = intentProofs.has('api-worker') || @@ -1322,6 +1322,27 @@ export function platformAdoptionIntentHash( .digest('hex'); } +async function listZoneDnsRecords( + client: PlanningClient, + zone: CloudflareZone, + hostname: string, +): ReturnType { + try { + return await client.listDnsRecords(zone.id, hostname); + } catch (error) { + if ( + error instanceof PlatformCloudflareApiError && + (error.status === 401 || error.status === 403) + ) { + throw new Error( + `platform: Cloudflare denied reading DNS records for ${zone.name} (HTTP ${error.status}). Add Zone → DNS → Edit for this zone to the management token, then rerun.`, + { cause: error }, + ); + } + throw error; + } +} + async function readRemoteOwnership( client: PlanningClient, accountId: string, diff --git a/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index f71b36b0e..d3968c271 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -86,13 +86,24 @@ function platformAccountTokenPermissions(includeAiGateway: boolean) { ]; } +/** + * Account token links cannot preselect a zone: Cloudflare documents only + * `permissionGroupKeys` and `name`, and the form puts zone permissions in an + * "All Domains" policy. Tell the user which dashboard choice to change. + */ +function specifiedDomainsInstruction(zones: Array): string { + return `Change the zone policy from All Domains to Specified Domains → ${zones.join(' and ')}.`; +} + +function plansZoneCreation(plan: PlatformInstallPlan): boolean { + return plan.actions.some((action) => action.kind === 'zone' && action.action === 'create'); +} + export function platformManagementSetupPage( plan: PlatformInstallPlan, options: { access?: PlatformManagementAccess | false } = {}, ): PlatformSetupPage { - const createsZone = plan.actions.some( - (action) => action.kind === 'zone' && action.action === 'create', - ); + const createsZone = plansZoneCreation(plan); const zoneName = plan.deployment.application.zoneName; const apiHostname = plan.deployment.controlPlane.apiHostname; const access = @@ -101,6 +112,12 @@ export function platformManagementSetupPage( plan.deployment.authentication.providers.includes('cloudflare-access') ? { protection: !!plan.deployment.authentication.accessProtection, readOnly: false } : undefined); + const zones = [ + ...(zoneName ? [zoneName] : []), + ...(apiHostname && apiHostname !== zoneName && !apiHostname.endsWith(`.${zoneName}`) + ? [`the zone for ${apiHostname}`] + : []), + ]; const accessLevel = access && access.readOnly ? 'read' : 'edit'; const appAccess = !!plan.deployment.appAccess; const appAccessLevel = appAccess ? 'edit' : accessLevel; @@ -138,10 +155,12 @@ export function platformManagementSetupPage( }), instructions: [ 'This token lets the Void CLI install your platform. Void will now open a pre-configured token creation site. Please review the permissions and then paste the token here.', - `Scope it to ${plan.account.name}${createsZone ? '; allow zone creation.' : zoneName ? ` and zone ${zoneName}.` : '.'}`, - ...(apiHostname && apiHostname !== zoneName && !apiHostname.endsWith(`.${zoneName}`) - ? [`Also include the zone for ${apiHostname}.`] - : []), + `Scope it to ${plan.account.name}.`, + ...(createsZone + ? [`Keep the zone policy on All Domains so Void can create ${zoneName}.`] + : zones.length + ? [specifiedDomainsInstruction(zones)] + : []), ].join('\n'), }; } @@ -171,6 +190,7 @@ export function platformSetupPages(plan: PlatformInstallPlan): { ] : []), ]; + const zoneName = plan.deployment.application.zoneName; const query = new URLSearchParams({ to: '/' + plan.account.id + '/api-tokens', name: plan.deployment.displayName + ' Runtime', @@ -181,8 +201,25 @@ export function platformSetupPages(plan: PlatformInstallPlan): { title: 'Cloudflare runtime token', linkInTitle: true, url: 'https://dash.cloudflare.com/?' + query, - instructions: + instructions: [ 'Create a Cloudflare runtime token that Void can use to manage the platform. Please review the permissions and then paste the token here.', + // A zone that Void creates later cannot be selected yet; All Domains + // lets Cache Purge reach it once it exists. + ...(zoneName && plansZoneCreation(plan) + ? [ + `Keep the zone policy on All Domains so the token can purge cache on ${zoneName} after Void creates it.`, + ] + : zoneName + ? [ + specifiedDomainsInstruction([ + zoneName, + ...(plan.deployment.capabilities.email + ? ['every zone that will carry mail'] + : []), + ]), + ] + : []), + ].join('\n'), }, github: { title: 'GitHub OAuth App', diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts new file mode 100644 index 000000000..f9bd4da29 --- /dev/null +++ b/packages/void/src/cli/platform-teardown.ts @@ -0,0 +1,1121 @@ +import { + PLATFORM_R2_OWNERSHIP_MARKER_KEY, + recordInstallationResource, + recordInstallationStep, + type InstallationResource, + type PlatformInstallationManifest, +} from '@void/platform'; +import { + PlatformCloudflareApiError, + type PlatformCloudflareClient, +} from './platform-cloudflare.ts'; +import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; + +/** + * `void platform uninstall --purge-resources` deletes every Cloudflare resource the + * installation created, in an order Cloudflare accepts: + * + * traffic (routes, custom domains, DNS) -> Access -> Email Routing rules + * -> queue consumers -> Workers except tail -> Queues -> dispatch namespace + * -> tail Worker -> Hyperdrive and KV -> R2 -> AI Gateway -> routing KV (last) + * + * Each resource is proven to belong to this installation right before its + * DELETE, and Cloudflare refuses a resource that something outside the + * installation still uses. The only forced DELETE is for a platform Worker + * that Cloudflare refused while every Worker it names as a dependent is one + * this installation created: the API and proxy Workers bind to each other, so + * neither can go first without it. A route, custom domain, DNS + * record, or Access resource that no longer matches what was recorded is kept + * and listed with the reason instead of stopping the run. Every finished + * deletion is checkpointed, so rerunning the command continues where it + * stopped. The routing KV namespace holds that checkpoint, so uninstall + * deletes it last. + * + * An uninstall without --purge-resources records what it leaves as retained. If it + * stopped before it finished, --purge-resources considers those resources again. + */ + +export const PURGE_RESOURCES_STEP_PREFIX = 'uninstall:purge-resources:'; + +export type PurgeResourcesClient = Pick< + PlatformCloudflareClient, + | 'deleteAccessResource' + | 'deleteDispatchNamespaceScript' + | 'deleteEmailRoutingRule' + | 'deleteQueueConsumer' + | 'deleteR2Object' + | 'deleteResource' + | 'deleteWorkerDomain' + | 'deleteWorkerRoute' + | 'emptyR2Bucket' + | 'forceDeleteWorker' + | 'getAccessResource' + | 'getEmailRoutingCatchAll' + | 'listAiGateways' + | 'listDispatchNamespaceScripts' + | 'listDispatchNamespaces' + | 'listDnsRecords' + | 'listEmailRoutingRules' + | 'listHyperdriveConfigs' + | 'listKvNamespaces' + | 'listQueueConsumers' + | 'listR2Buckets' + | 'listWorkerDependents' + | 'listWorkerDomains' + | 'listWorkerNames' + | 'listWorkerRoutes' + | 'resetEmailRoutingCatchAll' +>; + +export type PurgeResourcesCredential = Readonly<{ + source: 'environment' | 'saved' | 'oauth'; + /** The runtime token is available for AI Gateway calls. */ + aiGatewayToken: boolean; +}>; + +type AccessResource = NonNullable[number]; + +type Target = + | Readonly<{ phase: 'traffic'; resource: InstallationResource; worker: InstallationResource }> + | Readonly<{ + phase: + | 'dns' + // The resource is the email gateway Worker the rules send mail to. + | 'email' + | 'queue-consumers' + | 'workers' + | 'queues' + | 'dispatch' + | 'tail' + | 'data' + | 'r2' + | 'ai-gateway' + | 'routing'; + resource: InstallationResource; + }> + | Readonly<{ phase: 'access'; access: AccessResource }>; + +// Workers deleted in this order before the tail Worker. Cloudflare accepted it +// without force; anything else that sends logs to the tail Worker goes first. +const WORKER_ORDER = ['email-gateway-worker', 'proxy-worker', 'dispatch-worker', 'api-worker']; + +const PERMISSIONS = { + access: 'Account → Access: Apps and Policies → Edit', + aiGateway: 'Account → AI Gateway → Edit', + dns: 'Zone → DNS → Edit', + emailRouting: 'Zone → Email Routing Rules → Edit', + hyperdrive: 'Account → Hyperdrive → Edit', + kv: 'Account → Workers KV Storage → Edit', + queues: 'Account → Queues → Edit', + r2: 'Account → Workers R2 Storage → Edit', + routes: 'Zone → Workers Routes → Edit', + serviceTokens: 'Account → Access: Service Tokens → Edit', + workers: 'Account → Workers Scripts → Edit', +} as const; + +export function isPlatformTailWorker( + manifest: PlatformInstallationManifest, + resource: InstallationResource, +): boolean { + return ( + resource.kind === 'worker' && + (resource.key === 'tail-worker' || resource.name === manifest.deployment.resources.tailWorker) + ); +} + +export function purgeResourcesResumeCommand(manifest: PlatformInstallationManifest): string { + return `void platform uninstall ${manifest.id} --purge-resources`; +} + +function accessStepKey(access: AccessResource): string { + return `${PURGE_RESOURCES_STEP_PREFIX}access:${access.key}`; +} + +/** Access resources that --purge-resources already deleted. */ +export function isPurgedAccessResource( + manifest: PlatformInstallationManifest, + access: AccessResource, +): boolean { + return manifest.steps.some((step) => step.key === accessStepKey(access)); +} + +function hasStep(manifest: PlatformInstallationManifest, key: string): boolean { + return manifest.steps.some((step) => step.key === `${PURGE_RESOURCES_STEP_PREFIX}${key}`); +} + +function keptStepKey(key: string): string { + return `kept:${key}`; +} + +/** + * Why --purge-resources kept a resource it was going to delete, for example because + * a route now serves another Worker; null when it did not keep it. Access + * resources use the key `access:`. + */ +export function purgeResourcesKeptReason( + manifest: PlatformInstallationManifest, + key: string, +): string | null { + return ( + manifest.steps.find((step) => step.key === `${PURGE_RESOURCES_STEP_PREFIX}${keptStepKey(key)}`) + ?.resultHash ?? null + ); +} + +/** An uninstall that started has not finished, so what it kept can be reconsidered. */ +function isReopenable(manifest: PlatformInstallationManifest): boolean { + return manifest.status !== 'uninstalled'; +} + +/** + * A resource the installation created that --purge-resources may still delete: one + * that is ready, or one that an uninstall which stopped before it finished + * recorded as retained. Resources --purge-resources itself kept, and earlier + * versions of a resource kept when it was replaced, are never reconsidered. + */ +export function isPurgeResourcesCandidate( + manifest: PlatformInstallationManifest, + resource: InstallationResource, +): boolean { + return ( + resource.ownership === 'created' && + (resource.state === 'ready' || + (resource.state === 'retained' && + isReopenable(manifest) && + !resource.key.startsWith('retained:') && + purgeResourcesKeptReason(manifest, resource.key) === null)) + ); +} + +/** The zone that routes and DNS records live in, if it can still be used. */ +function usableZone(manifest: PlatformInstallationManifest): InstallationResource | undefined { + return manifest.resources.find( + (resource) => + resource.key === 'zone' && + (resource.state === 'ready' || (resource.state === 'retained' && isReopenable(manifest))), + ); +} + +/** + * A recorded resource whose live configuration no longer matches the + * installation's record. --purge-resources keeps such a DNS record and continues. + */ +export class PlatformResourceChangedError extends Error { + constructor( + readonly resourceKey: string, + readonly reason: string, + message: string, + ) { + super(message); + this.name = 'PlatformResourceChangedError'; + } +} + +/** The zone whose Email Routing rules may send mail to the gateway Worker. */ +function emailRoutingZone(manifest: PlatformInstallationManifest): string | null { + return manifest.deployment.capabilities.email + ? (manifest.deployment.emailSharedZoneId ?? null) + : null; +} + +/** Everything --purge-resources will delete, in deletion order. */ +function purgeResourcesTargets(manifest: PlatformInstallationManifest): Array { + const resources = manifest.resources; + const routing = resources.find((resource) => resource.key === 'routing-kv'); + const candidate = (resource: InstallationResource) => + isPurgeResourcesCandidate(manifest, resource); + // Without the remote checkpoint nothing can be proven, so nothing is deleted. + if (!routing || (routing.state !== 'ready' && !candidate(routing))) { + return []; + } + const zone = usableZone(manifest); + const workers = resources.filter((resource) => resource.kind === 'worker' && candidate(resource)); + const targets: Array = []; + for (const resource of resources) { + if ( + (resource.kind !== 'worker-route' && resource.kind !== 'worker-custom-domain') || + (resource.state !== 'ready' && resource.state !== 'retained') || + (resource.kind === 'worker-route' && !zone) || + purgeResourcesKeptReason(manifest, resource.key) !== null + ) { + continue; + } + const name = platformTrafficAttachmentWorker(manifest.deployment, resource); + const worker = workers.find((entry) => entry.name === name); + if (worker) { + targets.push({ phase: 'traffic', resource, worker }); + } + } + for (const resource of resources) { + if (resource.kind === 'dns-record' && candidate(resource) && zone) { + targets.push({ phase: 'dns', resource }); + } + } + const access = (manifest.authenticationResources ?? []).filter( + (entry) => + entry.ownership === 'created' && + !isPurgedAccessResource(manifest, entry) && + purgeResourcesKeptReason(manifest, `access:${entry.key}`) === null, + ); + // An application can admit the service token, so delete applications first. + for (const kind of ['application', 'service-token'] as const) { + for (const entry of access.filter((candidate) => candidate.kind === kind)) { + targets.push({ phase: 'access', access: entry }); + } + } + // Rules that send mail to the gateway Worker go before the Worker. + const gateway = workers.find((worker) => worker.key === 'email-gateway-worker'); + if (gateway && emailRoutingZone(manifest) && !hasStep(manifest, 'email-routing')) { + targets.push({ phase: 'email', resource: gateway }); + } + const queues = resources.filter((resource) => resource.kind === 'queue' && candidate(resource)); + for (const resource of queues) { + if (!hasStep(manifest, `consumers:${resource.key}`)) { + targets.push({ phase: 'queue-consumers', resource }); + } + } + const rank = (resource: InstallationResource) => { + const index = WORKER_ORDER.indexOf(resource.key); + return index < 0 ? WORKER_ORDER.length : index; + }; + for (const resource of workers + .filter((worker) => !isPlatformTailWorker(manifest, worker)) + .sort((left, right) => rank(left) - rank(right) || left.key.localeCompare(right.key))) { + targets.push({ phase: 'workers', resource }); + } + for (const resource of queues) { + targets.push({ phase: 'queues', resource }); + } + for (const resource of resources) { + if (resource.kind === 'dispatch-namespace' && candidate(resource)) { + targets.push({ phase: 'dispatch', resource }); + } + } + for (const resource of workers.filter((worker) => isPlatformTailWorker(manifest, worker))) { + targets.push({ phase: 'tail', resource }); + } + for (const kind of ['hyperdrive', 'kv-namespace'] as const) { + for (const resource of resources) { + if (resource.kind === kind && resource.key !== 'routing-kv' && candidate(resource)) { + targets.push({ phase: 'data', resource }); + } + } + } + for (const kind of ['r2-bucket', 'ai-gateway'] as const) { + for (const resource of resources) { + if (resource.kind === kind && candidate(resource)) { + targets.push({ phase: kind === 'r2-bucket' ? 'r2' : 'ai-gateway', resource }); + } + } + } + if (candidate(routing)) { + targets.push({ phase: 'routing', resource: routing }); + } + return targets; +} + +/** Whether --purge-resources removes, or has removed, the rules that send mail to the gateway Worker. */ +export function purgeResourcesHandlesEmailRouting(manifest: PlatformInstallationManifest): boolean { + return ( + hasStep(manifest, 'email-routing') || + purgeResourcesTargets(manifest).some((target) => target.phase === 'email') + ); +} + +/** Manifest resources that --purge-resources leaves in the account. */ +export function purgeResourcesKeptResources( + manifest: PlatformInstallationManifest, +): Array { + const targets = new Set( + purgeResourcesTargets(manifest).flatMap((target) => + 'resource' in target ? [target.resource.key] : [], + ), + ); + return manifest.resources.filter( + (resource) => + (resource.state === 'ready' || resource.state === 'retained') && !targets.has(resource.key), + ); +} + +function resourceLabel(resource: InstallationResource): string { + return `${resource.kind}: ${resource.name} (${resource.id})`; +} + +/** Plan lines for --purge-resources: what is deleted, in order, then what is kept. */ +export function describePurgeResourcesPlan(manifest: PlatformInstallationManifest): Array { + const targets = purgeResourcesTargets(manifest); + const lines: Array = []; + let step = 0; + let previous: Target['phase'] | null = null; + const consumerQueues: Array = []; + for (const target of targets) { + if (target.phase === 'email') { + step++; + previous = target.phase; + lines.push( + ` ${step}. Email Routing rules in zone ${emailRoutingZone(manifest)} that send mail to ${target.resource.name}, and its catch-all rule if it does (reset to drop mail, disabled)`, + ); + continue; + } + if (target.phase !== previous) { + step++; + previous = target.phase; + } + const prefix = ` ${step}. `; + if (target.phase === 'traffic') { + lines.push(`${prefix}${resourceLabel(target.resource)} -> ${target.worker.name}`); + } else if (target.phase === 'access') { + lines.push( + `${prefix}Access ${target.access.kind}: ${target.access.name} (${target.access.id})`, + ); + } else if (target.phase === 'queue-consumers') { + consumerQueues.push(target.resource.name); + if (consumerQueues.length === 1) { + lines.push(`${prefix}queue consumers run by this installation's Workers on:`); + } + lines.push(` ${target.resource.name}`); + } else if (target.phase === 'dispatch') { + lines.push( + `${prefix}${resourceLabel(target.resource)}, with any application Workers still in it`, + ); + } else if (target.phase === 'tail') { + lines.push(`${prefix}${resourceLabel(target.resource)}, the tail Worker, after the others`); + } else if (target.phase === 'workers' && target.resource.key === 'api-worker') { + lines.push(`${prefix}${resourceLabel(target.resource)}, with its Durable Object data`); + } else if (target.phase === 'r2') { + lines.push(`${prefix}${resourceLabel(target.resource)}, emptied first`); + } else if (target.phase === 'ai-gateway') { + lines.push( + `${prefix}${resourceLabel(target.resource)}, if Cloudflare shows it was created during installation; otherwise kept`, + ); + } else if (target.phase === 'routing') { + lines.push(`${prefix}${resourceLabel(target.resource)}, last; it holds uninstall progress`); + } else { + lines.push(`${prefix}${resourceLabel(target.resource)}`); + } + } + return lines.length + ? [ + 'Delete every resource this installation created (--purge-resources), in this order:', + ...lines, + ] + : ['--purge-resources finds no resource it can prove this installation created.']; +} + +/** Lines naming what --purge-resources never removes. */ +export function purgeResourcesAlwaysKept(): Array { + return [ + ' The external PostgreSQL database and its data.', + ' Analytics Engine datasets; Cloudflare has no API to delete them.', + ' Access policies and identity providers you chose.', + ' API tokens you created for this platform. Revoke them in the Cloudflare dashboard.', + ]; +} + +/** Token permissions --purge-resources needs for this manifest. */ +export function purgeResourcesPermissions(manifest: PlatformInstallationManifest): Array { + const targets = purgeResourcesTargets(manifest); + const needed = new Set(); + for (const target of targets) { + if (target.phase === 'access') { + needed.add( + target.access.kind === 'application' ? PERMISSIONS.access : PERMISSIONS.serviceTokens, + ); + continue; + } + if (target.phase === 'email') { + needed.add(PERMISSIONS.emailRouting); + continue; + } + const { kind } = target.resource; + if (kind === 'worker-route') { + needed.add(PERMISSIONS.routes); + } else if ( + kind === 'worker' || + kind === 'worker-custom-domain' || + kind === 'dispatch-namespace' + ) { + needed.add(PERMISSIONS.workers); + } else if (kind === 'dns-record') { + needed.add(PERMISSIONS.dns); + } else if (kind === 'queue') { + needed.add(PERMISSIONS.queues); + } else if (kind === 'kv-namespace') { + needed.add(PERMISSIONS.kv); + } else if (kind === 'hyperdrive') { + needed.add(PERMISSIONS.hyperdrive); + } else if (kind === 'r2-bucket') { + needed.add(PERMISSIONS.r2); + } else if (kind === 'ai-gateway') { + needed.add(PERMISSIONS.aiGateway); + } + } + return Object.values(PERMISSIONS).filter((permission) => needed.has(permission)); +} + +function deniedStatus(error: unknown): boolean { + return ( + error instanceof PlatformCloudflareApiError && (error.status === 401 || error.status === 403) + ); +} + +/** + * Check before the first change that the credential can make every planned + * deletion. Browser login cannot change DNS or Access, so those need an API + * token. For a token, read probes catch a missing account or zone grant; a + * read cannot prove Edit, so the plan names the full permission list too. + */ +export async function checkPurgeResourcesCredentials( + client: PurgeResourcesClient, + manifest: PlatformInstallationManifest, + credential: PurgeResourcesCredential, +): Promise { + const targets = purgeResourcesTargets(manifest); + const resources = targets.flatMap((target) => ('resource' in target ? [target.resource] : [])); + const access = targets.flatMap((target) => ('access' in target ? [target.access] : [])); + const has = (kind: InstallationResource['kind']) => + resources.some((resource) => resource.kind === kind); + const zone = manifest.resources.find((resource) => resource.key === 'zone'); + const emailZone = targets.some((target) => target.phase === 'email') + ? emailRoutingZone(manifest) + : null; + // Access can live in a separate identity account. + const accounts = [...new Set([manifest.account.id, ...access.map((entry) => entry.accountId)])]; + const scope = `${accounts.length === 1 ? 'account' : 'accounts'} ${accounts.join(' and ')}${has('dns-record') || has('worker-route') ? ` and zone ${zone?.name ?? ''}` : ''}${emailZone ? ` and Email Routing zone ${emailZone}` : ''}`; + if (credential.source === 'oauth') { + const browserMissing = [ + ...(has('dns-record') ? [PERMISSIONS.dns] : []), + ...(access.some((entry) => entry.kind === 'application') ? [PERMISSIONS.access] : []), + ...(access.some((entry) => entry.kind === 'service-token') + ? [PERMISSIONS.serviceTokens] + : []), + ...(has('ai-gateway') && !credential.aiGatewayToken ? [PERMISSIONS.aiGateway] : []), + ]; + if (browserMissing.length) { + throw new Error( + `platform: Cloudflare browser login cannot delete every resource --purge-resources plans. Set CLOUDFLARE_API_TOKEN to a token with ${purgeResourcesPermissions(manifest).join(', ')}, scoped to ${scope}, then rerun. Browser login is missing: ${browserMissing.join(', ')}. No Cloudflare resource was changed.`, + ); + } + } + const probes: Array<[string, () => Promise]> = []; + if (has('worker') || has('worker-custom-domain')) { + probes.push([PERMISSIONS.workers, () => client.listWorkerNames(manifest.account.id)]); + } + if (has('worker-custom-domain')) { + probes.push([PERMISSIONS.workers, () => client.listWorkerDomains(manifest.account.id)]); + } + if (has('worker-route') && zone) { + probes.push([PERMISSIONS.routes, () => client.listWorkerRoutes(zone.id)]); + } + for (const resource of resources) { + if (resource.kind === 'dns-record' && zone) { + probes.push([PERMISSIONS.dns, () => client.listDnsRecords(zone.id, resource.name)]); + } else if (resource.kind === 'queue') { + probes.push([ + PERMISSIONS.queues, + () => client.listQueueConsumers(manifest.account.id, resource.id), + ]); + } + } + for (const entry of access) { + probes.push([ + entry.kind === 'application' ? PERMISSIONS.access : PERMISSIONS.serviceTokens, + () => client.getAccessResource(entry.accountId, entry.kind, entry.id), + ]); + } + if (emailZone) { + probes.push([PERMISSIONS.emailRouting, () => client.listEmailRoutingRules(emailZone)]); + } + if (has('dispatch-namespace')) { + probes.push([PERMISSIONS.workers, () => client.listDispatchNamespaces(manifest.account.id)]); + } + if (has('kv-namespace')) { + probes.push([PERMISSIONS.kv, () => client.listKvNamespaces(manifest.account.id)]); + } + if (has('hyperdrive')) { + probes.push([PERMISSIONS.hyperdrive, () => client.listHyperdriveConfigs(manifest.account.id)]); + } + if (has('r2-bucket')) { + probes.push([PERMISSIONS.r2, () => client.listR2Buckets(manifest.account.id)]); + } + if (has('ai-gateway')) { + probes.push([PERMISSIONS.aiGateway, () => client.listAiGateways(manifest.account.id)]); + } + const missing = new Set(); + for (const [permission, probe] of probes) { + try { + await probe(); + } catch (error) { + if (!deniedStatus(error)) { + throw error; + } + missing.add(permission); + } + } + if (missing.size) { + throw new Error( + `platform: The Cloudflare credential cannot read every resource --purge-resources would delete. Add ${[...missing].join(', ')} for ${scope}, then rerun. No Cloudflare resource was changed.`, + ); + } +} + +type QueryConnection = Readonly<{ + query: = Record>( + sql: string, + values?: ReadonlyArray, + ) => Promise<{ rows: ReadonlyArray }>; +}>; + +const LISTED_PROJECTS = 20; + +/** Slugs of every project row, including projects whose deletion stopped partway. */ +export async function readPlatformProjectSlugsConnection( + client: QueryConnection, +): Promise> { + const table = await client.query<{ present: boolean }>( + `SELECT to_regclass('public.projects') IS NOT NULL AS "present"`, + ); + if (table.rows[0]?.present !== true) { + return []; + } + const rows = await client.query<{ slug: string }>( + 'SELECT "slug" FROM "projects" ORDER BY "slug"', + ); + return rows.rows.map((row) => row.slug); +} + +/** + * Projects own Workers, storage, and domains that only the running platform + * can delete. --purge-resources deletes the platform, so it refuses before any + * change while a project remains. + */ +export async function assertNoPlatformProjects( + read: (() => Promise>) | undefined, +): Promise { + let slugs: ReadonlyArray; + try { + if (!read) { + throw new Error('no platform database connection is available'); + } + slugs = await read(); + } catch (error) { + const reason = (error instanceof Error ? error.message : String(error)).replace( + /^platform: /, + '', + ); + throw new Error( + `platform: Refusing --purge-resources because the project list could not be read from the platform database: ${reason}\nMake the installation database reachable (export VOID_PLATFORM_DATABASE_URL if this machine has not recorded it), then retry.`, + ); + } + if (slugs.length) { + throw new Error( + [ + `platform: Refusing --purge-resources while the platform still has ${slugs.length === 1 ? '1 project' : `${slugs.length} projects`}.`, + "--purge-resources deletes the platform, and only the running platform can delete each project's Workers, storage, and domains.", + 'Delete each project first with `void project delete `, then retry:', + ...slugs.slice(0, LISTED_PROJECTS).map((slug) => ` ${slug}`), + ...(slugs.length > LISTED_PROJECTS + ? [` …and ${slugs.length - LISTED_PROJECTS} more`] + : []), + ].join('\n'), + ); + } +} + +export type PurgeResourcesContext = Readonly<{ + client: PurgeResourcesClient; + /** Save progress locally, in PostgreSQL, and in routing KV. */ + checkpoint: (manifest: PlatformInstallationManifest) => Promise; + /** Live ownership proof for these keys; returns the keys that no longer exist. */ + verify: ( + manifest: PlatformInstallationManifest, + keys: ReadonlySet, + ) => Promise>; + /** + * A value that changes when the bucket is deleted and created again under + * the same name; null when the bucket is gone. + */ + bucketGeneration: ( + manifest: PlatformInstallationManifest, + bucket: InstallationResource, + ) => Promise; + /** Project slugs in the platform database. */ + readProjects: () => Promise>; +}>; + +// The installer records a provision intent right before it creates the AI +// Gateway. Allow for clock differences between this machine and Cloudflare. +const AI_GATEWAY_CREATION_WINDOW_MS = 10 * 60_000; + +/** + * AI Gateway ids are chosen by the caller and can be reused after a delete, + * and gateways carry no ownership marker. Only a creation time next to the + * installer's provision intent shows that this is the gateway it created. + */ +function createdDuringInstallation( + manifest: PlatformInstallationManifest, + resource: InstallationResource, + createdAt: string | undefined, +): boolean { + const created = createdAt === undefined ? Number.NaN : Date.parse(createdAt); + if (Number.isNaN(created)) { + return false; + } + return manifest.steps.some( + (step) => + (step.key === `provision-intent:${resource.key}` || + step.key.startsWith(`provision-intent:${resource.key}:`)) && + Math.abs(created - Date.parse(step.completedAt)) <= AI_GATEWAY_CREATION_WINDOW_MS, + ); +} + +function ownershipRefusal( + manifest: PlatformInstallationManifest, + description: string, + reason: string, +): Error { + return new Error( + `platform: Refusing to delete ${description} because ${reason}. It may no longer belong to this installation. Delete or fix it in the Cloudflare dashboard if it is yours, then rerun: ${purgeResourcesResumeCommand(manifest)}`, + ); +} + +function cloudflareRefusal( + manifest: PlatformInstallationManifest, + description: string, + error: unknown, + hint: string, +): unknown { + if (!(error instanceof PlatformCloudflareApiError)) { + return error; + } + return new Error( + `platform: Cloudflare refused to delete ${description}: ${error.message}\n${hint}\nProgress is saved. Rerun: ${purgeResourcesResumeCommand(manifest)}`, + { cause: error }, + ); +} + +/** + * Delete every target in order. Returns the manifest with each deleted + * resource recorded; routing KV is left for the caller to delete last. + */ +export async function runPurgeResourcesTeardown( + context: PurgeResourcesContext, + initial: PlatformInstallationManifest, +): Promise { + const { client } = context; + let next = initial; + const accountId = next.account.id; + const save = async (manifest: PlatformInstallationManifest) => { + next = manifest; + await context.checkpoint(next); + }; + const markDeleted = async (key: string) => { + const resource = next.resources.find((entry) => entry.key === key)!; + await save( + recordInstallationResource(next, { ...resource, state: 'deleted' }, new Date().toISOString()), + ); + }; + const recordStep = async (key: string, resultHash = 'completed') => { + const now = new Date().toISOString(); + await save( + recordInstallationStep( + next, + { completedAt: now, key: `${PURGE_RESOURCES_STEP_PREFIX}${key}`, resultHash }, + now, + ), + ); + }; + /** + * Live proof for these keys. A resource an earlier uninstall recorded as + * retained is proven like a ready one; only its recorded state differs. + */ + const verify = (keys: ReadonlySet) => + context.verify( + { + ...next, + resources: next.resources.map((resource) => + keys.has(resource.key) && resource.state === 'retained' + ? { ...resource, state: 'ready' as const } + : resource, + ), + }, + keys, + ); + /** + * Prove the resource; false when it is already gone (and recorded). + * + * Cloudflare has no conditional DELETE: Workers and dispatch namespaces are + * addressed by name, and a route or custom domain can be changed in place. + * Something replaced between this proof and the DELETE can still be removed. + * That one-request window is accepted because --purge-resources is opt-in and its + * plan lists every resource first. A forced Worker DELETE has the same + * window between reading its dependents and the DELETE. + */ + const prove = async (resource: InstallationResource): Promise => { + const missing = await verify(new Set(['routing-kv', resource.key])); + if (missing.has(resource.key)) { + await markDeleted(resource.key); + return false; + } + return true; + }; + /** Keep a resource that no longer matches the record, and save why. */ + const keep = async (key: string, reason: string) => { + const resource = next.resources.find((entry) => entry.key === key); + if (resource?.state === 'ready') { + await save( + recordInstallationResource( + next, + { ...resource, state: 'retained' }, + new Date().toISOString(), + ), + ); + } + await recordStep(keptStepKey(key), reason); + }; + const remove = async (resource: InstallationResource, hint: string, zoneId?: string) => { + if (!(await prove(resource))) { + return; + } + try { + await client.deleteResource(accountId, resource, zoneId); + } catch (error) { + throw cloudflareRefusal(next, `${resource.kind} ${resource.name}`, error, hint); + } + await markDeleted(resource.key); + }; + const zone = usableZone(next); + const createdWorkerNames = new Set( + next.resources + .filter((resource) => resource.kind === 'worker' && resource.ownership === 'created') + .map((resource) => resource.name), + ); + /** The recorded Worker under this name that this run still deletes, if any. */ + const workerTarget = (name: string) => + next.resources.find( + (resource) => + resource.kind === 'worker' && + resource.name === name && + isPurgeResourcesCandidate(next, resource), + ); + const workerHint = + 'A Worker this installation did not create may still use it, for example by sending it logs as a tail Worker or through a service binding. Remove that link in the Cloudflare dashboard.'; + /** + * Delete a platform Worker. When Cloudflare refuses because other Workers + * still bind to it, force the DELETE only if each of them is a Worker this + * run deletes too and still carries this installation's ownership marker. + */ + const removeWorker = async (resource: InstallationResource) => { + if (!(await prove(resource))) { + return; + } + try { + await client.deleteResource(accountId, resource); + } catch (error) { + if (!(error instanceof PlatformCloudflareApiError)) { + throw error; + } + const dependents = await client + .listWorkerDependents(accountId, resource.name) + .catch(() => null); + const outside = dependents?.workers.filter((name) => !workerTarget(name)) ?? []; + // Prove each dependent live, like the Worker itself: a name match alone + // could be another Worker that took a recorded name. + const keys = outside.length + ? [] + : (dependents?.workers.map((name) => workerTarget(name)!.key) ?? []); + const missing = + dependents && !dependents.pagesFunction && keys.length + ? await verify(new Set(['routing-kv', ...keys])) + : null; + if ( + !dependents || + dependents.pagesFunction || + dependents.workers.length === 0 || + outside.length > 0 || + !missing || + keys.some((key) => missing.has(key)) + ) { + throw cloudflareRefusal( + next, + `${resource.kind} ${resource.name}`, + error, + outside.length + ? `Workers outside this installation still use it: ${outside.join(', ')}. Remove that link in the Cloudflare dashboard.` + : workerHint, + ); + } + try { + await client.forceDeleteWorker(accountId, resource.name); + } catch (forced) { + throw cloudflareRefusal(next, `${resource.kind} ${resource.name}`, forced, workerHint); + } + } + await markDeleted(resource.key); + }; + + for (const target of purgeResourcesTargets(initial)) { + if (target.phase === 'access') { + const { access } = target; + const live = await client.getAccessResource(access.accountId, access.kind, access.id); + if (live) { + if (live.name !== access.name || !access.name.startsWith(`void-${next.id}-`)) { + await keep( + `access:${access.key}`, + `its live name ${JSON.stringify(live.name)} is not the name this installation recorded`, + ); + continue; + } + try { + await client.deleteAccessResource(access.accountId, access.kind, access.id); + } catch (error) { + throw cloudflareRefusal( + next, + `Access ${access.kind} ${access.name}`, + error, + 'Check that nothing outside this installation still uses it.', + ); + } + } + await recordStep(`access:${access.key}`); + continue; + } + const current = next.resources.find((resource) => resource.key === target.resource.key); + // Already handled on an earlier run, or by an earlier step of this one. + if ( + !current || + current.state === 'deleted' || + purgeResourcesKeptReason(next, current.key) !== null || + (target.phase !== 'traffic' && !isPurgeResourcesCandidate(next, current)) + ) { + continue; + } + switch (target.phase) { + case 'traffic': { + const worker = next.resources.find((resource) => resource.key === target.worker.key); + if (!worker || !isPurgeResourcesCandidate(next, worker) || !(await prove(worker))) { + // The Worker it served is gone, so the attachment cannot be proven. + break; + } + if (current.kind === 'worker-route') { + const live = (await client.listWorkerRoutes(zone!.id)).find( + (route) => route.id === current.id, + ); + if (live && (live.pattern !== current.name || live.script !== worker.name)) { + await keep( + current.key, + `it now sends ${JSON.stringify(live.pattern)} to ${JSON.stringify(live.script)}`, + ); + break; + } + if (live) { + await client.deleteWorkerRoute(zone!.id, current.id); + } + } else { + const live = (await client.listWorkerDomains(accountId)).find( + (domain) => domain.id === current.id, + ); + if (live && (live.hostname !== current.name || live.service !== worker.name)) { + await keep( + current.key, + `it now sends ${JSON.stringify(live.hostname)} to ${JSON.stringify(live.service)}`, + ); + break; + } + if (live) { + await client.deleteWorkerDomain(accountId, current.id); + } + } + await markDeleted(current.key); + break; + } + case 'dns': + try { + await remove(current, 'Check the record in the Cloudflare dashboard.', zone!.id); + } catch (error) { + if ( + !(error instanceof PlatformResourceChangedError && error.resourceKey === current.key) + ) { + throw error; + } + await keep(current.key, error.reason); + } + break; + case 'email': { + // Prove the gateway Worker is still this installation's before + // touching rules that send mail to it. + if (!(await prove(current))) { + break; + } + const zoneId = emailRoutingZone(next)!; + const hint = + 'Check the Email Routing rules for this zone in the Cloudflare dashboard. Never disable Email Routing for the zone.'; + try { + for (const rule of await client.listEmailRoutingRules(zoneId)) { + if (rule.workerName === current.name) { + await client.deleteEmailRoutingRule(zoneId, rule.id); + } + } + const catchAll = await client.getEmailRoutingCatchAll(zoneId); + if (catchAll?.workerName === current.name) { + await client.resetEmailRoutingCatchAll(zoneId); + } + } catch (error) { + throw cloudflareRefusal( + next, + `Email Routing rules that send mail to ${current.name}`, + error, + hint, + ); + } + await recordStep('email-routing'); + break; + } + case 'queue-consumers': { + if (!(await prove(current))) { + break; + } + for (const consumer of await client.listQueueConsumers(accountId, current.id)) { + if (consumer.script && createdWorkerNames.has(consumer.script)) { + await client.deleteQueueConsumer(accountId, current.id, consumer.id); + } + } + await recordStep(`consumers:${current.key}`); + break; + } + case 'workers': + await removeWorker(current); + break; + case 'tail': + await remove(current, workerHint); + break; + case 'queues': + await remove( + current, + 'A Worker this installation did not create may still use the Queue as a producer or consumer. Remove it from that Worker in the Cloudflare dashboard.', + ); + break; + case 'dispatch': { + if (!(await prove(current))) { + break; + } + // A platform API that creates projects without the configuration + // lock can add one after the final check, and it owns an application + // Worker here. + const projects = await context.readProjects(); + if (projects.length) { + throw new Error( + `platform: Stopped before deleting the application Workers in dispatch namespace ${current.name} because ${projects.length === 1 ? 'a project was' : 'projects were'} created while this uninstall ran: ${projects.join(', ')}. Nothing in the namespace was deleted. --purge-resources refuses while the platform database lists a project. Remove each one from the "projects" table once you have dealt with its Cloudflare resources, then rerun: ${purgeResourcesResumeCommand(next)}`, + ); + } + for (const script of await client.listDispatchNamespaceScripts(accountId, current.name)) { + try { + await client.deleteDispatchNamespaceScript(accountId, current.name, script); + } catch (error) { + throw cloudflareRefusal(next, `application Worker ${script}`, error, workerHint); + } + } + try { + await client.deleteResource(accountId, current); + } catch (error) { + throw cloudflareRefusal( + next, + `dispatch-namespace ${current.name}`, + error, + 'Check the namespace in the Cloudflare dashboard.', + ); + } + await markDeleted(current.key); + break; + } + case 'ai-gateway': { + const live = (await client.listAiGateways(accountId)).find( + (gateway) => gateway.id === current.id && gateway.name === current.name, + ); + if (live && !createdDuringInstallation(next, current, live.createdAt)) { + // It may be a replacement with the same id, so keep and list it. + await keep(current.key, 'Cloudflare does not show it was created during installation'); + break; + } + await remove(current, 'Check that nothing outside this installation still uses it.'); + break; + } + case 'data': + await remove(current, 'Check that nothing outside this installation still uses it.'); + break; + case 'r2': + await purgeBucket(current); + break; + case 'routing': + // The caller deletes routing KV after recording completion. + break; + } + } + return next; + + async function purgeBucket(bucket: InstallationResource): Promise { + const markerStep = `r2-marker:${bucket.key}`; + if (!hasStep(next, markerStep)) { + if (!(await prove(bucket))) { + return; + } + // Keep the ownership marker until last, and prove the bucket again + // before every page of deletes. + await client.emptyR2Bucket( + accountId, + bucket.name, + new Set([PLATFORM_R2_OWNERSHIP_MARKER_KEY]), + async () => { + const missing = await verify(new Set(['routing-kv', bucket.key])); + if (missing.has(bucket.key)) { + throw ownershipRefusal( + next, + `R2 bucket ${bucket.name}`, + 'it disappeared while it was being emptied', + ); + } + }, + ); + if (!(await prove(bucket))) { + return; + } + // Once the marker is gone, the generation saved with this step is the + // proof that a rerun still sees the same bucket. + const generation = await context.bucketGeneration(next, bucket); + if (generation === null) { + await markDeleted(bucket.key); + return; + } + await recordStep(markerStep, generation); + await client.deleteR2Object(accountId, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY); + } else { + const saved = next.steps.find( + (step) => step.key === `${PURGE_RESOURCES_STEP_PREFIX}${markerStep}`, + ); + const generation = await context.bucketGeneration(next, bucket); + if (generation === null) { + await markDeleted(bucket.key); + return; + } + if (generation !== saved?.resultHash) { + throw ownershipRefusal( + next, + `R2 bucket ${bucket.name}`, + 'its creation fingerprint changed', + ); + } + await client.deleteR2Object(accountId, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY); + } + try { + await client.deleteResource(accountId, bucket); + } catch (error) { + throw cloudflareRefusal( + next, + `r2-bucket ${bucket.name}`, + error, + 'Cloudflare deletes only empty buckets. If objects were added after it was emptied, delete them in the Cloudflare dashboard.', + ); + } + await markDeleted(bucket.key); + } +} diff --git a/packages/void/src/cli/platform-traffic.ts b/packages/void/src/cli/platform-traffic.ts index a766d586f..ad0227f93 100644 --- a/packages/void/src/cli/platform-traffic.ts +++ b/packages/void/src/cli/platform-traffic.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { createApplicationRoutes, recordInstallationResource, + type InstallationResource, type PlatformDeploymentManifest, type PlatformInstallationManifest, } from '@void/platform'; @@ -46,6 +47,30 @@ function trafficConfigHash(kind: 'worker-custom-domain' | 'worker-route', value: return createHash('sha256').update(JSON.stringify({ kind, value })).digest('hex'); } +/** + * The installer Worker that a recorded route or custom domain serves, when the + * record matches exactly what the installer publishes for this deployment. + */ +export function platformTrafficAttachmentWorker( + deployment: PlatformDeploymentManifest, + resource: Pick, +): string | null { + const managed = platformManagedWorkerTraffic(deployment); + if (resource.kind === 'worker-route') { + const target = managed.routes.find((route) => route.pattern === resource.name); + return target && trafficConfigHash('worker-route', target) === resource.configHash + ? target.script + : null; + } + if (resource.kind === 'worker-custom-domain') { + const target = managed.customDomains.find((domain) => domain.hostname === resource.name); + return target && trafficConfigHash('worker-custom-domain', target) === resource.configHash + ? target.service + : null; + } + return null; +} + export function trackedPlatformWorkerTraffic( _manifest: PlatformInstallationManifest, _workers?: ReadonlySet, diff --git a/packages/void/src/cli/platform-zero-trust-uninstall.ts b/packages/void/src/cli/platform-zero-trust-uninstall.ts index 086870e74..66e00072f 100644 --- a/packages/void/src/cli/platform-zero-trust-uninstall.ts +++ b/packages/void/src/cli/platform-zero-trust-uninstall.ts @@ -189,8 +189,9 @@ export async function readPlatformProjectZeroTrustStateConnection( /** * Take the Zero Trust configuration lock exclusively on this session. Every - * API path that records or creates a Void-managed Access application holds - * the same lock, so none can commit until the returned release runs. + * API path that records or creates a Void-managed Access application or + * creates a project holds the same lock, so none can commit until the + * returned release runs. */ export async function lockPlatformProjectZeroTrustConnection( client: QueryConnection, diff --git a/packages/void/src/cli/prepare.ts b/packages/void/src/cli/prepare.ts index 5486fda0b..7afe8be72 100644 --- a/packages/void/src/cli/prepare.ts +++ b/packages/void/src/cli/prepare.ts @@ -1,5 +1,6 @@ -import { log, note, outro } from '@voidzero-dev/vite-plus-prompts'; +import { log, outro } from '@voidzero-dev/vite-plus-prompts'; import { prepareProject } from '../codegen/prepare.ts'; +import { note } from './output.ts'; import type { PrepareArgs } from './prepare-args.ts'; export async function runPrepareCommand(root: string, _args: PrepareArgs): Promise { diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index add3e4b2f..c63db2c51 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -1,4 +1,4 @@ -import { log, note, outro } from '@voidzero-dev/vite-plus-prompts'; +import { log, outro } from '@voidzero-dev/vite-plus-prompts'; import { getAccountCredential, getToken } from './auth.ts'; import { PlatformApiError, @@ -6,7 +6,7 @@ import { type ProjectInvitationResponse, type ProjectMemberResponse, } from './client.ts'; -import { formatCount } from './output.ts'; +import { formatCount, note } from './output.ts'; import { parsePlatformErrorBody, platformErrorMessage } from './platform-errors.ts'; import type { ProjectArgs } from './project-args.ts'; import { readProjectConfig } from './project.ts'; diff --git a/packages/void/src/cli/project-zero-trust.ts b/packages/void/src/cli/project-zero-trust.ts index 9f4c2603a..7207a2db2 100644 --- a/packages/void/src/cli/project-zero-trust.ts +++ b/packages/void/src/cli/project-zero-trust.ts @@ -1,11 +1,11 @@ -import { log, note, outro } from '@voidzero-dev/vite-plus-prompts'; +import { log, outro } from '@voidzero-dev/vite-plus-prompts'; import { getToken } from './auth.ts'; import { PlatformClient, type ProjectZeroTrustResponse, type ProjectZeroTrustUpdateResponse, } from './client.ts'; -import { sanitizeTerminalText } from './output.ts'; +import { note, sanitizeTerminalText } from './output.ts'; import type { ProjectArgs } from './project-args.ts'; import { readProjectConfig } from './project.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 41e06e735..537850b0c 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -493,6 +493,14 @@ function generateWrapperSource( features.hasMigrations ) { lines.push(' if (request.method === "GET" && url.pathname === "/__void/ready") {'); + // An entry without a `fetch` handler (e.g. a framework build whose + // entry is a bare function) would fail every app request, so it must + // not pass the probe and get promoted. + lines.push(' if (typeof original?.fetch !== "function") {'); + lines.push( + ' return Response.json({ ready: false, error: "worker entry has no fetch handler" }, { status: 503, headers: { "cache-control": "no-store" } });', + ); + lines.push(' }'); lines.push( ` return Response.json({ ready: true${features.versionMetadataBinding ? `, versionId: env[${JSON.stringify(features.versionMetadataBinding)}]?.id` : ''} }${features.versionMetadataBinding ? ', { headers: { "cache-control": "no-store" } }' : ''});`, ); diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 36abe0b49..a706fe74d 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -787,6 +787,32 @@ export function voidPlugin(options?: { } : undefined; + // vinext's `config` hook sets the worker environment's build input to its + // own RSC entry. Vite merges `config` results in plugin order, so with + // `[voidPlugin(), vinext()]` that input replaced the Cloudflare plugin's + // worker entry, and the build shipped vinext's bare handler instead of + // `workerConfig.main` (Void's trigger wrapper). Restore the Cloudflare + // entry after every plugin's config is applied so plugin order does not + // matter. The entry id is the Cloudflare plugin's own virtual module, which + // re-exports `main` and injects its nodejs_compat globals. + const vinextWorkerEntry: import('vite').Plugin | undefined = + framework === 'vinext-app' || framework === 'vinext-pages' + ? { + name: 'void:vinext-worker-entry', + enforce: 'post', + configEnvironment(name, environmentConfig) { + if (name !== viteEnvironment.name) { + return; + } + environmentConfig.build ??= {}; + environmentConfig.build.rolldownOptions = { + ...environmentConfig.build.rolldownOptions, + input: { index: 'virtual:cloudflare/worker-entry' }, + }; + }, + } + : undefined; + return [ createCloudflareBuiltinsExternalPlugin(), createWorkerEnvironmentSanitizerPlugin([ @@ -813,6 +839,7 @@ export function voidPlugin(options?: { }), migrationPlugin(root, dialect, localD1.databaseId, localD1.persistRoot), ...(vinextCompat ? [vinextCompat] : []), + ...(vinextWorkerEntry ? [vinextWorkerEntry] : []), frameworkBuildWrapperPlugin, frameworkTriggersPlugin(root, { devTriggerToken }), devTriggersHintPlugin(root, { framework: true, devTriggerToken }), diff --git a/packages/void/src/migrations/plan.ts b/packages/void/src/migrations/plan.ts index 27f00d5bc..63651f96a 100644 --- a/packages/void/src/migrations/plan.ts +++ b/packages/void/src/migrations/plan.ts @@ -110,7 +110,7 @@ export function classifyMigration(migration: { ...(classification.kind === 'legacy-unsplit-multi-statement' ? { warnings: [ - `Migration ${migration.name} contains multiple statements without Drizzle statement breakpoints; it will run through the legacy D1 path.`, + `Migration ${migration.name} contains multiple statements without Drizzle statement breakpoints. Add a \`--> statement-breakpoint\` line between statements so each one runs separately.`, ], } : {}), diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index dd21785d5..7332fb61e 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -405,15 +405,24 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis return { pages, layouts, namedLayouts }; } +// Same leading-script rule as @void/md's extractScript: a markdown page may open +// with a \n\n---\n\ntitle: Hello\ndescription: A post\n\n---\n\n# Hello', + ); + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'post'); + expect(page!.metadata).toEqual({ title: 'Hello', description: 'A post' }); + }); + + it('ignores a --- block that is not at the start of the .md body', async () => { + writeFile('post.md', '# Hello\n\n---\ntitle: Not frontmatter\n---\n'); + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'post'); + expect(page!.metadata).toBeUndefined(); + }); + it('auto-prerenders island page but preserves user revalidate from server file', async () => { writeFile('pricing.island.vue', ''); writeFile('pricing.server.ts', 'export const revalidate = 60;'); @@ -707,6 +731,17 @@ const example = 'export const layout = "home"'; expect(page!.layout).toBe(false); }); + it('extracts layout from markdown frontmatter after a leading \n---\nlayout: post\n---\n# Hello', + ); + const result = await scanPages(TMP); + const page = result.pages.find((p) => p.componentId === 'blog/hello'); + expect(page!.layout).toBe('post'); + }); + it('throws when named layout reference is not found', async () => { writeFile( 'index.vue', diff --git a/packages/void/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index c9916559b..84330b149 100644 --- a/packages/void/test/unit/platform-args.test.ts +++ b/packages/void/test/unit/platform-args.test.ts @@ -172,12 +172,29 @@ describe('platform args', () => { selector: '', plan: false, purgeData: true, + purgeResources: false, deleteZone: false, yes: false, }); expect(() => parsePlatformArgs(['uninstall', '--delete-zone', '--keep-zone'])).toThrow(); }); + it('treats --purge-resources as a purge of data and every other created resource', () => { + expect(parsePlatformArgs(['uninstall', 'team', '--purge-resources', '--plan'])).toEqual({ + subcommand: 'uninstall', + selector: 'team', + plan: true, + purgeData: true, + purgeResources: true, + deleteZone: null, + yes: false, + }); + expect(parsePlatformArgs(['uninstall', '--purge-data', '--purge-resources'])).toMatchObject({ + purgeData: true, + purgeResources: true, + }); + }); + it('parses explicit lifecycle acknowledgement', () => { expect(parsePlatformArgs(['upgrade', '--yes'])).toMatchObject({ subcommand: 'upgrade', diff --git a/packages/void/test/unit/platform-auth-config.test.ts b/packages/void/test/unit/platform-auth-config.test.ts index 682c3f29f..28126ab3b 100644 --- a/packages/void/test/unit/platform-auth-config.test.ts +++ b/packages/void/test/unit/platform-auth-config.test.ts @@ -199,6 +199,21 @@ describe('platform config auth execution', () => { ).rejects.toThrow('match'); expect(calls.every((call) => !call.body)).toBe(true); }); + it('lists login methods as readable text, and as JSON only with --json', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runPlatformAuthConfig(parsePlatformConfigArgs(['auth', 'list'])); + const human = output.mock.calls.map(([line]) => String(line)).join('\n'); + expect(human.trimStart().startsWith('{')).toBe(false); + expect(human).toContain('Initialized: true'); + expect(human).toContain('Company SSO'); + + output.mockClear(); + await runPlatformAuthConfig(parsePlatformConfigArgs(['auth', 'list', '--json'])); + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ + initialized: true, + methods: [{ id: 'company' }], + }); + }); it('does not open or poll a browser test sent by another origin', async () => { const client = new OperatorClient('https://api.example.com', 'fixture'); await expect( diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index da1016793..dfcef54bf 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -881,6 +881,363 @@ describe('platform Cloudflare client', () => { expect(deleted).toEqual([['a.txt', 'b.txt'], ['nested/c.txt']]); }); + it('checks ownership again before each page of R2 object deletes', async () => { + const events: Array = []; + const client = new PlatformCloudflareClient('token', { + fetcher: async (input, init) => { + const url = new URL(String(input)); + if (init?.method === 'DELETE') { + const keys = JSON.parse(String(init.body)) as Array; + events.push(`delete ${keys.join(',')}`); + return Response.json({ success: true, result: keys.map((key) => ({ key })) }); + } + const cursor = url.searchParams.get('cursor') ?? ''; + return Response.json( + cursor + ? { success: true, result: [{ key: 'b.txt' }] } + : { success: true, result: [{ key: 'a.txt' }], result_info: { cursor: 'next' } }, + ); + }, + }); + + await client.emptyR2Bucket('account', 'void-assets', new Set(), async () => { + events.push('check'); + }); + expect(events).toEqual(['check', 'delete a.txt', 'check', 'delete b.txt']); + + await expect( + client.emptyR2Bucket('account', 'void-assets', new Set(), async () => { + throw new Error('replaced'); + }), + ).rejects.toThrow('replaced'); + expect(events).toEqual(['check', 'delete a.txt', 'check', 'delete b.txt']); + }); + + it('uses the documented teardown endpoints, never sends force, and treats 404 as gone', async () => { + const requests: Array = []; + const client = new PlatformCloudflareClient('token', { + fetcher: async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? 'GET'; + requests.push(`${method} ${url.pathname}${url.search}`); + if (url.pathname.endsWith('/gone')) { + return Response.json( + { success: false, errors: [{ code: 10007, message: 'not found' }] }, + { status: 404 }, + ); + } + if (method === 'DELETE') { + // Some Cloudflare DELETE endpoints answer without a result field. + return Response.json({ success: true, errors: [], messages: [] }); + } + if (url.pathname.endsWith('/consumers')) { + return Response.json({ + success: true, + result: [ + { + consumer_id: 'c1', + queue_name: 'void-team-usage', + script_name: 'void-team-api', + settings: { batch_size: 10 }, + type: 'worker', + }, + { + consumer_id: 'c2', + queue_name: 'void-team-usage', + script_name: 'other', + type: 'worker', + }, + { consumer_id: 'c3', queue_name: 'void-team-usage', type: 'http_pull' }, + ], + }); + } + if (url.pathname.endsWith('/scripts')) { + return Response.json({ success: true, result: [{ id: 'app-one' }] }); + } + if (url.pathname.includes('/access/')) { + return Response.json({ success: true, result: { id: 'app-id', name: 'void-app' } }); + } + return Response.json({ success: true, result: [] }); + }, + }); + + await client.deleteResource('account', { + id: 'worker-id', + kind: 'worker', + name: 'void-team-api', + }); + await client.deleteResource('account', { id: 'gone', kind: 'worker', name: 'gone' }); + await client.deleteWorkerRoute('zone', 'route-id'); + await client.deleteWorkerRoute('zone', 'gone'); + await client.deleteWorkerDomain('account', 'domain-id'); + await expect(client.listQueueConsumers('account', 'queue-id')).resolves.toEqual([ + { id: 'c1', script: 'void-team-api' }, + { id: 'c2', script: 'other' }, + { id: 'c3', script: null }, + ]); + await client.deleteQueueConsumer('account', 'queue-id', 'c1'); + await expect(client.listDispatchNamespaceScripts('account', 'void-users')).resolves.toEqual([ + 'app-one', + ]); + await client.deleteDispatchNamespaceScript('account', 'void-users', 'app-one'); + await expect(client.getAccessResource('account', 'application', 'app-id')).resolves.toEqual({ + id: 'app-id', + name: 'void-app', + }); + await expect(client.getAccessResource('account', 'service-token', 'gone')).resolves.toBeNull(); + await client.deleteAccessResource('account', 'application', 'app-id'); + await client.deleteAccessResource('account', 'service-token', 'token-id'); + + expect(requests).toEqual([ + 'DELETE /client/v4/accounts/account/workers/scripts/void-team-api', + 'DELETE /client/v4/accounts/account/workers/scripts/gone', + 'DELETE /client/v4/zones/zone/workers/routes/route-id', + 'DELETE /client/v4/zones/zone/workers/routes/gone', + 'DELETE /client/v4/accounts/account/workers/domains/domain-id', + 'GET /client/v4/accounts/account/queues/queue-id/consumers', + 'DELETE /client/v4/accounts/account/queues/queue-id/consumers/c1', + 'GET /client/v4/accounts/account/workers/dispatch/namespaces/void-users/scripts', + 'DELETE /client/v4/accounts/account/workers/dispatch/namespaces/void-users/scripts/app-one', + 'GET /client/v4/accounts/account/access/apps/app-id', + 'GET /client/v4/accounts/account/access/service_tokens/gone', + 'DELETE /client/v4/accounts/account/access/apps/app-id', + 'DELETE /client/v4/accounts/account/access/service_tokens/token-id', + ]); + expect(requests.some((request) => request.includes('force'))).toBe(false); + }); + + it('reads what depends on a Worker like wrangler delete, and forces only on request', async () => { + const requests: Array = []; + const client = new PlatformCloudflareClient('token', { + fetcher: async (input, init) => { + const url = new URL(String(input)); + requests.push(`${init?.method ?? 'GET'} ${url.pathname}${url.search}`); + if (url.pathname.endsWith('/references')) { + // The shape Cloudflare returned for a platform API Worker. + return Response.json({ + success: true, + result: { + services: { + outgoing: [{ name: 'PLATFORM_PROXY', service: 'void-team-proxy' }], + incoming: [{ name: 'API', service: 'void-team-proxy', environment: 'production' }], + }, + domains: [], + durable_objects: [ + { name: 'USAGE_COUNTER', service: 'void-team-api', environment: 'production' }, + { name: 'SHARED', service: 'customer-worker', environment: 'production' }, + ], + dispatch_outbounds: [{ script: 'outbound-user', namespace: 'ns' }], + }, + }); + } + if (url.pathname.includes('/workers/tails/by-consumer/')) { + return Response.json({ + success: true, + result: [{ producer: { script: 'void-team-proxy' }, consumer: { script: 'x' } }], + }); + } + return Response.json({ success: true, result: null }); + }, + }); + + await expect(client.listWorkerDependents('account', 'void-team-api')).resolves.toEqual({ + pagesFunction: false, + workers: ['void-team-proxy', 'customer-worker', 'outbound-user'], + }); + await client.forceDeleteWorker('account', 'void-team-api'); + + expect(requests).toEqual([ + 'GET /client/v4/accounts/account/workers/scripts/void-team-api/references', + 'GET /client/v4/accounts/account/workers/tails/by-consumer/void-team-api', + 'DELETE /client/v4/accounts/account/workers/scripts/void-team-api?force=true', + ]); + }); + + it('reads a queue consumer Worker from script_name and refuses a Worker consumer without one', async () => { + const respond = (rows: ReadonlyArray>) => + new PlatformCloudflareClient('token', { + fetcher: async () => Response.json({ success: true, result: rows }), + }).listQueueConsumers('account', 'queue-id'); + + // Wrangler still reads these older names for the same Worker. + await expect( + respond([ + { consumer_id: 'c1', script: 'void-team-api', type: 'worker' }, + { consumer_id: 'c2', service: 'other', type: 'worker' }, + ]), + ).resolves.toEqual([ + { id: 'c1', script: 'void-team-api' }, + { id: 'c2', script: 'other' }, + ]); + await expect(respond([{ consumer_id: 'c1', type: 'worker' }])).rejects.toThrow( + 'platform: Queue consumer Worker name is missing from the Cloudflare response.', + ); + }); + + it('reads and detaches Email Routing rules through the documented endpoints', async () => { + const requests: Array = []; + const bodies: Array = []; + const worker = (name: string) => [{ type: 'worker', value: [name] }]; + const client = new PlatformCloudflareClient('token', { + fetcher: async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? 'GET'; + requests.push(`${method} ${url.pathname}${url.search}`); + if (init?.body) { + bodies.push(JSON.parse(String(init.body))); + } + if (url.pathname.endsWith('/catch_all') && method === 'GET') { + return url.pathname.includes('/empty-zone/') + ? Response.json( + { success: false, errors: [{ code: 1000, message: 'not found' }] }, + { status: 404 }, + ) + : Response.json({ + success: true, + result: { enabled: true, actions: worker('void-team-email-gateway') }, + }); + } + if (method === 'GET') { + const page = url.searchParams.get('page'); + return Response.json({ + success: true, + result: + page === '1' + ? [ + { id: 'r1', enabled: true, actions: worker('void-team-email-gateway') }, + ...Array.from({ length: 48 }, (_, index) => ({ + id: `other-${index}`, + enabled: true, + actions: [{ type: 'forward', value: ['a@example.com'] }], + })), + // The catch-all has its own endpoint. + { id: 'all', enabled: true, matchers: [{ type: 'all' }], actions: [] }, + ] + : [ + { + tag: 'r2', + enabled: false, + actions: [...worker('void-team-email-gateway'), { type: 'drop' }], + }, + ], + }); + } + return Response.json({ success: true, result: {} }); + }, + }); + + const rules = await client.listEmailRoutingRules('zone'); + expect(rules[0]).toEqual({ id: 'r1', enabled: true, workerName: 'void-team-email-gateway' }); + expect(rules.at(-1)).toEqual({ id: 'r2', enabled: false, workerName: null }); + expect(rules.some((rule) => rule.id === 'all')).toBe(false); + await expect(client.getEmailRoutingCatchAll('zone')).resolves.toEqual({ + enabled: true, + workerName: 'void-team-email-gateway', + }); + await expect(client.getEmailRoutingCatchAll('empty-zone')).resolves.toBeNull(); + await client.deleteEmailRoutingRule('zone', 'r1'); + await client.resetEmailRoutingCatchAll('zone'); + + expect(requests).toEqual([ + 'GET /client/v4/zones/zone/email/routing/rules?page=1&per_page=50', + 'GET /client/v4/zones/zone/email/routing/rules?page=2&per_page=50', + 'GET /client/v4/zones/zone/email/routing/rules/catch_all', + 'GET /client/v4/zones/empty-zone/email/routing/rules/catch_all', + 'DELETE /client/v4/zones/zone/email/routing/rules/r1', + 'PUT /client/v4/zones/zone/email/routing/rules/catch_all', + ]); + expect(bodies).toEqual([ + { matchers: [{ type: 'all' }], actions: [{ type: 'drop' }], enabled: false }, + ]); + }); + + it('reports a refused Worker deletion with Cloudflare error codes', async () => { + const client = new PlatformCloudflareClient('token', { + fetcher: async () => + Response.json( + { + success: false, + errors: [ + { + code: 100109, + message: + 'Cannot delete this Worker as it is a Tail Worker consumer for other Workers.', + }, + ], + }, + { status: 422 }, + ), + }); + + await expect( + client.deleteResource('account', { id: 'tail', kind: 'worker', name: 'void-team-tail' }), + ).rejects.toThrow( + 'Cannot delete this Worker as it is a Tail Worker consumer for other Workers. (code 100109)', + ); + }); + + it('reads an AI Gateway creation time without a zone as UTC, whatever the local zone', async () => { + const client = new PlatformCloudflareClient('token', { + fetcher: async () => + Response.json({ + success: true, + // The live API answers in this form. + result: [ + { id: 'void-team', created_at: '2026-10-05 18:29:14' }, + { id: 'odd', created_at: 'not a time' }, + ], + }), + }); + + await expect(client.listAiGateways('account')).resolves.toEqual([ + { createdAt: '2026-10-05T18:29:14.000Z', id: 'void-team', name: 'void-team' }, + { id: 'odd', name: 'odd' }, + ]); + }); + + it('deletes an AI Gateway with the runtime credential when one is set', async () => { + const authorizations: Array = []; + const client = new PlatformCloudflareClient('operator-token', { + fetcher: async (_input, init) => { + authorizations.push(new Headers(init?.headers).get('Authorization')); + return Response.json({ success: true, result: null }); + }, + }).withAiGatewayToken('runtime-token'); + + await client.deleteResource('account', { + id: 'void-team', + kind: 'ai-gateway', + name: 'void-team', + }); + expect(authorizations).toEqual(['Bearer runtime-token']); + }); + + it('deletes a dispatch namespace by its name, not its id', async () => { + const requests: Array<{ method: string; pathname: string }> = []; + const client = new PlatformCloudflareClient('token', { + fetcher: async (input, init) => { + requests.push({ + method: init?.method ?? 'GET', + pathname: new URL(String(input)).pathname, + }); + return Response.json({ success: true, result: null }); + }, + }); + + await client.deleteResource('account', { + id: '5f2f1fa4-9a2f-4f4f-9713-9c27b6a1dd7f', + kind: 'dispatch-namespace', + name: 'void-team-users', + }); + + expect(requests).toEqual([ + { + method: 'DELETE', + pathname: '/client/v4/accounts/account/workers/dispatch/namespaces/void-team-users', + }, + ]); + }); + it('uses the documented AI Gateway lifecycle endpoints and creation shape', async () => { const requests: Array<{ body: unknown; method: string; pathname: string }> = []; const client = new PlatformCloudflareClient('token', { @@ -908,7 +1265,11 @@ describe('platform Cloudflare client', () => { id: 'void-team', name: 'void-team', }); - await client.deleteResource('account', 'ai-gateway', 'void-team'); + await client.deleteResource('account', { + id: 'void-team', + kind: 'ai-gateway', + name: 'void-team', + }); expect(requests).toEqual([ { @@ -936,6 +1297,21 @@ describe('platform Cloudflare client', () => { ]); }); + it('reports when Cloudflare created each AI Gateway', async () => { + const client = new PlatformCloudflareClient('token', { + fetcher: async () => + Response.json({ + success: true, + result: [{ id: 'void-team', created_at: '2026-09-14T00:00:01.400Z' }, { id: 'other' }], + }), + }); + + await expect(client.listAiGateways('account')).resolves.toEqual([ + { createdAt: '2026-09-14T00:00:01.400Z', id: 'void-team', name: 'void-team' }, + { id: 'other', name: 'other' }, + ]); + }); + it('creates and deletes the proxied wildcard application DNS record in its zone', async () => { const requests: Array<{ body: unknown; method: string; pathname: string }> = []; const dns = { @@ -963,7 +1339,11 @@ describe('platform Cloudflare client', () => { await expect(client.listDnsRecords('zone-id', '*.apps.example.com')).resolves.toEqual([dns]); await expect(client.createDnsRecord('zone-id', '*.apps.example.com')).resolves.toEqual(dns); - await client.deleteResource('account', 'dns-record', 'dns-id', 'zone-id'); + await client.deleteResource( + 'account', + { id: 'dns-id', kind: 'dns-record', name: '*.apps.example.com' }, + 'zone-id', + ); expect(requests).toEqual([ { body: null, method: 'GET', pathname: '/client/v4/zones/zone-id/dns_records' }, diff --git a/packages/void/test/unit/platform-connect.test.ts b/packages/void/test/unit/platform-connect.test.ts index e19d8d2da..1626d546e 100644 --- a/packages/void/test/unit/platform-connect.test.ts +++ b/packages/void/test/unit/platform-connect.test.ts @@ -87,6 +87,89 @@ describe('platform discovery', () => { rmSync(directory, { recursive: true, force: true }); } }); + it('reports a disabled platform and how to enable it', async () => { + const directory = mkdtempSync(join(tmpdir(), 'void-status-')); + vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', directory); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + const fetcher = vi.fn(async () => + Response.json({ error: 'Platform disabled' }, { status: 503 }), + ); + vi.stubGlobal('fetch', fetcher); + try { + const timestamp = '2026-10-05T00:00:00.000Z'; + const manifest = createPlatformInstallationManifest({ + account: { id: 'account', name: 'Account' }, + createdAt: timestamp, + deployment: createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', + }), + id: 'disabled-installation', + ownershipNonce: '0123456789abcdef0123456789abcdef', + platformVersion: '1.0.0', + }); + writeInstallationManifest({ + ...manifest, + status: 'disabled', + endpoints: { apiUrl: 'https://api.example.com', dashboardUrl: null, proxyUrl: null }, + }); + await expect( + runPlatformCommand('.', { subcommand: 'status', selector: manifest.id }), + ).resolves.toBeUndefined(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(output).toHaveBeenCalledWith( + 'Platform: disabled (API and application traffic are paused).', + ); + expect(output).toHaveBeenCalledWith('Enable it: void platform enable disabled-installation'); + expect(output).not.toHaveBeenCalledWith(expect.stringContaining('is reachable')); + } finally { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); + } + }); + it('reports an upgrade or repair, not a disabled platform, while maintenance pauses traffic', async () => { + const directory = mkdtempSync(join(tmpdir(), 'void-status-')); + vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', directory); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ error: 'Platform disabled', reason: 'maintenance' }, { status: 503 }), + ), + ); + try { + const manifest = createPlatformInstallationManifest({ + account: { id: 'account', name: 'Account' }, + createdAt: '2026-10-05T00:00:00.000Z', + deployment: createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', + }), + id: 'maintenance-installation', + ownershipNonce: '0123456789abcdef0123456789abcdef', + platformVersion: '1.0.0', + }); + writeInstallationManifest({ + ...manifest, + status: 'verifying', + endpoints: { apiUrl: 'https://api.example.com', dashboardUrl: null, proxyUrl: null }, + }); + await expect( + runPlatformCommand('.', { subcommand: 'status', selector: manifest.id }), + ).rejects.toThrow('upgrade or repair'); + expect(output).not.toHaveBeenCalledWith(expect.stringContaining('disabled')); + expect(output).not.toHaveBeenCalledWith(expect.stringContaining('void platform enable')); + } finally { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); + } + }); it('accepts a valid document from its declared origin', async () => { const response = new Response(JSON.stringify(document)); Object.defineProperty(response, 'url', { diff --git a/packages/void/test/unit/platform-database-lock.test.ts b/packages/void/test/unit/platform-database-lock.test.ts index 5481a5767..d8075e346 100644 --- a/packages/void/test/unit/platform-database-lock.test.ts +++ b/packages/void/test/unit/platform-database-lock.test.ts @@ -66,3 +66,29 @@ it('preserves every database lifecycle capability through the pool wrapper on in expect(await lock.prepareSandboxDrain!(2)).toEqual(checkpoint); await expect(lock.verifyDatabase!(directory, runtime.database)).resolves.toBeUndefined(); }, 30_000); + +it('refuses leftover migration history before claiming and still resumes its own claim', async () => { + const url = 'postgresql://owner:pw@db.example.com/platform?sslmode=require'; + await database.exec(` + CREATE SCHEMA "drizzle"; + CREATE TABLE "drizzle"."__drizzle_migrations" ("id" serial PRIMARY KEY, "hash" text NOT NULL); + `); + await expect(claimPlatformDatabase(url, 'test-install', 'test-owner')).rejects.toThrow( + 'non-empty PostgreSQL database (found drizzle.__drizzle_migrations)', + ); + const claim = await database.query( + `SELECT to_regclass('"__void_platform_installation"')::text AS "relation"`, + ); + expect(claim.rows).toEqual([{ relation: null }]); + + // A resumed installation has already claimed the database and written its + // own migration history; the matching claim must still be accepted. + await database.exec(`DROP SCHEMA "drizzle" CASCADE`); + await claimPlatformDatabase(url, 'test-install', 'test-owner'); + lock = await acquirePlatformDatabaseLifecycleLock(url, 'test-install', 'test-owner'); + const runtime = readPlatformRuntimeManifest(); + await lock.prepareDatabase!(getPlatformRuntimeDirectory(), runtime.database, null, false); + await lock(); + lock = undefined; + await expect(claimPlatformDatabase(url, 'test-install', 'test-owner')).resolves.toBeUndefined(); +}, 30_000); diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 7d0e593bb..08b2ee23d 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -6358,7 +6358,13 @@ describe('platform installer', () => { expect(retryPreviousConfigs.slice(0, 5).every((config) => config.workers_dev === false)).toBe( true, ); - expect(retryPreviousConfigs.slice(5).every((config) => config.workers_dev === true)).toBe(true); + expect( + retryPreviousConfigs + .slice(5) + .every( + (config) => config.workers_dev === (config.name !== deployment.resources.tailWorker), + ), + ).toBe(true); expect( retryPreviousConfigs .slice(5) @@ -6402,8 +6408,11 @@ describe('platform installer', () => { expect((config.vars as Record).PLATFORM_TRAFFIC_STATE).not.toBe('enabled'); } for (const config of enabledConfigs.slice(5)) { - expect(config.workers_dev).toBe(true); - expect(config.preview_urls).toBe(config.name !== deployment.resources.emailGatewayWorker); + expect(config.workers_dev).toBe(config.name !== deployment.resources.tailWorker); + expect(config.preview_urls).toBe( + config.name !== deployment.resources.emailGatewayWorker && + config.name !== deployment.resources.tailWorker, + ); if (!String(config.name).endsWith('-tail')) { expect((config.vars as Record).PLATFORM_TRAFFIC_STATE).toBe('enabled'); } diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 0a9713f2a..b5232c285 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -660,6 +660,7 @@ describe('installed platform upgrade', () => { deleteZone: null, plan, purgeData: true, + purgeResources: false, selector: state.manifest!.id, subcommand: 'uninstall', yes: true, diff --git a/packages/void/test/unit/platform-management.test.ts b/packages/void/test/unit/platform-management.test.ts index 242f17815..962af48cb 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -15,7 +15,8 @@ import { type PlatformInstallationManifest, type ResourceOwnership, } from '@void/platform'; -import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { log, note } from '@voidzero-dev/vite-plus-prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { disablePlatform, resolvePlatformRuntimeTokenToPreflight, @@ -29,6 +30,7 @@ import { platformProvisionIntentHash, } from '../../src/cli/platform-plan.ts'; import { + installationManifestPath, listInstallationManifests, promoteInstallationSecrets, readInstallationRecoverySecrets, @@ -41,9 +43,16 @@ import { readPlatformProjectZeroTrustStateConnection, } from '../../src/cli/platform-zero-trust-uninstall.ts'; +vi.mock('@voidzero-dev/vite-plus-prompts', async (original) => { + const actual = await original(); + return { ...actual, log: { ...actual.log, success: vi.fn() }, note: vi.fn() }; +}); + const directories: Array = []; beforeEach(() => { + vi.mocked(note).mockClear(); + vi.mocked(log.success).mockClear(); process.env.VOID_PLATFORM_RECOVERY_KEY = Buffer.alloc(32, 7).toString('base64'); }); @@ -164,7 +173,10 @@ function client(manifest: PlatformInstallationManifest) { get removedRoutes() { return removedRoutes; }, - deleteResource: async (_account: string, kind: string, id: string): Promise => { + deleteResource: async ( + _account: string, + { id, kind }: Readonly<{ id: string; kind: string; name: string }>, + ): Promise => { // Like Cloudflare, refuse to delete a Queue while a platform Worker // still binds it. const bindingWorker = (await fake.listWorkerNames())[0]; @@ -296,11 +308,30 @@ function ownershipMarker( ); } +function noteText(title: string): string { + return String( + vi + .mocked(note) + .mock.calls.filter(([, noteTitle]) => noteTitle === title) + .at(-1)?.[0] ?? '', + ); +} + +function expectInOrder(text: string, lines: ReadonlyArray): void { + const positions = lines.map((line) => text.indexOf(line)); + expect( + positions.every((position) => position >= 0), + text, + ).toBe(true); + expect(positions, text).toEqual([...positions].sort((left, right) => left - right)); +} + const uninstallArgs = { subcommand: 'uninstall' as const, selector: '', plan: false, purgeData: false, + purgeResources: false, deleteZone: null, yes: true, }; @@ -1138,6 +1169,19 @@ describe('platform lifecycle management', () => { expect(databaseCheckpoints.at(-1)?.status).toBe('disabled'); }); + it('describes the disable plan in user terms', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + + await disablePlatform(fake, manifest, true); + + expect(noteText('Disable plan')).toBe( + 'Block all platform traffic and the platform API. Routes, custom domains, Workers, and data stay installed.', + ); + expect(fake.disabledWrites).toEqual([]); + }); + it('selects cross-host disabled and uninstalled state over a stale local manifest', () => { const local = installedManifest(); const disabled = { ...local, desiredState: 'disabled' as const, status: 'disabled' as const }; @@ -1391,6 +1435,118 @@ describe('platform lifecycle management', () => { expect(retained).toHaveLength(15); }); + it('lists every kept resource in manual cleanup order after uninstall', async () => { + const manifest = { + ...installedManifest(), + authenticationResources: [ + { + key: 'login-access', + kind: 'application' as const, + accountId: 'account', + id: 'access-app-id', + name: 'login-access', + ownership: 'created' as const, + }, + ], + }; + persist(manifest); + const fake = client(manifest); + + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); + + const kept = noteText('Kept for manual cleanup'); + expectInOrder(kept, [ + 'dns-record: application-dns (application-dns-id)', + 'queue: cron-queue (cron-queue-id)', + 'queue: usage-queue (usage-queue-id)', + 'worker: api-worker (void-example-api)', + 'worker: dispatch-worker (void-example-dispatch)', + 'dispatch-namespace: dispatch-namespace (dispatch-namespace-id)', + // Cloudflare refuses the tail Worker while other Workers send it logs. + 'worker: tail-worker (void-example-tail)', + 'kv-namespace: routing-kv (routing-kv-id)', + 'hyperdrive: hyperdrive (hyperdrive-id)', + 'r2-bucket: assets-bucket (assets-bucket-id)', + 'ai-gateway: ai-gateway (ai-gateway-id)', + 'zone: zone (zone-id)', + 'created application: login-access (access-app-id, account account)', + ]); + expect(vi.mocked(log.success).mock.calls.at(-1)?.[0]).toBe( + `Example was uninstalled. The full record is in ${installationManifestPath('account', manifest.id)}.`, + ); + }); + + it('names the resources a purge plan keeps', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + + await uninstallPlatform( + fake, + manifest, + { ...uninstallArgs, plan: true, purgeData: true }, + uninstallDependencies, + ); + + const plan = noteText('Uninstall plan'); + expectInOrder(plan, [ + 'dns-record: application-dns (application-dns-id)', + 'queue: usage-queue (usage-queue-id)', + 'worker: api-worker (void-example-api)', + 'r2-bucket: assets-bucket (assets-bucket-id)', + 'ai-gateway: ai-gateway (ai-gateway-id)', + 'zone: zone (zone-id)', + ]); + expect(plan).not.toContain('kv-namespace:'); + expect(plan).not.toContain('hyperdrive:'); + expect(plan).not.toContain('dispatch-namespace:'); + expect(plan).not.toContain('Email Routing'); + expect(fake.deleted).toEqual([]); + }); + + it('says Email Routing is kept when the installation sends email', async () => { + const base = installedManifest(); + const manifest = { + ...base, + deployment: { + ...base.deployment, + capabilities: { ...base.deployment.capabilities, email: true }, + emailSenderDomain: 'mail.example.com', + emailSharedZoneId: 'zone-id', + }, + }; + persist(manifest); + const fake = client(manifest); + const emailLine = `Email Routing for mail.example.com (zone zone-id): its routing DNS records and any catch-all rule that sends mail to ${base.deployment.resources.emailGatewayWorker}.`; + + await uninstallPlatform( + fake, + manifest, + { ...uninstallArgs, plan: true }, + uninstallDependencies, + ); + expect(noteText('Uninstall plan')).toContain(emailLine); + + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); + expect(noteText('Kept for manual cleanup')).toContain(emailLine); + }); + + it('records an AI Gateway already removed in Cloudflare as deleted', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + fake.listAiGateways = async () => []; + + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); + + expect(fake.deleted).toEqual([]); + expect( + listInstallationManifests()[0]?.resources.find((resource) => resource.key === 'ai-gateway') + ?.state, + ).toBe('deleted'); + expect(noteText('Kept for manual cleanup')).not.toContain('ai-gateway:'); + }); + it('refuses automatic deletion of an installer-created zone', async () => { const manifest = installedManifest(); persist(manifest); @@ -1509,11 +1665,11 @@ describe('platform lifecycle management', () => { persist(manifest); const first = client(manifest); const originalDelete = first.deleteResource; - first.deleteResource = async (account, kind, id) => { - if (kind === 'dispatch-namespace') { + first.deleteResource = async (account, resource) => { + if (resource.kind === 'dispatch-namespace') { throw new Error('temporary deletion failure'); } - await originalDelete(account, kind, id); + await originalDelete(account, resource); }; await expect( uninstallPlatform( @@ -1620,9 +1776,9 @@ describe('platform lifecycle management', () => { persist(manifest); const first = client(manifest); const originalDelete = first.deleteResource; - first.deleteResource = async (account, kind, id) => { - await originalDelete(account, kind, id); - if (kind === 'kv-namespace' && id === 'routing-kv-id') { + first.deleteResource = async (account, resource) => { + await originalDelete(account, resource); + if (resource.kind === 'kv-namespace' && resource.id === 'routing-kv-id') { throw new Error('connection closed after routing deletion'); } }; diff --git a/packages/void/test/unit/platform-plan.test.ts b/packages/void/test/unit/platform-plan.test.ts index f9f62fdd6..4172a455b 100644 --- a/packages/void/test/unit/platform-plan.test.ts +++ b/packages/void/test/unit/platform-plan.test.ts @@ -642,6 +642,42 @@ describe('platform installation planning', () => { ), ).rejects.toThrow('KV access denied'); }); + it('names the DNS permission when the token cannot read DNS records', async () => { + const zone = { id: 'zone', name: 'example.app', nameServers: [], status: 'active' }; + const deniedDns = async () => { + throw new PlatformCloudflareApiError('Authentication error', 403, '/zones/zone/dns_records'); + }; + const wildcard = createPlatformInstallPlan( + client({ findZone: async () => zone, listDnsRecords: deniedDns }), + input, + null, + ); + await expect(wildcard).rejects.toThrow( + 'platform: Cloudflare denied reading DNS records for example.app (HTTP 403). Add Zone → DNS → Edit for this zone to the management token, then rerun.', + ); + await expect(wildcard).rejects.not.toThrow('Authentication error'); + + const apiHostname = createPlatformInstallPlan( + client({ findZoneForHostname: async () => zone, listDnsRecords: deniedDns }), + { ...input, controlPlaneDomain: 'api.example.app' }, + null, + ); + await expect(apiHostname).rejects.toThrow('Zone → DNS → Edit'); + await expect(apiHostname).rejects.not.toThrow('Authentication error'); + + await expect( + createPlatformInstallPlan( + client({ + findZone: async () => zone, + listDnsRecords: async () => { + throw new PlatformCloudflareApiError('service unavailable', 503); + }, + }), + input, + null, + ), + ).rejects.toThrow('service unavailable'); + }); it('plans workers.dev without any zone, DNS, or certificate access', async () => { const forbidden = async () => { throw new Error('zone access must not occur'); diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 66895e438..489f454d7 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -125,7 +125,11 @@ describe('platform credential setup links', () => { permissions.some(({ key }: { key: string }) => ['cache', 'account_api_tokens'].includes(key)), ).toBe(false); expect(page.instructions).toBe( - 'This token lets the Void CLI install your platform. Void will now open a pre-configured token creation site. Please review the permissions and then paste the token here.\nScope it to Account and zone example.com.', + [ + 'This token lets the Void CLI install your platform. Void will now open a pre-configured token creation site. Please review the permissions and then paste the token here.', + 'Scope it to Account.', + 'Change the zone policy from All Domains to Specified Domains → example.com.', + ].join('\n'), ); const createZone = platformManagementSetupPage({ ...original, @@ -143,7 +147,10 @@ describe('platform credential setup links', () => { expect( JSON.parse(new URL(createZone.url).searchParams.get('permissionGroupKeys')!), ).toContainEqual({ key: 'zone', type: 'edit' }); - expect(createZone.instructions).toContain('allow zone creation'); + expect(createZone.instructions).toContain( + 'Keep the zone policy on All Domains so Void can create example.com.', + ); + expect(createZone.instructions).not.toContain('Specified Domains'); const separateApi = platformManagementSetupPage({ ...original, deployment: { @@ -151,7 +158,26 @@ describe('platform credential setup links', () => { controlPlane: { ...original.deployment.controlPlane, apiHostname: 'api.company.net' }, }, }); - expect(separateApi.instructions).toContain('Also include the zone for api.company.net.'); + expect(separateApi.instructions).toContain( + 'Change the zone policy from All Domains to Specified Domains → example.com and the zone for api.company.net.', + ); + const workersDevApi = platformManagementSetupPage({ + ...plan(), + deployment: { + ...plan().deployment, + controlPlane: { ...plan().deployment.controlPlane, apiHostname: 'api.company.net' }, + }, + }); + expect(workersDevApi.instructions).toContain( + 'Change the zone policy from All Domains to Specified Domains → the zone for api.company.net.', + ); + }); + it('cannot preselect a zone in the account token link, so it names the dashboard change', () => { + // Account token template links accept only permissionGroupKeys and name. + const url = new URL(platformManagementSetupPage(plan('example.com')).url); + expect([...url.searchParams.keys()]).toEqual(['to', 'name', 'permissionGroupKeys']); + const runtime = new URL(platformSetupPages(plan('example.com')).runtime.url); + expect([...runtime.searchParams.keys()]).toEqual(['to', 'name', 'permissionGroupKeys']); }); it('round-trips planned defaults, Hyperdrive, dashboard, and login choices into install arguments', () => { @@ -380,6 +406,45 @@ describe('platform credential setup links', () => { key: 'cache', type: 'purge', }); + expect(page.instructions).toBe( + [ + 'Create a Cloudflare runtime token that Void can use to manage the platform. Please review the permissions and then paste the token here.', + 'Change the zone policy from All Domains to Specified Domains → example.com.', + ].join('\n'), + ); + const original = plan('example.com'); + const withEmail = platformSetupPages({ + ...original, + deployment: { + ...original.deployment, + capabilities: { ...original.deployment.capabilities, email: true }, + }, + }).runtime; + expect(withEmail.instructions).toContain( + 'Change the zone policy from All Domains to Specified Domains → example.com and every zone that will carry mail.', + ); + }); + it('keeps the runtime token on All Domains when Void creates the zone', () => { + // The zone does not exist yet, so Specified Domains cannot select it. + const page = platformSetupPages({ + ...plan('example.com'), + actions: [ + { + kind: 'zone', + key: 'zone', + action: 'create', + id: null, + name: 'example.com', + reason: 'test', + }, + ], + }).runtime; + expect(page.instructions).toBe( + [ + 'Create a Cloudflare runtime token that Void can use to manage the platform. Please review the permissions and then paste the token here.', + 'Keep the zone policy on All Domains so the token can purge cache on example.com after Void creates it.', + ].join('\n'), + ); }); it('opens only in interactive mode and retains a manual link if opening fails', () => { const open = vi.fn(() => { diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts new file mode 100644 index 000000000..ea8ff88e2 --- /dev/null +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -0,0 +1,1521 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createPlatformDeploymentManifest, + createPlatformInstallationManifest, + createPlatformResourceOwnershipMarker, + PLATFORM_DISABLED_KEY, + PLATFORM_R2_OWNERSHIP_MARKER_KEY, + recordInstallationResource, + recordInstallationStep, + transitionInstallation, + type InstallationResource, + type InstallationResourceKind, + type PlatformInstallationManifest, + type ResourceOwnership, +} from '@void/platform'; +import { log, note } from '@voidzero-dev/vite-plus-prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + PlatformCloudflareApiError, + PlatformCloudflareClient, +} from '../../src/cli/platform-cloudflare.ts'; +import { uninstallPlatform } from '../../src/cli/platform-management.ts'; +import { + listInstallationManifests, + promoteInstallationSecrets, + readInstallationRecoverySecrets, + writeInstallationManifest, + writePendingInstallationSecrets, +} from '../../src/cli/platform-registry.ts'; +import { readPlatformProjectSlugsConnection } from '../../src/cli/platform-teardown.ts'; +import { recordPlatformWorkerTraffic } from '../../src/cli/platform-traffic.ts'; +import { projectZeroTrustUninstallState } from '../../src/cli/platform-zero-trust-uninstall.ts'; + +vi.mock('@voidzero-dev/vite-plus-prompts', async (original) => { + const actual = await original(); + return { + ...actual, + log: { ...actual.log, success: vi.fn(), warn: vi.fn() }, + note: vi.fn(), + outro: vi.fn(), + }; +}); + +const directories: Array = []; + +beforeEach(() => { + vi.mocked(note).mockClear(); + vi.mocked(log.warn).mockClear(); + process.env.VOID_PLATFORM_RECOVERY_KEY = Buffer.alloc(32, 7).toString('base64'); +}); + +afterEach(() => { + delete process.env.VOID_PLATFORM_RECOVERY_KEY; + delete process.env.VOID_PLATFORM_REGISTRY_DIR; + for (const directory of directories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + +const deployment = createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + controlPlane: { apiHostname: 'api.example.com' }, + displayName: 'Example', + installationSlug: 'example', +}); +const names = deployment.resources; +const emailDeployment = createPlatformDeploymentManifest({ + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + capabilities: { email: true }, + controlPlane: { apiHostname: 'api.example.com' }, + displayName: 'Example', + emailSenderDomain: 'mail.example.com', + emailSharedZoneId: 'mail-zone', + installationSlug: 'example', +}); +const INSTALLATION_ID = 'example-a1b2c3d4'; +const FINGERPRINT = '2026-09-14T00:00:00.000Z'; +const RESUME = `void platform uninstall ${INSTALLATION_ID} --purge-resources`; +// The installer records its intent right before it creates the AI Gateway. +const AI_GATEWAY_INTENT_AT = '2026-09-14T00:00:01.000Z'; + +function hash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function installedManifest( + options: { dispatchNamespace?: boolean; email?: boolean } = {}, +): PlatformInstallationManifest { + let manifest = createPlatformInstallationManifest({ + account: { id: 'account', name: 'Account' }, + createdAt: '2026-09-14T00:00:00.000Z', + deployment: options.email ? emailDeployment : deployment, + id: INSTALLATION_ID, + ownershipNonce: '0123456789abcdef0123456789abcdef', + platformVersion: '1.0.0', + }); + const resources: Array<[string, InstallationResourceKind, ResourceOwnership, string, string]> = [ + ['zone', 'zone', 'adopted', 'zone-id', 'example.com'], + ['application-dns', 'dns-record', 'created', 'dns-id', '*.apps.example.com'], + ['routing-kv', 'kv-namespace', 'created', 'routing-id', names.routingKv], + ['isr-cache-kv', 'kv-namespace', 'created', 'isr-id', names.isrCacheKv], + ['customer-kv', 'kv-namespace', 'adopted', 'customer-kv-id', 'customer-kv'], + ['assets-bucket', 'r2-bucket', 'created', names.assetsBucket, names.assetsBucket], + ['cron-queue', 'queue', 'created', 'cron-id', names.cronQueue], + ['usage-queue', 'queue', 'created', 'usage-id', names.usageQueue], + ['hyperdrive', 'hyperdrive', 'created', 'hyperdrive-id', names.hyperdrive], + ['ai-gateway', 'ai-gateway', 'created', names.aiGateway, names.aiGateway], + ['api-worker', 'worker', 'created', names.apiWorker, names.apiWorker], + ['proxy-worker', 'worker', 'created', names.proxyWorker, names.proxyWorker], + [ + 'email-gateway-worker', + 'worker', + 'created', + names.emailGatewayWorker, + names.emailGatewayWorker, + ], + ['dispatch-worker', 'worker', 'created', names.dispatchWorker, names.dispatchWorker], + ['tail-worker', 'worker', 'created', names.tailWorker, names.tailWorker], + ...(options.dispatchNamespace === false + ? [] + : [ + [ + 'dispatch-namespace', + 'dispatch-namespace', + 'created', + 'namespace-uuid', + names.dispatchNamespace, + ] as [string, InstallationResourceKind, ResourceOwnership, string, string], + ]), + ]; + for (const [key, kind, ownership, id, name] of resources) { + manifest = recordInstallationResource( + manifest, + { + configHash: + kind === 'dns-record' + ? hash({ content: '100::', kind, name, proxied: true, type: 'AAAA' }) + : kind === 'r2-bucket' + ? hash({ fingerprint: FINGERPRINT, kind, name }) + : 'hash', + id, + key, + kind, + name, + ownership, + state: 'ready', + }, + '2026-09-14T00:00:01.000Z', + ); + } + manifest = recordPlatformWorkerTraffic( + manifest, + names.dispatchWorker, + { + customDomains: [], + routes: [ + { id: 'route-1', pattern: '*.apps.example.com/*', script: names.dispatchWorker }, + { id: 'route-2', pattern: '*.apps.example.com', script: names.dispatchWorker }, + ], + }, + '2026-09-14T00:00:01.000Z', + ); + manifest = recordPlatformWorkerTraffic( + manifest, + names.apiWorker, + { + customDomains: [{ hostname: 'api.example.com', id: 'domain-1', service: names.apiWorker }], + routes: [], + }, + '2026-09-14T00:00:01.000Z', + ); + manifest = { + ...manifest, + authenticationResources: [ + { + key: 'login', + kind: 'application', + accountId: 'account', + id: 'access-app', + name: `void-${INSTALLATION_ID}-login-0123456789abcdef`, + ownership: 'created', + }, + { + key: 'service-token', + kind: 'service-token', + accountId: 'account', + id: 'access-token', + name: `void-${INSTALLATION_ID}-access-probes-0123456789abcdef`, + ownership: 'created', + }, + { + key: 'company', + kind: 'application', + accountId: 'account', + id: 'company-app', + name: 'company-app', + ownership: 'adopted', + }, + ], + }; + manifest = recordInstallationStep( + manifest, + { + completedAt: AI_GATEWAY_INTENT_AT, + key: 'provision-intent:ai-gateway:0123456789abcdef', + resultHash: 'intent', + }, + AI_GATEWAY_INTENT_AT, + ); + for (const status of [ + 'planned', + 'provisioning', + 'deploying', + 'verifying', + 'installed', + ] as const) { + manifest = transitionInstallation(manifest, status, '2026-09-14T00:00:02.000Z'); + } + return manifest; +} + +function persist(manifest: PlatformInstallationManifest): void { + const directory = mkdtempSync(join(tmpdir(), 'void-platform-teardown-test-')); + directories.push(directory); + process.env.VOID_PLATFORM_REGISTRY_DIR = directory; + writeInstallationManifest(manifest); + writePendingInstallationSecrets(manifest, { databaseUrl: 'postgres://recovery' }); + promoteInstallationSecrets(manifest); +} + +function marker(manifest: PlatformInstallationManifest, resource: InstallationResource): string { + return JSON.stringify( + createPlatformResourceOwnershipMarker({ + accountId: manifest.account.id, + installationId: manifest.id, + ownershipNonce: manifest.ownershipNonce, + resourceId: resource.id, + resourceKey: resource.key, + resourceKind: resource.kind, + resourceName: resource.name, + }), + ); +} + +/** A Cloudflare account that enforces the delete rules seen live. */ +function cloudflare(manifest: PlatformInstallationManifest) { + const calls: Array = []; + const ready = (kind: InstallationResourceKind) => + manifest.resources + .filter((resource) => resource.kind === kind && resource.state === 'ready') + .map((resource) => ({ id: resource.id, name: resource.name })); + const workerMarkers = new Map( + manifest.resources + .filter((resource) => resource.kind === 'worker') + .map((resource) => [resource.name, marker(manifest, resource)]), + ); + const bucket = manifest.resources.find((resource) => resource.kind === 'r2-bucket')!; + const live = { + aiGateways: ready('ai-gateway').map( + (entry): { id: string; name: string; createdAt?: string } => ({ + ...entry, + createdAt: '2026-09-14T00:00:01.400Z', + }), + ), + dispatchNamespaces: ready('dispatch-namespace'), + namespaceScripts: new Set( + manifest.resources.some((resource) => resource.kind === 'dispatch-namespace') + ? ['leftover-app'] + : [], + ), + dns: ready('dns-record'), + hyperdrives: ready('hyperdrive'), + kv: ready('kv-namespace'), + queues: ready('queue'), + consumers: new Map>([ + ['cron-id', [{ id: 'cron-consumer', script: names.apiWorker }]], + [ + 'usage-id', + [ + { id: 'usage-consumer', script: names.apiWorker }, + { id: 'foreign-consumer', script: 'someone-else' }, + ], + ], + ]), + buckets: new Map([ + [ + bucket.name, + { + fingerprint: FINGERPRINT, + objects: new Map([ + [PLATFORM_R2_OWNERSHIP_MARKER_KEY, marker(manifest, bucket)], + ['assets/a.txt', 'a'], + ]), + }, + ], + ]), + workers: workerMarkers, + routes: [ + { id: 'route-1', pattern: '*.apps.example.com/*', script: names.dispatchWorker as string }, + { id: 'route-2', pattern: '*.apps.example.com', script: names.dispatchWorker as string }, + ], + domains: [{ hostname: 'api.example.com', id: 'domain-1', service: names.apiWorker as string }], + access: new Map( + (manifest.authenticationResources ?? []).map((resource) => [ + resource.id, + { accountId: resource.accountId, name: resource.name }, + ]), + ), + // A Worker the installation did not create that sends logs to the tail Worker. + foreignTailProducer: false, + // Service bindings as the installer deploys them: the API Worker checks + // the proxy through one, and the proxy and email gateway call the API. + serviceBindings: [ + { from: names.apiWorker as string, to: names.proxyWorker as string }, + { from: names.proxyWorker as string, to: names.apiWorker as string }, + { from: names.emailGatewayWorker as string, to: names.apiWorker as string }, + ], + // Workers outside the installation, which bind like any other Worker. + foreignWorkers: new Set(), + emailRules: [] as Array<{ id: string; enabled: boolean; workerName: string | null }>, + catchAll: null as { enabled: boolean; workerName: string | null } | null, + }; + let remote: string | null = JSON.stringify(manifest); + const failures = new Map(); + const fail = (call: string) => { + const error = failures.get(call); + if (error) { + failures.delete(call); + throw error; + } + }; + const routingLive = () => live.kv.some((entry) => entry.id === 'routing-id'); + const bindersOf = (name: string) => + live.serviceBindings + .filter( + (binding) => + binding.to === name && + binding.from !== name && + (live.workers.has(binding.from) || live.foreignWorkers.has(binding.from)), + ) + .map((binding) => binding.from); + const remove = (list: Array, id: string) => { + const index = list.findIndex((entry) => entry.id === id); + if (index >= 0) { + list.splice(index, 1); + } + }; + const fake = { + calls, + live, + failures, + deleteKvValue: async () => {}, + deleteResource: async ( + _account: string, + resource: Readonly<{ id: string; kind: string; name: string }>, + zoneId?: string, + ): Promise => { + const call = `DELETE ${resource.kind} ${resource.name}`; + calls.push(call); + fail(call); + if (resource.kind === 'worker') { + if ( + resource.name === names.tailWorker && + (live.foreignTailProducer || + live.workers.has(names.dispatchWorker) || + live.namespaceScripts.size > 0) + ) { + throw new PlatformCloudflareApiError( + 'Cloudflare API error: Cannot delete this Worker as it is a Tail Worker consumer for other Workers. (code 100109) (HTTP 422; DELETE /workers/scripts).', + 422, + ); + } + if ([...live.consumers.values()].flat().some((entry) => entry.script === resource.name)) { + throw new PlatformCloudflareApiError('Worker is a queue consumer.', 400); + } + const binders = bindersOf(resource.name); + if (binders.length) { + throw new PlatformCloudflareApiError( + `Cloudflare API error: Cannot delete service '${resource.name}' because it is still referenced by service bindings in Workers '${binders.join("', '")}'. Please remove bindings pointing to it and try again. (code 10142) (HTTP 400; DELETE /workers/scripts).`, + 400, + ); + } + live.workers.delete(resource.name); + } else if (resource.kind === 'queue') { + // The API and dispatch Workers produce to the platform Queues. + const bound = [names.apiWorker, names.dispatchWorker].find((name) => + live.workers.has(name), + ); + if (bound) { + throw new PlatformCloudflareApiError(`Queue is bound by ${bound}.`, 400); + } + remove(live.queues, resource.id); + } else if (resource.kind === 'dispatch-namespace') { + expect(resource.name).toBe(names.dispatchNamespace); + if (live.namespaceScripts.size > 0) { + throw new PlatformCloudflareApiError('Namespace has scripts.', 400); + } + remove(live.dispatchNamespaces, resource.id); + } else if (resource.kind === 'r2-bucket') { + if ((live.buckets.get(resource.name)?.objects.size ?? 0) > 0) { + throw new PlatformCloudflareApiError('The bucket you tried to delete is not empty.', 409); + } + live.buckets.delete(resource.name); + } else if (resource.kind === 'dns-record') { + expect(zoneId).toBe('zone-id'); + remove(live.dns, resource.id); + } else if (resource.kind === 'kv-namespace') { + remove(live.kv, resource.id); + } else if (resource.kind === 'hyperdrive') { + remove(live.hyperdrives, resource.id); + } else if (resource.kind === 'ai-gateway') { + remove(live.aiGateways, resource.id); + } else { + throw new Error(`unexpected delete ${resource.kind}`); + } + }, + forceDeleteWorker: async (_account: string, name: string) => { + const call = `DELETE worker ${name} force`; + calls.push(call); + fail(call); + live.workers.delete(name); + }, + listWorkerDependents: async (_account: string, name: string) => { + calls.push(`GET worker-dependents ${name}`); + return { pagesFunction: false, workers: bindersOf(name) }; + }, + deleteR2Object: async (_account: string, name: string, key: string) => { + calls.push(`DELETE r2-object ${key}`); + live.buckets.get(name)?.objects.delete(key); + }, + emptyR2Bucket: async ( + _account: string, + name: string, + preserved: ReadonlySet = new Set(), + beforeDelete?: () => Promise, + ) => { + calls.push(`EMPTY r2-bucket ${name}`); + const objects = live.buckets.get(name)?.objects; + const keys = [...(objects?.keys() ?? [])].filter((key) => !preserved.has(key)); + if (keys.length) { + await beforeDelete?.(); + for (const key of keys) { + objects!.delete(key); + } + } + }, + deleteWorkerRoute: async (_zone: string, id: string) => { + calls.push(`DELETE worker-route ${id}`); + remove(live.routes, id); + }, + deleteWorkerDomain: async (_account: string, id: string) => { + calls.push(`DELETE worker-custom-domain ${id}`); + remove(live.domains, id); + }, + deleteQueueConsumer: async (_account: string, queue: string, id: string) => { + calls.push(`DELETE queue-consumer ${queue} ${id}`); + remove(live.consumers.get(queue) ?? [], id); + }, + deleteDispatchNamespaceScript: async (_account: string, namespace: string, script: string) => { + calls.push(`DELETE dispatch-script ${namespace} ${script}`); + live.namespaceScripts.delete(script); + }, + deleteAccessResource: async (account: string, kind: string, id: string) => { + calls.push(`DELETE access ${kind} ${id}`); + if (live.access.get(id)?.accountId === account) { + live.access.delete(id); + } + }, + // Access ids are scoped to their account, like Cloudflare's API. + getAccessResource: async (account: string, kind: string, id: string) => { + calls.push(`GET access ${kind} ${id}`); + const entry = live.access.get(id); + return entry?.accountId === account ? { id, name: entry.name } : null; + }, + findZone: async (_account: string, name: string) => + name === 'example.com' ? { id: 'zone-id', name, nameServers: [], status: 'active' } : null, + getHyperdriveConfig: async (_account: string, id: string) => { + const entry = live.hyperdrives.find((candidate) => candidate.id === id); + if (!entry) { + throw new PlatformCloudflareApiError('missing', 404); + } + return { + ...entry, + origin: { + database: 'void', + host: 'db.example.com', + port: 5432, + scheme: 'postgresql' as const, + user: 'user', + }, + }; + }, + getKvValue: async () => (routingLive() ? remote : null), + getR2Object: async (_account: string, name: string, key: string) => + live.buckets.get(name)?.objects.get(key) ?? null, + getWorkerPlainTextBindings: async ( + _account: string, + name: string, + ): Promise> => { + const value = live.workers.get(name); + return value ? { PLATFORM_OWNERSHIP_MARKER: value } : {}; + }, + listAiGateways: async () => [...live.aiGateways], + listDispatchNamespaces: async () => { + calls.push('GET dispatch-namespaces'); + return [...live.dispatchNamespaces]; + }, + listDispatchNamespaceScripts: async (_account: string, namespace: string) => { + calls.push(`GET dispatch-scripts ${namespace}`); + return [...live.namespaceScripts]; + }, + listDnsRecords: async (_zone: string, name: string) => + live.dns + .filter((entry) => entry.name === name) + .map((entry) => ({ ...entry, content: '100::', proxied: true, type: 'AAAA' })), + listHyperdriveConfigs: async () => [...live.hyperdrives], + listKvNamespaces: async () => [...live.kv], + listQueues: async (_account: string, name: string) => + live.queues.filter((entry) => entry.name === name), + listQueueConsumers: async (_account: string, queue: string) => [ + ...(live.consumers.get(queue) ?? []), + ], + listR2Buckets: async () => + [...live.buckets].map(([name, value]) => ({ + fingerprint: value.fingerprint, + id: name, + name, + })), + listWorkerDomains: async () => [...live.domains], + listWorkerNames: async () => [...live.workers.keys()], + listWorkerRoutes: async () => [...live.routes], + listWorkerServiceRoutes: async (): Promise> => [], + putKvValue: async (_account: string, _namespace: string, key: string, value: string) => { + calls.push(key === PLATFORM_DISABLED_KEY ? 'PUT disabled' : 'PUT checkpoint'); + if (key !== PLATFORM_DISABLED_KEY) { + remote = value; + } + }, + removeRoutes: async () => {}, + listEmailRoutingRules: async (zone: string) => { + calls.push(`GET email-rules ${zone}`); + return [...live.emailRules]; + }, + deleteEmailRoutingRule: async (zone: string, id: string) => { + calls.push(`DELETE email-rule ${zone} ${id}`); + remove(live.emailRules, id); + }, + getEmailRoutingCatchAll: async (zone: string) => { + calls.push(`GET email-catch-all ${zone}`); + return live.catchAll; + }, + resetEmailRoutingCatchAll: async (zone: string) => { + calls.push(`RESET email-catch-all ${zone}`); + live.catchAll = { enabled: false, workerName: null }; + }, + }; + return fake; +} + +const purgeResourcesArgs = { + subcommand: 'uninstall' as const, + selector: '', + plan: false, + purgeData: true, + purgeResources: true, + deleteZone: null, + yes: true, +}; + +function dependencies(overrides: Record = {}) { + return { + interactive: false, + lockProjectZeroTrust: async () => async () => {}, + fenceProjectZeroTrust: async () => {}, + liftProjectZeroTrustFence: async () => {}, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(undefined, []), + readProjects: async (): Promise> => [], + credential: { source: 'environment' as const, aiGatewayToken: false }, + ...overrides, + }; +} + +function noteText(title: string): string { + return String( + vi + .mocked(note) + .mock.calls.filter(([, noteTitle]) => noteTitle === title) + .at(-1)?.[0] ?? '', + ); +} + +function expectInOrder(text: string, lines: ReadonlyArray): void { + const positions = lines.map((line) => text.indexOf(line)); + expect( + positions.map((position, index) => [lines[index], position >= 0]), + text, + ).toEqual(lines.map((line) => [line, true])); + expect(positions, text).toEqual([...positions].sort((left, right) => left - right)); +} + +function deletes(fake: ReturnType): Array { + return fake.calls.filter((call) => call.startsWith('DELETE') || call.startsWith('EMPTY')); +} + +function changes(fake: ReturnType): Array { + return fake.calls.filter((call) => !call.startsWith('GET')); +} + +const FULL_ORDER = [ + 'DELETE worker-custom-domain domain-1', + 'DELETE worker-route route-1', + 'DELETE worker-route route-2', + 'DELETE dns-record *.apps.example.com', + 'DELETE access application access-app', + 'DELETE access service-token access-token', + 'DELETE queue-consumer cron-id cron-consumer', + 'DELETE queue-consumer usage-id usage-consumer', + `DELETE worker ${names.emailGatewayWorker}`, + `DELETE worker ${names.proxyWorker}`, + `DELETE worker ${names.proxyWorker} force`, + `DELETE worker ${names.dispatchWorker}`, + `DELETE worker ${names.apiWorker}`, + `DELETE queue ${names.cronQueue}`, + `DELETE queue ${names.usageQueue}`, + `DELETE dispatch-script ${names.dispatchNamespace} leftover-app`, + `DELETE dispatch-namespace ${names.dispatchNamespace}`, + `DELETE worker ${names.tailWorker}`, + `DELETE hyperdrive ${names.hyperdrive}`, + `DELETE kv-namespace ${names.isrCacheKv}`, + `EMPTY r2-bucket ${names.assetsBucket}`, + `DELETE r2-object ${PLATFORM_R2_OWNERSHIP_MARKER_KEY}`, + `DELETE r2-bucket ${names.assetsBucket}`, + `DELETE ai-gateway ${names.aiGateway}`, + `DELETE kv-namespace ${names.routingKv}`, +]; + +describe('uninstall --purge-resources', () => { + it('plans every deletion in order and every kept resource, using only reads', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + + await uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, dependencies()); + + const plan = noteText('Uninstall plan'); + expectInOrder(plan, [ + 'Delete every resource this installation created (--purge-resources), in this order:', + `1. worker-custom-domain: api.example.com (domain-1) -> ${names.apiWorker}`, + `1. worker-route: *.apps.example.com/* (route-1) -> ${names.dispatchWorker}`, + `1. worker-route: *.apps.example.com (route-2) -> ${names.dispatchWorker}`, + '2. dns-record: *.apps.example.com (dns-id)', + `3. Access application: void-${INSTALLATION_ID}-login-0123456789abcdef (access-app)`, + `3. Access service-token: void-${INSTALLATION_ID}-access-probes-0123456789abcdef (access-token)`, + "4. queue consumers run by this installation's Workers on:", + names.cronQueue, + names.usageQueue, + `5. worker: ${names.emailGatewayWorker}`, + `5. worker: ${names.proxyWorker}`, + `5. worker: ${names.dispatchWorker}`, + `5. worker: ${names.apiWorker} (${names.apiWorker}), with its Durable Object data`, + `6. queue: ${names.cronQueue} (cron-id)`, + `6. queue: ${names.usageQueue} (usage-id)`, + `7. dispatch-namespace: ${names.dispatchNamespace} (namespace-uuid), with any application Workers still in it`, + `8. worker: ${names.tailWorker} (${names.tailWorker}), the tail Worker, after the others`, + `9. hyperdrive: ${names.hyperdrive} (hyperdrive-id)`, + `9. kv-namespace: ${names.isrCacheKv} (isr-id)`, + `10. r2-bucket: ${names.assetsBucket} (${names.assetsBucket}), emptied first`, + `11. ai-gateway: ${names.aiGateway} (${names.aiGateway})`, + `12. kv-namespace: ${names.routingKv} (routing-id), last; it holds uninstall progress`, + 'Keep for manual cleanup:', + 'kv-namespace: customer-kv (customer-kv-id)', + 'zone: example.com (zone-id)', + 'Keep Access resources you connected:', + 'adopted application: company-app (company-app, account account)', + 'Always kept:', + 'The external PostgreSQL database and its data.', + 'Analytics Engine datasets', + 'API tokens you created for this platform.', + 'API token needs: Account → Access: Apps and Policies → Edit', + 'Zone → DNS → Edit', + `rerun: ${RESUME}`, + ]); + expect(plan).not.toContain(`Access application: company-app`); + expect(changes(fake)).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('installed'); + }); + + it('deletes in an order Cloudflare accepts, with the tail Worker after its producers and routing KV last', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).toEqual(FULL_ORDER); + expect(fake.calls.indexOf('PUT disabled')).toBeLessThan(fake.calls.indexOf(FULL_ORDER[0]!)); + // Only the proxy Worker, which the API Worker binds to, is forced, and + // only after Cloudflare refused it and named no Worker outside the + // installation. + expect(fake.calls.filter((call) => call.includes('force'))).toEqual([ + `DELETE worker ${names.proxyWorker} force`, + ]); + expect(fake.calls.indexOf(`GET worker-dependents ${names.proxyWorker}`)).toBeGreaterThan( + fake.calls.indexOf(`DELETE worker ${names.proxyWorker}`), + ); + // Never touched: the adopted zone and KV, the adopted Access application, + // and a Queue consumer run by a Worker the installation did not create. + expect(fake.live.kv).toEqual([{ id: 'customer-kv-id', name: 'customer-kv' }]); + expect(fake.live.access.has('company-app')).toBe(true); + expect(fake.live.consumers.get('usage-id')).toEqual([ + { id: 'foreign-consumer', script: 'someone-else' }, + ]); + const final = listInstallationManifests()[0]!; + expect(final.status).toBe('uninstalled'); + expect( + final.resources + .filter((resource) => resource.state !== 'deleted') + .map((resource) => `${resource.key}:${resource.state}`), + ).toEqual(['customer-kv:retained', 'zone:retained']); + expect(readInstallationRecoverySecrets(final)).toBeNull(); + const kept = noteText('Kept for manual cleanup'); + expectInOrder(kept, [ + 'kv-namespace: customer-kv (customer-kv-id)', + 'zone: example.com (zone-id)', + 'adopted application: company-app', + 'Always kept:', + 'The external PostgreSQL database and its data.', + ]); + expect(kept).not.toContain('access-app'); + }); + + it('detaches queue consumers that Cloudflare names by script_name before deleting their Workers', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + // Answer with the documented consumer shape, read by the real client. + fake.listQueueConsumers = async (account: string, queue: string) => [ + ...(await new PlatformCloudflareClient('token', { + fetcher: async () => + Response.json({ + success: true, + result: (fake.live.consumers.get(queue) ?? []).map((entry) => + entry.script === null + ? { consumer_id: entry.id, type: 'http_pull' } + : { consumer_id: entry.id, script_name: entry.script, type: 'worker' }, + ), + }), + }).listQueueConsumers(account, queue)), + ]; + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(fake.live.consumers.get('cron-id')).toEqual([]); + expect(fake.live.consumers.get('usage-id')).toEqual([ + { id: 'foreign-consumer', script: 'someone-else' }, + ]); + expect(fake.calls).toContain('DELETE queue-consumer cron-id cron-consumer'); + expect(fake.calls).toContain('DELETE queue-consumer usage-id usage-consumer'); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('resumes after a failure without repeating finished deletions', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.failures.set(`DELETE queue ${names.usageQueue}`, new Error('temporary failure')); + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('temporary failure'); + const stopped = listInstallationManifests()[0]!; + expect(stopped.status).toBe('failed'); + expect(fake.live.workers.has(names.apiWorker)).toBe(false); + expect(fake.live.workers.has(names.tailWorker)).toBe(true); + + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); + + // The failed DELETE is retried once; nothing else is sent twice. + const sent = deletes(fake); + const retried = `DELETE queue ${names.usageQueue}`; + expect(sent.filter((call) => call === retried)).toHaveLength(2); + expect(sent.filter((call, index) => call !== retried && sent.indexOf(call) !== index)).toEqual( + [], + ); + expect(new Set(sent)).toEqual(new Set(FULL_ORDER)); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('resumes an R2 deletion after its ownership marker was removed', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('temporary failure'); + expect(fake.live.buckets.get(names.assetsBucket)?.objects.size).toBe(0); + + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + purgeResourcesArgs, + dependencies(), + ); + expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('refuses an R2 bucket replaced after its ownership marker was removed', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('temporary failure'); + fake.live.buckets.set(names.assetsBucket, { + fingerprint: '2026-10-01T00:00:00.000Z', + objects: new Map([['someone-else.txt', 'x']]), + }); + + await expect( + uninstallPlatform(fake, listInstallationManifests()[0]!, purgeResourcesArgs, dependencies()), + ).rejects.toThrow(`Refusing to delete R2 bucket ${names.assetsBucket}`); + expect(fake.live.buckets.get(names.assetsBucket)?.objects.has('someone-else.txt')).toBe(true); + }); + + it('refuses before any change when a Worker no longer carries this installation marker', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + const replacement = { ...manifest, ownershipNonce: 'replacement-owner-nonce-00000000' }; + fake.live.workers.set( + names.proxyWorker, + marker( + replacement, + manifest.resources.find((resource) => resource.key === 'proxy-worker')!, + ), + ); + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('belongs to another installation'); + expect(changes(fake)).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('installed'); + }); + + it('keeps a route that now serves another Worker, lists why, and does not let it block a rerun', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.routes[1]!.script = 'someone-else'; + // A later, unrelated stop makes the run resume. + fake.live.foreignTailProducer = true; + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow(`Cloudflare refused to delete worker ${names.tailWorker}`); + const stopped = listInstallationManifests()[0]!; + expect( + stopped.resources.find((resource) => resource.key === 'worker-route:route-2')?.state, + ).toBe('retained'); + expect(deletes(fake)).toContain(`DELETE worker ${names.apiWorker}`); + + fake.live.foreignTailProducer = false; + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).not.toContain('DELETE worker-route route-2'); + expect(fake.live.routes).toEqual([ + { id: 'route-2', pattern: '*.apps.example.com', script: 'someone-else' }, + ]); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + expect(noteText('Kept for manual cleanup')).toContain( + 'worker-route: *.apps.example.com (route-2), kept because it now sends "*.apps.example.com" to "someone-else"', + ); + }); + + it('keeps a custom domain that now serves another Worker and continues', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.domains[0]!.service = 'someone-else'; + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).not.toContain('DELETE worker-custom-domain domain-1'); + expect(fake.live.domains).toHaveLength(1); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + expect(noteText('Kept for manual cleanup')).toContain( + 'worker-custom-domain: api.example.com (domain-1), kept because it now sends "api.example.com" to "someone-else"', + ); + }); + + it('keeps a DNS record whose configuration changed and continues', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.listDnsRecords = async (_zone: string, name: string) => + fake.live.dns + .filter((entry) => entry.name === name) + .map((entry) => ({ ...entry, content: '192.0.2.1', proxied: false, type: 'A' })); + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).not.toContain('DELETE dns-record *.apps.example.com'); + expect(fake.live.dns).toHaveLength(1); + const final = listInstallationManifests()[0]!; + expect(final.status).toBe('uninstalled'); + expect(final.resources.find((resource) => resource.key === 'application-dns')?.state).toBe( + 'retained', + ); + expect(noteText('Kept for manual cleanup')).toContain( + 'dns-record: *.apps.example.com (dns-id), kept because its configuration changed after installation', + ); + }); + + it('keeps an Access application whose name changed and continues', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.access.set('access-app', { accountId: 'account', name: 'renamed' }); + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).not.toContain('DELETE access application access-app'); + expect(deletes(fake)).toContain('DELETE access service-token access-token'); + expect(fake.live.access.get('access-app')?.name).toBe('renamed'); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + expect(noteText('Kept for manual cleanup')).toContain( + `created application: void-${INSTALLATION_ID}-login-0123456789abcdef (access-app, account account), kept because its live name "renamed" is not the name this installation recorded`, + ); + }); + + it('detaches Email Routing rules that send mail to the gateway Worker before deleting it', async () => { + const manifest = installedManifest({ email: true }); + persist(manifest); + const fake = cloudflare(manifest); + const gateway = names.emailGatewayWorker; + fake.live.emailRules.push( + { id: 'ours', enabled: true, workerName: gateway }, + { id: 'ours-disabled', enabled: false, workerName: gateway }, + { id: 'theirs', enabled: true, workerName: 'someone-else' }, + { id: 'forward', enabled: true, workerName: null }, + ); + fake.live.catchAll = { enabled: true, workerName: gateway }; + + await uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, dependencies()); + expect(noteText('Uninstall plan')).toContain( + `4. Email Routing rules in zone mail-zone that send mail to ${gateway}, and its catch-all rule if it does (reset to drop mail, disabled)`, + ); + expect(changes(fake)).toEqual([]); + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expectInOrder(changes(fake).join('\n'), [ + 'DELETE access service-token access-token', + 'DELETE email-rule mail-zone ours', + 'DELETE email-rule mail-zone ours-disabled', + 'RESET email-catch-all mail-zone', + `DELETE worker ${gateway}`, + ]); + expect(fake.live.emailRules.map((rule) => rule.id)).toEqual(['theirs', 'forward']); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + expect(noteText('Kept for manual cleanup')).toContain( + "Email Routing for mail.example.com (zone mail-zone): its settings and the sender domain's MX, SPF, and DKIM records.", + ); + }); + + it('leaves an Email Routing catch-all that sends mail to another Worker', async () => { + const manifest = installedManifest({ email: true }); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.catchAll = { enabled: true, workerName: 'someone-else' }; + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(fake.calls).not.toContain('RESET email-catch-all mail-zone'); + expect(fake.live.catchAll).toEqual({ enabled: true, workerName: 'someone-else' }); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('names the Email Routing permission when the credential cannot read the rules', async () => { + const manifest = installedManifest({ email: true }); + persist(manifest); + const fake = cloudflare(manifest); + fake.listEmailRoutingRules = async () => { + throw new PlatformCloudflareApiError('Authentication error', 403); + }; + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow( + 'Add Zone → Email Routing Rules → Edit for account account and zone example.com and Email Routing zone mail-zone, then rerun. No Cloudflare resource was changed.', + ); + expect(changes(fake)).toEqual([]); + }); + + it('makes no Email Routing call when the platform does not send email', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + + await uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(fake.calls.filter((call) => /email-(rule|catch-all)/.test(call))).toEqual([]); + expect(noteText('Uninstall plan')).not.toContain('Email Routing Rules'); + }); + + it('finishes with --purge-resources what a stopped --purge-data uninstall kept', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + + // The namespace still holds an application Worker, so --purge-data stops. + await expect( + uninstallPlatform( + fake, + manifest, + { ...purgeResourcesArgs, purgeResources: false }, + dependencies(), + ), + ).rejects.toThrow('Namespace has scripts.'); + const stopped = listInstallationManifests()[0]!; + expect(stopped.status).toBe('failed'); + expect(stopped.resources.find((resource) => resource.key === 'api-worker')?.state).toBe( + 'retained', + ); + + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); + + expect(new Set(deletes(fake))).toEqual(new Set(FULL_ORDER)); + const final = listInstallationManifests()[0]!; + expect(final.status).toBe('uninstalled'); + expect( + final.resources + .filter((resource) => resource.state !== 'deleted') + .map((resource) => `${resource.key}:${resource.state}`), + ).toEqual(['customer-kv:retained', 'zone:retained']); + }); + + it('finishes with --purge-resources what a stopped plain uninstall kept, including routing KV', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + const putKvValue = fake.putKvValue; + let failed = false; + // Stop at the last write, after every resource was recorded as kept. + fake.putKvValue = async (account: string, namespace: string, key: string, value: string) => { + if (!failed && value.includes('"status":"uninstalled"')) { + failed = true; + throw new Error('checkpoint write failed'); + } + return putKvValue(account, namespace, key, value); + }; + const plain = { ...purgeResourcesArgs, purgeResources: false, purgeData: false }; + + await expect(uninstallPlatform(fake, manifest, plain, dependencies())).rejects.toThrow( + 'checkpoint write failed', + ); + const stopped = listInstallationManifests()[0]!; + expect(stopped.status).toBe('failed'); + expect(stopped.resources.find((resource) => resource.key === 'routing-kv')?.state).toBe( + 'retained', + ); + expect(deletes(fake)).toEqual([]); + + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).toEqual(FULL_ORDER); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('refuses before any change when a Worker a stopped uninstall kept no longer carries this installation marker', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + const putKvValue = fake.putKvValue; + let failed = false; + // Stop at the last write, after every resource was recorded as kept. + fake.putKvValue = async (account: string, namespace: string, key: string, value: string) => { + if (!failed && value.includes('"status":"uninstalled"')) { + failed = true; + throw new Error('checkpoint write failed'); + } + return putKvValue(account, namespace, key, value); + }; + await expect( + uninstallPlatform( + fake, + manifest, + { ...purgeResourcesArgs, purgeResources: false, purgeData: false }, + dependencies(), + ), + ).rejects.toThrow('checkpoint write failed'); + const stopped = listInstallationManifests()[0]!; + expect(stopped.resources.find((resource) => resource.key === 'proxy-worker')?.state).toBe( + 'retained', + ); + const replacement = { ...manifest, ownershipNonce: 'replacement-owner-nonce-00000000' }; + fake.live.workers.set( + names.proxyWorker, + marker( + replacement, + manifest.resources.find((resource) => resource.key === 'proxy-worker')!, + ), + ); + const before = changes(fake).length; + + await expect( + uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('belongs to another installation'); + expect(changes(fake).slice(before)).toEqual([]); + const final = listInstallationManifests()[0]!; + expect(final.status).toBe('failed'); + // Kept resources stay recorded as kept. + expect(final.resources.find((resource) => resource.key === 'proxy-worker')?.state).toBe( + 'retained', + ); + }); + + it('lists an Access resource an earlier --purge-resources kept with the other kept resources, not as connected', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.access.set('access-app', { accountId: 'account', name: 'renamed' }); + // A later, unrelated stop makes the run resume. + fake.live.foreignTailProducer = true; + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow(`Cloudflare refused to delete worker ${names.tailWorker}`); + const stopped = listInstallationManifests()[0]!; + + await uninstallPlatform(fake, stopped, { ...purgeResourcesArgs, plan: true }, dependencies()); + + const plan = noteText('Uninstall plan'); + const kept = `created application: void-${INSTALLATION_ID}-login-0123456789abcdef (access-app, account account), kept because its live name "renamed" is not the name this installation recorded`; + expectInOrder(plan, [ + 'Keep for manual cleanup:', + kept, + 'Keep Access resources you connected:', + 'adopted application: company-app', + ]); + expect(plan.slice(plan.indexOf('Keep Access resources you connected:'))).not.toContain( + 'access-app', + ); + }); + + it('never reopens an uninstall that finished', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + await uninstallPlatform( + fake, + manifest, + { ...purgeResourcesArgs, purgeResources: false, purgeData: false }, + dependencies(), + ); + const finished = listInstallationManifests()[0]!; + expect(finished.status).toBe('uninstalled'); + const before = changes(fake).length; + + await uninstallPlatform(fake, finished, { ...purgeResourcesArgs, plan: true }, dependencies()); + expect(noteText('Uninstall plan')).toContain('--purge-resources finds no resource'); + await expect( + uninstallPlatform(fake, finished, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('Invalid installation transition: uninstalled -> uninstalling.'); + + expect(changes(fake).slice(before)).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('never forces a Worker that a Worker outside the installation binds to, and names it', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.foreignWorkers.add('customer-worker'); + fake.live.serviceBindings.push({ from: 'customer-worker', to: names.proxyWorker as string }); + + const error = await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()).catch( + (caught: unknown) => caught as Error, + ); + expect(error?.message).toContain( + `Cloudflare refused to delete worker ${names.proxyWorker}: Cloudflare API error: Cannot delete service`, + ); + expect(error?.message).toContain( + 'Workers outside this installation still use it: customer-worker.', + ); + expect(error?.message).toContain(`Progress is saved. Rerun: ${RESUME}`); + expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + expect(fake.live.workers.has(names.proxyWorker)).toBe(true); + expect(fake.live.workers.has(names.apiWorker)).toBe(true); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + + fake.live.serviceBindings.pop(); + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + purgeResourcesArgs, + dependencies(), + ); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('never forces a Worker whose dependent has a recorded name but is not proven to be the installation’s', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + // While the run is going, another Worker takes the API Worker's name and + // still binds to the proxy. + const replacement = { ...manifest, ownershipNonce: 'replacement-owner-nonce-00000000' }; + const deleteResource = fake.deleteResource; + fake.deleteResource = async (...args: Parameters) => { + if (args[1].name === names.proxyWorker) { + fake.live.workers.set( + names.apiWorker as string, + marker( + replacement, + manifest.resources.find((resource) => resource.key === 'api-worker')!, + ), + ); + } + return deleteResource(...args); + }; + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow(`worker ${names.apiWorker} belongs to another installation`); + expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + expect(fake.live.workers.has(names.proxyWorker)).toBe(true); + }); + + it('never forces a Worker whose dependent was recorded as deleted', async () => { + const recorded = installedManifest(); + const manifest = { + ...recorded, + resources: recorded.resources.map((resource) => + resource.key === 'api-worker' ? { ...resource, state: 'deleted' as const } : resource, + ), + }; + persist(manifest); + const fake = cloudflare(recorded); + + const error = await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()).catch( + (caught: unknown) => caught as Error, + ); + expect(error?.message).toContain( + `Workers outside this installation still use it: ${names.apiWorker}.`, + ); + expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + expect(fake.live.workers.has(names.proxyWorker)).toBe(true); + }); + + it('never forces a Worker when Cloudflare cannot list what depends on it', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.listWorkerDependents = async () => { + throw new PlatformCloudflareApiError('Authentication error (code 10000)', 403); + }; + + const error = await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()).catch( + (caught: unknown) => caught as Error, + ); + expect(error?.message).toContain(`Cloudflare refused to delete worker ${names.proxyWorker}`); + expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + expect(fake.live.workers.has(names.proxyWorker)).toBe(true); + }); + + it('stops with a clear message when Cloudflare refuses the tail Worker, and never forces it', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.foreignTailProducer = true; + + const error = await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()).catch( + (caught: unknown) => caught as Error, + ); + expect(error?.message).toContain( + `Cloudflare refused to delete worker ${names.tailWorker}: Cloudflare API error: Cannot delete this Worker as it is a Tail Worker consumer for other Workers. (code 100109)`, + ); + expect(error?.message).toContain('for example by sending it logs as a tail Worker'); + expect(error?.message).toContain(`Progress is saved. Rerun: ${RESUME}`); + expect( + deletes(fake).filter((call) => call === `DELETE worker ${names.tailWorker}`), + ).toHaveLength(1); + expect(fake.calls).not.toContain(`DELETE worker ${names.tailWorker} force`); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + + fake.live.foreignTailProducer = false; + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + purgeResourcesArgs, + dependencies(), + ); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('refuses before any change while projects remain, in the plan too', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + const withProjects = dependencies({ readProjects: async () => ['blog', 'shop'] }); + const message = [ + 'platform: Refusing --purge-resources while the platform still has 2 projects.', + "--purge-resources deletes the platform, and only the running platform can delete each project's Workers, storage, and domains.", + 'Delete each project first with `void project delete `, then retry:', + ' blog', + ' shop', + ].join('\n'); + + await expect( + uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, withProjects), + ).rejects.toThrow(message); + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, withProjects), + ).rejects.toThrow(message); + await expect( + uninstallPlatform( + fake, + manifest, + purgeResourcesArgs, + dependencies({ + readProjects: async () => { + throw new Error('connection refused'); + }, + }), + ), + ).rejects.toThrow( + 'project list could not be read from the platform database: connection refused', + ); + expect(fake.calls).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('installed'); + }); + + it('reads project slugs, including a database without projects', async () => { + const query = vi.fn(async (sql: string) => ({ + rows: sql.includes('to_regclass') ? [{ present: true }] : [{ slug: 'blog' }], + })); + await expect(readPlatformProjectSlugsConnection({ query } as never)).resolves.toEqual(['blog']); + await expect( + readPlatformProjectSlugsConnection({ + query: async () => ({ rows: [{ present: false }] }), + } as never), + ).resolves.toEqual([]); + }); + + it('refuses browser login before any change and names the token permissions', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + const oauth = dependencies({ credential: { source: 'oauth', aiGatewayToken: true } }); + + await expect(uninstallPlatform(fake, manifest, purgeResourcesArgs, oauth)).rejects.toThrow( + 'Browser login is missing: Zone → DNS → Edit, Account → Access: Apps and Policies → Edit, Account → Access: Service Tokens → Edit. No Cloudflare resource was changed.', + ); + expect(changes(fake)).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('installed'); + + // The plan still prints and warns instead of failing. + await uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, oauth); + expect(noteText('Uninstall plan')).toContain('Delete every resource'); + expect(String(vi.mocked(log.warn).mock.calls.at(-1)?.[0])).toContain( + 'Set CLOUDFLARE_API_TOKEN', + ); + }); + + it('names a token permission that a read probe shows is missing', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.listDnsRecords = async () => { + throw new PlatformCloudflareApiError('Authentication error', 403); + }; + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow( + 'cannot read every resource --purge-resources would delete. Add Zone → DNS → Edit for account account and zone example.com, then rerun. No Cloudflare resource was changed.', + ); + expect(changes(fake)).toEqual([]); + }); + + it('makes no Workers for Platforms call when the installation has no dispatch namespace', async () => { + const manifest = installedManifest({ dispatchNamespace: false }); + persist(manifest); + const fake = cloudflare(manifest); + + await uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(fake.calls.filter((call) => /dispatch-(namespace|script)/.test(call))).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('deletes an Access service token in the identity account that holds it', async () => { + const base = installedManifest(); + const manifest = { + ...base, + authenticationResources: base.authenticationResources!.map((resource) => + resource.key === 'service-token' ? { ...resource, accountId: 'identity' } : resource, + ), + }; + persist(manifest); + const fake = cloudflare(manifest); + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(fake.live.access.has('access-token')).toBe(false); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('names the identity account when the credential cannot read its Access resources', async () => { + const base = installedManifest(); + const manifest = { + ...base, + authenticationResources: base.authenticationResources!.map((resource) => + resource.key === 'service-token' ? { ...resource, accountId: 'identity' } : resource, + ), + }; + persist(manifest); + const fake = cloudflare(manifest); + const getAccessResource = fake.getAccessResource; + fake.getAccessResource = async (account: string, kind: string, id: string) => { + if (account === 'identity') { + throw new PlatformCloudflareApiError('Authentication error', 403); + } + return getAccessResource(account, kind, id); + }; + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow( + 'Add Account → Access: Service Tokens → Edit for accounts account and identity and zone example.com, then rerun.', + ); + expect(changes(fake)).toEqual([]); + }); + + it('keeps an AI Gateway that Cloudflare shows was not created during installation', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + // Someone deleted the original gateway and made a new one with the same id. + fake.live.aiGateways[0]!.createdAt = '2026-10-01T00:00:00.000Z'; + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).not.toContain(`DELETE ai-gateway ${names.aiGateway}`); + expect(fake.live.aiGateways.map((gateway) => gateway.id)).toEqual([names.aiGateway]); + const final = listInstallationManifests()[0]!; + expect(final.status).toBe('uninstalled'); + expect(final.resources.find((resource) => resource.key === 'ai-gateway')?.state).toBe( + 'retained', + ); + expect(noteText('Kept for manual cleanup')).toContain( + `ai-gateway: ${names.aiGateway} (${names.aiGateway})`, + ); + }); + + it('keeps an AI Gateway whose creation time Cloudflare does not report', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + delete fake.live.aiGateways[0]!.createdAt; + + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); + + expect(deletes(fake)).not.toContain(`DELETE ai-gateway ${names.aiGateway}`); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('stops before deleting application Workers when a project appears during the run', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + let reads = 0; + // Both checks before the first change see no project; one is created after. + const readProjects = async () => (++reads > 2 ? ['late-project'] : []); + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies({ readProjects })), + ).rejects.toThrow( + `platform: Stopped before deleting the application Workers in dispatch namespace ${names.dispatchNamespace} because a project was created while this uninstall ran: late-project.`, + ); + expect(deletes(fake).some((call) => call.startsWith('DELETE dispatch-'))).toBe(false); + expect(fake.live.namespaceScripts.has('leftover-app')).toBe(true); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + }); + + it('resumes an R2 deletion for a bucket recorded before creation fingerprints', async () => { + let manifest = installedManifest(); + const bucket = manifest.resources.find((resource) => resource.kind === 'r2-bucket')!; + manifest = recordInstallationResource( + manifest, + { ...bucket, configHash: hash({ kind: 'r2-bucket', name: bucket.name }) }, + manifest.updatedAt, + ); + persist(manifest); + const fake = cloudflare(manifest); + fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); + + await expect( + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), + ).rejects.toThrow('temporary failure'); + expect(fake.live.buckets.get(names.assetsBucket)?.objects.size).toBe(0); + + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + purgeResourcesArgs, + dependencies(), + ); + expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); +}); diff --git a/packages/void/test/unit/platform-trigger-proof.test.ts b/packages/void/test/unit/platform-trigger-proof.test.ts index d7a0adf60..9ba977028 100644 --- a/packages/void/test/unit/platform-trigger-proof.test.ts +++ b/packages/void/test/unit/platform-trigger-proof.test.ts @@ -111,6 +111,17 @@ it.each([ }, ); +it('requires trigger publication to disable a workers.dev subdomain that is still enabled', async () => { + expect( + await platformWorkerLiveTriggersMatch({ + accountId: 'account', + workerName: 'test-worker', + desired: { ...desired, workers_dev: false }, + readCloudflare: liveProof().readCloudflare, + }), + ).toBe(false); +}); + it('requires normal trigger publication for unsupported Email Routing and Workflow triggers', async () => { const live = liveProof(); expect( diff --git a/packages/void/test/unit/plugin-inference.test.ts b/packages/void/test/unit/plugin-inference.test.ts index 2ee767d59..e3396cd3b 100644 --- a/packages/void/test/unit/plugin-inference.test.ts +++ b/packages/void/test/unit/plugin-inference.test.ts @@ -155,6 +155,36 @@ describe('inferProjectBindings', () => { expect(result.needsLive).toBe(true); }); + it('detects live from a stream module outside the default directories only when scanned', () => { + const root = createTempRoot(); + mkdirSync(join(root, 'routes'), { recursive: true }); + mkdirSync(join(root, 'lib'), { recursive: true }); + writeFileSync( + join(root, 'lib', 'live.ts'), + ` + import { defineLiveStream } from "void/live"; + export const live = defineLiveStream({ id: "app" }); + `, + ); + writeFileSync( + join(root, 'routes', 'live.ts'), + ` + import { defineHandler } from "void"; + import { live } from "../lib/live"; + export const GET = defineHandler((c) => live.connect(c)); + `, + ); + + expect(inferProjectBindings(root).needsLive).toBe(false); + expect(inferProjectBindings(root, undefined, ['routes', 'lib']).needsLive).toBe(true); + }); + + it('detects live in the react-pages playground', () => { + const playground = join(import.meta.dirname, '../../../../playground/react-pages'); + + expect(inferProjectBindings(playground).needsLive).toBe(true); + }); + it('detects auth from void/auth import in routes/', () => { const root = createTempRoot(); mkdirSync(join(root, 'routes', 'api'), { recursive: true }); diff --git a/packages/void/test/unit/project-delete-command.test.ts b/packages/void/test/unit/project-delete-command.test.ts new file mode 100644 index 000000000..0ba4e0be5 --- /dev/null +++ b/packages/void/test/unit/project-delete-command.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +vi.mock('../../src/cli/auth', async (importOriginal) => ({ + ...(await importOriginal()), + getToken: () => 'test-token', +})); + +vi.mock('../../src/cli/client', async (importOriginal) => ({ + ...(await importOriginal()), + PlatformClient: class { + async listProjects() { + return [{ id: 'proj_123', slug: 'my-app' }]; + } + async deleteProject() { + return {}; + } + }, +})); + +const roots: Array = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function captureOutput(): { output: () => string; restore: () => void } { + const chunks: Array = []; + const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }); + const log = vi.spyOn(console, 'log').mockImplementation((...args: Array) => { + chunks.push(args.map(String).join(' ') + '\n'); + }); + return { + output: () => chunks.join(''), + restore: () => { + write.mockRestore(); + log.mockRestore(); + }, + }; +} + +describe('void project delete', () => { + it('reports the deletion once', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-root-')); + roots.push(root); + const { runDeleteCommand } = await import('../../src/cli/delete.ts'); + + const capture = captureOutput(); + try { + await runDeleteCommand(root, 'my-app', { force: true }); + } finally { + capture.restore(); + } + const out = capture.output(); + + expect(out).toContain('Deleted my-app'); + expect(out).not.toContain('Project deleted.'); + }); +}); diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 2f877ccec..df09d5223 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -74,6 +74,23 @@ describe('repeated wrapper generation', () => { expect((await worker.fetch(new Request('https://app/__void/ready'), {}, {})).status).toBe(200); }); + it('fails readiness when the wrapped entry has no fetch handler', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-wrapper-no-fetch-')); + roots.push(root); + writeFileSync( + join(root, 'index.mjs'), + 'export default function handler() { return new Response("bare"); }', + ); + await generateWrapper(root, 'index.mjs', root, { ...base, readiness: true }); + const worker = (await import(pathToFileURL(join(root, 'index.mjs')).href)).default; + const response = await worker.fetch(new Request('https://app/__void/ready'), {}, {}); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + ready: false, + error: 'worker entry has no fetch handler', + }); + }); + it('serves cached framework HTML while leaving client data requests live', async () => { const root = mkdtempSync(join(tmpdir(), 'void-framework-isr-')); roots.push(root); diff --git a/platform/packages/api/src/deploy/pipeline.ts b/platform/packages/api/src/deploy/pipeline.ts index 579114f88..05449485a 100644 --- a/platform/packages/api/src/deploy/pipeline.ts +++ b/platform/packages/api/src/deploy/pipeline.ts @@ -832,7 +832,10 @@ export async function runDeploymentPipeline(input: { }); } + // `assetCount` includes the cache-buster and gates Cloudflare asset + // work; `deployedAssetCount` is what the user deployed and is reported. const assetCount = Object.keys(assetManifest).length; + const deployedAssetCount = Object.keys(incomingAssetManifest).length; projectDomains ??= await getDomainsByProjectId(context.get('db'), projectId); @@ -851,7 +854,7 @@ export async function runDeploymentPipeline(input: { }, manifest, emit, - assetCount, + assetCount: deployedAssetCount, context, deployment, projectId, @@ -940,6 +943,7 @@ export async function runDeploymentPipeline(input: { project, deployment, assetCount, + deployedAssetCount, dispatchNamespace, workerName, assetManifest, @@ -1050,6 +1054,7 @@ export async function runDeploymentPipeline(input: { project, snapshotCronState, assetCount, + deployedAssetCount, assetConfig, get routingSwitched() { return routingSwitched!; diff --git a/platform/packages/api/src/deploy/publish-worker.ts b/platform/packages/api/src/deploy/publish-worker.ts index a368523b8..cfd3247c5 100644 --- a/platform/packages/api/src/deploy/publish-worker.ts +++ b/platform/packages/api/src/deploy/publish-worker.ts @@ -92,6 +92,7 @@ export async function publishWorkerDeployment(input: { readonly project: Project; readonly snapshotCronState: () => Promise; readonly assetCount: number; + readonly deployedAssetCount: number; readonly assetConfig: DeployManifest['assetConfig']; routingSwitched: boolean; cronSchedulerWriteAttempted: boolean; @@ -148,6 +149,7 @@ export async function publishWorkerDeployment(input: { project, snapshotCronState, assetCount, + deployedAssetCount, assetConfig, actor, planLimits, @@ -706,7 +708,7 @@ export async function publishWorkerDeployment(input: { ...activeDomains.map((d) => `https://${d.hostname}`), `https://${platformProjectHostname(project, application)}`, ], - assets: assetCount, + assets: deployedAssetCount, workers: workerFiles.length, ...(migrationsApplied > 0 && { migrations: migrationsApplied }), }); diff --git a/platform/packages/api/src/deploy/upload-worker.ts b/platform/packages/api/src/deploy/upload-worker.ts index 500cc0653..d54790904 100644 --- a/platform/packages/api/src/deploy/upload-worker.ts +++ b/platform/packages/api/src/deploy/upload-worker.ts @@ -48,6 +48,7 @@ export async function uploadDeploymentWorker(input: { readonly project: Project; readonly deployment: Deployment; readonly assetCount: number; + readonly deployedAssetCount: number; readonly dispatchNamespace: string; readonly workerName: string; readonly assetManifest: Record; @@ -70,6 +71,7 @@ export async function uploadDeploymentWorker(input: { project, deployment, assetCount, + deployedAssetCount, dispatchNamespace, workerName, assetManifest, @@ -204,9 +206,9 @@ export async function uploadDeploymentWorker(input: { const newAssets = 0; emit({ event: 'asset_upload', - total: assetCount, + total: deployedAssetCount, new: newAssets, - cached: assetCount - newAssets, + cached: deployedAssetCount - newAssets, }); completionJwt = await uploadAssetBuckets( diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index a017e8f38..69f2d7f01 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -89,9 +89,13 @@ export type ApiEnv = Env & { DISPATCH_WORKER_NAME?: string; /** * Name of the tail-consumer Worker that receives runtime logs (Tail Worker). - * Wired into per-user-script `tail_consumers` metadata on each WfP upload - * so user scripts have explicit, belt-and-suspenders log fan-out in - * addition to the dispatch worker's namespace-wide propagation. + * Wired into per-user-script `tail_consumers` metadata on each WfP upload. + * This is the only log path for invocations that skip the dispatch Worker + * (crons, `/__void/migrate`, Durable Object alarms). Queue deliveries go + * through the dispatch Worker. The dispatch Worker's own consumer also + * receives the user items it invoked, each in its own tail invocation, so + * those rows are stored twice; log queries collapse them + * (`observability/log-rows.ts`). * Falls back to `void-tail` when unset. */ TAIL_SERVICE_NAME?: string; diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index efc569571..9d804d1f9 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -228,9 +228,12 @@ const app = new Hono() context.req.header('X-Void-Auth-Setup') && context.req.method === 'POST' && context.req.path === '/internal/platform/auth-initialize'; - const trafficPaused = + // An upgrade or repair pauses traffic until it commits. + const maintenance = context.env.PLATFORM_TRAFFIC_STATE === 'staging' || - context.env.PLATFORM_TRAFFIC_STATE === 'sandbox-drain' || + context.env.PLATFORM_TRAFFIC_STATE === 'sandbox-drain'; + const trafficPaused = + maintenance || !!( !authenticationSetup && !installerProbe && @@ -261,7 +264,12 @@ const app = new Hono() } context.header('Cache-Control', 'no-store'); context.header('Retry-After', '30'); - return context.json({ error: 'Platform disabled' }, 503); + return context.json( + maintenance + ? { error: 'Platform disabled', reason: 'maintenance' } + : { error: 'Platform disabled' }, + 503, + ); } await next(); }) diff --git a/platform/packages/api/src/observability/log-rows.ts b/platform/packages/api/src/observability/log-rows.ts new file mode 100644 index 000000000..f0f89e92d --- /dev/null +++ b/platform/packages/api/src/observability/log-rows.ts @@ -0,0 +1,13 @@ +/** + * Collapses identical rows in the tail Worker's logs dataset. + * + * A request routed through the dispatch Worker reaches the tail Worker twice: + * once through the dispatch Worker's tail consumer and once through the user + * Worker's own consumer. Cloudflare delivers each copy in its own tail + * invocation, so the tail Worker cannot drop one, and both copies are stored as + * identical rows. Grouping by every stored column returns one row per + * invocation. A grouped query must select `min(timestamp)` (aliased, since an + * alias named `timestamp` would shadow the `WHERE` filter) and order by + * selected aliases only. + */ +export const LOG_ROW_GROUP_BY = 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'; diff --git a/platform/packages/api/src/operator/deployment-operations.ts b/platform/packages/api/src/operator/deployment-operations.ts index 2317caba9..0d5cf77c8 100644 --- a/platform/packages/api/src/operator/deployment-operations.ts +++ b/platform/packages/api/src/operator/deployment-operations.ts @@ -1,6 +1,7 @@ import { and, count, desc, eq, getTableColumns, ilike, or } from 'drizzle-orm'; import { cancelDeployment, getDeployment, getProject, type Db } from '../db.ts'; import { type ApiEnv, isDevBypass } from '../env.ts'; +import { LOG_ROW_GROUP_BY } from '../observability/log-rows.ts'; import { deployments, projects } from '../schema.ts'; import { OperatorError } from './common.ts'; @@ -164,7 +165,7 @@ type LogRow = { method: string; statusCode: string; ts: number; - timestamp: string | number; + ingestedAt: string | number; }; const FOLLOW_OVERLAP_MS = 5 * 60 * 1000; @@ -261,14 +262,16 @@ export async function deploymentLogs( clock === 'ingestion' ? `timestamp >= toDateTime(${sqlString(sinceStr)}) AND timestamp < toDateTime(${sqlString(untilStr)})` : `timestamp >= toDateTime(${sqlString(sinceStr)}) AND timestamp < toDateTime(${sqlString(untilStr)}) AND double1 >= ${since} AND double1 < ${until}`; + // A grouped query can order only by its selected aliases. const order = clock === 'ingestion' - ? `timestamp ${direction}, double1 ${direction}` - : `double1 ${direction}, timestamp ${direction}`; - const query = `SELECT blob1 as outcome, blob2 as logData, blob3 as exceptionData, blob4 as url, blob5 as method, blob6 as statusCode, double1 as ts, timestamp + ? `ingestedAt ${direction}, ts ${direction}` + : `ts ${direction}, ingestedAt ${direction}`; + const query = `SELECT blob1 as outcome, blob2 as logData, blob3 as exceptionData, blob4 as url, blob5 as method, blob6 as statusCode, double1 as ts, min(timestamp) as ingestedAt FROM ${dataset} WHERE index1 = ${sqlString(`user-${project.slug}`)} AND blob7 = ${sqlString(deployment.id)} AND ${timeFilter} - ORDER BY ${order}, blob1 ${direction}, blob2 ${direction}, blob3 ${direction}, blob4 ${direction}, blob5 ${direction}, blob6 ${direction} LIMIT ${limit + 1} OFFSET ${offset}`; + ${LOG_ROW_GROUP_BY} + ORDER BY ${order}, outcome ${direction}, logData ${direction}, exceptionData ${direction}, url ${direction}, method ${direction}, statusCode ${direction} LIMIT ${limit + 1} OFFSET ${offset}`; const response = await fetch( `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/analytics_engine/sql`, { @@ -296,7 +299,7 @@ export async function deploymentLogs( url: row.url, method: row.method, status: row.statusCode, - ingestedAt: analyticsTimestamp(row.timestamp), + ingestedAt: analyticsTimestamp(row.ingestedAt), })), nextCursor, watermark: String(until), diff --git a/platform/packages/api/src/routes/project-tokens.ts b/platform/packages/api/src/routes/project-tokens.ts index ec91f2d25..d2f23b41c 100644 --- a/platform/packages/api/src/routes/project-tokens.ts +++ b/platform/packages/api/src/routes/project-tokens.ts @@ -65,9 +65,9 @@ function publicCredential(row: typeof projectDeployCredentials.$inferSelect) { return { id: row.id, name: row.name, - createdAt: row.created_at, - expiresAt: row.expires_at, - revokedAt: row.revoked_at, + createdAt: new Date(row.created_at).toISOString(), + expiresAt: new Date(row.expires_at).toISOString(), + revokedAt: row.revoked_at === null ? null : new Date(row.revoked_at).toISOString(), }; } diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index d9f3d2eb2..d385fb5e6 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -58,6 +58,7 @@ import { type ApiEnv, isDevBypass } from '../env.ts'; import { validateSubmittedExecutionPlans } from '../migrations/execution-plan.ts'; import { ensureTrackingTable, queryD1, runRemoteMigrations } from '../migrations/remote-runner.ts'; import { getProjectDeploymentWorkerName, getProjectQueueName } from '../naming.ts'; +import { LOG_ROW_GROUP_BY } from '../observability/log-rows.ts'; import { resolvePlan, resolvePlanLimits } from '../plans.ts'; import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; import { encryptProjectSecret, decryptProjectSecret } from '../project-secrets.ts'; @@ -88,7 +89,8 @@ import { resolveRoutingUndo, } from '../routing.ts'; import type { Build, DeployManifest, Project, ProjectRole, User } from '../types.ts'; -import { zeroTrustNotReadyMessage } from '../zero-trust/errors.ts'; +import { ZeroTrustUnavailableError, zeroTrustNotReadyMessage } from '../zero-trust/errors.ts'; +import { lockZeroTrustConfiguration } from '../zero-trust/lock.ts'; import { initializeNewProjectZeroTrust } from '../zero-trust/project.ts'; import { resolveProjectZeroTrustRouting, @@ -742,7 +744,13 @@ const app = new Hono() const userId = context.get('user').id; try { - const project = await createProject(context.get('db'), userId, body.slug); + // `void platform uninstall --purge-resources` holds this lock across its last + // check for projects and records the uninstall before releasing it, so a + // project either commits before that check or is refused here. + const project = await context.get('db').transaction(async (tx) => { + await lockZeroTrustConfiguration(tx, 'shared'); + return createProject(tx, userId, body.slug); + }); let zeroTrust: Awaited> = null; let zeroTrustWarning: string | undefined; try { @@ -813,6 +821,14 @@ const app = new Hono() 201, ); } catch (err) { + // Only the configuration lock above throws this here: the uninstall + // record exists. + if (err instanceof ZeroTrustUnavailableError) { + return context.json( + { error: 'The platform is being uninstalled, so new projects cannot be created.' }, + err.status, + ); + } // Unique constraint violation — slug was taken between check and insert if ( err instanceof Error && @@ -2668,11 +2684,12 @@ const app = new Hono() blob6 as statusCode, blob7 as deployId, double1 as ts, - timestamp + min(timestamp) as ingestedAt FROM ${dataset} WHERE index1 = '${workerName}' AND timestamp >= toDateTime('${sinceStr}')${deployFilter}${levelFilter} - ORDER BY timestamp DESC + ${LOG_ROW_GROUP_BY} + ORDER BY ingestedAt DESC LIMIT ${limit} `; diff --git a/platform/packages/api/src/zero-trust/lock.ts b/platform/packages/api/src/zero-trust/lock.ts index b8797e6e2..1c53e03a8 100644 --- a/platform/packages/api/src/zero-trust/lock.ts +++ b/platform/packages/api/src/zero-trust/lock.ts @@ -11,14 +11,15 @@ import { markProjectError } from './state.ts'; export const PLATFORM_UNINSTALL_SETTING = 'platform.uninstall'; /** - * The installation configuration lock. Project mutations, deployments and - * route publication hold it shared; installation operations take it exclusive - * to claim, checkpoint and finish, so they never overtake project work. - * Platform uninstall holds the same key from its final check until it disables - * traffic (packages/void/src/cli/platform-zero-trust-uninstall.ts), and records - * the uninstall before it releases. A request admitted before traffic was - * disabled may still be waiting here, so every holder refuses once that record - * exists rather than create an Access application uninstall did not see. + * The installation configuration lock. Project creation and mutations, + * deployments and route publication hold it shared; installation operations + * take it exclusive to claim, checkpoint and finish, so they never overtake + * project work. Platform uninstall holds the same key from its final check + * until it disables traffic + * (packages/void/src/cli/platform-zero-trust-uninstall.ts), and records the + * uninstall before it releases. A request admitted before traffic was disabled + * may still be waiting here, so every holder refuses once that record exists + * rather than create an Access application uninstall did not see. */ export async function lockZeroTrustConfiguration( db: DbOrTx, diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 0c372ebb4..8bd884da5 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -2615,7 +2615,10 @@ describe('GET /admin/deployments/:id/logs', () => { `timestamp >= toDateTime('${new Date(now - 60_000).toISOString().replace('T', ' ').slice(0, 19)}')`, ); expect(query).not.toMatch(/AND double1 >= \d+/); - expect(query).toContain('ORDER BY timestamp DESC, double1 DESC'); + expect(query).toContain( + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1', + ); + expect(query).toContain('ORDER BY ingestedAt DESC, ts DESC'); return Response.json({ data: [ { @@ -2626,7 +2629,7 @@ describe('GET /admin/deployments/:id/logs', () => { method: 'GET', statusCode: '500', ts: oldRequestStart, - timestamp: new Date(newlyIngested).toISOString(), + ingestedAt: new Date(newlyIngested).toISOString(), }, ], }); diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 2f8c7153d..b6870e026 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -2198,6 +2198,27 @@ describe('POST /projects/:id/deploy', () => { expect(eventNames).toContain('worker_upload_done'); }); + it('reports the deployed asset count without the internal cache-buster', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'asset-count-test'); + mockCfApi(); + + const res = await app.fetch( + await buildDeployRequest(env, project.id, user.jwt, { + assets: { + 'index.html': '

hi

', + 'assets/app.js': "console.log('hi')", + }, + }), + env, + ); + + expect(res.status).toBe(200); + const events = await readAllEvents(res); + expect(requireEvent(events, 'asset_upload')).toMatchObject({ total: 2 }); + expect(requireEvent(events, 'done').assets).toBe(2); + }); + it('retries plain-text asset upload response and succeeds on next attempt', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'asset-text-test'); diff --git a/platform/packages/api/test/integration/api-health.test.ts b/platform/packages/api/test/integration/api-health.test.ts index 995c8681b..139bd732b 100644 --- a/platform/packages/api/test/integration/api-health.test.ts +++ b/platform/packages/api/test/integration/api-health.test.ts @@ -73,7 +73,7 @@ describe('GET /health', () => { PLATFORM_TRAFFIC_STATE: 'staging', }); expect(res.status).toBe(503); - expect(await res.json()).toEqual({ error: 'Platform disabled' }); + expect(await res.json()).toEqual({ error: 'Platform disabled', reason: 'maintenance' }); }); it('keeps public API traffic closed during a Sandbox drain', async () => { @@ -82,7 +82,7 @@ describe('GET /health', () => { PLATFORM_TRAFFIC_STATE: 'sandbox-drain', }); expect(res.status).toBe(503); - expect(await res.json()).toEqual({ error: 'Platform disabled' }); + expect(await res.json()).toEqual({ error: 'Platform disabled', reason: 'maintenance' }); }); it('admits the authenticated installer Sandbox drain preflight during maintenance', async () => { diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index e42cec57e..e08c49c53 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -682,6 +682,49 @@ describe('project CI credential ownership', () => { .where(eq(projectDeployCredentials.project_id, project.id)), ).toMatchObject([{ id: 'deleting-credential-row', revoked_at: null }]); }); + + it('returns ISO-8601 timestamps from create, list, and renew', async () => { + const isoTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + const owner = await createTestUser(env.db, 'credential-iso-owner'); + const project = await createTestProject(env.db, owner.id, 'credential-iso-project'); + + const created = await app.fetch( + credentialRequest(project.id, owner.jwt, 'POST', '', { name: 'ISO CI', expiresInDays: 30 }), + env, + ); + expect(created.status).toBe(201); + const credential = await created.json<{ id: string; createdAt: string; expiresAt: string }>(); + expect(credential.createdAt).toMatch(isoTimestamp); + expect(credential.expiresAt).toMatch(isoTimestamp); + + const renewed = await app.fetch( + credentialRequest(project.id, owner.jwt, 'POST', `/${credential.id}/renew`, { + expiresInDays: 30, + }), + env, + ); + expect(renewed.status).toBe(200); + const replacement = await renewed.json<{ createdAt: string; expiresAt: string }>(); + expect(replacement.createdAt).toMatch(isoTimestamp); + expect(replacement.expiresAt).toMatch(isoTimestamp); + + const listed = await app.fetch(credentialRequest(project.id, owner.jwt, 'GET'), env); + expect(listed.status).toBe(200); + const rows = + await listed.json< + Array<{ id: string; createdAt: string; expiresAt: string; revokedAt: string | null }> + >(); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.createdAt).toMatch(isoTimestamp); + expect(row.expiresAt).toMatch(isoTimestamp); + if (row.id === credential.id) { + expect(row.revokedAt).toMatch(isoTimestamp); + } else { + expect(row.revokedAt).toBeNull(); + } + } + }); }); describe('POST /projects/:id/prerender-token', () => { @@ -3126,7 +3169,11 @@ describe('GET /projects/:id/logs', () => { analyticsQuery: (_url, init) => { const body = typeof init?.body === 'string' ? init.body : ''; expect(body).toContain('user-my-app'); - expect(body).toContain('ORDER BY timestamp DESC'); + // The dispatch Worker's tail consumer and the user Worker's own consumer + // each deliver a dispatched request, so identical rows are collapsed. + expect(body).toContain('GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'); + expect(body).toContain('min(timestamp) as ingestedAt'); + expect(body).toContain('ORDER BY ingestedAt DESC'); return Response.json(mockData); }, }); diff --git a/platform/packages/api/test/integration/operator-operations.test.ts b/platform/packages/api/test/integration/operator-operations.test.ts index 1b3474416..54cb6d5be 100644 --- a/platform/packages/api/test/integration/operator-operations.test.ts +++ b/platform/packages/api/test/integration/operator-operations.test.ts @@ -328,7 +328,7 @@ describe('operator deployment operations', () => { const deployment = await createTestDeployment(env.db, project.id, 'live'); const row = { ts: Date.now() - 1000, - timestamp: new Date(Date.now() - 500).toISOString(), + ingestedAt: new Date(Date.now() - 500).toISOString(), outcome: 'ok', logData: '[]', exceptionData: '[]', @@ -357,7 +357,10 @@ describe('operator deployment operations', () => { const last = (await second.json()) as { data: Array }; expect(last).toMatchObject({ nextCursor: null, watermark: page.watermark }); expect(last.data).toHaveLength(1); - expect(fetch.mock.calls[0][1]?.body).toContain('ORDER BY double1 ASC, timestamp ASC'); + expect(fetch.mock.calls[0][1]?.body).toContain( + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1', + ); + expect(fetch.mock.calls[0][1]?.body).toContain('ORDER BY ts ASC, ingestedAt ASC'); expect(fetch.mock.calls[0][1]?.body).toMatch(/double1 >= \d+ AND double1 < \d+/); expect(fetch.mock.calls[1][1]?.body).toContain('OFFSET 2'); expect((await req(`/deployments/${deployment.id}/logs?cursor=garbage`)).status).toBe(400); @@ -373,7 +376,7 @@ describe('operator deployment operations', () => { data: [ { ts: 120_000, - timestamp: '1970-01-01 00:09:00', + ingestedAt: '1970-01-01 00:09:00', outcome: 'ok', logData: '[]', exceptionData: '[]', @@ -402,7 +405,8 @@ describe('operator deployment operations', () => { expect(query).toContain( "timestamp >= toDateTime('1970-01-01 00:03:00') AND timestamp < toDateTime('1970-01-01 00:10:00')", ); - expect(query).toContain('ORDER BY timestamp ASC, double1 ASC'); + expect(query).toContain('GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'); + expect(query).toContain('ORDER BY ingestedAt ASC, ts ASC'); expect(query).not.toContain('double1 >= 480000'); expect((await req(`/deployments/${deployment.id}/logs?after=garbage`)).status).toBe(400); expect((await req(`/deployments/${deployment.id}/logs?after=600001`)).status).toBe(400); 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 d16c53f29..3d65408e7 100644 --- a/platform/packages/api/test/integration/platform-email-bootstrap.test.ts +++ b/platform/packages/api/test/integration/platform-email-bootstrap.test.ts @@ -208,7 +208,7 @@ describe('POST /internal/platform/email-bootstrap', () => { it('does not let an invalid probe token bypass the lifecycle gate', async () => { const response = await app.fetch(request('wrong-token'), bindings()); expect(response.status).toBe(503); - expect(await response.json()).toEqual({ error: 'Platform disabled' }); + expect(await response.json()).toEqual({ error: 'Platform disabled', reason: 'maintenance' }); }); it('fails readiness when the durable setup operation is blocked', async () => { diff --git a/platform/packages/api/test/integration/platform-status.test.ts b/platform/packages/api/test/integration/platform-status.test.ts index 3cf0b8c9d..dfa2b8cc2 100644 --- a/platform/packages/api/test/integration/platform-status.test.ts +++ b/platform/packages/api/test/integration/platform-status.test.ts @@ -151,7 +151,7 @@ it('keeps writes and other API routes gated while redirecting admin navigation t headers: { Origin: 'http://localhost' }, }); expect(write.status).toBe(503); - expect(await write.json()).toEqual({ error: 'Platform disabled' }); + expect(await write.json()).toEqual({ error: 'Platform disabled', reason: 'maintenance' }); } expect((await request('/admin/health')).status).toBe(503); expect((await request('/auth/callback?state=not-admin&code=fixture')).status).toBe(503); diff --git a/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts b/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts index 3340e6384..31992a97e 100644 --- a/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts +++ b/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts @@ -228,7 +228,9 @@ describe('Zero Trust disable public bypass lifecycle', () => { bypass_application_name: null, }); } - }, 10_000); + // 101 projects take about 1.5s alone but more than 10s while the full + // suite shares the machine with other test runs. + }, 30_000); it('keeps the public exception usable when wildcard deletion fails', async () => { const owner = await createTestUser(env.db, 'wildcard-failure-owner'); diff --git a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts index e673b242c..8c4c55d59 100644 --- a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts +++ b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts @@ -9,6 +9,10 @@ import { it, vi, } from 'vite-plus/test'; +import { + assertNoPlatformProjects, + readPlatformProjectSlugsConnection, +} from '../../../../../packages/void/src/cli/platform-teardown.ts'; import { assertProjectZeroTrustAllowsUninstall, fencePlatformProjectZeroTrustConnection, @@ -240,6 +244,70 @@ describe('platform uninstall check against the API database', () => { expect(cloudflare.applications.size).toBeGreaterThan(0); }); + it('refuses project creation while the uninstall fence is in place', async () => { + const owner = await createTestUser(env.db, 'fenced-creator'); + const platform = bindings(); + const connection = (env.db as unknown as { $client: QueryConnection }).$client; + + // The request was admitted before traffic was disabled. + await fencePlatformProjectZeroTrustConnection(connection); + const response = await app.fetch( + new Request('http://localhost/projects', { + method: 'POST', + headers: { Authorization: `Bearer ${owner.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'after-final-check' }), + }), + platform, + ); + expect(response.status).toBe(409); + expect(((await response.json()) as { error: string }).error).toBe( + 'The platform is being uninstalled, so new projects cannot be created.', + ); + expect(await readPlatformProjectSlugsConnection(connection)).toEqual([]); + }); + + it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( + 'keeps a project created during the purge-resources final check from committing after it', + async () => { + const owner = await createTestUser(env.db, 'racing-creator'); + const platform = bindings(); + const session = new pg.Client({ connectionString: process.env.VOID_TEST_DATABASE_URL }); + await session.connect(); + try { + const unlock = await lockPlatformProjectZeroTrustConnection(session); + const readProjects = () => readPlatformProjectSlugsConnection(session); + await assertNoPlatformProjects(readProjects); + + // Admitted before traffic was disabled, it reaches the insert after + // the final check passed. + const created = Promise.resolve( + app.fetch( + new Request('http://localhost/projects', { + method: 'POST', + headers: { + Authorization: `Bearer ${owner.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ slug: 'racing-project' }), + }), + platform, + ), + ).then((response) => response.status); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(await readProjects()).toEqual([]); + + await fencePlatformProjectZeroTrustConnection(session); + await unlock(); + expect(await created).toBe(409); + // Teardown deletes the platform only while this stays empty. + await expect(assertNoPlatformProjects(readProjects)).resolves.toBeUndefined(); + } finally { + await liftPlatformProjectZeroTrustFenceConnection(session); + await session.end(); + } + }, + ); + it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( 'refuses Zero Trust writers that waited on the uninstall lock once it is released', async () => { diff --git a/platform/packages/dispatch/src/index.ts b/platform/packages/dispatch/src/index.ts index dc0db3351..8c0b73421 100644 --- a/platform/packages/dispatch/src/index.ts +++ b/platform/packages/dispatch/src/index.ts @@ -1415,7 +1415,7 @@ export default { ), ); ctx.waitUntil( - populateJsonEdgeCache(cache, jsonCacheKeyForWrite, workerResponse, ttl, 'MISS'), + populateJsonEdgeCache(cache, jsonCacheKeyForWrite, workerResponse, ttl, 'HIT'), ); const cacheHeaders = new Headers(workerResponse.headers); cacheHeaders.set( @@ -1426,9 +1426,13 @@ export default { markSharedCachePolicy(cacheHeaders); cacheHeaders.delete('x-void-page-data'); cacheHeaders.delete('set-cookie'); + // Later requests read this copy from the edge, so it is stored as a HIT; + // only this client response reports the MISS. + const edgeHeaders = new Headers(cacheHeaders); + edgeHeaders.set('x-isr-cache', 'HIT'); const cacheResponse = new Response(workerResponse.clone().body, { status: workerResponse.status, - headers: cacheHeaders, + headers: edgeHeaders, }); ctx.waitUntil(cache.put(cacheKeyForWrite, cacheResponse.clone())); @@ -1885,7 +1889,7 @@ export default { originalUrlVariant, ); ctx.waitUntil( - populateJsonEdgeCache(applicationEdgeCache, jsonCacheKey, response, isrTtl, 'MISS'), + populateJsonEdgeCache(applicationEdgeCache, jsonCacheKey, response, isrTtl, 'HIT'), ); } } diff --git a/platform/packages/dispatch/test/unit/index.test.ts b/platform/packages/dispatch/test/unit/index.test.ts index 3509a6f8d..2663a8397 100644 --- a/platform/packages/dispatch/test/unit/index.test.ts +++ b/platform/packages/dispatch/test/unit/index.test.ts @@ -3222,6 +3222,38 @@ describe('ISR JSON edge cache (pages protocol)', () => { expect(await navigation.json()).toEqual(JSON.parse(pageData)); }); + it('serves the edge copies written on a miss as HIT for HTML and pages requests', async () => { + const { env, workerFetch } = createEnv({ + routing: isrRouting, + workerResponse: new Response(pageHtml(), { + headers: { 'Content-Type': 'text/html' }, + }), + }); + + const ctx = createCtx(); + const first = await app.fetch(new Request('https://my-app.void.app/dashboard'), env, ctx); + expect(first.headers.get('x-isr-cache')).toBe('MISS'); + await Promise.all((ctx.waitUntil as ReturnType).mock.calls.map((c) => c[0])); + + // No edge cache reset: both follow-up requests are served from the edge copy. + const html = await app.fetch( + new Request('https://my-app.void.app/dashboard'), + env, + createCtx(), + ); + expect(html.headers.get('x-isr-cache')).toBe('HIT'); + expect(await html.text()).toBe(pageHtml()); + + const json = await app.fetch( + new Request('https://my-app.void.app/dashboard', { headers: { 'X-VoidPages': 'true' } }), + env, + createCtx(), + ); + expect(json.headers.get('x-isr-cache')).toBe('HIT'); + expect(await json.json()).toEqual(JSON.parse(pageData)); + expect(workerFetch).toHaveBeenCalledOnce(); + }); + it('background revalidation populates JSON edge cache', async () => { const { env, workerFetch } = createEnv({ routing: isrRouting, diff --git a/platform/packages/tail/README.md b/platform/packages/tail/README.md index e694ff3ab..82b83bddb 100644 --- a/platform/packages/tail/README.md +++ b/platform/packages/tail/README.md @@ -9,6 +9,10 @@ Tail consumer for deployed user workers. - `projectKey`: `user-{slug}` - `deployId`: `dpl_*` when present - Writes one Analytics Engine row per trace event. +- A request routed through the dispatch Worker arrives twice: through the + dispatch Worker's consumer and through the user Worker's own consumer. + Cloudflare delivers each copy in a separate invocation, so this Worker writes + both and the API's log queries collapse identical rows. Versioned deploys use script names like `user-my-app-dpl_abc123`. Legacy unversioned names like `user-my-app` are still accepted for older deployments. diff --git a/playground/analog/db/migrations/001_create_users.sql b/playground/analog/db/migrations/001_create_users.sql index 2f0357d1b..4fc30029c 100644 --- a/playground/analog/db/migrations/001_create_users.sql +++ b/playground/analog/db/migrations/001_create_users.sql @@ -3,7 +3,9 @@ CREATE TABLE IF NOT EXISTS users ( name TEXT NOT NULL, email TEXT NOT NULL UNIQUE ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/analog/db/migrations/meta/001_snapshot.json b/playground/analog/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..1d01fd3a8 --- /dev/null +++ b/playground/analog/db/migrations/meta/001_snapshot.json @@ -0,0 +1,55 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "40a62e14-fb62-4374-b942-106e5fc011d1", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/analog/db/migrations/meta/_journal.json b/playground/analog/db/migrations/meta/_journal.json new file mode 100644 index 000000000..3d3161afb --- /dev/null +++ b/playground/analog/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791214068679, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/analog/db/schema.ts b/playground/analog/db/schema.ts index e0d0b9570..b19edefe1 100644 --- a/playground/analog/db/schema.ts +++ b/playground/analog/db/schema.ts @@ -3,5 +3,5 @@ import { sqliteTable, integer, text } from 'void/schema-d1'; export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), - email: text('email').notNull(), + email: text('email').notNull().unique(), }); diff --git a/playground/astro/db/migrations/20260412171331_serious_mindworm.sql b/playground/astro/db/migrations/20260412171331_serious_mindworm.sql index e70bab383..5e0709b65 100644 --- a/playground/astro/db/migrations/20260412171331_serious_mindworm.sql +++ b/playground/astro/db/migrations/20260412171331_serious_mindworm.sql @@ -3,7 +3,9 @@ CREATE TABLE `users` ( `name` text NOT NULL, `email` text NOT NULL ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/nuxt/db/migrations/20260412171408_fancy_vermin.sql b/playground/nuxt/db/migrations/20260412171408_fancy_vermin.sql index e70bab383..5e0709b65 100644 --- a/playground/nuxt/db/migrations/20260412171408_fancy_vermin.sql +++ b/playground/nuxt/db/migrations/20260412171408_fancy_vermin.sql @@ -3,7 +3,9 @@ CREATE TABLE `users` ( `name` text NOT NULL, `email` text NOT NULL ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/pg-test/db/migrations/meta/0000_snapshot.json b/playground/pg-test/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..9feee1438 --- /dev/null +++ b/playground/pg-test/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,124 @@ +{ + "id": "610c4b16-2710-4792-a277-acf2512645e1", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_author_id_users_id_fk": { + "name": "posts_author_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/playground/pg-test/db/migrations/meta/_journal.json b/playground/pg-test/db/migrations/meta/_journal.json new file mode 100644 index 000000000..27c573340 --- /dev/null +++ b/playground/pg-test/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791214069387, + "tag": "0000_initial", + "breakpoints": true + } + ] +} diff --git a/playground/pg-test/db/schema.ts b/playground/pg-test/db/schema.ts index fafe428ee..11ad924d7 100644 --- a/playground/pg-test/db/schema.ts +++ b/playground/pg-test/db/schema.ts @@ -1,6 +1,6 @@ import { minLength, pipe } from 'valibot'; import { createInsertSchema } from 'void/drizzle-valibot'; -import { pgTable, serial, text, boolean, timestamp } from 'void/schema-pg'; +import { pgTable, serial, integer, text, boolean, timestamp } from 'void/schema-pg'; export const users = pgTable('users', { id: serial('id').primaryKey(), @@ -19,7 +19,7 @@ export const posts = pgTable('posts', { id: serial('id').primaryKey(), title: text('title').notNull(), body: text('body').notNull(), - authorId: serial('author_id') + authorId: integer('author_id') .notNull() .references(() => users.id), createdAt: timestamp('created_at').notNull().defaultNow(), diff --git a/playground/react-pages/db/migrations/meta/001_snapshot.json b/playground/react-pages/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..e2f655086 --- /dev/null +++ b/playground/react-pages/db/migrations/meta/001_snapshot.json @@ -0,0 +1,57 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "00082a7a-cb82-446d-9538-6d5545bd3d01", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/react-pages/db/migrations/meta/_journal.json b/playground/react-pages/db/migrations/meta/_journal.json new file mode 100644 index 000000000..b8587d96c --- /dev/null +++ b/playground/react-pages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791214069039, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/react-pages/db/schema.ts b/playground/react-pages/db/schema.ts index e0d0b9570..51c1a8556 100644 --- a/playground/react-pages/db/schema.ts +++ b/playground/react-pages/db/schema.ts @@ -1,7 +1,9 @@ +import { sql } from 'void/db'; import { sqliteTable, integer, text } from 'void/schema-d1'; export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), email: text('email').notNull(), + createdAt: text('created_at').default(sql`(datetime('now'))`), }); diff --git a/playground/react-pages/pages/live.server.ts b/playground/react-pages/pages/live.server.ts index 74c72ddc4..8c7b94ae7 100644 --- a/playground/react-pages/pages/live.server.ts +++ b/playground/react-pages/pages/live.server.ts @@ -1,5 +1,5 @@ import { defineHandler, defineHead } from 'void'; -import { LIVE_PLAYGROUND_TOPIC } from '../lib/live.ts'; +import { LIVE_PLAYGROUND_TOPIC } from '../src/live.ts'; export type Props = Readonly<{ topic: string; diff --git a/playground/react-pages/routes/api/live.ts b/playground/react-pages/routes/api/live.ts index a693bdde7..d895a1c0f 100644 --- a/playground/react-pages/routes/api/live.ts +++ b/playground/react-pages/routes/api/live.ts @@ -1,5 +1,5 @@ import { defineHandler } from 'void'; -import { live } from '../../lib/live.ts'; +import { live } from '../../src/live.ts'; export const GET = defineHandler((c) => live.connect(c)); diff --git a/playground/react-pages/routes/api/live/publish.ts b/playground/react-pages/routes/api/live/publish.ts index dcdf6d99a..716247c89 100644 --- a/playground/react-pages/routes/api/live/publish.ts +++ b/playground/react-pages/routes/api/live/publish.ts @@ -1,5 +1,5 @@ import { defineHandler } from 'void'; -import { LIVE_PLAYGROUND_TOPIC, live } from '../../../lib/live.ts'; +import { LIVE_PLAYGROUND_TOPIC, live } from '../../../src/live.ts'; export const POST = defineHandler(async (c) => { const body = await c.req.json<{ message?: unknown }>().catch(() => null); diff --git a/playground/react-pages/lib/live.ts b/playground/react-pages/src/live.ts similarity index 100% rename from playground/react-pages/lib/live.ts rename to playground/react-pages/src/live.ts diff --git a/playground/react-router/db/migrations/20260412171408_complete_wildside.sql b/playground/react-router/db/migrations/20260412171408_complete_wildside.sql index e70bab383..5e0709b65 100644 --- a/playground/react-router/db/migrations/20260412171408_complete_wildside.sql +++ b/playground/react-router/db/migrations/20260412171408_complete_wildside.sql @@ -3,7 +3,9 @@ CREATE TABLE `users` ( `name` text NOT NULL, `email` text NOT NULL ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/sveltekit/db/migrations/20260412171409_icy_thundra.sql b/playground/sveltekit/db/migrations/20260412171409_icy_thundra.sql index e70bab383..5e0709b65 100644 --- a/playground/sveltekit/db/migrations/20260412171409_icy_thundra.sql +++ b/playground/sveltekit/db/migrations/20260412171409_icy_thundra.sql @@ -3,7 +3,9 @@ CREATE TABLE `users` ( `name` text NOT NULL, `email` text NOT NULL ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/tanstack-start/db/migrations/001_create_users.sql b/playground/tanstack-start/db/migrations/001_create_users.sql index 2f0357d1b..4fc30029c 100644 --- a/playground/tanstack-start/db/migrations/001_create_users.sql +++ b/playground/tanstack-start/db/migrations/001_create_users.sql @@ -3,7 +3,9 @@ CREATE TABLE IF NOT EXISTS users ( name TEXT NOT NULL, email TEXT NOT NULL UNIQUE ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/tanstack-start/db/migrations/meta/001_snapshot.json b/playground/tanstack-start/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..1e971515d --- /dev/null +++ b/playground/tanstack-start/db/migrations/meta/001_snapshot.json @@ -0,0 +1,55 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8084a142-8c46-480d-9eac-178cebe88abf", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/tanstack-start/db/migrations/meta/_journal.json b/playground/tanstack-start/db/migrations/meta/_journal.json new file mode 100644 index 000000000..93ebc8733 --- /dev/null +++ b/playground/tanstack-start/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791214083267, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/tanstack-start/db/schema.ts b/playground/tanstack-start/db/schema.ts index e0d0b9570..b19edefe1 100644 --- a/playground/tanstack-start/db/schema.ts +++ b/playground/tanstack-start/db/schema.ts @@ -3,5 +3,5 @@ import { sqliteTable, integer, text } from 'void/schema-d1'; export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), - email: text('email').notNull(), + email: text('email').notNull().unique(), }); diff --git a/playground/vinext-app/db/migrations/001_create_users.sql b/playground/vinext-app/db/migrations/001_create_users.sql index 2f0357d1b..4fc30029c 100644 --- a/playground/vinext-app/db/migrations/001_create_users.sql +++ b/playground/vinext-app/db/migrations/001_create_users.sql @@ -3,7 +3,9 @@ CREATE TABLE IF NOT EXISTS users ( name TEXT NOT NULL, email TEXT NOT NULL UNIQUE ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/vinext-app/db/migrations/meta/001_snapshot.json b/playground/vinext-app/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..00e2c49e2 --- /dev/null +++ b/playground/vinext-app/db/migrations/meta/001_snapshot.json @@ -0,0 +1,55 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "89d2be21-ba49-4583-8c1c-b26d8ff8f0fa", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/vinext-app/db/migrations/meta/_journal.json b/playground/vinext-app/db/migrations/meta/_journal.json new file mode 100644 index 000000000..a5d73d76b --- /dev/null +++ b/playground/vinext-app/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791214083603, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/vinext-app/db/schema.ts b/playground/vinext-app/db/schema.ts index e0d0b9570..b19edefe1 100644 --- a/playground/vinext-app/db/schema.ts +++ b/playground/vinext-app/db/schema.ts @@ -3,5 +3,5 @@ import { sqliteTable, integer, text } from 'void/schema-d1'; export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), - email: text('email').notNull(), + email: text('email').notNull().unique(), }); diff --git a/playground/vinext-pages/db/migrations/001_create_users.sql b/playground/vinext-pages/db/migrations/001_create_users.sql index 2f0357d1b..4fc30029c 100644 --- a/playground/vinext-pages/db/migrations/001_create_users.sql +++ b/playground/vinext-pages/db/migrations/001_create_users.sql @@ -3,7 +3,9 @@ CREATE TABLE IF NOT EXISTS users ( name TEXT NOT NULL, email TEXT NOT NULL UNIQUE ); - +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com'); +--> statement-breakpoint INSERT INTO users (name, email) VALUES ('Charlie', 'charlie@example.com'); diff --git a/playground/vinext-pages/db/migrations/meta/001_snapshot.json b/playground/vinext-pages/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..362f87f70 --- /dev/null +++ b/playground/vinext-pages/db/migrations/meta/001_snapshot.json @@ -0,0 +1,55 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "315bf79a-9b3c-45ac-8f9c-9d5415cf5723", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/vinext-pages/db/migrations/meta/_journal.json b/playground/vinext-pages/db/migrations/meta/_journal.json new file mode 100644 index 000000000..2f75421c1 --- /dev/null +++ b/playground/vinext-pages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791214083944, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/vinext-pages/db/schema.ts b/playground/vinext-pages/db/schema.ts index e0d0b9570..b19edefe1 100644 --- a/playground/vinext-pages/db/schema.ts +++ b/playground/vinext-pages/db/schema.ts @@ -3,5 +3,5 @@ import { sqliteTable, integer, text } from 'void/schema-d1'; export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), - email: text('email').notNull(), + email: text('email').notNull().unique(), }); diff --git a/playground/vue-pages/db/migrations/meta/001_snapshot.json b/playground/vue-pages/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..11d00b0a9 --- /dev/null +++ b/playground/vue-pages/db/migrations/meta/001_snapshot.json @@ -0,0 +1,57 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ccabec36-6b8e-464e-bd52-6b9177601dd3", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/vue-pages/db/migrations/meta/_journal.json b/playground/vue-pages/db/migrations/meta/_journal.json new file mode 100644 index 000000000..6185a22dd --- /dev/null +++ b/playground/vue-pages/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1791214084283, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/vue-pages/db/schema.ts b/playground/vue-pages/db/schema.ts index e0d0b9570..51c1a8556 100644 --- a/playground/vue-pages/db/schema.ts +++ b/playground/vue-pages/db/schema.ts @@ -1,7 +1,9 @@ +import { sql } from 'void/db'; import { sqliteTable, integer, text } from 'void/schema-d1'; export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), email: text('email').notNull(), + createdAt: text('created_at').default(sql`(datetime('now'))`), });