From 826db02a040c689a616c41643cb1832d9c408510 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 21:59:50 +0800 Subject: [PATCH 01/49] Delete the Workers for Platforms dispatch namespace by name on uninstall Cloudflare addresses dispatch namespaces by name, but uninstall sent the namespace UUID. Cloudflare answered 404, which uninstall treats as already gone, so the namespace survived while the manifest recorded it as deleted. deleteResource now takes the manifest resource and uses its name for dispatch namespaces and its id for every other kind. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-cloudflare.ts | 7 ++-- packages/void/src/cli/platform-management.ts | 2 +- .../test/unit/platform-cloudflare.test.ts | 38 ++++++++++++++++++- .../test/unit/platform-management.test.ts | 17 +++++---- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index fe709ab1d..1fd5ee5a9 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -1023,12 +1023,13 @@ 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; const account = encodeURIComponent(accountId); - const encoded = encodeURIComponent(id); + // Cloudflare addresses dispatch namespaces by name; every other kind by id. + const encoded = encodeURIComponent(kind === 'dispatch-namespace' ? resource.name : resource.id); const path = kind === 'kv-namespace' ? `/accounts/${account}/storage/kv/namespaces/${encoded}` diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index df258299a..34149156d 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -1104,7 +1104,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); } diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index da1016793..35071ab23 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -881,6 +881,32 @@ describe('platform Cloudflare client', () => { expect(deleted).toEqual([['a.txt', 'b.txt'], ['nested/c.txt']]); }); + 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 +934,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([ { @@ -963,7 +993,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-management.test.ts b/packages/void/test/unit/platform-management.test.ts index 242f17815..6b61780e6 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -164,7 +164,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]; @@ -1509,11 +1512,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 +1623,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'); } }; From 45e54254fdc54a4f1afa882552047fd256ee546b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:01:05 +0800 Subject: [PATCH 02/49] Refuse databases with leftover migration history before claiming them The installer's empty-database check only looked at tables on the search path, so a database that still held drizzle.__drizzle_migrations passed as empty. The leftover history was then read as already-applied migrations after the claim and after Cloudflare resources were created, and the install failed late. The check now also looks at the drizzle schema and refuses the database up front. A resumed install that already holds the matching claim is unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cli/platform-installer-database.ts | 5 +++- .../test/unit/platform-database-lock.test.ts | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/platform-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index e383801e6..0f30156d4 100644 --- a/packages/void/src/cli/platform-installer-database.ts +++ b/packages/void/src/cli/platform-installer-database.ts @@ -918,10 +918,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/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); From af6d7769874c14a3557463645ecb7f078dbcb26e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:06:19 +0800 Subject: [PATCH 03/49] List the resources uninstall leaves behind `void platform uninstall` now names every resource it keeps (kind, name and id) in the order of the manual cleanup steps, plus recorded Access resources. `--plan` shows the same list before any change. When the installation sends email, both say that Email Routing for the sender domain is kept. The final message points to the local installation record. A created AI Gateway that is already gone from Cloudflare is now recorded as deleted instead of retained. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 2 +- packages/void/src/cli/platform-management.ts | 114 +++++++++++++- .../test/unit/platform-management.test.ts | 139 +++++++++++++++++- 3 files changed, 248 insertions(+), 7 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index de11b74a8..ceca3614e 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -31,7 +31,7 @@ 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, zones, DNS records, routes, custom domains, and Cloudflare Access resources are always retained. To also remove eligible data resources owned by the installer: diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 34149156d..4a106425f 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -11,6 +11,7 @@ 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'; @@ -49,6 +50,7 @@ import { } from './platform-plan.ts'; import { acquirePlatformLifecycleLock, + installationManifestPath, listInstallationManifests, readInstallationRecoverySecrets, removeInstallationRecoverySecrets, @@ -894,7 +896,14 @@ export async function uninstallPlatform( 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') : ''}`, + }${describeKeptResources( + manifest, + plannedKeptResources(manifest, retainData, hasRoutingCheckpoint), + 'Keep for manual cleanup:', + 'Retain Access resources for deliberate cleanup:', + ) + .map((line) => `\n${line}`) + .join('')}`, 'Uninstall plan', ); if (args.plan) { @@ -1077,7 +1086,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; } @@ -1116,10 +1131,17 @@ export async function uninstallPlatform( removeInstallationRecoverySecrets(next); } removePlatformConnectionForInstallation(next.id); + const kept = describeKeptResources( + next, + next.resources.filter((resource) => resource.state === 'retained'), + 'Cloudflare resources:', + 'Access resources:', + ); + 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 +1234,88 @@ function isOwnershipVerifiableKind(kind: InstallationResource['kind']): boolean ); } +// Kinds in the order of the manual cleanup steps in the uninstall guide. +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 describeKeptResources( + manifest: PlatformInstallationManifest, + resources: ReadonlyArray, + heading: string, + accessHeading: string, +): Array { + const sorted = [...resources].sort( + (left, right) => + KEPT_RESOURCE_ORDER.indexOf(left.kind) - KEPT_RESOURCE_ORDER.indexOf(right.kind), + ); + const isTraffic = (resource: InstallationResource) => + KEPT_RESOURCE_ORDER.indexOf(resource.kind) < KEPT_RESOURCE_ORDER.indexOf('queue'); + const line = (resource: InstallationResource) => + ` ${resource.kind}: ${resource.name} (${resource.id})`; + 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 + ? [ + 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 kept = [ + ...sorted.filter(isTraffic).map(line), + ...email, + ...sorted.filter((resource) => !isTraffic(resource)).map(line), + ]; + const access = (manifest.authenticationResources ?? []).map( + (resource) => + ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})`, + ); + 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; diff --git a/packages/void/test/unit/platform-management.test.ts b/packages/void/test/unit/platform-management.test.ts index 6b61780e6..4b9f4e284 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'); }); @@ -299,6 +308,24 @@ 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: '', @@ -1394,6 +1421,116 @@ 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)', + '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); From cfccc04fe5c7f920ea39e48503db715befe2303b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:10:23 +0800 Subject: [PATCH 04/49] Draw CLI notes safely in zero-width and tiny terminals The shared box renderer threw "Invalid count value: -1" when the terminal reported fewer than six columns, as a 0x0 pty does, so any note() call (for example the uninstall plan) crashed the command. Route every void note() through a small wrapper in output.ts that treats a non-positive width as unknown (80 columns, like the rest of the CLI) and draws narrower terminals at the smallest width the box supports. The vendored renderer stays byte-for-byte upstream. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/delete.ts | 4 +-- .../void/src/cli/deploy-cloudflare-publish.ts | 2 +- .../void/src/cli/deploy-cloudflare-static.ts | 2 +- .../void/src/cli/deploy-managed-framework.ts | 3 +- packages/void/src/cli/deploy-managed-full.ts | 3 +- .../void/src/cli/deploy-managed-static.ts | 3 +- packages/void/src/cli/domain.ts | 4 +-- packages/void/src/cli/email.ts | 4 +-- packages/void/src/cli/env.ts | 3 +- packages/void/src/cli/help.ts | 4 +-- packages/void/src/cli/output.ts | 26 ++++++++++++++++ packages/void/src/cli/platform-domain.ts | 3 +- packages/void/src/cli/platform-management.ts | 4 +-- packages/void/src/cli/prepare.ts | 3 +- packages/void/src/cli/project-team.ts | 4 +-- packages/void/src/cli/project-zero-trust.ts | 4 +-- packages/void/test/unit/output-note.test.ts | 30 +++++++++++++++++++ 17 files changed, 81 insertions(+), 25 deletions(-) create mode 100644 packages/void/test/unit/output-note.test.ts diff --git a/packages/void/src/cli/delete.ts b/packages/void/src/cli/delete.ts index 113c49dde..1537e5d60 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'; 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-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/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..c53bfb60d 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; 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-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-management.ts b/packages/void/src/cli/platform-management.ts index 4a106425f..221647b70 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -14,8 +14,8 @@ import { 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 { 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/test/unit/output-note.test.ts b/packages/void/test/unit/output-note.test.ts new file mode 100644 index 000000000..5399e1984 --- /dev/null +++ b/packages/void/test/unit/output-note.test.ts @@ -0,0 +1,30 @@ +import type { Writable } from 'node:stream'; +import { stripVTControlCharacters } from 'node:util'; +import { expect, it } from 'vite-plus/test'; +import { note } from '../../src/cli/output.ts'; + +function renderNote(columns: number): Array { + let text = ''; + const output = { + columns, + write(chunk: string) { + text += chunk; + return true; + }, + } as unknown as Writable; + note('Disable all platform traffic.\nNever delete the zone.', 'Uninstall plan', { output }); + return stripVTControlCharacters(text).trimEnd().split('\n'); +} + +it('draws a note at the default width when the terminal reports zero columns', () => { + const lines = renderNote(0); + expect(lines[0]).toContain('Uninstall plan'); + expect(lines.join('\n')).toContain('Disable all platform traffic.'); + expect(lines.at(-1)).toMatch(/^╰─+╯$/); +}); + +it('draws a note in a terminal narrower than the box', () => { + const lines = renderNote(2); + expect(lines[0]).toMatch(/^╭.*╮$/); + expect(lines.at(-1)).toMatch(/^╰─+╯$/); +}); From a92a2b21821f09bddc93f76fed57f79a10888fcb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:11:15 +0800 Subject: [PATCH 05/49] Document a manual cleanup order that Cloudflare accepts The uninstall guide now deletes the tail Worker last, after the platform Workers and the dispatch namespace apps that send logs to it, and adds an Email Routing step before the email gateway Worker is deleted. It also says where the local uninstall record and recovery file stay, and that both can be deleted after cleanup. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index ceca3614e..1f3359d21 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -31,7 +31,9 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -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, 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, zones, DNS records, routes, custom domains, Email Routing settings, and Cloudflare Access resources are always retained. + +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: @@ -46,13 +48,15 @@ Even with `--purge-data`, Void retains Workers, the Queues they use, R2, AI Gate 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. From 5ce1e49314e2b41420f9c7cf6ac7baa9a2c6acef Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:41:40 +0800 Subject: [PATCH 06/49] Add Cloudflare calls needed to tear down a platform The platform Cloudflare client can now delete Worker routes, custom domains, Queue consumers, dispatch namespace scripts, and Access applications and service tokens, and list Queue consumers and namespace scripts. Workers are deleted by script name, and an AI Gateway is deleted with the runtime credential when one is set, like its other calls. Deletes never send force, treat 404 as already gone, accept a success response without a result, and include Cloudflare error codes in their errors (for example 100109 for a tail Worker still in use). Emptying an R2 bucket can run an ownership check before each page of deletes. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-cloudflare.ts | 160 +++++++++++++++++- .../test/unit/platform-cloudflare.test.ts | 156 +++++++++++++++++ 2 files changed, 307 insertions(+), 9 deletions(-) diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index 1fd5ee5a9..e1ed511a8 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -90,6 +90,12 @@ export type CloudflareWorkerServiceRoute = Readonly<{ pattern: string; zoneName: string; }>; +export type CloudflareQueueConsumer = Readonly<{ + id: string; + /** Worker that consumes the Queue; null for an HTTP pull consumer. */ + script: string | null; +}>; +export type PlatformAccessResourceKind = 'application' | 'service-token'; export type PlatformManagedWorkerTraffic = Readonly<{ customDomains: ReadonlyArray>; routes: ReadonlyArray>; @@ -220,11 +226,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, @@ -947,6 +949,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 +989,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)); } @@ -1027,9 +1034,15 @@ export class PlatformCloudflareClient { zoneId?: string, ): Promise { const { kind } = resource; + if (kind === 'ai-gateway' && this.#aiGatewayClient) { + return this.#aiGatewayClient.deleteResource(accountId, resource, zoneId); + } const account = encodeURIComponent(accountId); - // Cloudflare addresses dispatch namespaces by name; every other kind by id. - const encoded = encodeURIComponent(kind === 'dispatch-namespace' ? resource.name : resource.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}` @@ -1055,16 +1068,126 @@ 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. Never sends + * `force`, so 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 { + 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'), + script: + typeof entry.script === 'string' + ? entry.script + : typeof entry.service === 'string' + ? entry.service + : null, + })); + } + + 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 { - await this.request(path, { method: 'DELETE' }); + 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)); + } + async removeRoutes( _accountId: string, _zoneId: string | null, @@ -1096,6 +1219,25 @@ 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 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('/'); } diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index 35071ab23..8138d2274 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -881,6 +881,162 @@ 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', script: 'void-team-api', type: 'worker' }, + { consumer_id: 'c2', service: 'other', type: 'worker' }, + { consumer_id: 'c3', 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('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('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', { From 1794a91eedf234fa2f9731135201c65172f7a0b1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:41:41 +0800 Subject: [PATCH 07/49] Add `void platform uninstall --purge-all` to remove everything Void created `--purge-all` includes `--purge-data` and also deletes the platform Workers, Queues, the dispatch namespace scripts, the R2 bucket, the AI Gateway, the platform DNS record, Worker routes and custom domains, and the Access applications and service tokens the installer created. It keeps zones, adopted resources, external PostgreSQL, Email Routing settings, Analytics Engine datasets, and API tokens. It deletes in an order Cloudflare accepts: traffic and DNS, Access, Queue consumers, Workers except the tail Worker, Queues, the dispatch namespace, the tail Worker, Hyperdrive and KV, R2 (emptied first), the AI Gateway, and routing KV last. Each resource is proven to belong to the installation right before its DELETE. Routes and custom domains must still serve the installer Worker they were recorded for. Progress is checkpointed, so rerunning the command continues after a failure. Before any change it refuses while projects remain, and checks that the credential can make every planned deletion: browser login cannot delete DNS records or Access resources, and missing token grants are named. `--plan` lists every resource it will delete, in order, and every resource it keeps, with the token permissions it needs. Ownership checks now list dispatch namespaces only when one is being checked, so installations without Workers for Platforms make no such call. The kept-resource list after uninstall now puts the tail Worker after the dispatch namespace, matching the manual cleanup steps. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 28 +- docs/reference/cli/platform-installation.md | 6 +- packages/void/src/cli/help.ts | 5 + packages/void/src/cli/platform-args.ts | 9 +- .../src/cli/platform-installer-database.ts | 22 + .../void/src/cli/platform-installer-types.ts | 2 + packages/void/src/cli/platform-installer.ts | 1 + packages/void/src/cli/platform-management.ts | 213 ++++- packages/void/src/cli/platform-teardown.ts | 787 ++++++++++++++++ packages/void/src/cli/platform-traffic.ts | 25 + packages/void/test/unit/platform-args.test.ts | 17 + .../unit/platform-management-upgrade.test.ts | 1 + .../test/unit/platform-management.test.ts | 3 + .../void/test/unit/platform-teardown.test.ts | 860 ++++++++++++++++++ 14 files changed, 1946 insertions(+), 33 deletions(-) create mode 100644 packages/void/src/cli/platform-teardown.ts create mode 100644 packages/void/test/unit/platform-teardown.test.ts diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index 1f3359d21..9ecf9989a 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -31,7 +31,7 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -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, zones, DNS records, routes, custom domains, Email Routing settings, 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, zones, and Email Routing settings are always retained. DNS records, routes, custom domains, and Cloudflare Access resources are retained unless you use `--purge-all`. 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. @@ -41,11 +41,33 @@ 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-all`. + +### Remove everything Void created + +To delete every Cloudflare resource the installation created: + +```sh +void platform uninstall [id] --purge-all --plan +void platform uninstall [id] --purge-all +``` + +`--purge-all` 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. + +It never removes zones, adopted resources, external PostgreSQL, 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-all` 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 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. + +Choose `--purge-all` when you uninstall. Once a plain or `--purge-data` 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-all`, 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. 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. diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 354122fb9..010f7987d 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-all] [--keep-zone] [--yes] ``` `discover --installation` limits recovery and endpoint verification to one installation in a shared Cloudflare account. @@ -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-all` 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 still keeps zones, adopted resources, external PostgreSQL, Email Routing settings, and API tokens. 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. See [Disable and Uninstall](../../guide/platform/installation/uninstall.md#remove-retained-resources) for the full removal policy and the cleanup order. diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index c53bfb60d..7c5107ae3 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -580,6 +580,11 @@ const HELP_TREE: HelpTree = { command: '--purge-data', description: 'Delete installer-owned data even when it contains apps', }, + { + command: '--purge-all', + 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/platform-args.ts b/packages/void/src/cli/platform-args.ts index ca3b32bb5..b8f650d0f 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. */ + purgeAll: boolean; deleteZone: boolean | null; yes: boolean; }; @@ -333,7 +335,9 @@ 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-all', '--delete-zone', '--keep-zone'].includes(arg), + ), subcommand, { allowYes: true }, ); @@ -344,7 +348,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-all'), + purgeAll: rest.includes('--purge-all'), yes: parsed.yes, deleteZone: rest.includes('--delete-zone') ? true diff --git a/packages/void/src/cli/platform-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index 0f30156d4..57ee480d7 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-all 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); diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index e3a846a42..950f66cce 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-all 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 221647b70..78372848a 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -34,6 +34,7 @@ import { createPlatformInstallationSecrets, installPlatform, loadPlatformRuntime, + readPlatformProjectSlugs, readPlatformProjectZeroTrustState, REMOTE_INSTALLATION_MANIFEST_KEY, resolveBootstrapGitHubId, @@ -58,6 +59,21 @@ import { resolveInstallationManifest, writeInstallationManifest, } from './platform-registry.ts'; +import { + assertNoPlatformProjects, + checkPurgeAllCredentials, + describePurgeAllPlan, + isPlatformTailWorker, + isPurgedAccessResource, + purgeAllAlwaysKept, + purgeAllKeptResources, + purgeAllPermissions, + purgeAllResumeCommand, + PURGE_ALL_STEP_PREFIX, + runPurgeAllTeardown, + type PurgeAllClient, + type PurgeAllCredential, +} from './platform-teardown.ts'; import { trackedPlatformWorkerTraffic } from './platform-traffic.ts'; import { assertProjectZeroTrustAllowsUninstall, @@ -86,7 +102,8 @@ export type PlatformManagementClient = Pick< >; export type PlatformUninstallClient = PlatformManagementClient & - Pick; + Pick & + Partial; export type PlatformOwnershipClient = Pick< PlatformManagementClient, @@ -159,6 +176,10 @@ export type PlatformManagementDependencies = Readonly<{ fenceProjectZeroTrust?: () => Promise; /** Removes that fence when uninstall stops before it disables traffic. */ liftProjectZeroTrustFence?: () => Promise; + /** Project slugs; --purge-all refuses to start while any remain or when this cannot read them. */ + readProjects?: () => Promise>; + /** Where the operator credential came from; --purge-all checks it before any change. */ + credential?: PurgeAllCredential; }>; export function selectAuthoritativePlatformManifest( @@ -280,6 +301,10 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise readPlatformProjectSlugs(databaseUrl, manifest.id, manifest.ownershipNonce), + credential: { source: operatorCredential.source, aiGatewayToken: !!gatewayToken }, }); return; } @@ -872,6 +897,7 @@ export async function uninstallPlatform( dependencies.checkpoint, ); const retainData = !args.purgeData; + const purgeAll = args.purgeAll; const hasRoutingCheckpoint = manifest.resources.some( (resource) => resource.key === 'routing-kv' && resource.state === 'ready', ); @@ -886,26 +912,46 @@ export async function uninstallPlatform( ); } await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); + if (purgeAll) { + 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 purgeClient = purgeAll ? requirePurgeAllClient(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.' - }${describeKeptResources( - manifest, - plannedKeptResources(manifest, retainData, hasRoutingCheckpoint), - 'Keep for manual cleanup:', - 'Retain Access resources for deliberate cleanup:', - ) - .map((line) => `\n${line}`) - .join('')}`, + purgeAll + ? purgeAllPlanText(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 (purgeClient) { + const credentialCheck = checkPurgeAllCredentials( + purgeClient, + 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; @@ -920,9 +966,11 @@ export async function uninstallPlatform( } if (interactive && !args.yes) { const proceed = await confirmAction( - args.purgeData - ? 'Permanently purge installer-owned platform data?' - : 'Uninstall this platform?', + purgeAll + ? 'Permanently delete every Cloudflare resource this installation created?' + : args.purgeData + ? 'Permanently purge installer-owned platform data?' + : 'Uninstall this platform?', false, ); if (!proceed) { @@ -931,11 +979,21 @@ export async function uninstallPlatform( } } + const purgeAllKept = new Set( + purgeAll ? purgeAllKeptResources(manifest).map((resource) => resource.key) : [], + ); const resourcesToDelete = new Set( manifest.resources .filter( (resource) => - shouldDeleteResource(resource, retainData) && + (shouldDeleteResource(resource, retainData) || + (purgeAll && + resource.state === 'ready' && + !purgeAllKept.has(resource.key) && + isOwnershipVerifiableKind(resource.kind) && + // After its ownership marker was removed, the bucket is proven + // by its creation fingerprint instead. + !hasLifecycleStep(manifest, `${PURGE_ALL_STEP_PREFIX}r2-marker:${resource.key}`))) && (hasRoutingCheckpoint || resource.kind === 'zone') && !( resource.kind === 'dns-record' && @@ -993,6 +1051,9 @@ export async function uninstallPlatform( let next = manifest; try { await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); + if (purgeAll) { + await assertNoPlatformProjects(dependencies.readProjects); + } fenced = true; await fenceProjectZeroTrust(); next = transitionInstallation(manifest, 'uninstalling', new Date().toISOString()); @@ -1051,6 +1112,36 @@ export async function uninstallPlatform( } } } + if (purgeClient && hasRoutingCheckpoint) { + next = await runPurgeAllTeardown( + { + client: purgeClient, + 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, + }), + bucketGenerationMatches: async (checkpointManifest, bucket) => { + const live = (await client.listR2Buckets(checkpointManifest.account.id)).find( + (entry) => entry.name === bucket.name, + ); + if (!live) { + return null; + } + return ( + live.fingerprint !== undefined && + bucket.configHash === r2BucketConfigHash(bucket.name, live.fingerprint) + ); + }, + }, + next, + ); + } const deletionPriority: Record = { worker: 0, queue: 1, @@ -1137,6 +1228,9 @@ export async function uninstallPlatform( 'Cloudflare resources:', 'Access resources:', ); + if (purgeAll) { + kept.push('Always kept:', ...purgeAllAlwaysKept()); + } if (kept.length) { note(kept.join('\n'), 'Kept for manual cleanup'); } @@ -1234,7 +1328,9 @@ function isOwnershipVerifiableKind(kind: InstallationResource['kind']): boolean ); } -// Kinds in the order of the manual cleanup steps in the uninstall guide. +// 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', @@ -1271,6 +1367,64 @@ function plannedKeptResources( ); } +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 purgeAllPlanText(manifest: PlatformInstallationManifest): string { + const permissions = purgeAllPermissions(manifest); + // Created Access resources are deleted; only adopted ones stay. + const keepManifest = { + ...manifest, + authenticationResources: (manifest.authenticationResources ?? []).filter( + (resource) => resource.ownership !== 'created', + ), + }; + return [ + 'Disable all platform traffic through installation-owned routing storage.', + ...describePurgeAllPlan(manifest), + ...describeKeptResources( + keepManifest, + purgeAllKeptResources(manifest), + 'Keep for manual cleanup:', + 'Keep Access resources you connected:', + ), + 'Always kept:', + ...purgeAllAlwaysKept(), + ...(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: ${purgeAllResumeCommand(manifest)}`, + ].join('\n'); +} + +function requirePurgeAllClient(client: PlatformUninstallClient): PurgeAllClient { + const required: ReadonlyArray = [ + 'deleteAccessResource', + 'deleteDispatchNamespaceScript', + 'deleteQueueConsumer', + 'deleteR2Object', + 'deleteWorkerDomain', + 'deleteWorkerRoute', + 'emptyR2Bucket', + 'getAccessResource', + 'listDispatchNamespaceScripts', + 'listQueueConsumers', + 'listWorkerRoutes', + ]; + if (required.some((method) => typeof client[method] !== 'function')) { + throw new Error('platform: This Cloudflare client cannot run --purge-all.'); + } + return client as PurgeAllClient; +} + function describeKeptResources( manifest: PlatformInstallationManifest, resources: ReadonlyArray, @@ -1278,8 +1432,7 @@ function describeKeptResources( accessHeading: string, ): Array { const sorted = [...resources].sort( - (left, right) => - KEPT_RESOURCE_ORDER.indexOf(left.kind) - KEPT_RESOURCE_ORDER.indexOf(right.kind), + (left, right) => keptResourceRank(manifest, left) - keptResourceRank(manifest, right), ); const isTraffic = (resource: InstallationResource) => KEPT_RESOURCE_ORDER.indexOf(resource.kind) < KEPT_RESOURCE_ORDER.indexOf('queue'); @@ -1306,10 +1459,12 @@ function describeKeptResources( ...email, ...sorted.filter((resource) => !isTraffic(resource)).map(line), ]; - const access = (manifest.authenticationResources ?? []).map( - (resource) => - ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})`, - ); + const access = (manifest.authenticationResources ?? []) + .filter((resource) => !isPurgedAccessResource(manifest, resource)) + .map( + (resource) => + ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})`, + ); return [ ...(kept.length ? [heading, ...kept] : []), ...(access.length ? [accessHeading, ...access] : []), @@ -1496,7 +1651,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), @@ -1705,7 +1864,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-all 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-teardown.ts b/packages/void/src/cli/platform-teardown.ts new file mode 100644 index 000000000..d1c84eca9 --- /dev/null +++ b/packages/void/src/cli/platform-teardown.ts @@ -0,0 +1,787 @@ +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-all` deletes every Cloudflare resource the + * installation created, in an order Cloudflare accepts: + * + * traffic (routes, custom domains, DNS) -> Access -> 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 no DELETE sends `force`, so Cloudflare refuses a resource that + * something outside the installation still uses. 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. + */ + +export const PURGE_ALL_STEP_PREFIX = 'uninstall:purge-all:'; + +export type PurgeAllClient = Pick< + PlatformCloudflareClient, + | 'deleteAccessResource' + | 'deleteDispatchNamespaceScript' + | 'deleteQueueConsumer' + | 'deleteR2Object' + | 'deleteResource' + | 'deleteWorkerDomain' + | 'deleteWorkerRoute' + | 'emptyR2Bucket' + | 'getAccessResource' + | 'listAiGateways' + | 'listDispatchNamespaceScripts' + | 'listDispatchNamespaces' + | 'listDnsRecords' + | 'listHyperdriveConfigs' + | 'listKvNamespaces' + | 'listQueueConsumers' + | 'listR2Buckets' + | 'listWorkerDomains' + | 'listWorkerNames' + | 'listWorkerRoutes' +>; + +export type PurgeAllCredential = 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' + | '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', + 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 purgeAllResumeCommand(manifest: PlatformInstallationManifest): string { + return `void platform uninstall ${manifest.id} --purge-all`; +} + +function accessStepKey(access: AccessResource): string { + return `${PURGE_ALL_STEP_PREFIX}access:${access.key}`; +} + +/** Access resources that --purge-all 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_ALL_STEP_PREFIX}${key}`); +} + +function created(resource: InstallationResource): boolean { + return resource.ownership === 'created' && resource.state === 'ready'; +} + +/** Everything --purge-all will delete, in deletion order. */ +function purgeAllTargets(manifest: PlatformInstallationManifest): Array { + const resources = manifest.resources; + const routing = resources.find((resource) => resource.key === 'routing-kv'); + // Without the remote checkpoint nothing can be proven, so nothing is deleted. + if (routing?.state !== 'ready') { + return []; + } + const zone = resources.find((resource) => resource.key === 'zone' && resource.state === 'ready'); + const workers = resources.filter((resource) => resource.kind === 'worker' && created(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) + ) { + 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' && created(resource) && zone) { + targets.push({ phase: 'dns', resource }); + } + } + const access = (manifest.authenticationResources ?? []).filter( + (entry) => entry.ownership === 'created' && !isPurgedAccessResource(manifest, entry), + ); + // 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 }); + } + } + const queues = resources.filter((resource) => resource.kind === 'queue' && created(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' && created(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' && created(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 && created(resource)) { + targets.push({ phase: kind === 'r2-bucket' ? 'r2' : 'ai-gateway', resource }); + } + } + } + if (created(routing)) { + targets.push({ phase: 'routing', resource: routing }); + } + return targets; +} + +/** Manifest resources that --purge-all leaves in the account. */ +export function purgeAllKeptResources( + manifest: PlatformInstallationManifest, +): Array { + const targets = new Set( + purgeAllTargets(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-all: what is deleted, in order, then what is kept. */ +export function describePurgeAllPlan(manifest: PlatformInstallationManifest): Array { + const targets = purgeAllTargets(manifest); + const lines: Array = []; + let step = 0; + let previous: Target['phase'] | null = null; + const consumerQueues: Array = []; + for (const target of targets) { + 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 === '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-all), in this order:', ...lines] + : ['--purge-all finds no resource it can prove this installation created.']; +} + +/** Lines naming what --purge-all never removes. */ +export function purgeAllAlwaysKept(): 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-all needs for this manifest. */ +export function purgeAllPermissions(manifest: PlatformInstallationManifest): Array { + const targets = purgeAllTargets(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; + } + 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 checkPurgeAllCredentials( + client: PurgeAllClient, + manifest: PlatformInstallationManifest, + credential: PurgeAllCredential, +): Promise { + const targets = purgeAllTargets(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 scope = `account ${manifest.account.id}${has('dns-record') || has('worker-route') ? ` and zone ${zone?.name ?? ''}` : ''}`; + 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-all plans. Set CLOUDFLARE_API_TOKEN to a token with ${purgeAllPermissions(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(manifest.account.id, entry.kind, entry.id), + ]); + } + 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-all 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-all 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-all 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-all while the platform still has ${slugs.length === 1 ? '1 project' : `${slugs.length} projects`}.`, + "--purge-all 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 PurgeAllContext = Readonly<{ + client: PurgeAllClient; + /** 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>; + /** True when the live bucket keeps its recorded creation fingerprint; null when it is gone. */ + bucketGenerationMatches: ( + manifest: PlatformInstallationManifest, + bucket: InstallationResource, + ) => Promise; +}>; + +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: ${purgeAllResumeCommand(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: ${purgeAllResumeCommand(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 runPurgeAllTeardown( + context: PurgeAllContext, + 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) => { + const now = new Date().toISOString(); + await save( + recordInstallationStep( + next, + { completedAt: now, key: `${PURGE_ALL_STEP_PREFIX}${key}`, resultHash: 'completed' }, + now, + ), + ); + }; + /** Prove the resource; false when it is already gone (and recorded). */ + const prove = async (resource: InstallationResource): Promise => { + const missing = await context.verify(next, new Set(['routing-kv', resource.key])); + if (missing.has(resource.key)) { + await markDeleted(resource.key); + return false; + } + return true; + }; + 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 = next.resources.find( + (resource) => resource.key === 'zone' && resource.state === 'ready', + ); + const createdWorkerNames = new Set( + next.resources + .filter((resource) => resource.kind === 'worker' && resource.ownership === 'created') + .map((resource) => resource.name), + ); + 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.'; + + for (const target of purgeAllTargets(initial)) { + if (target.phase === 'access') { + const { access } = target; + const live = await client.getAccessResource(accountId, access.kind, access.id); + if (live) { + if (live.name !== access.name || !access.name.startsWith(`void-${next.id}-`)) { + throw ownershipRefusal( + next, + `Access ${access.kind} ${access.id}`, + `its live name ${JSON.stringify(live.name)} is not the name this installation recorded`, + ); + } + try { + await client.deleteAccessResource(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' || + (target.phase !== 'traffic' && current.state !== 'ready') + ) { + continue; + } + switch (target.phase) { + case 'traffic': { + const worker = next.resources.find((resource) => resource.key === target.worker.key); + if (worker?.state !== 'ready' || !(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)) { + throw ownershipRefusal( + next, + `Worker route ${current.name} (${current.id})`, + `it now sends ${JSON.stringify(live.pattern)} to ${JSON.stringify(live.script)}`, + ); + } + 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)) { + throw ownershipRefusal( + next, + `custom domain ${current.name} (${current.id})`, + `it now sends ${JSON.stringify(live.hostname)} to ${JSON.stringify(live.service)}`, + ); + } + if (live) { + await client.deleteWorkerDomain(accountId, current.id); + } + } + await markDeleted(current.key); + break; + } + case 'dns': + await remove(current, 'Check the record in the Cloudflare dashboard.', zone!.id); + 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': + 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; + } + 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 'data': + case 'ai-gateway': + 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 context.verify(next, 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; + } + await recordStep(markerStep); + await client.deleteR2Object(accountId, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY); + } else { + // The marker may already be gone; the creation fingerprint still proves + // this is the same bucket. + const matches = await context.bucketGenerationMatches(next, bucket); + if (matches === null) { + await markDeleted(bucket.key); + return; + } + if (!matches) { + 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/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index c9916559b..fcab0c0c6 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, + purgeAll: false, deleteZone: false, yes: false, }); expect(() => parsePlatformArgs(['uninstall', '--delete-zone', '--keep-zone'])).toThrow(); }); + it('treats --purge-all as a purge of data and every other created resource', () => { + expect(parsePlatformArgs(['uninstall', 'team', '--purge-all', '--plan'])).toEqual({ + subcommand: 'uninstall', + selector: 'team', + plan: true, + purgeData: true, + purgeAll: true, + deleteZone: null, + yes: false, + }); + expect(parsePlatformArgs(['uninstall', '--purge-data', '--purge-all'])).toMatchObject({ + purgeData: true, + purgeAll: true, + }); + }); + it('parses explicit lifecycle acknowledgement', () => { expect(parsePlatformArgs(['upgrade', '--yes'])).toMatchObject({ subcommand: 'upgrade', diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 0a9713f2a..f108908b0 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, + purgeAll: 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 4b9f4e284..668fd1431 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -331,6 +331,7 @@ const uninstallArgs = { selector: '', plan: false, purgeData: false, + purgeAll: false, deleteZone: null, yes: true, }; @@ -1448,6 +1449,8 @@ describe('platform lifecycle management', () => { '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)', 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..b90219d5d --- /dev/null +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -0,0 +1,860 @@ +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, + 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 } 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 INSTALLATION_ID = 'example-a1b2c3d4'; +const FINGERPRINT = '2026-09-14T00:00:00.000Z'; +const RESUME = `void platform uninstall ${INSTALLATION_ID} --purge-all`; + +function hash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function installedManifest( + options: { dispatchNamespace?: boolean } = {}, +): PlatformInstallationManifest { + let manifest = createPlatformInstallationManifest({ + account: { id: 'account', name: 'Account' }, + createdAt: '2026-09-14T00:00:00.000Z', + 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', + }, + ], + }; + 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'), + 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, resource.name]), + ), + // A Worker the installation did not create that sends logs to the tail Worker. + foreignTailProducer: false, + }; + 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 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); + } + 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}`); + } + }, + 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}`); + live.access.delete(id); + }, + getAccessResource: async (_account: string, kind: string, id: string) => { + calls.push(`GET access ${kind} ${id}`); + const name = live.access.get(id); + return name ? { id, 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 () => {}, + }; + return fake; +} + +const purgeAllArgs = { + subcommand: 'uninstall' as const, + selector: '', + plan: false, + purgeData: true, + purgeAll: 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.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-all', () => { + 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, { ...purgeAllArgs, plan: true }, dependencies()); + + const plan = noteText('Uninstall plan'); + expectInOrder(plan, [ + 'Delete every resource this installation created (--purge-all), 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, purgeAllArgs, dependencies()); + + expect(deletes(fake)).toEqual(FULL_ORDER); + expect(fake.calls.indexOf('PUT disabled')).toBeLessThan(fake.calls.indexOf(FULL_ORDER[0]!)); + expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + // 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('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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, dependencies())).rejects.toThrow( + 'temporary failure', + ); + expect(fake.live.buckets.get(names.assetsBucket)?.objects.size).toBe(0); + + await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, 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, purgeAllArgs, 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]!, purgeAllArgs, 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, purgeAllArgs, dependencies())).rejects.toThrow( + 'belongs to another installation', + ); + expect(changes(fake)).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('installed'); + }); + + it('stops at a route that now serves another Worker, and continues once it is gone', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + fake.live.routes[1]!.script = 'someone-else'; + + await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( + `Refusing to delete Worker route *.apps.example.com (route-2) because it now sends "*.apps.example.com" to "someone-else". It may no longer belong to this installation. Delete or fix it in the Cloudflare dashboard if it is yours, then rerun: ${RESUME}`, + ); + expect(deletes(fake)).toEqual([ + 'DELETE worker-custom-domain domain-1', + 'DELETE worker-route route-1', + ]); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + + fake.live.routes.splice(0); + await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, dependencies()); + expect(deletes(fake)).not.toContain('DELETE worker-route route-2'); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + 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, purgeAllArgs, 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.some((call) => call.includes('force'))).toBe(false); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + + fake.live.foreignTailProducer = false; + await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, 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-all while the platform still has 2 projects.', + "--purge-all 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, { ...purgeAllArgs, plan: true }, withProjects), + ).rejects.toThrow(message); + await expect(uninstallPlatform(fake, manifest, purgeAllArgs, withProjects)).rejects.toThrow( + message, + ); + await expect( + uninstallPlatform( + fake, + manifest, + purgeAllArgs, + 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, purgeAllArgs, 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, { ...purgeAllArgs, 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, purgeAllArgs, dependencies())).rejects.toThrow( + 'cannot read every resource --purge-all 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, { ...purgeAllArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + + expect(fake.calls.filter((call) => /dispatch-(namespace|script)/.test(call))).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); +}); From 7331f9b7ee82eda556f16770e69be250288b0249 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 22:58:35 +0800 Subject: [PATCH 08/49] Prove each --purge-all deletion against the right owner - Look up and delete Access resources in the account that holds them, so a service token in a separate identity account is deleted instead of being recorded as gone. The credential check names that account too. - Delete the AI Gateway only when Cloudflare reports it was created next to the installer's provision intent. A gateway created later under the same id, or one without a reported creation time, is kept and listed. - Read the project list again before deleting the application Workers in the dispatch namespace, and stop if a project was created during the run. - Save the R2 bucket's generation when its ownership marker is removed, so a rerun can finish buckets recorded before creation fingerprints existed. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 4 +- packages/void/src/cli/platform-cloudflare.ts | 7 +- packages/void/src/cli/platform-management.ts | 13 +- packages/void/src/cli/platform-teardown.ts | 100 ++++++++++-- .../test/unit/platform-cloudflare.test.ts | 15 ++ .../void/test/unit/platform-teardown.test.ts | 151 +++++++++++++++++- 6 files changed, 257 insertions(+), 33 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index 9ecf9989a..f929bf223 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -52,14 +52,14 @@ void platform uninstall [id] --purge-all --plan void platform uninstall [id] --purge-all ``` -`--purge-all` 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. +`--purge-all` 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. It never removes zones, adopted resources, external PostgreSQL, 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-all` 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. +- 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 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. diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index e1ed511a8..2ff15f7c8 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -41,6 +41,8 @@ export class PlatformWorkerRoutesChangedError extends Error { export type CloudflareAccount = Readonly<{ id: string; name: string }>; export type CloudflareNamedResource = Readonly<{ + /** When Cloudflare created it, where the API reports that. */ + createdAt?: string; fingerprint?: string; id: string; name: string; @@ -286,9 +288,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); @@ -297,6 +299,7 @@ export class PlatformCloudflareClient { } } return entries.map((entry) => ({ + ...(typeof entry.created_at === 'string' ? { createdAt: entry.created_at } : {}), id: requiredString(entry.id, 'AI Gateway id'), name: requiredString(entry.id, 'AI Gateway id'), })); diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 78372848a..0c8379d24 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -1126,18 +1126,19 @@ export async function uninstallPlatform( allowDeletedRoutingCheckpoint: true, databaseUrl: dependencies.databaseUrl, }), - bucketGenerationMatches: async (checkpointManifest, bucket) => { + bucketGeneration: async (checkpointManifest, bucket) => { const live = (await client.listR2Buckets(checkpointManifest.account.id)).find( - (entry) => entry.name === bucket.name, + (entry) => entry.id === bucket.id && entry.name === bucket.name, ); if (!live) { return null; } - return ( - live.fingerprint !== undefined && - bucket.configHash === r2BucketConfigHash(bucket.name, live.fingerprint) - ); + return live.fingerprint === undefined + ? 'unknown' + : r2BucketConfigHash(bucket.name, live.fingerprint); }, + // --purge-all already refused unless the project list could be read. + readProjects: () => dependencies.readProjects!(), }, next, ); diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index d1c84eca9..bb9188c89 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -268,6 +268,10 @@ export function describePurgeAllPlan(manifest: PlatformInstallationManifest): Ar 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 { @@ -349,7 +353,9 @@ export async function checkPurgeAllCredentials( const has = (kind: InstallationResource['kind']) => resources.some((resource) => resource.kind === kind); const zone = manifest.resources.find((resource) => resource.key === 'zone'); - const scope = `account ${manifest.account.id}${has('dns-record') || has('worker-route') ? ` and zone ${zone?.name ?? ''}` : ''}`; + // 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 ?? ''}` : ''}`; if (credential.source === 'oauth') { const browserMissing = [ ...(has('dns-record') ? [PERMISSIONS.dns] : []), @@ -388,7 +394,7 @@ export async function checkPurgeAllCredentials( for (const entry of access) { probes.push([ entry.kind === 'application' ? PERMISSIONS.access : PERMISSIONS.serviceTokens, - () => client.getAccessResource(manifest.account.id, entry.kind, entry.id), + () => client.getAccessResource(entry.accountId, entry.kind, entry.id), ]); } if (has('dispatch-namespace')) { @@ -496,13 +502,44 @@ export type PurgeAllContext = Readonly<{ manifest: PlatformInstallationManifest, keys: ReadonlySet, ) => Promise>; - /** True when the live bucket keeps its recorded creation fingerprint; null when it is gone. */ - bucketGenerationMatches: ( + /** + * 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; + ) => 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, @@ -549,12 +586,12 @@ export async function runPurgeAllTeardown( recordInstallationResource(next, { ...resource, state: 'deleted' }, new Date().toISOString()), ); }; - const recordStep = async (key: string) => { + const recordStep = async (key: string, resultHash = 'completed') => { const now = new Date().toISOString(); await save( recordInstallationStep( next, - { completedAt: now, key: `${PURGE_ALL_STEP_PREFIX}${key}`, resultHash: 'completed' }, + { completedAt: now, key: `${PURGE_ALL_STEP_PREFIX}${key}`, resultHash }, now, ), ); @@ -593,7 +630,7 @@ export async function runPurgeAllTeardown( for (const target of purgeAllTargets(initial)) { if (target.phase === 'access') { const { access } = target; - const live = await client.getAccessResource(accountId, access.kind, access.id); + const live = await client.getAccessResource(access.accountId, access.kind, access.id); if (live) { if (live.name !== access.name || !access.name.startsWith(`void-${next.id}-`)) { throw ownershipRefusal( @@ -603,7 +640,7 @@ export async function runPurgeAllTeardown( ); } try { - await client.deleteAccessResource(accountId, access.kind, access.id); + await client.deleteAccessResource(access.accountId, access.kind, access.id); } catch (error) { throw cloudflareRefusal( next, @@ -693,6 +730,14 @@ export async function runPurgeAllTeardown( if (!(await prove(current))) { break; } + // A project created after the check before the first change, while + // the platform API still answered, 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-all 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: ${purgeAllResumeCommand(next)}`, + ); + } for (const script of await client.listDispatchNamespaceScripts(accountId, current.name)) { try { await client.deleteDispatchNamespaceScript(accountId, current.name, script); @@ -713,8 +758,25 @@ export async function runPurgeAllTeardown( 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 save( + recordInstallationResource( + next, + { ...current, state: 'retained' }, + new Date().toISOString(), + ), + ); + break; + } + await remove(current, 'Check that nothing outside this installation still uses it.'); + break; + } case 'data': - case 'ai-gateway': await remove(current, 'Check that nothing outside this installation still uses it.'); break; case 'r2': @@ -753,17 +815,23 @@ export async function runPurgeAllTeardown( if (!(await prove(bucket))) { return; } - await recordStep(markerStep); + // 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 { - // The marker may already be gone; the creation fingerprint still proves - // this is the same bucket. - const matches = await context.bucketGenerationMatches(next, bucket); - if (matches === null) { + const saved = next.steps.find((step) => step.key === `${PURGE_ALL_STEP_PREFIX}${markerStep}`); + const generation = await context.bucketGeneration(next, bucket); + if (generation === null) { await markDeleted(bucket.key); return; } - if (!matches) { + if (generation !== saved?.resultHash) { throw ownershipRefusal( next, `R2 bucket ${bucket.name}`, diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index 8138d2274..e8dbed650 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -1122,6 +1122,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 = { diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index b90219d5d..24e85a1f6 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -9,6 +9,7 @@ import { PLATFORM_DISABLED_KEY, PLATFORM_R2_OWNERSHIP_MARKER_KEY, recordInstallationResource, + recordInstallationStep, transitionInstallation, type InstallationResource, type InstallationResourceKind, @@ -66,6 +67,8 @@ const names = deployment.resources; const INSTALLATION_ID = 'example-a1b2c3d4'; const FINGERPRINT = '2026-09-14T00:00:00.000Z'; const RESUME = `void platform uninstall ${INSTALLATION_ID} --purge-all`; +// 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'); @@ -186,6 +189,15 @@ function installedManifest( }, ], }; + 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', @@ -235,7 +247,12 @@ function cloudflare(manifest: PlatformInstallationManifest) { ); const bucket = manifest.resources.find((resource) => resource.kind === 'r2-bucket')!; const live = { - aiGateways: ready('ai-gateway'), + 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') @@ -275,7 +292,10 @@ function cloudflare(manifest: PlatformInstallationManifest) { ], domains: [{ hostname: 'api.example.com', id: 'domain-1', service: names.apiWorker as string }], access: new Map( - (manifest.authenticationResources ?? []).map((resource) => [resource.id, resource.name]), + (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, @@ -394,14 +414,17 @@ function cloudflare(manifest: PlatformInstallationManifest) { calls.push(`DELETE dispatch-script ${namespace} ${script}`); live.namespaceScripts.delete(script); }, - deleteAccessResource: async (_account: string, kind: string, id: string) => { + deleteAccessResource: async (account: string, kind: string, id: string) => { calls.push(`DELETE access ${kind} ${id}`); - live.access.delete(id); + if (live.access.get(id)?.accountId === account) { + live.access.delete(id); + } }, - getAccessResource: async (_account: string, kind: string, id: string) => { + // 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 name = live.access.get(id); - return name ? { id, name } : null; + 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, @@ -857,4 +880,118 @@ describe('uninstall --purge-all', () => { 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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, dependencies())).rejects.toThrow( + 'temporary failure', + ); + expect(fake.live.buckets.get(names.assetsBucket)?.objects.size).toBe(0); + + await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, dependencies()); + expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); }); From 878cbf64765f707419f046b8f8bb6b3d588d4779 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:15:55 +0800 Subject: [PATCH 09/49] Keep changed resources, detach Email Routing, and finish stopped uninstalls with --purge-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A route, custom domain, DNS record, or Access resource that no longer matches what the installation recorded is kept instead of stopping the run. It is listed with the reason in the final kept note, and a rerun does not look at it again. - When the platform sends email, --purge-all deletes the Email Routing rules in the sender zone that send mail to this installation's email gateway Worker, and resets such a catch-all rule to its disabled drop default, after proving the Worker still carries the installation marker and before deleting it. Rules for other Workers, Email Routing itself, and the sender domain's MX, SPF, and DKIM records are kept and listed. The credential check names Zone → Email Routing Rules → Edit, and only when email is enabled. - If a plain or --purge-data uninstall stopped before it finished, --purge-all now deletes the resources that run recorded as retained, including routing KV, with the same proof as ready ones. A finished uninstall still cannot be reopened. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 12 +- docs/reference/cli/platform-installation.md | 2 +- packages/void/src/cli/platform-cloudflare.ts | 81 ++++++ packages/void/src/cli/platform-management.ts | 64 +++-- packages/void/src/cli/platform-teardown.ts | 270 ++++++++++++++--- .../test/unit/platform-cloudflare.test.ts | 77 +++++ .../void/test/unit/platform-teardown.test.ts | 271 +++++++++++++++++- 7 files changed, 701 insertions(+), 76 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index f929bf223..ad2b04f26 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -31,7 +31,7 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -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, zones, and Email Routing settings are always retained. DNS records, routes, custom domains, and Cloudflare Access resources are retained unless you use `--purge-all`. +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-all` 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-all`. 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. @@ -54,16 +54,20 @@ void platform uninstall [id] --purge-all `--purge-all` 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. -It never removes zones, adopted resources, external PostgreSQL, 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. +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-all` 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. +- 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. 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. -Choose `--purge-all` when you uninstall. Once a plain or `--purge-data` uninstall finishes, use the manual steps below instead. +If a plain or `--purge-data` uninstall stopped before it finished, rerun it with `--purge-all` to also delete what it kept. Once an uninstall finishes, use the manual steps below instead. ### Remove retained resources diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 010f7987d..88ac15be9 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -139,6 +139,6 @@ An upgrade completes after the new Workers pass health checks. If rollout fails, 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-all` 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 still keeps zones, adopted resources, external PostgreSQL, Email Routing settings, and API tokens. 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. +`--purge-all` 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/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index 2ff15f7c8..3709adbe2 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -98,6 +98,12 @@ export type CloudflareQueueConsumer = Readonly<{ 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>; @@ -1191,6 +1197,67 @@ export class PlatformCloudflareClient { 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, @@ -1230,6 +1297,20 @@ function accessResourcePath( 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 diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 0c8379d24..9cd719888 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -64,8 +64,12 @@ import { checkPurgeAllCredentials, describePurgeAllPlan, isPlatformTailWorker, + isPurgeAllCandidate, isPurgedAccessResource, + PlatformResourceChangedError, purgeAllAlwaysKept, + purgeAllHandlesEmailRouting, + purgeAllKeptReason, purgeAllKeptResources, purgeAllPermissions, purgeAllResumeCommand, @@ -898,9 +902,12 @@ export async function uninstallPlatform( ); const retainData = !args.purgeData; const purgeAll = args.purgeAll; - const hasRoutingCheckpoint = manifest.resources.some( - (resource) => resource.key === 'routing-kv' && resource.state === 'ready', - ); + const routingKv = manifest.resources.find((resource) => resource.key === 'routing-kv'); + // --purge-all also uses routing KV that an uninstall which stopped before it + // finished recorded as retained. + const reopenedRouting = + purgeAll && routingKv?.state === 'retained' && isPurgeAllCandidate(manifest, routingKv); + const hasRoutingCheckpoint = routingKv?.state === 'ready' || reopenedRouting; const createdZone = manifest.resources.find( (resource) => resource.key === 'zone' && resource.ownership === 'created', ); @@ -991,6 +998,9 @@ export async function uninstallPlatform( resource.state === 'ready' && !purgeAllKept.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_ALL_STEP_PREFIX}r2-marker:${resource.key}`))) && @@ -1064,9 +1074,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( @@ -1168,7 +1176,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; } @@ -1228,6 +1241,7 @@ export async function uninstallPlatform( next.resources.filter((resource) => resource.state === 'retained'), 'Cloudflare resources:', 'Access resources:', + purgeAll && purgeAllHandlesEmailRouting(next), ); if (purgeAll) { kept.push('Always kept:', ...purgeAllAlwaysKept()); @@ -1379,11 +1393,13 @@ function keptResourceRank( function purgeAllPlanText(manifest: PlatformInstallationManifest): string { const permissions = purgeAllPermissions(manifest); - // Created Access resources are deleted; only adopted ones stay. + // Created Access resources are deleted unless an earlier run kept them. const keepManifest = { ...manifest, authenticationResources: (manifest.authenticationResources ?? []).filter( - (resource) => resource.ownership !== 'created', + (resource) => + resource.ownership !== 'created' || + purgeAllKeptReason(manifest, `access:${resource.key}`) !== null, ), }; return [ @@ -1394,6 +1410,7 @@ function purgeAllPlanText(manifest: PlatformInstallationManifest): string { purgeAllKeptResources(manifest), 'Keep for manual cleanup:', 'Keep Access resources you connected:', + purgeAllHandlesEmailRouting(manifest), ), 'Always kept:', ...purgeAllAlwaysKept(), @@ -1410,15 +1427,19 @@ function requirePurgeAllClient(client: PlatformUninstallClient): PurgeAllClient const required: ReadonlyArray = [ 'deleteAccessResource', 'deleteDispatchNamespaceScript', + 'deleteEmailRoutingRule', 'deleteQueueConsumer', 'deleteR2Object', 'deleteWorkerDomain', 'deleteWorkerRoute', 'emptyR2Bucket', 'getAccessResource', + 'getEmailRoutingCatchAll', 'listDispatchNamespaceScripts', + 'listEmailRoutingRules', 'listQueueConsumers', 'listWorkerRoutes', + 'resetEmailRoutingCatchAll', ]; if (required.some((method) => typeof client[method] !== 'function')) { throw new Error('platform: This Cloudflare client cannot run --purge-all.'); @@ -1431,14 +1452,20 @@ function describeKeptResources( resources: ReadonlyArray, heading: string, accessHeading: string, + /** --purge-all 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 = purgeAllKeptReason(manifest, key); + return reason === null ? '' : `, kept because ${reason}`; + }; const line = (resource: InstallationResource) => - ` ${resource.kind}: ${resource.name} (${resource.id})`; + ` ${resource.kind}: ${resource.name} (${resource.id})${why(resource.key)}`; const { capabilities, emailSenderDomain, @@ -1450,9 +1477,11 @@ function describeKeptResources( // the Workers. const email = capabilities.email ? [ - 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}.`, + 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 kept = [ @@ -1464,7 +1493,7 @@ function describeKeptResources( .filter((resource) => !isPurgedAccessResource(manifest, resource)) .map( (resource) => - ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})`, + ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})${why(`access:${resource.key}`)}`, ); return [ ...(kept.length ? [heading, ...kept] : []), @@ -1745,8 +1774,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, ); } } diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index bb9188c89..a68e8fca2 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -15,15 +15,21 @@ import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; * `void platform uninstall --purge-all` deletes every Cloudflare resource the * installation created, in an order Cloudflare accepts: * - * traffic (routes, custom domains, DNS) -> Access -> queue consumers - * -> Workers except tail -> Queues -> dispatch namespace -> tail Worker - * -> Hyperdrive and KV -> R2 -> AI Gateway -> routing KV (last) + * 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 no DELETE sends `force`, so Cloudflare refuses a resource that - * something outside the installation still uses. 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. + * something outside the installation still uses. 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-all records what it leaves as retained. If it + * stopped before it finished, --purge-all considers those resources again. */ export const PURGE_ALL_STEP_PREFIX = 'uninstall:purge-all:'; @@ -32,6 +38,7 @@ export type PurgeAllClient = Pick< PlatformCloudflareClient, | 'deleteAccessResource' | 'deleteDispatchNamespaceScript' + | 'deleteEmailRoutingRule' | 'deleteQueueConsumer' | 'deleteR2Object' | 'deleteResource' @@ -39,10 +46,12 @@ export type PurgeAllClient = Pick< | 'deleteWorkerRoute' | 'emptyR2Bucket' | 'getAccessResource' + | 'getEmailRoutingCatchAll' | 'listAiGateways' | 'listDispatchNamespaceScripts' | 'listDispatchNamespaces' | 'listDnsRecords' + | 'listEmailRoutingRules' | 'listHyperdriveConfigs' | 'listKvNamespaces' | 'listQueueConsumers' @@ -50,6 +59,7 @@ export type PurgeAllClient = Pick< | 'listWorkerDomains' | 'listWorkerNames' | 'listWorkerRoutes' + | 'resetEmailRoutingCatchAll' >; export type PurgeAllCredential = Readonly<{ @@ -65,6 +75,8 @@ type Target = | Readonly<{ phase: | 'dns' + // The resource is the email gateway Worker the rules send mail to. + | 'email' | 'queue-consumers' | 'workers' | 'queues' @@ -86,6 +98,7 @@ 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', @@ -125,26 +138,99 @@ function hasStep(manifest: PlatformInstallationManifest, key: string): boolean { return manifest.steps.some((step) => step.key === `${PURGE_ALL_STEP_PREFIX}${key}`); } -function created(resource: InstallationResource): boolean { - return resource.ownership === 'created' && resource.state === 'ready'; +function keptStepKey(key: string): string { + return `kept:${key}`; +} + +/** + * Why --purge-all 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 purgeAllKeptReason( + manifest: PlatformInstallationManifest, + key: string, +): string | null { + return ( + manifest.steps.find((step) => step.key === `${PURGE_ALL_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-all may still delete: one + * that is ready, or one that an uninstall which stopped before it finished + * recorded as retained. Resources --purge-all itself kept, and earlier + * versions of a resource kept when it was replaced, are never reconsidered. + */ +export function isPurgeAllCandidate( + manifest: PlatformInstallationManifest, + resource: InstallationResource, +): boolean { + return ( + resource.ownership === 'created' && + (resource.state === 'ready' || + (resource.state === 'retained' && + isReopenable(manifest) && + !resource.key.startsWith('retained:') && + purgeAllKeptReason(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-all 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-all will delete, in deletion order. */ function purgeAllTargets(manifest: PlatformInstallationManifest): Array { const resources = manifest.resources; const routing = resources.find((resource) => resource.key === 'routing-kv'); + const candidate = (resource: InstallationResource) => isPurgeAllCandidate(manifest, resource); // Without the remote checkpoint nothing can be proven, so nothing is deleted. - if (routing?.state !== 'ready') { + if (!routing || (routing.state !== 'ready' && !candidate(routing))) { return []; } - const zone = resources.find((resource) => resource.key === 'zone' && resource.state === 'ready'); - const workers = resources.filter((resource) => resource.kind === 'worker' && created(resource)); + 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) + (resource.kind === 'worker-route' && !zone) || + purgeAllKeptReason(manifest, resource.key) !== null ) { continue; } @@ -155,12 +241,15 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array } } for (const resource of resources) { - if (resource.kind === 'dns-record' && created(resource) && zone) { + 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), + (entry) => + entry.ownership === 'created' && + !isPurgedAccessResource(manifest, entry) && + purgeAllKeptReason(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) { @@ -168,7 +257,12 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array targets.push({ phase: 'access', access: entry }); } } - const queues = resources.filter((resource) => resource.kind === 'queue' && created(resource)); + // 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 }); @@ -187,7 +281,7 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array targets.push({ phase: 'queues', resource }); } for (const resource of resources) { - if (resource.kind === 'dispatch-namespace' && created(resource)) { + if (resource.kind === 'dispatch-namespace' && candidate(resource)) { targets.push({ phase: 'dispatch', resource }); } } @@ -196,24 +290,32 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array } for (const kind of ['hyperdrive', 'kv-namespace'] as const) { for (const resource of resources) { - if (resource.kind === kind && resource.key !== 'routing-kv' && created(resource)) { + 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 && created(resource)) { + if (resource.kind === kind && candidate(resource)) { targets.push({ phase: kind === 'r2-bucket' ? 'r2' : 'ai-gateway', resource }); } } } - if (created(routing)) { + if (candidate(routing)) { targets.push({ phase: 'routing', resource: routing }); } return targets; } +/** Whether --purge-all removes, or has removed, the rules that send mail to the gateway Worker. */ +export function purgeAllHandlesEmailRouting(manifest: PlatformInstallationManifest): boolean { + return ( + hasStep(manifest, 'email-routing') || + purgeAllTargets(manifest).some((target) => target.phase === 'email') + ); +} + /** Manifest resources that --purge-all leaves in the account. */ export function purgeAllKeptResources( manifest: PlatformInstallationManifest, @@ -241,6 +343,14 @@ export function describePurgeAllPlan(manifest: PlatformInstallationManifest): Ar 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; @@ -304,6 +414,10 @@ export function purgeAllPermissions(manifest: PlatformInstallationManifest): Arr ); continue; } + if (target.phase === 'email') { + needed.add(PERMISSIONS.emailRouting); + continue; + } const { kind } = target.resource; if (kind === 'worker-route') { needed.add(PERMISSIONS.routes); @@ -353,9 +467,12 @@ export async function checkPurgeAllCredentials( 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 ?? ''}` : ''}`; + 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] : []), @@ -397,6 +514,9 @@ export async function checkPurgeAllCredentials( () => 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)]); } @@ -596,15 +716,45 @@ export async function runPurgeAllTeardown( ), ); }; + /** + * 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). */ const prove = async (resource: InstallationResource): Promise => { - const missing = await context.verify(next, new Set(['routing-kv', resource.key])); + 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; @@ -616,9 +766,7 @@ export async function runPurgeAllTeardown( } await markDeleted(resource.key); }; - const zone = next.resources.find( - (resource) => resource.key === 'zone' && resource.state === 'ready', - ); + const zone = usableZone(next); const createdWorkerNames = new Set( next.resources .filter((resource) => resource.kind === 'worker' && resource.ownership === 'created') @@ -633,11 +781,11 @@ export async function runPurgeAllTeardown( const live = await client.getAccessResource(access.accountId, access.kind, access.id); if (live) { if (live.name !== access.name || !access.name.startsWith(`void-${next.id}-`)) { - throw ownershipRefusal( - next, - `Access ${access.kind} ${access.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); @@ -658,14 +806,15 @@ export async function runPurgeAllTeardown( if ( !current || current.state === 'deleted' || - (target.phase !== 'traffic' && current.state !== 'ready') + purgeAllKeptReason(next, current.key) !== null || + (target.phase !== 'traffic' && !isPurgeAllCandidate(next, current)) ) { continue; } switch (target.phase) { case 'traffic': { const worker = next.resources.find((resource) => resource.key === target.worker.key); - if (worker?.state !== 'ready' || !(await prove(worker))) { + if (!worker || !isPurgeAllCandidate(next, worker) || !(await prove(worker))) { // The Worker it served is gone, so the attachment cannot be proven. break; } @@ -674,11 +823,11 @@ export async function runPurgeAllTeardown( (route) => route.id === current.id, ); if (live && (live.pattern !== current.name || live.script !== worker.name)) { - throw ownershipRefusal( - next, - `Worker route ${current.name} (${current.id})`, + 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); @@ -688,11 +837,11 @@ export async function runPurgeAllTeardown( (domain) => domain.id === current.id, ); if (live && (live.hostname !== current.name || live.service !== worker.name)) { - throw ownershipRefusal( - next, - `custom domain ${current.name} (${current.id})`, + 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); @@ -702,8 +851,47 @@ export async function runPurgeAllTeardown( break; } case 'dns': - await remove(current, 'Check the record in the Cloudflare dashboard.', zone!.id); + 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; @@ -764,13 +952,7 @@ export async function runPurgeAllTeardown( ); if (live && !createdDuringInstallation(next, current, live.createdAt)) { // It may be a replacement with the same id, so keep and list it. - await save( - recordInstallationResource( - next, - { ...current, state: 'retained' }, - new Date().toISOString(), - ), - ); + 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.'); @@ -802,7 +984,7 @@ export async function runPurgeAllTeardown( bucket.name, new Set([PLATFORM_R2_OWNERSHIP_MARKER_KEY]), async () => { - const missing = await context.verify(next, new Set(['routing-kv', bucket.key])); + const missing = await verify(new Set(['routing-kv', bucket.key])); if (missing.has(bucket.key)) { throw ownershipRefusal( next, diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index e8dbed650..16d22bfdf 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -995,6 +995,83 @@ describe('platform Cloudflare client', () => { expect(requests.some((request) => request.includes('force'))).toBe(false); }); + 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 () => diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index 24e85a1f6..5653902ce 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -64,6 +64,15 @@ const deployment = createPlatformDeploymentManifest({ 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-all`; @@ -75,12 +84,12 @@ function hash(value: unknown): string { } function installedManifest( - options: { dispatchNamespace?: boolean } = {}, + options: { dispatchNamespace?: boolean; email?: boolean } = {}, ): PlatformInstallationManifest { let manifest = createPlatformInstallationManifest({ account: { id: 'account', name: 'Account' }, createdAt: '2026-09-14T00:00:00.000Z', - deployment, + deployment: options.email ? emailDeployment : deployment, id: INSTALLATION_ID, ownershipNonce: '0123456789abcdef0123456789abcdef', platformVersion: '1.0.0', @@ -299,6 +308,8 @@ function cloudflare(manifest: PlatformInstallationManifest) { ), // A Worker the installation did not create that sends logs to the tail Worker. foreignTailProducer: false, + 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(); @@ -491,6 +502,22 @@ function cloudflare(manifest: PlatformInstallationManifest) { } }, 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; } @@ -740,24 +767,246 @@ describe('uninstall --purge-all', () => { expect(listInstallationManifests()[0]?.status).toBe('installed'); }); - it('stops at a route that now serves another Worker, and continues once it is gone', async () => { + 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, purgeAllArgs, dependencies())).rejects.toThrow( - `Refusing to delete Worker route *.apps.example.com (route-2) because it now sends "*.apps.example.com" to "someone-else". It may no longer belong to this installation. Delete or fix it in the Cloudflare dashboard if it is yours, then rerun: ${RESUME}`, + `Cloudflare refused to delete worker ${names.tailWorker}`, ); - expect(deletes(fake)).toEqual([ - 'DELETE worker-custom-domain domain-1', - 'DELETE worker-route route-1', - ]); - expect(listInstallationManifests()[0]?.status).toBe('failed'); + 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, purgeAllArgs, dependencies()); - fake.live.routes.splice(0); - await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, { ...purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, purgeAllArgs, 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, { ...purgeAllArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, purgeAllArgs, 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-all 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, { ...purgeAllArgs, purgeAll: 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, purgeAllArgs, 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-all 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 = { ...purgeAllArgs, purgeAll: 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, purgeAllArgs, dependencies()); + + expect(deletes(fake)).toEqual(FULL_ORDER); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('never reopens an uninstall that finished', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = cloudflare(manifest); + await uninstallPlatform( + fake, + manifest, + { ...purgeAllArgs, purgeAll: false, purgeData: false }, + dependencies(), + ); + const finished = listInstallationManifests()[0]!; + expect(finished.status).toBe('uninstalled'); + const before = changes(fake).length; + + await uninstallPlatform(fake, finished, { ...purgeAllArgs, plan: true }, dependencies()); + expect(noteText('Uninstall plan')).toContain('--purge-all finds no resource'); + await expect(uninstallPlatform(fake, finished, purgeAllArgs, dependencies())).rejects.toThrow( + 'Invalid installation transition: uninstalled -> uninstalling.', + ); + + expect(changes(fake).slice(before)).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); }); From c6d5c646a57f61cadb035cc9e4a38301c65d29fc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:25:15 +0800 Subject: [PATCH 10/49] Tell installers to switch token zone policies to Specified Domains Cloudflare's account token links cannot preselect a zone, so the dashboard opens zone permissions under All Domains. The management and runtime token prompts now name the exact change (All Domains to Specified Domains and the zone to select), keep All Domains when Void creates the zone, and the installation docs say the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platform/installation/credentials.md | 4 +- docs/guide/platform/installation/setup.md | 2 +- packages/void/src/cli/platform-setup.ts | 37 ++++++++++++-- .../void/test/unit/platform-setup.test.ts | 49 +++++++++++++++++-- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index 0c48d797b..c3ada9ab2 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 the management token. 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/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/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index f71b36b0e..d4201adc1 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -86,6 +86,15 @@ 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 ')}.`; +} + export function platformManagementSetupPage( plan: PlatformInstallPlan, options: { access?: PlatformManagementAccess | false } = {}, @@ -101,6 +110,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 +153,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 +188,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 +199,17 @@ 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.', + ...(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/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 66895e438..7c7dfb9f5 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,23 @@ 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('opens only in interactive mode and retains a manual link if opening fails', () => { const open = vi.fn(() => { From 08e52263d6acfc63df6f6e1a9eb6fc60d454848e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:26:33 +0800 Subject: [PATCH 11/49] Read markdown page frontmatter after a leading script block Page title, description and layout in .md files are now found when the page opens 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', From c368981675ed6715cf446c6433453b4a63bed523 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:27:26 +0800 Subject: [PATCH 12/49] Keep the React Pages live stream in a scanned directory The playground defined its live stream in lib/, which binding inference does not scan, so builds emitted no VOID_LIVE Durable Object and /api/live/publish failed. Move it to src/live.ts as the live guide shows, document that the module importing void/live must be in a scanned directory, and list void/live in the resource inference table. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/live.md | 2 ++ docs/reference/resource-inference.md | 1 + .../void/test/unit/plugin-inference.test.ts | 30 +++++++++++++++++++ playground/react-pages/pages/live.server.ts | 2 +- playground/react-pages/routes/api/live.ts | 2 +- .../react-pages/routes/api/live/publish.ts | 2 +- playground/react-pages/{lib => src}/live.ts | 0 7 files changed, 36 insertions(+), 3 deletions(-) rename playground/react-pages/{lib => src}/live.ts (100%) diff --git a/docs/guide/live.md b/docs/guide/live.md index e778e9068..380ce158e 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. A module in an unscanned directory such as `lib/` needs that directory in [`inference.scanDirs`](../reference/resource-inference.md#custom-scan-directories). + Expose it from a normal route: ```ts 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/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/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 From 9103b46013bd8cf791c45a521818578513dd9894 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:28:43 +0800 Subject: [PATCH 13/49] Prove reopened resources before any change, and list kept Access resources with other kept items - When --purge-all finishes an uninstall that stopped before it finished, the resources that run recorded as retained are now proven before the first change, like ready ones. A Worker that no longer carries this installation's marker stops the run before anything is deleted, and the retained resources stay recorded as retained. - An Access resource that an earlier --purge-all kept because it changed is listed under the kept resources with its reason, not under "Keep Access resources you connected". Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-management.ts | 44 ++++++++--- .../void/test/unit/platform-teardown.test.ts | 75 +++++++++++++++++++ 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 9cd719888..d5fb5fb7f 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -995,7 +995,9 @@ export async function uninstallPlatform( (resource) => (shouldDeleteResource(resource, retainData) || (purgeAll && - resource.state === 'ready' && + // Includes what an uninstall that stopped before it finished + // recorded as retained, so a replaced one stops the run here too. + (resource.state === 'ready' || isPurgeAllCandidate(manifest, resource)) && !purgeAllKept.has(resource.key) && isOwnershipVerifiableKind(resource.kind) && // A changed DNS record is kept, not a reason to stop, so it is @@ -1020,12 +1022,27 @@ export async function uninstallPlatform( .map((resource) => resource.key), ...resourcesToDelete, ]); + // Ownership checks only look at ready resources. Prove retained ones that + // --purge-all 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, @@ -1484,17 +1501,22 @@ function describeKeptResources( : ` 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-all kept because they changed are listed with the + // other kept resources, not as ones the operator connected. + const keptAccess = (resource: (typeof remainingAccess)[number]) => + purgeAllKeptReason(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 = (manifest.authenticationResources ?? []) - .filter((resource) => !isPurgedAccessResource(manifest, resource)) - .map( - (resource) => - ` ${resource.ownership} ${resource.kind}: ${resource.name} (${resource.id}, account ${resource.accountId})${why(`access:${resource.key}`)}`, - ); + const access = remainingAccess.filter((resource) => !keptAccess(resource)).map(accessLine); return [ ...(kept.length ? [heading, ...kept] : []), ...(access.length ? [accessHeading, ...access] : []), diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index 5653902ce..79dad6c89 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -986,6 +986,81 @@ describe('uninstall --purge-all', () => { 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, + { ...purgeAllArgs, purgeAll: 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, purgeAllArgs, 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-all 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, purgeAllArgs, dependencies())).rejects.toThrow( + `Cloudflare refused to delete worker ${names.tailWorker}`, + ); + const stopped = listInstallationManifests()[0]!; + + await uninstallPlatform(fake, stopped, { ...purgeAllArgs, 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); From faf9dfddbf5d1c65793d5dc13c8f5904b722f097 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:31:58 +0800 Subject: [PATCH 14/49] Add Drizzle metadata to playgrounds with hand-written migrations analog, react-pages, tanstack-start, vinext-app, vinext-pages, vue-pages and pg-test shipped a hand-written initial SQL migration without a Drizzle journal or snapshot, so every deploy stopped at the schema drift check. Their schemas also disagreed with the SQL (missing unique email, missing created_at, serial author_id). Align each db/schema.ts with its SQL and add a journal plus snapshot under the existing migration tag, keeping the SQL files unchanged, so the drift check passes and later `void db generate` runs diff from this baseline. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../db/migrations/meta/001_snapshot.json | 55 ++++++++ .../analog/db/migrations/meta/_journal.json | 13 ++ playground/analog/db/schema.ts | 2 +- .../db/migrations/meta/0000_snapshot.json | 124 ++++++++++++++++++ .../pg-test/db/migrations/meta/_journal.json | 13 ++ playground/pg-test/db/schema.ts | 4 +- .../db/migrations/meta/001_snapshot.json | 57 ++++++++ .../db/migrations/meta/_journal.json | 13 ++ playground/react-pages/db/schema.ts | 2 + .../db/migrations/meta/001_snapshot.json | 55 ++++++++ .../db/migrations/meta/_journal.json | 13 ++ playground/tanstack-start/db/schema.ts | 2 +- .../db/migrations/meta/001_snapshot.json | 55 ++++++++ .../db/migrations/meta/_journal.json | 13 ++ playground/vinext-app/db/schema.ts | 2 +- .../db/migrations/meta/001_snapshot.json | 55 ++++++++ .../db/migrations/meta/_journal.json | 13 ++ playground/vinext-pages/db/schema.ts | 2 +- .../db/migrations/meta/001_snapshot.json | 57 ++++++++ .../db/migrations/meta/_journal.json | 13 ++ playground/vue-pages/db/schema.ts | 2 + 21 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 playground/analog/db/migrations/meta/001_snapshot.json create mode 100644 playground/analog/db/migrations/meta/_journal.json create mode 100644 playground/pg-test/db/migrations/meta/0000_snapshot.json create mode 100644 playground/pg-test/db/migrations/meta/_journal.json create mode 100644 playground/react-pages/db/migrations/meta/001_snapshot.json create mode 100644 playground/react-pages/db/migrations/meta/_journal.json create mode 100644 playground/tanstack-start/db/migrations/meta/001_snapshot.json create mode 100644 playground/tanstack-start/db/migrations/meta/_journal.json create mode 100644 playground/vinext-app/db/migrations/meta/001_snapshot.json create mode 100644 playground/vinext-app/db/migrations/meta/_journal.json create mode 100644 playground/vinext-pages/db/migrations/meta/001_snapshot.json create mode 100644 playground/vinext-pages/db/migrations/meta/_journal.json create mode 100644 playground/vue-pages/db/migrations/meta/001_snapshot.json create mode 100644 playground/vue-pages/db/migrations/meta/_journal.json 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/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/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/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/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'))`), }); From b9ea2ce15aced71d60b12e7c136fa77bf3c2b1e5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:31:59 +0800 Subject: [PATCH 15/49] Explain how to add Drizzle metadata when deploy finds hand-written migrations When db/migrations has SQL files but no Drizzle journal or snapshot, the drift error told users to run `void db generate`, which cannot work: it emits a migration that creates the existing tables again and fails the fresh-database check. The error now names the missing metadata and the steps to add it, and keeps the generate advice for Drizzle-managed projects. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/deploy-drift.ts | 28 +++++++++++++++-- packages/void/src/cli/deploy-migrations.ts | 2 +- packages/void/test/unit/deploy-drift.test.ts | 32 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) 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-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/test/unit/deploy-drift.test.ts b/packages/void/test/unit/deploy-drift.test.ts index bdf15c953..6eafdf99e 100644 --- a/packages/void/test/unit/deploy-drift.test.ts +++ b/packages/void/test/unit/deploy-drift.test.ts @@ -12,6 +12,7 @@ import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import { generateVoidAuthDrizzleSchema } from '../../src/auth/drizzle.ts'; +import { getDeploySchemaDriftMessage } from '../../src/cli/deploy-drift.ts'; import { hasDeploySchemaDrift, validateAuthMigrationSchema } from '../../src/cli/deploy.ts'; import { writeDrizzleConfig } from '../../src/drizzle/config.ts'; import { collectMigrations } from '../../src/migrations/collect.ts'; @@ -166,6 +167,37 @@ describe('hasDeploySchemaDrift', { timeout: driftTestTimeout }, () => { } }); + it('explains how to add Drizzle metadata when SQL migrations have none', () => { + const root = createTempApp(); + writeSqliteSchema(root); + mkdirSync(join(root, 'db', 'migrations'), { recursive: true }); + writeFileSync( + join(root, 'db', 'migrations', '001_create_users.sql'), + 'CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);\n', + ); + + expect(hasDeploySchemaDrift(root, 'sqlite')).toBe(true); + + const message = getDeploySchemaDriftMessage(root); + expect(message).toContain('db/migrations/ has SQL migrations without Drizzle metadata'); + expect(message).toContain('`void db generate` cannot fix this'); + expect(message).toContain('scratch copy'); + expect(message).toContain('meta/_journal.json'); + expect(message).not.toContain('Run `void db generate`, review'); + }); + + it('keeps the generate advice when Drizzle metadata exists', () => { + const root = createTempApp(); + writeSqliteSchema(root); + generateMigrations(root); + writeSqliteSchema(root, { includeEmail: true }); + + expect(hasDeploySchemaDrift(root, 'sqlite')).toBe(true); + expect(getDeploySchemaDriftMessage(root)).toContain( + 'Run `void db generate`, review the generated migration', + ); + }); + it('throws when drizzle-kit cannot evaluate the schema', () => { const root = createTempApp(); writeFileSync( From 210342a42db54f7856c170ec6502f532ab3e38d7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:48:05 +0800 Subject: [PATCH 16/49] Give the 101-project Zero Trust disable test room under a loaded machine The test drives two full project batches and passes in about 1.5s alone, but took 10.4-14.9s when the platform API suite ran alongside other test runs, so its 10s limit timed it out. Under that load it timed out in 8 of 9 runs before and 0 of 9 after (slowest 15.5s). This matches the 30s limit the 103-project domain activation test already uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/test/integration/zero-trust-disable-bypass.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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'); From 445bf274ec7f5257daa4e37d6ddf932b44168c3b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:57:01 +0800 Subject: [PATCH 17/49] Keep the runtime token on All Domains when Void creates the zone The runtime token prompt told installers to switch to Specified Domains even when the install plan creates the zone, which cannot be selected before it exists. It now keeps All Domains in that case so Cache Purge reaches the new zone, matching the management token prompt and the credentials guide. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../platform/installation/credentials.md | 2 +- packages/void/src/cli/platform-setup.ts | 28 +++++++++++++------ .../void/test/unit/platform-setup.test.ts | 22 +++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index c3ada9ab2..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, 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 the management token. Save the tokens 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 diff --git a/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index d4201adc1..d3968c271 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -95,13 +95,15 @@ 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 = @@ -201,14 +203,22 @@ export function platformSetupPages(plan: PlatformInstallPlan): { url: 'https://dash.cloudflare.com/?' + query, instructions: [ 'Create a Cloudflare runtime token that Void can use to manage the platform. Please review the permissions and then paste the token here.', - ...(zoneName + // A zone that Void creates later cannot be selected yet; All Domains + // lets Cache Purge reach it once it exists. + ...(zoneName && plansZoneCreation(plan) ? [ - specifiedDomainsInstruction([ - zoneName, - ...(plan.deployment.capabilities.email ? ['every zone that will carry mail'] : []), - ]), + `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: { diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 7c7dfb9f5..489f454d7 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -424,6 +424,28 @@ describe('platform credential setup links', () => { '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(() => { throw new Error('no browser'); From 34d7387c9d4d3bfbd4eaee932b3a04f9e60427fd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 5 Oct 2026 23:57:02 +0800 Subject: [PATCH 18/49] Say that a custom live stream directory must keep the default scan list Setting inference.scanDirs replaces the default directories, so the live guide now tells users to list the defaults they still use alongside lib/. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/live.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/live.md b/docs/guide/live.md index 380ce158e..f89301623 100644 --- a/docs/guide/live.md +++ b/docs/guide/live.md @@ -22,7 +22,7 @@ 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. A module in an unscanned directory such as `lib/` needs that directory in [`inference.scanDirs`](../reference/resource-inference.md#custom-scan-directories). +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: From 89dd0caf81b6fa515fadb7b2d584a498cd007eb9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 00:17:08 +0800 Subject: [PATCH 19/49] Read the queue consumer Worker from script_name during --purge-all Cloudflare names a Worker queue consumer in script_name. Teardown only read script and service, so it skipped the installation's own consumers and then failed to delete their Workers and Queues. Read script_name first, keep the older names Wrangler still reads, and refuse a Worker consumer that has no name instead of skipping it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-cloudflare.ts | 16 +++++--- .../test/unit/platform-cloudflare.test.ts | 38 +++++++++++++++++-- .../void/test/unit/platform-teardown.test.ts | 35 ++++++++++++++++- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index 3709adbe2..97a4b3493 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -94,7 +94,7 @@ export type CloudflareWorkerServiceRoute = Readonly<{ }>; export type CloudflareQueueConsumer = Readonly<{ id: string; - /** Worker that consumes the Queue; null for an HTTP pull consumer. */ + /** Worker that consumes the Queue (`script_name`); null for an HTTP pull consumer. */ script: string | null; }>; export type PlatformAccessResourceKind = 'application' | 'service-token'; @@ -1132,12 +1132,16 @@ export class PlatformCloudflareClient { ); 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: - typeof entry.script === 'string' - ? entry.script - : typeof entry.service === 'string' - ? entry.service - : null, + entry.type === 'http_pull' + ? null + : requiredString( + entry.script_name ?? entry.script ?? entry.service, + 'Queue consumer Worker name', + ), })); } diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index 16d22bfdf..e135f9769 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -934,9 +934,20 @@ describe('platform Cloudflare client', () => { return Response.json({ success: true, result: [ - { consumer_id: 'c1', script: 'void-team-api', type: 'worker' }, - { consumer_id: 'c2', service: 'other', type: 'worker' }, - { consumer_id: 'c3', type: 'http_pull' }, + { + 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' }, ], }); } @@ -995,6 +1006,27 @@ describe('platform Cloudflare client', () => { expect(requests.some((request) => request.includes('force'))).toBe(false); }); + 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 = []; diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index 79dad6c89..d9be67073 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -18,7 +18,10 @@ import { } 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 } from '../../src/cli/platform-cloudflare.ts'; +import { + PlatformCloudflareApiError, + PlatformCloudflareClient, +} from '../../src/cli/platform-cloudflare.ts'; import { uninstallPlatform } from '../../src/cli/platform-management.ts'; import { listInstallationManifests, @@ -685,6 +688,36 @@ describe('uninstall --purge-all', () => { 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, purgeAllArgs, 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); From 1ea4335f07a94d0e7db7b9f396573aba6d25c5bf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 00:19:58 +0800 Subject: [PATCH 20/49] Refuse new projects once purge-all passes its final project check Project creation now takes the shared configuration lock that `void platform uninstall` holds across its last project check. A create request admitted before traffic was disabled either commits before that check, so --purge-all refuses before changing anything, or waits and is refused with 409 once the uninstall is recorded. Before, such a project could commit right after the check and was only noticed after traffic, Access, Workers and Queues were already deleted. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-management.ts | 7 +- packages/void/src/cli/platform-teardown.ts | 5 +- .../src/cli/platform-zero-trust-uninstall.ts | 5 +- platform/packages/api/src/routes/projects.ts | 14 +++- platform/packages/api/src/zero-trust/lock.ts | 4 +- .../integration/zero-trust-uninstall.test.ts | 68 +++++++++++++++++++ 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index d5fb5fb7f..856a49d45 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -1050,9 +1050,10 @@ 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 + // 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. diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index a68e8fca2..a10c604a6 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -918,8 +918,9 @@ export async function runPurgeAllTeardown( if (!(await prove(current))) { break; } - // A project created after the check before the first change, while - // the platform API still answered, owns an application Worker here. + // 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( 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/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index d9f3d2eb2..b480ad655 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -88,7 +88,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 +743,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-all` 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 +820,9 @@ const app = new Hono() 201, ); } catch (err) { + if (err instanceof ZeroTrustUnavailableError) { + return context.json({ error: err.message }, err.status); + } // Unique constraint violation — slug was taken between check and insert if ( err instanceof Error && diff --git a/platform/packages/api/src/zero-trust/lock.ts b/platform/packages/api/src/zero-trust/lock.ts index b8797e6e2..57d5fe22e 100644 --- a/platform/packages/api/src/zero-trust/lock.ts +++ b/platform/packages/api/src/zero-trust/lock.ts @@ -11,8 +11,8 @@ 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 + * 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 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..16b54702e 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).toContain( + 'The platform is being uninstalled', + ); + expect(await readPlatformProjectSlugsConnection(connection)).toEqual([]); + }); + + it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( + 'keeps a project created during the purge-all 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 () => { From f47ef16c53830e9c5b4902aab451107ac8c80baf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 00:21:25 +0800 Subject: [PATCH 21/49] Warn against running purge-all while the platform's Workers or routes are changing --purge-all proves each resource right before deleting it, but Cloudflare has no conditional delete, so a change made in between is not detected. Say so in the uninstall guide and next to the proof helper. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 2 +- packages/void/src/cli/platform-teardown.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index ad2b04f26..0cb1b8f68 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -63,7 +63,7 @@ Before it changes anything, `--purge-all` 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. +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-all` 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. diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index a10c604a6..1886c60e8 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -732,7 +732,15 @@ export async function runPurgeAllTeardown( }, keys, ); - /** Prove the resource; false when it is already gone (and recorded). */ + /** + * 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-all is opt-in, never + * sends `force`, and its plan lists every resource first. + */ const prove = async (resource: InstallationResource): Promise => { const missing = await verify(new Set(['routing-kv', resource.key])); if (missing.has(resource.key)) { From e54ba5196d7cf50079617a9ec007d8c779826699 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 00:24:36 +0800 Subject: [PATCH 22/49] Tell project creation the platform is being uninstalled Creating a project while `void platform uninstall` is in progress now answers 409 with "The platform is being uninstalled, so new projects cannot be created." instead of the Zero Trust and deployments message shared by other refused requests. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-management.ts | 7 +++---- platform/packages/api/src/routes/projects.ts | 7 ++++++- platform/packages/api/src/zero-trust/lock.ts | 15 ++++++++------- .../test/integration/zero-trust-uninstall.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 856a49d45..556ddc806 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -1053,10 +1053,9 @@ export async function uninstallPlatform( // 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. + // 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( diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index b480ad655..faf0153bc 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -820,8 +820,13 @@ 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: err.message }, err.status); + 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 ( diff --git a/platform/packages/api/src/zero-trust/lock.ts b/platform/packages/api/src/zero-trust/lock.ts index 57d5fe22e..1c53e03a8 100644 --- a/platform/packages/api/src/zero-trust/lock.ts +++ b/platform/packages/api/src/zero-trust/lock.ts @@ -12,13 +12,14 @@ export const PLATFORM_UNINSTALL_SETTING = 'platform.uninstall'; /** * 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. + * 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/zero-trust-uninstall.test.ts b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts index 16b54702e..f23cafc82 100644 --- a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts +++ b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts @@ -260,8 +260,8 @@ describe('platform uninstall check against the API database', () => { platform, ); expect(response.status).toBe(409); - expect(((await response.json()) as { error: string }).error).toContain( - 'The platform is being uninstalled', + 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([]); }); From d16e31de753e1332eefb64aec7e2543681e773ff Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:26:48 +0800 Subject: [PATCH 23/49] Write each user log line once when the dispatch Worker also tails it A tail batch from the dispatch Worker's consumer holds the dispatcher item and the user Worker items it invoked. Each user Worker's own tail consumer already delivers those items, so the tail Worker now writes only the dispatcher items from such batches. Batches without a dispatcher item (including crons, migrations, alarms and queues) are written as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/env.ts | 8 ++- platform/packages/tail/README.md | 4 ++ platform/packages/tail/src/index.ts | 24 ++++--- .../packages/tail/test/tail-dedupe.test.ts | 62 +++++++++++++++++++ 4 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 platform/packages/tail/test/tail-dedupe.test.ts diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index a017e8f38..028728f4d 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -89,9 +89,11 @@ 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, queues). The dispatch + * Worker's own consumer also receives the user items it invoked; the tail + * Worker keeps only the dispatcher items from those batches. * Falls back to `void-tail` when unset. */ TAIL_SERVICE_NAME?: string; diff --git a/platform/packages/tail/README.md b/platform/packages/tail/README.md index e694ff3ab..83c3f4e01 100644 --- a/platform/packages/tail/README.md +++ b/platform/packages/tail/README.md @@ -9,6 +9,9 @@ Tail consumer for deployed user workers. - `projectKey`: `user-{slug}` - `deployId`: `dpl_*` when present - Writes one Analytics Engine row per trace event. +- Skips user Worker items in a batch that also holds a dispatch Worker item. + That batch comes from the dispatch Worker's consumer; each user Worker's own + `tail_consumers` entry already delivers those items. Versioned deploys use script names like `user-my-app-dpl_abc123`. Legacy unversioned names like `user-my-app` are still accepted for older deployments. @@ -32,6 +35,7 @@ Versioned deploys use script names like `user-my-app-dpl_abc123`. Legacy unversi ```text src/index.ts # tail() handler + parseScriptName() test/parse-script-name.test.ts +test/tail-dedupe.test.ts ``` ## Configuration and Deployment diff --git a/platform/packages/tail/src/index.ts b/platform/packages/tail/src/index.ts index 446c0435c..5c61a555e 100644 --- a/platform/packages/tail/src/index.ts +++ b/platform/packages/tail/src/index.ts @@ -39,16 +39,26 @@ export function parseScriptName( return null; } +function isDispatchKey(projectKey: string): boolean { + return projectKey.startsWith('__platform-'); +} + export default { async tail(events: ReadonlyArray, env: Env) { - for (const event of events) { - const scriptName = event.scriptName; - if (!scriptName) { - continue; - } + const items = events.flatMap((event) => { + const parsed = event.scriptName + ? parseScriptName(event.scriptName, env.PLATFORM_DISPATCH_WORKER_NAME) + : null; + return parsed ? [{ event, parsed }] : []; + }); + // A batch from the dispatch Worker's consumer holds the dispatcher item + // plus the user Worker items it invoked. Each user Worker also has its own + // tail consumer, which delivers those same items, so keep only the + // dispatcher items here to avoid writing every user log line twice. + const fromDispatch = items.some(({ parsed }) => isDispatchKey(parsed.projectKey)); - const parsed = parseScriptName(scriptName, env.PLATFORM_DISPATCH_WORKER_NAME); - if (!parsed) { + for (const { event, parsed } of items) { + if (fromDispatch && !isDispatchKey(parsed.projectKey)) { continue; } diff --git a/platform/packages/tail/test/tail-dedupe.test.ts b/platform/packages/tail/test/tail-dedupe.test.ts new file mode 100644 index 000000000..f2e40b00a --- /dev/null +++ b/platform/packages/tail/test/tail-dedupe.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vite-plus/test'; +import worker from '../src/index.ts'; + +function traceItem(scriptName: string, url: string): TraceItem { + return { + scriptName, + outcome: 'ok', + eventTimestamp: 1, + event: { request: { url, method: 'GET' }, response: { status: 200 } }, + logs: [], + exceptions: [], + } as unknown as TraceItem; +} + +function run(events: Array, dispatchName?: string) { + const writeDataPoint = vi.fn(); + const env = { + ANALYTICS: { writeDataPoint } as unknown as AnalyticsEngineDataset, + PLATFORM_DISPATCH_WORKER_NAME: dispatchName, + }; + return worker.tail(events, env).then(() => writeDataPoint.mock.calls.map((call) => call[0])); +} + +describe('tail batch dedupe', () => { + it('writes only the dispatcher item when the batch comes from the dispatch Worker', async () => { + const points = await run( + [ + traceItem('void-dispatch-e2e2', 'https://app.example.com/'), + traceItem('user-app-dpl_abc', 'https://app.example.com/'), + ], + 'void-dispatch-e2e2', + ); + expect(points.map((point) => point.indexes[0])).toEqual(['__platform-dispatch']); + }); + + it('recognizes the default dispatch Worker names', async () => { + const points = await run([ + traceItem('void-dispatch-staging', 'https://app.example.com/'), + traceItem('user-app-dpl_abc', 'https://app.example.com/'), + ]); + expect(points.map((point) => point.indexes[0])).toEqual(['__platform-dispatch-staging']); + }); + + it('writes a user item delivered by its own tail consumer', async () => { + const points = await run( + [traceItem('user-app-dpl_abc', 'https://app.example.com/')], + 'void-dispatch-e2e2', + ); + expect(points.map((point) => point.indexes[0])).toEqual(['user-app']); + }); + + it('writes every user item when the batch has no dispatcher item', async () => { + const points = await run( + [ + traceItem('user-app-dpl_abc', 'https://app.example.com/a'), + traceItem('user-other-dpl_def', 'https://other.example.com/b'), + ], + 'void-dispatch-e2e2', + ); + expect(points.map((point) => point.indexes[0])).toEqual(['user-app', 'user-other']); + }); +}); From 66cf307afd01fbde416f0530e4df4bdb96b74c97 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:28:02 +0800 Subject: [PATCH 24/49] Store platform ISR edge copies as cache hits On an ISR miss the dispatch Worker wrote its HTML and JSON edge copies with `x-isr-cache: MISS`, so every later request served from the edge still reported MISS. The edge copies now say HIT, matching the direct runtime; only the response that triggered the render reports MISS. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/dispatch/src/index.ts | 10 ++++-- .../packages/dispatch/test/unit/index.test.ts | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) 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, From c1443044f262c5253a6bfb8033c9ca68ddc346bb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:35:54 +0800 Subject: [PATCH 25/49] Run legacy multi-statement migrations statement by statement D1 exec() treats each line as its own statement, so a multi-statement migration without Drizzle breakpoints failed on any multi-line statement such as CREATE TABLE. Legacy migrations now split on top-level semicolons (ignoring strings, quoted identifiers and comments) and run the statements plus the tracking insert in one D1 batch. SQL that may hold a BEGIN ... END body, such as CREATE TRIGGER, still falls back to exec(). The legacy-plan warning now tells users to add `--> statement-breakpoint` between statements, and the playground migrations use breakpoints. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/deploy-core/src/index.ts | 1 + packages/deploy-core/src/migration-plan.ts | 41 ++++++- .../test/unit/migration-plan.test.ts | 47 ++++++++ packages/void/src/migrations/plan.ts | 2 +- .../void/src/runtime/migration-handler.ts | 48 ++++++-- .../migration-binding-consistency.test.ts | 103 +++++++++++++++--- .../test/unit/migration-concurrency.test.ts | 12 +- .../analog/db/migrations/001_create_users.sql | 4 +- .../20260412171331_serious_mindworm.sql | 4 +- .../20260412171408_fancy_vermin.sql | 4 +- .../20260412171408_complete_wildside.sql | 4 +- .../migrations/20260412171409_icy_thundra.sql | 4 +- .../db/migrations/001_create_users.sql | 4 +- .../db/migrations/001_create_users.sql | 4 +- .../db/migrations/001_create_users.sql | 4 +- 15 files changed, 240 insertions(+), 46 deletions(-) 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/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/runtime/migration-handler.ts b/packages/void/src/runtime/migration-handler.ts index bfd5256b5..00babdb18 100644 --- a/packages/void/src/runtime/migration-handler.ts +++ b/packages/void/src/runtime/migration-handler.ts @@ -1,4 +1,4 @@ -import { canonicalJson } from '@void/deploy-core'; +import { canonicalJson, splitSqlStatements } from '@void/deploy-core'; export type MigrationEntry = { name: string; @@ -458,11 +458,34 @@ async function executeMigrationPlan( return; } - await db.exec(migration.sql); - await db + await runUnsplitMigration(db, migration, appliedAt, fencedAttemptId); +} + +/** + * D1 `exec()` treats each line as its own statement, so multi-line statements + * fail there. Run each top-level statement through one batch instead, and keep + * `exec()` only for SQL the splitter cannot divide safely (trigger bodies). + */ +async function runUnsplitMigration( + db: D1Database, + migration: MigrationEntry, + appliedAt: string, + fencedAttemptId?: string, +): Promise { + const record = db .prepare('INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)') - .bind(migration.name, appliedAt) - .run(); + .bind(migration.name, appliedAt); + const statements = splitSqlStatements(migration.sql); + if (statements) { + await db.batch([ + ...ownershipFence(db, fencedAttemptId), + ...statements.map((statement) => db.prepare(statement)), + record, + ]); + return; + } + await db.exec(migration.sql); + await record.run(); } /** Fail the whole D1 batch if recovery has revoked this executor's ownership. */ @@ -676,18 +699,16 @@ async function runD1LegacyMigrations( if (statements.length === 1) { if (isSingleSqlStatement(statements[0])) { await db.prepare(statements[0]).run(); + await recordLegacyMigration(db, migration.name); } else { - await db.exec(migration.sql); + await runUnsplitMigration(db, migration, new Date().toISOString()); } } else { for (const statement of statements) { await db.prepare(statement).run(); } + await recordLegacyMigration(db, migration.name); } - await db - .prepare('INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)') - .bind(migration.name, new Date().toISOString()) - .run(); migrationResults.push({ name: migration.name, applied: true, @@ -711,6 +732,13 @@ async function runD1LegacyMigrations( return { ok: true, migrations: migrationResults }; } +async function recordLegacyMigration(db: D1Database, name: string): Promise { + await db + .prepare('INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)') + .bind(name, new Date().toISOString()) + .run(); +} + function splitMigrationSql(sql: string): Array { const lines = sql.split(/\r?\n/); if ( diff --git a/packages/void/test/unit/migration-binding-consistency.test.ts b/packages/void/test/unit/migration-binding-consistency.test.ts index 54ce92259..83d322f02 100644 --- a/packages/void/test/unit/migration-binding-consistency.test.ts +++ b/packages/void/test/unit/migration-binding-consistency.test.ts @@ -466,9 +466,15 @@ describe('createMigrationHandler D1 execution', () => { }; } - it('passes the full migration SQL to D1 instead of splitting on semicolons', async () => { - const sql = `CREATE TABLE notes (message TEXT DEFAULT 'a;b');\nINSERT INTO notes DEFAULT VALUES;`; - const { calls, db } = createMockD1(); + it('runs unsplit multi-statement migrations statement by statement in one D1 batch', async () => { + const createTable = ['CREATE TABLE notes (', " message TEXT DEFAULT 'a;b'", ');'].join('\n'); + const sql = `${createTable}\nINSERT INTO notes DEFAULT VALUES;`; + const { calls, db } = createMockD1({ + failExec: (executedSql) => + executedSql.includes('\n') + ? new Error('D1_EXEC_ERROR: Error in line 1: CREATE TABLE notes (: incomplete input') + : undefined, + }); const handler = createMigrationHandler( { __VOID_PROXY_TOKEN: proxyToken, DB: db }, [{ name: '0001_semicolon.sql', sql }], @@ -488,15 +494,80 @@ describe('createMigrationHandler D1 execution', () => { expect(body.migrations).toEqual([ { name: '0001_semicolon.sql', applied: true, durationMs: expect.any(Number) }, ]); + expect(calls.exec).toEqual([]); + expect(calls.batchSql).toEqual([ + [ + createTable, + 'INSERT INTO notes DEFAULT VALUES;', + 'INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)', + ], + ]); + }); + + it('keeps raw exec for legacy trigger migrations that cannot be split safely', async () => { + 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'); + const { calls, db } = createMockD1(); + const handler = createMigrationHandler( + { __VOID_PROXY_TOKEN: proxyToken, DB: db }, + [{ name: '0001_trigger.sql', sql }], + 'sqlite', + ); + + const response = await handler( + new Request('https://example.com/__void/migrate', { + method: 'POST', + headers: { 'x-void-internal': proxyToken }, + }), + ); + const body = (await response.json()) as MigrationResponse; + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); expect(calls.exec).toEqual([sql]); - expect(calls.run).toEqual([ - `CREATE TABLE IF NOT EXISTS _void_migrations ( - name TEXT PRIMARY KEY, - applied_at TEXT NOT NULL - )`, + expect(calls.batch).toBe(0); + }); + + it('runs protocol v2 legacy multi-statement migrations through one D1 batch', async () => { + const createTable = ['CREATE TABLE notes (', ' id INTEGER PRIMARY KEY', ');'].join('\n'); + const sql = `-- void:allow-unsplit-multi-statement\n${createTable}\nINSERT INTO notes DEFAULT VALUES;`; + const migrations = [{ name: '0001_legacy.sql', sql }]; + const { calls, db } = createMockD1({ + failExec: (executedSql) => + executedSql.includes('\n') + ? new Error('D1_EXEC_ERROR: Error in line 1: CREATE TABLE notes (: incomplete input') + : undefined, + }); + const handler = createMigrationHandler( + { __VOID_PROXY_TOKEN: proxyToken, DB: db }, + migrations, + 'sqlite', + undefined, + buildMigrationPlanBundle(migrations), + ); + + const response = await handler( + new Request('https://example.com/__void/migrate/app', { + body: JSON.stringify({ attemptId: 'attempt_legacy', deploymentId: 'dpl_1' }), + headers: { 'content-type': 'application/json', 'x-void-internal': proxyToken }, + method: 'POST', + }), + ); + const body = (await response.json()) as MigrationResponse; + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.confirmedApplied).toEqual(['0001_legacy.sql']); + expect(calls.exec).toEqual([]); + expect(calls.batchSql).toContainEqual([ + `-- void:allow-unsplit-multi-statement\n${createTable}`, + 'INSERT INTO notes DEFAULT VALUES;', 'INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)', ]); - expect(calls.batch).toBe(0); }); it('applies scaffolded multiline single-statement D1 migrations without raw exec', async () => { @@ -613,13 +684,13 @@ describe('createMigrationHandler D1 execution', () => { expect(response.status).toBe(200); expect(body.ok).toBe(true); - expect(calls.exec).toEqual([sql]); - expect(calls.run).toEqual([ - `CREATE TABLE IF NOT EXISTS _void_migrations ( - name TEXT PRIMARY KEY, - applied_at TEXT NOT NULL - )`, - 'INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)', + expect(calls.exec).toEqual([]); + expect(calls.batchSql).toEqual([ + [ + "CREATE TABLE notes (message TEXT DEFAULT '--> statement-breakpoint');", + "INSERT INTO notes VALUES ('literal --> statement-breakpoint value');", + 'INSERT OR IGNORE INTO _void_migrations (name, applied_at) VALUES (?, ?)', + ], ]); }); diff --git a/packages/void/test/unit/migration-concurrency.test.ts b/packages/void/test/unit/migration-concurrency.test.ts index 5c4cd8563..662651890 100644 --- a/packages/void/test/unit/migration-concurrency.test.ts +++ b/packages/void/test/unit/migration-concurrency.test.ts @@ -26,7 +26,6 @@ function createDatabase( hooks: { beforeBatch?: (sql: Array) => Promise; afterLockWrite?: () => Promise; - beforeExec?: () => Promise; } = {}, ) { const sqlite = new DatabaseSync(':memory:'); @@ -61,7 +60,6 @@ function createDatabase( const db = { prepare, async exec(sql: string) { - await hooks.beforeExec?.(); sqlite.exec(sql); }, async batch(statements: Array>) { @@ -148,13 +146,15 @@ describe('D1 migration ownership', () => { ).toBe('unknown'); }); - it('keeps the lock while a legacy raw execution is in flight', async () => { + it('keeps the lock while an unfenced legacy migration is in flight', async () => { const paused = deferred(); const resume = deferred(); const { sqlite, handler } = createDatabase({ - beforeExec: async () => { - paused.resolve(); - await resume.promise; + beforeBatch: async (sql) => { + if (sql.includes('UPDATE balance SET amount = amount - 10;')) { + paused.resolve(); + await resume.promise; + } }, }); const owner = handler([ 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/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/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/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-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'); From b68a97980d838d1a252c9986d2e525c0b593ab61 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:36:45 +0800 Subject: [PATCH 26/49] Show migration warnings after deploy and describe the migration check accurately The CLI dropped migration_warning events, so warnings from the migration runner never reached the user. They now print with log.warn, which stays visible above the spinner. The migration_start count covers every bundled migration, not only the unapplied ones, so the spinner now reads "Checking N bundled migration(s)..." instead of "Running N migration(s)...". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/src/cli/deploy-managed-events.ts | 9 ++++++- packages/void/test/unit/deploy-events.test.ts | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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/test/unit/deploy-events.test.ts b/packages/void/test/unit/deploy-events.test.ts index 94341663d..20f3c3f61 100644 --- a/packages/void/test/unit/deploy-events.test.ts +++ b/packages/void/test/unit/deploy-events.test.ts @@ -121,6 +121,32 @@ describe('applyDeployEvent', () => { expect(calls.message).toHaveLength(0); }); + it('logs migration_warning above the spinner so it stays visible after deploy', async () => { + const { spinner, calls } = createMockSpinner(); + const { log } = await import('@voidzero-dev/vite-plus-prompts'); + const warnSpy = log.warn as ReturnType; + warnSpy.mockClear(); + + const result = applyDeployEvent( + { event: 'migration_warning', message: 'Migration 0002 was edited after apply' }, + spinner, + ); + + expect(result).toEqual({ kind: 'continue' }); + expect(warnSpy).toHaveBeenCalledExactlyOnceWith('Migration 0002 was edited after apply'); + expect(calls.message).toHaveLength(0); + }); + + it('describes migration_start as checking bundled migrations, not running them', () => { + const { spinner, calls } = createMockSpinner(); + applyDeployEvent({ event: 'migration_start', pending: 1 }, spinner); + applyDeployEvent({ event: 'migration_start', pending: 3 }, spinner); + expect(calls.message).toEqual([ + 'Checking 1 bundled migration...', + 'Checking 3 bundled migrations...', + ]); + }); + it('returns {kind:"start"} with deploymentId so the caller can capture it', () => { const { spinner } = createMockSpinner(); const result = applyDeployEvent( From 7bd7095738d3e65134ac3d5f9f58e1b4cb8a31a2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:43:07 +0800 Subject: [PATCH 27/49] Build the vinext worker entry regardless of plugin order With `[voidPlugin(), vinext()]`, vinext's RSC entry replaced the Cloudflare worker entry for the rsc environment, so the build shipped vinext's bare handler instead of Void's wrapper and the worker could not serve requests. Void now restores the Cloudflare worker entry after all plugin configs are applied. The deploy wrapper's readiness probe now answers 503 with ready:false when the wrapped entry has no fetch handler, so such a build is not promoted. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/wrapper.ts | 8 ++ packages/void/src/index.ts | 27 ++++++ .../void/test/integration/vinext-app.test.ts | 86 ++++++++++++++++++- packages/void/test/unit/wrapper.test.ts | 17 ++++ 4 files changed, 137 insertions(+), 1 deletion(-) 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/test/integration/vinext-app.test.ts b/packages/void/test/integration/vinext-app.test.ts index 1ffca2dc8..ad11face1 100644 --- a/packages/void/test/integration/vinext-app.test.ts +++ b/packages/void/test/integration/vinext-app.test.ts @@ -1,6 +1,8 @@ +import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import spawn from 'cross-spawn'; -import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vite-plus/test'; import { createIsolatedPlayground, getPort, @@ -9,6 +11,7 @@ import { collectServerOutput, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; +import { collectWorkerFiles } from '../../src/cli/package.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const PLAYGROUND = 'playground/vinext-app'; @@ -67,3 +70,84 @@ describe('playground/vinext-app dev', () => { expect(html).toContain('ok'); }); }); + +describe('playground/vinext-app build', () => { + const playgrounds: Array = []; + let runtime: Miniflare | undefined; + + afterEach(async () => { + await runtime?.dispose(); + runtime = undefined; + }); + + afterAll(() => { + for (const playground of playgrounds.splice(0)) { + playground.cleanup(); + } + }); + + it.each([['voidPlugin(), vinext()'], ['vinext(), voidPlugin()']])( + 'builds the Void worker entry with plugins [%s]', + async (order) => { + const playground = createIsolatedPlayground(ROOT, PLAYGROUND); + playgrounds.push(playground); + writeFileSync( + join(playground.root, 'vite.config.ts'), + `import vinext from 'vinext'; +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [${order}], +}); +`, + ); + const result = await new Promise<{ status: number | null; output: string }>( + (resolve, reject) => { + const child = spawn('vp', ['build'], { + cwd: playground.root, + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout!.on('data', (chunk: Buffer) => (output += String(chunk))); + child.stderr!.on('data', (chunk: Buffer) => (output += String(chunk))); + child.on('error', reject); + child.on('close', (status) => resolve({ status, output })); + }, + ); + expect(result.status, result.output).toBe(0); + + // A worker whose entry is vinext's bare RSC handler (a function default + // export) never reaches Void's wrapper and cannot serve requests. + const serverDir = join(playground.root, 'dist', 'server'); + const config = JSON.parse(readFileSync(join(serverDir, 'wrangler.json'), 'utf8')); + const files = collectWorkerFiles(serverDir); + const modulePaths = [ + config.main, + ...Array.from(files.keys()).filter((path) => path !== config.main), + ]; + runtime = new Miniflare( + convertV4MiniflareOptions({ + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + modules: modulePaths + .filter((path) => /\.(?:js|mjs)$/.test(path)) + .map((path) => ({ + type: 'ESModule', + path: join(serverDir, path), + contents: files.get(path)!, + })), + modulesRoot: serverDir, + d1Databases: ['DB'], + kvNamespaces: ['KV'], + }), + ); + const response = await runtime.dispatchFetch('http://localhost/'); + const html = await response.text(); + expect(response.status, html.slice(0, 500)).toBe(200); + expect(html).toContain('vinext App Router + Void'); + }, + 180_000, + ); +}); 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); From ba4b877bfe8cefb8750e097851d27b9c6655af42 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:45:42 +0800 Subject: [PATCH 28/49] Name the DNS permission when the install plan cannot read DNS records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Cloudflare answers 401 or 403 while the install plan reads DNS records for the application wildcard or the custom API hostname, report which zone was denied and that the management token needs Zone → DNS → Edit, instead of surfacing the raw "Authentication error". Other errors pass through. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-plan.ts | 25 +++++++++++-- packages/void/test/unit/platform-plan.test.ts | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) 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/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'); From b163a16bafcc399ca59f7558236d20534c78a3cb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:49:59 +0800 Subject: [PATCH 29/49] Keep the platform tail worker off workers.dev and preview URLs The tail worker only receives trace events from the dispatch worker, so it no longer publishes a workers.dev subdomain or preview URLs when platform endpoints are public. Upgrades turn off an existing tail subdomain because the live subdomain no longer matches the desired trigger state. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/platform/src/worker-config.ts | 3 ++ .../platform/test/unit/worker-config.test.ts | 39 +++++++++++++++++++ .../void/test/unit/platform-installer.test.ts | 15 +++++-- .../test/unit/platform-trigger-proof.test.ts | 11 ++++++ 4 files changed, 65 insertions(+), 3 deletions(-) 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/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-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( From 02f1b5a8778b4b10fb16bae2a3d10fea756576ad Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:52:56 +0800 Subject: [PATCH 30/49] Report the deployed asset count without the internal cache-buster Worker deploys add a per-deploy cache-buster file to the asset manifest sent to Cloudflare. The asset_upload event and the final `assets` count included it, so a deploy of N files reported N + 1. Report the count of the user's assets instead, and keep the internal count for gating Cloudflare asset work. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/deploy/pipeline.ts | 7 ++++++- .../packages/api/src/deploy/publish-worker.ts | 4 +++- .../packages/api/src/deploy/upload-worker.ts | 6 ++++-- .../api/test/integration/api-deploy.test.ts | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) 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/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'); From b066962f5d8eaff5f3bb37087014d657182d5b8c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:53:56 +0800 Subject: [PATCH 31/49] Show per-path ISR rules in the project status ISR column `void project status` showed `-` in the isr column for deployments that only configure per-path revalidation. The column now shows the global revalidate interval when set, otherwise the number of per-path rules (for example `1 rule`), and `-` only when ISR is not configured. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/list.ts | 9 ++- packages/void/test/unit/list-command.test.ts | 60 +++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) 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/test/unit/list-command.test.ts b/packages/void/test/unit/list-command.test.ts index 358d5d456..08a3d9e1b 100644 --- a/packages/void/test/unit/list-command.test.ts +++ b/packages/void/test/unit/list-command.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; @@ -9,6 +9,7 @@ const mockGetToken = vi.fn<() => string | null>(); vi.mock('../../src/cli/auth', async (importOriginal) => ({ ...(await importOriginal()), getToken: () => mockGetToken(), + getApiUrl: () => 'https://api.example.test', })); function setIsolatedHome(): void { @@ -105,4 +106,61 @@ describe('runListCommand', () => { expect(out).toContain('No linked project found'); expect(exitSpy).toHaveBeenCalled(); }); + + it('summarizes per-path revalidation rules in the isr column', async () => { + setIsolatedHome(); + mockGetToken.mockReturnValue('test-token'); + const root = mkdtempSync(join(tmpdir(), 'void-list-')); + tempHomes.push(root); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ + projectId: 'proj-1', + slug: 'demo', + }), + ); + const deployment = (id: string, manifest: Record) => ({ + id, + status: 'live', + source: 'cli', + commit: null, + manifest: JSON.stringify(manifest), + error_message: null, + retained: true, + applied_migrations: null, + created_at: '2026-01-01T00:00:00Z', + deployed_at: '2026-01-01T00:00:00Z', + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + deployments: [ + deployment('dep-one-rule', { type: 'ssr', pathRevalidate: { '/a': 60 } }), + deployment('dep-two-rules', { type: 'ssr', pathRevalidate: { '/a': 60, '/b': 30 } }), + deployment('dep-global', { + type: 'ssr', + revalidate: 120, + pathRevalidate: { '/a': 60 }, + }), + deployment('dep-none', { type: 'ssr' }), + ], + activeDeploymentId: 'dep-one-rule', + }), + ), + ); + + const capture = captureOutput(); + const runListCommand = await importRunListCommand(); + await runListCommand(root); + const out = capture.output(); + capture.restore(); + + const rowFor = (id: string) => out.split('\n').find((line) => line.includes(id)) ?? ''; + expect(rowFor('dep-one-rule')).toMatch(/\b1 rule\b/); + expect(rowFor('dep-two-rules')).toMatch(/\b2 rules\b/); + expect(rowFor('dep-global')).toMatch(/\b120s\b/); + expect(rowFor('dep-none')).not.toMatch(/rule|\d+s\b/); + }); }); From b6a29f9a1b20913f2a446bdd1c4590ffd8fb66d3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:54:42 +0800 Subject: [PATCH 32/49] Give each concurrent deploy its own log file Deploy log names only carried a millisecond timestamp and were opened in append mode, so two deploys started at the same moment on one machine wrote interleaved records into one file. Add the process id and a short random suffix to the name and open it exclusively. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/log-file.ts | 9 +++- .../void/test/unit/deploy-log-file.test.ts | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/void/test/unit/deploy-log-file.test.ts 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/test/unit/deploy-log-file.test.ts b/packages/void/test/unit/deploy-log-file.test.ts new file mode 100644 index 000000000..49e752372 --- /dev/null +++ b/packages/void/test/unit/deploy-log-file.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; + +const home = vi.hoisted(() => ({ dir: '' })); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => home.dir }; +}); + +const { openDeployLog } = await import('../../src/cli/log-file.ts'); + +describe('openDeployLog', () => { + beforeEach(() => { + home.dir = mkdtempSync(join(tmpdir(), 'void-deploy-log-')); + }); + + afterEach(() => { + vi.useRealTimers(); + rmSync(home.dir, { recursive: true, force: true }); + }); + + // Two deploys started at the same moment (for example two apps deployed in + // parallel from one machine) must not interleave their records in one file. + it('gives loggers opened in the same millisecond their own files', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-06T00:00:00.000Z')); + + const first = openDeployLog(); + const second = openDeployLog(); + + expect(first.path).not.toBeNull(); + expect(second.path).not.toBeNull(); + expect(first.path).not.toBe(second.path); + expect(first.path).toContain(String(process.pid)); + + first.info('first_only'); + second.info('second_only'); + await first.close(); + await second.close(); + + const firstText = readFileSync(first.path!, 'utf8'); + const secondText = readFileSync(second.path!, 'utf8'); + expect(firstText).toContain('first_only'); + expect(firstText).not.toContain('second_only'); + expect(secondText).toContain('second_only'); + expect(secondText).not.toContain('first_only'); + }); +}); From 4134a65d38aee52467ca770318d6aa55f9651824 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:58:27 +0800 Subject: [PATCH 33/49] Report a disabled platform clearly in platform status and operator commands `void platform status` now prints that the platform is disabled and the `void platform enable` command when discovery returns the traffic gate's 503 response, instead of exiting with a raw discovery error. Operator requests that fail without a message now include the API error, so a disabled platform reads "Platform disabled. Run `void platform enable`." Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/operator-client.ts | 4 +- packages/void/src/cli/platform-cmd.ts | 9 +++- packages/void/src/cli/platform-discovery.ts | 24 ++++++++++- packages/void/test/unit/operator-cli.test.ts | 20 +++++++++ .../void/test/unit/platform-connect.test.ts | 43 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/void/src/cli/operator-client.ts b/packages/void/src/cli/operator-client.ts index ade837f95..a4187bb29 100644 --- a/packages/void/src/cli/operator-client.ts +++ b/packages/void/src/cli/operator-client.ts @@ -136,7 +136,9 @@ export class OperatorClient { throw new OperatorClientError( typeof result.message === 'string' ? result.message - : `Operator request failed (HTTP ${response.status}).`, + : typeof result.error === 'string' + ? `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/platform-cmd.ts b/packages/void/src/cli/platform-cmd.ts index 2d6c310ca..2a4ec3c95 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,6 +195,13 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro try { discovery = await discoverPlatform(apiUrl); } catch (error) { + if (error instanceof PlatformDisabledError) { + 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.', diff --git a/packages/void/src/cli/platform-discovery.ts b/packages/void/src/cli/platform-discovery.ts index 026e2c504..990f652a5 100644 --- a/packages/void/src/cli/platform-discovery.ts +++ b/packages/void/src/cli/platform-discovery.ts @@ -1,14 +1,34 @@ 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 { + constructor(message: string) { + super(message); + this.name = 'PlatformDisabledError'; + } +} + /** 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); + } + } else { + await response.body?.cancel(); + } + throw new Error(message); } let value: unknown; try { diff --git a/packages/void/test/unit/operator-cli.test.ts b/packages/void/test/unit/operator-cli.test.ts index 64f353c9e..c5716c84c 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -799,6 +799,26 @@ describe('operator transport and execution', () => { ).rejects.toMatchObject({ code: 'partial_failure', details }); expect(fetcher).toHaveBeenCalledTimes(1); }); + it('names a disabled platform and how to restore it', async () => { + const fetcher = vi + .fn() + .mockResolvedValue(Response.json({ error: 'Platform disabled' }, { status: 503 })); + await expect( + new OperatorClient('https://one.example', 'operator', fetcher).request('/users'), + ).rejects.toMatchObject({ + code: 'Platform disabled', + status: 503, + message: 'Operator request failed (HTTP 503): Platform disabled. Run `void platform enable`.', + }); + }); + it('includes the error code when the API sends no message', async () => { + const fetcher = vi + .fn() + .mockResolvedValue(Response.json({ error: 'not_found' }, { status: 404 })); + await expect( + new OperatorClient('https://one.example', 'operator', fetcher).request('/users/x'), + ).rejects.toMatchObject({ message: 'Operator request failed (HTTP 404): not_found.' }); + }); it('uses short read deadlines, longer mutation deadlines, and bounded overrides', async () => { vi.useFakeTimers(); const fetcher = vi.fn().mockImplementation(async (_input, init) => { diff --git a/packages/void/test/unit/platform-connect.test.ts b/packages/void/test/unit/platform-connect.test.ts index e19d8d2da..2bea6a15a 100644 --- a/packages/void/test/unit/platform-connect.test.ts +++ b/packages/void/test/unit/platform-connect.test.ts @@ -87,6 +87,49 @@ 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('accepts a valid document from its declared origin', async () => { const response = new Response(JSON.stringify(document)); Object.defineProperty(response, 'url', { From 49e1a3295f3bec24ab424991dbc99f10079ac6c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 03:59:17 +0800 Subject: [PATCH 34/49] Describe the platform disable plan in plain terms The disable plan now says it blocks all platform traffic and the platform API, and that routes, custom domains, Workers, and data stay installed. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-management.ts | 2 +- packages/void/test/unit/platform-management.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 556ddc806..e2320addd 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -811,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) { diff --git a/packages/void/test/unit/platform-management.test.ts b/packages/void/test/unit/platform-management.test.ts index 668fd1431..2220e291c 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -1169,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 }; From fa26db44322bbf260a48a43f36b056b25da01fa0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:00:48 +0800 Subject: [PATCH 35/49] Document platform disable scope, enable redeploy, SPA asset 404s, and CI project selection Disable also blocks the platform API and CLI, leaving only the administrator status page and sign-in. Enable redeploys and verifies the platform Workers before restoring traffic. On a Void platform, SPAs return 404 for missing files under the hashed assets folder. CI deploys must choose a project with VOID_PROJECT, --project, or a committed .void/project.json. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/app-types.md | 2 +- .../platform/installation/first-deployment.md | 4 +++- docs/guide/platform/installation/uninstall.md | 4 +++- docs/reference/cli/platform-installation.md | 18 +++++++++--------- 4 files changed, 16 insertions(+), 12 deletions(-) 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/platform/installation/first-deployment.md b/docs/guide/platform/installation/first-deployment.md index 8d43e8135..a05a47806 100644 --- a/docs/guide/platform/installation/first-deployment.md +++ b/docs/guide/platform/installation/first-deployment.md @@ -34,7 +34,9 @@ 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`. 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/uninstall.md b/docs/guide/platform/installation/uninstall.md index 0cb1b8f68..0af62d969 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 diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 88ac15be9..0767a75f7 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -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. From 782516baa7f701a396d74fd1b754566b5ea4bba1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:01:47 +0800 Subject: [PATCH 36/49] Return ISO-8601 timestamps from project token endpoints Token create, list, and renew responses passed Postgres timestamp strings through unchanged (for example "2026-10-06 04:01:25.899+08"). They now return UTC ISO-8601 strings; a missing revocation time stays null. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/api/src/routes/project-tokens.ts | 6 +-- .../api/test/integration/api-projects.test.ts | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) 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/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index e42cec57e..532c78674 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', () => { From 0856cf2389c678b09d58e1307213ae84f96dd721 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:06:28 +0800 Subject: [PATCH 37/49] Show readable labels in operator output and platform auth config Operator human output now uses sentence-case field labels that keep known acronyms (API version, GitHub OAuth, Callback URL, Installation ID). `void platform config auth` commands print the same readable text by default and keep JSON for --json. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/operator-cmd.ts | 31 +++++++++-- packages/void/src/cli/platform-auth-config.ts | 23 ++++---- packages/void/test/unit/operator-cli.test.ts | 54 +++++++++++++++---- .../test/unit/platform-auth-config.test.ts | 15 ++++++ 4 files changed, 101 insertions(+), 22 deletions(-) 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/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 { .mocked(console.log) .mock.calls.map(([line]) => String(line)) .join('\n'); - expect(printed).toContain('needs Migration: 2'); - expect(printed).toContain('invalid: false'); - expect(printed).toContain('bundle Stored: true'); - expect(printed).toContain('total Pages: 2'); + expect(printed).toContain('Needs migration: 2'); + expect(printed).toContain('Invalid: false'); + expect(printed).toContain('Bundle stored: true'); + expect(printed).toContain('Total pages: 2'); + expect(printed).toContain('Deployments:'); } }, ); @@ -1298,11 +1299,46 @@ describe('operator transport and execution', () => { await runOperatorCommand(parseOperatorArgs(['system', 'events'])); const rendered = write.mock.calls.map(([line]) => String(line)).join('\n'); - expect(rendered).toContain('version id: version-failed'); - expect(rendered).toContain('error message: Version no longer exists.'); - expect(rendered).toContain('audit Error: Could not persist the outcome.'); - expect(rendered).toContain('action: workers/api/rollback'); - expect(rendered).toContain('target: {"worker":"api","versionId":"version-failed"}'); + expect(rendered).toContain('Version ID: version-failed'); + expect(rendered).toContain('Error message: Version no longer exists.'); + expect(rendered).toContain('Audit error: Could not persist the outcome.'); + expect(rendered).toContain('Action: workers/api/rollback'); + expect(rendered).toContain('Target: {"worker":"api","versionId":"version-failed"}'); + }); + + it('renders sentence-case field labels that keep known acronyms', async () => { + vi.stubEnv('VOID_API_URL', 'https://one.example'); + vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator'); + const write = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + apiVersion: 3, + githubOAuth: true, + customDomains: false, + callbackUrl: 'https://one.example/auth/callback', + installation_id: 'install-1', + http_status: 200, + capabilities: { workerIds: 2 }, + }), + ), + ); + + await runOperatorCommand(parseOperatorArgs(['system', 'health'])); + + expect(write.mock.calls.map(([line]) => String(line)).join('\n')).toBe( + [ + 'API version: 3', + 'GitHub OAuth: true', + 'Custom domains: false', + 'Callback URL: https://one.example/auth/callback', + 'Installation ID: install-1', + 'HTTP status: 200', + 'Capabilities:', + ' Worker IDs: 2', + ].join('\n'), + ); }); it('follows build log sequence and stops when terminal', async () => { 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( From c8052406d4c7c23d8934f0b8ead926da6a2e974c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:24:37 +0800 Subject: [PATCH 38/49] Delete the platform API and proxy Workers that bind to each other during --purge-all The API Worker checks the proxy through a service binding and the proxy calls the API through another, so Cloudflare refused whichever went first and --purge-all stopped at the proxy Worker. When Cloudflare refuses a platform Worker, purge-all now reads what depends on it the way `wrangler delete` does and forces the DELETE only when every dependent is a Worker this installation created. A Worker outside the installation still stops the run, and the message now names it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-cloudflare.ts | 57 +++++++++++- packages/void/src/cli/platform-management.ts | 2 + packages/void/src/cli/platform-teardown.ts | 58 +++++++++++- .../test/unit/platform-cloudflare.test.ts | 47 ++++++++++ .../void/test/unit/platform-teardown.test.ts | 91 ++++++++++++++++++- 5 files changed, 246 insertions(+), 9 deletions(-) diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index 97a4b3493..ad1aabebd 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -92,6 +92,16 @@ 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. */ @@ -1081,9 +1091,9 @@ export class PlatformCloudflareClient { } /** - * DELETE one resource by the key Cloudflare addresses it with. Never sends - * `force`, so Cloudflare refuses a resource another Worker still uses. A - * 404 means it is already gone. + * 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}`, { @@ -1145,6 +1155,47 @@ export class PlatformCloudflareClient { })); } + 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)}`, diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index e2320addd..2805a7c55 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -1450,11 +1450,13 @@ function requirePurgeAllClient(client: PlatformUninstallClient): PurgeAllClient 'deleteWorkerDomain', 'deleteWorkerRoute', 'emptyR2Bucket', + 'forceDeleteWorker', 'getAccessResource', 'getEmailRoutingCatchAll', 'listDispatchNamespaceScripts', 'listEmailRoutingRules', 'listQueueConsumers', + 'listWorkerDependents', 'listWorkerRoutes', 'resetEmailRoutingCatchAll', ]; diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index 1886c60e8..8d6efab10 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -20,8 +20,11 @@ import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; * -> 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 no DELETE sends `force`, so Cloudflare refuses a resource that - * something outside the installation still uses. A route, custom domain, DNS + * 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 @@ -45,6 +48,7 @@ export type PurgeAllClient = Pick< | 'deleteWorkerDomain' | 'deleteWorkerRoute' | 'emptyR2Bucket' + | 'forceDeleteWorker' | 'getAccessResource' | 'getEmailRoutingCatchAll' | 'listAiGateways' @@ -56,6 +60,7 @@ export type PurgeAllClient = Pick< | 'listKvNamespaces' | 'listQueueConsumers' | 'listR2Buckets' + | 'listWorkerDependents' | 'listWorkerDomains' | 'listWorkerNames' | 'listWorkerRoutes' @@ -738,8 +743,9 @@ export async function runPurgeAllTeardown( * 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-all is opt-in, never - * sends `force`, and its plan lists every resource first. + * That one-request window is accepted because --purge-all 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])); @@ -782,6 +788,48 @@ export async function runPurgeAllTeardown( ); 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 + * installation created, which this run deletes too. + */ + 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) => !createdWorkerNames.has(name)) ?? []; + if ( + !dependents || + dependents.pagesFunction || + dependents.workers.length === 0 || + outside.length > 0 + ) { + 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 purgeAllTargets(initial)) { if (target.phase === 'access') { @@ -913,6 +961,8 @@ export async function runPurgeAllTeardown( break; } case 'workers': + await removeWorker(current); + break; case 'tail': await remove(current, workerHint); break; diff --git a/packages/void/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index e135f9769..4cee94f5d 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -1006,6 +1006,53 @@ describe('platform Cloudflare client', () => { 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', { diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index d9be67073..70716b3f7 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -311,6 +311,15 @@ function cloudflare(manifest: PlatformInstallationManifest) { ), // 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, }; @@ -324,6 +333,15 @@ function cloudflare(manifest: PlatformInstallationManifest) { } }; 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) { @@ -358,6 +376,13 @@ function cloudflare(manifest: PlatformInstallationManifest) { 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. @@ -392,6 +417,16 @@ function cloudflare(manifest: PlatformInstallationManifest) { 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); @@ -585,6 +620,7 @@ const FULL_ORDER = [ '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}`, @@ -661,7 +697,15 @@ describe('uninstall --purge-all', () => { expect(deletes(fake)).toEqual(FULL_ORDER); expect(fake.calls.indexOf('PUT disabled')).toBeLessThan(fake.calls.indexOf(FULL_ORDER[0]!)); - expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + // 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' }]); @@ -1118,6 +1162,49 @@ describe('uninstall --purge-all', () => { 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, purgeAllArgs, 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]!, purgeAllArgs, dependencies()); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + 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, purgeAllArgs, 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); @@ -1135,7 +1222,7 @@ describe('uninstall --purge-all', () => { expect( deletes(fake).filter((call) => call === `DELETE worker ${names.tailWorker}`), ).toHaveLength(1); - expect(fake.calls.some((call) => call.includes('force'))).toBe(false); + expect(fake.calls).not.toContain(`DELETE worker ${names.tailWorker} force`); expect(listInstallationManifests()[0]?.status).toBe('failed'); fake.live.foreignTailProducer = false; From 880816587fda1497c8527a44cba9fff7ec9729a9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:29:02 +0800 Subject: [PATCH 39/49] Read AI Gateway creation times as UTC so --purge-all deletes the installer's gateway Cloudflare reports an AI Gateway's created_at without a zone, for example "2026-10-05 18:29:14". JavaScript read that as local time, so outside UTC the gateway the installer created seemed hours away from its creation record, and --purge-all kept it as not created during installation. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-cloudflare.ts | 15 ++++++++++++++- .../test/unit/platform-cloudflare.test.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index ad1aabebd..f0a65a67c 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -315,7 +315,7 @@ export class PlatformCloudflareClient { } } return entries.map((entry) => ({ - ...(typeof entry.created_at === 'string' ? { createdAt: entry.created_at } : {}), + ...withCreatedAt(entry.created_at), id: requiredString(entry.id, 'AI Gateway id'), name: requiredString(entry.id, 'AI Gateway id'), })); @@ -1381,6 +1381,19 @@ 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/test/unit/platform-cloudflare.test.ts b/packages/void/test/unit/platform-cloudflare.test.ts index 4cee94f5d..dfcef54bf 100644 --- a/packages/void/test/unit/platform-cloudflare.test.ts +++ b/packages/void/test/unit/platform-cloudflare.test.ts @@ -1176,6 +1176,25 @@ describe('platform Cloudflare client', () => { ); }); + 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', { From dc00c83470e5dc6273de726362b7c4788f0714a8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:42:23 +0800 Subject: [PATCH 40/49] Print one result line when deleting a project `void project delete` printed "Project deleted." from its spinner and then "Deleted ". The spinner now clears, so only "Deleted " remains. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/delete.ts | 2 +- .../test/unit/project-delete-command.test.ts | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 packages/void/test/unit/project-delete-command.test.ts diff --git a/packages/void/src/cli/delete.ts b/packages/void/src/cli/delete.ts index 1537e5d60..2a15f7f65 100644 --- a/packages/void/src/cli/delete.ts +++ b/packages/void/src/cli/delete.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/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.'); + }); +}); From d2a37db22ad594aab5c43078b32b4ed1c9448678 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:44:03 +0800 Subject: [PATCH 41/49] Prove a Worker's dependents before --purge-all forces its delete --purge-all forced a platform Worker's DELETE when each Worker that bound to it had the name of a Worker the installation created. A Worker that took one of those names during the run, or after the installation's own Worker was recorded as deleted, passed that check, so the forced DELETE left it with a broken binding. Each dependent must now be a Worker this run still deletes and must carry this installation's ownership marker; otherwise the run stops before forcing anything. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-teardown.ts | 23 +++++++-- .../void/test/unit/platform-teardown.test.ts | 49 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index 8d6efab10..7be1205f2 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -786,12 +786,18 @@ export async function runPurgeAllTeardown( .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 && isPurgeAllCandidate(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 - * installation created, which this run deletes too. + * run deletes too and still carries this installation's ownership marker. */ const removeWorker = async (resource: InstallationResource) => { if (!(await prove(resource))) { @@ -806,12 +812,23 @@ export async function runPurgeAllTeardown( const dependents = await client .listWorkerDependents(accountId, resource.name) .catch(() => null); - const outside = dependents?.workers.filter((name) => !createdWorkerNames.has(name)) ?? []; + 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 + outside.length > 0 || + !missing || + keys.some((key) => missing.has(key)) ) { throw cloudflareRefusal( next, diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index 70716b3f7..77668c474 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -1189,6 +1189,55 @@ describe('uninstall --purge-all', () => { 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, purgeAllArgs, 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, purgeAllArgs, 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); From b8fda926002d9a314e5157833409d89d9d567ac9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:46:07 +0800 Subject: [PATCH 42/49] Tell an upgrade or repair apart from a disabled platform An upgrade's staged window and a Sandbox drain close the API traffic gate with the same 503 as `void platform disable`, so `void platform status` reported the platform as disabled, suggested `void platform enable` on machines without the saved operation, and exited 0. Operator commands suggested enabling it too. The API now adds `reason: "maintenance"` to that 503 while an upgrade or repair has traffic paused. `void platform status` then exits with an error that says an upgrade or repair is in progress, as the docs describe, and operator commands say to retry when it finishes instead of suggesting `void platform enable`. A platform without the field is still reported as disabled. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/operator-client.ts | 4 +- packages/void/src/cli/platform-cmd.ts | 8 +++- packages/void/src/cli/platform-discovery.ts | 11 ++++- packages/void/test/unit/operator-cli.test.ts | 15 +++++++ .../void/test/unit/platform-connect.test.ts | 40 +++++++++++++++++++ platform/packages/api/src/index.ts | 14 +++++-- .../api/test/integration/api-health.test.ts | 4 +- .../platform-email-bootstrap.test.ts | 2 +- .../test/integration/platform-status.test.ts | 2 +- 9 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/void/src/cli/operator-client.ts b/packages/void/src/cli/operator-client.ts index a4187bb29..1154e7be1 100644 --- a/packages/void/src/cli/operator-client.ts +++ b/packages/void/src/cli/operator-client.ts @@ -137,7 +137,9 @@ export class OperatorClient { typeof result.message === 'string' ? result.message : typeof result.error === 'string' - ? `Operator request failed (HTTP ${response.status}): ${result.error}.${result.error === 'Platform disabled' ? ' Run `void platform enable`.' : ''}` + ? 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, diff --git a/packages/void/src/cli/platform-cmd.ts b/packages/void/src/cli/platform-cmd.ts index 2a4ec3c95..df4794877 100644 --- a/packages/void/src/cli/platform-cmd.ts +++ b/packages/void/src/cli/platform-cmd.ts @@ -195,7 +195,7 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro try { discovery = await discoverPlatform(apiUrl); } catch (error) { - if (error instanceof PlatformDisabledError) { + 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}` : ''}`); @@ -207,6 +207,12 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro '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 990f652a5..0fa8be22c 100644 --- a/packages/void/src/cli/platform-discovery.ts +++ b/packages/void/src/cli/platform-discovery.ts @@ -3,7 +3,11 @@ import { normalizePlatformApiUrl } from './platform-registry.ts'; /** Discovery was refused because the platform traffic gate is closed. */ export class PlatformDisabledError extends Error { - constructor(message: string) { + constructor( + message: string, + /** True when an upgrade or repair paused traffic, not `void platform disable`. */ + readonly maintenance = false, + ) { super(message); this.name = 'PlatformDisabledError'; } @@ -23,7 +27,10 @@ export async function readPlatformDiscoveryResponse( typeof body === 'object' && (body as Record).error === 'Platform disabled' ) { - throw new PlatformDisabledError(message); + throw new PlatformDisabledError( + message, + (body as Record).reason === 'maintenance', + ); } } else { await response.body?.cancel(); diff --git a/packages/void/test/unit/operator-cli.test.ts b/packages/void/test/unit/operator-cli.test.ts index 34ce78d3f..ba2f1972a 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -812,6 +812,21 @@ describe('operator transport and execution', () => { message: 'Operator request failed (HTTP 503): Platform disabled. Run `void platform enable`.', }); }); + it('does not suggest enabling a platform paused for an upgrade or repair', async () => { + const fetcher = vi + .fn() + .mockResolvedValue( + Response.json({ error: 'Platform disabled', reason: 'maintenance' }, { status: 503 }), + ); + await expect( + new OperatorClient('https://one.example', 'operator', fetcher).request('/users'), + ).rejects.toMatchObject({ + code: 'Platform disabled', + status: 503, + message: + 'Operator request failed (HTTP 503): the platform is paused for an upgrade or repair. Retry when it finishes.', + }); + }); it('includes the error code when the API sends no message', async () => { const fetcher = vi .fn() diff --git a/packages/void/test/unit/platform-connect.test.ts b/packages/void/test/unit/platform-connect.test.ts index 2bea6a15a..1626d546e 100644 --- a/packages/void/test/unit/platform-connect.test.ts +++ b/packages/void/test/unit/platform-connect.test.ts @@ -130,6 +130,46 @@ describe('platform discovery', () => { 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/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/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/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); From b255cd631f1048c2bde1634cad1685300dcfcc0d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:47:14 +0800 Subject: [PATCH 43/49] Warn only about migrations that have not been applied yet Deploys now print migration warnings, and the runner built them from every bundled migration. An app with an older migration that lacks statement breakpoints saw the advice to add them on every deploy, though that migration never runs again. The runner now drops the warning once the migration is recorded as applied. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/migration-handler.ts | 6 +++++- .../void/test/unit/migration-concurrency.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/void/src/runtime/migration-handler.ts b/packages/void/src/runtime/migration-handler.ts index 00babdb18..02c12bab5 100644 --- a/packages/void/src/runtime/migration-handler.ts +++ b/packages/void/src/runtime/migration-handler.ts @@ -283,13 +283,17 @@ async function runD1AppMigrations( const migrationResults: Array = []; const confirmedApplied: Array = []; const confirmedSkipped: Array = []; - const warnings = verified.plans.flatMap((plan) => plan.warnings ?? []); + let warnings = verified.plans.flatMap((plan) => plan.warnings ?? []); let stability: 'stable' | 'legacy' = 'stable'; try { await createTrackingTable(db); await upgradeTrackingTable(db, fenced ? attempt.attemptId : undefined); const applied = await readAppliedSet(db); + // A migration applied earlier never runs again, so its warning cannot be acted on. + warnings = verified.plans.flatMap((plan, index) => + applied.has(migrations[index]!.name) ? [] : (plan.warnings ?? []), + ); for (let index = 0; index < migrations.length; index++) { const migration = migrations[index]!; diff --git a/packages/void/test/unit/migration-concurrency.test.ts b/packages/void/test/unit/migration-concurrency.test.ts index 662651890..009dc6056 100644 --- a/packages/void/test/unit/migration-concurrency.test.ts +++ b/packages/void/test/unit/migration-concurrency.test.ts @@ -179,6 +179,22 @@ describe('D1 migration ownership', () => { expect((await handler([])(apply('successor'))).status).toBe(200); }); + it('warns about a migration without statement breakpoints only while it is pending', async () => { + const { handler } = createDatabase(); + const owner = handler([ + { + name: 'legacy.sql', + sql: 'UPDATE balance SET amount = amount - 10; UPDATE balance SET amount = amount - 10;', + }, + ]); + const first = (await (await owner(apply('first'))).json()) as MigrationResponse; + expect(first.confirmedApplied).toEqual(['legacy.sql']); + expect(first.warnings).toEqual([expect.stringContaining('Migration legacy.sql')]); + const later = (await (await owner(apply('later'))).json()) as MigrationResponse; + expect(later.confirmedSkipped).toEqual(['legacy.sql']); + expect(later.warnings ?? []).toEqual([]); + }); + it('keeps completed attempts idempotent and rolls back failing migration batches', async () => { const { sqlite, handler } = createDatabase(); const owner = handler(migrations); From 7dad32aa1823ea02ff57b70818677000dea95ad3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:47:42 +0800 Subject: [PATCH 44/49] Explain how to commit .void/project.json in an app that ignores .void The CI guide suggests committing .void/project.json, but new apps list .void in .gitignore, so git refuses to add it. The guide now gives the ignore rules that keep the rest of .void out and let the link in. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/first-deployment.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/platform/installation/first-deployment.md b/docs/guide/platform/installation/first-deployment.md index a05a47806..c6a6a1196 100644 --- a/docs/guide/platform/installation/first-deployment.md +++ b/docs/guide/platform/installation/first-deployment.md @@ -36,7 +36,9 @@ 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. 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`. Rotate with `void project token renew ` and revoke with +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 From 61a16372a540f8a11fe139b8210ce55500e22e1c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:48:06 +0800 Subject: [PATCH 45/49] Correct which invocations skip the dispatch Worker in the tail Worker note Queue deliveries reach user Workers through the dispatch Worker, so they are not among the invocations whose only log path is the user Worker's own tail consumer. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/env.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index 028728f4d..b52849910 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -91,9 +91,10 @@ export type ApiEnv = Env & { * Name of the tail-consumer Worker that receives runtime logs (Tail Worker). * 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, queues). The dispatch - * Worker's own consumer also receives the user items it invoked; the tail - * Worker keeps only the dispatcher items from those batches. + * (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; the tail Worker keeps only the + * dispatcher items from those batches. * Falls back to `void-tail` when unset. */ TAIL_SERVICE_NAME?: string; From b03ab84d18f508007606d7cf6a1194fcd4ceb8d6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 04:51:36 +0800 Subject: [PATCH 46/49] Load the platform discovery module under Node's TypeScript type stripping The maintenance flag on PlatformDisabledError was a constructor parameter property, which Node's strip-only TypeScript mode rejects, so scripts that import the CLI source directly failed to load. It is now a plain field. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/platform-discovery.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/void/src/cli/platform-discovery.ts b/packages/void/src/cli/platform-discovery.ts index 0fa8be22c..05084b1e3 100644 --- a/packages/void/src/cli/platform-discovery.ts +++ b/packages/void/src/cli/platform-discovery.ts @@ -3,13 +3,13 @@ import { normalizePlatformApiUrl } from './platform-registry.ts'; /** Discovery was refused because the platform traffic gate is closed. */ export class PlatformDisabledError extends Error { - constructor( - message: string, - /** True when an upgrade or repair paused traffic, not `void platform disable`. */ - readonly maintenance = false, - ) { + /** 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; } } From 31cf75fdc9e3fc2f356083cd5f6a4770e7beba5d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 05:21:29 +0800 Subject: [PATCH 47/49] Collapse duplicate runtime log rows when reading project logs A request routed through the dispatch Worker reaches the tail Worker twice: through the dispatch Worker's tail consumer and through the user Worker's own consumer. On a live install Cloudflare delivered each copy in its own tail invocation, never in one batch with the dispatcher item, so the tail Worker could not drop the second copy and every request appeared twice in `void project logs` and the deployment log views. The tail Worker again writes every item it receives. The project log query and the operator deployment log query now group by every stored column, so each invocation is returned once, and paging still counts unique rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/env.ts | 5 +- .../api/src/observability/log-rows.ts | 13 ++++ .../api/src/operator/deployment-operations.ts | 15 +++-- platform/packages/api/src/routes/projects.ts | 6 +- .../test/integration/admin-dashboard.test.ts | 7 ++- .../api/test/integration/api-projects.test.ts | 6 +- .../integration/operator-operations.test.ts | 12 ++-- platform/packages/tail/README.md | 8 +-- platform/packages/tail/src/index.ts | 24 +++---- .../packages/tail/test/tail-dedupe.test.ts | 62 ------------------- 10 files changed, 58 insertions(+), 100 deletions(-) create mode 100644 platform/packages/api/src/observability/log-rows.ts delete mode 100644 platform/packages/tail/test/tail-dedupe.test.ts diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index b52849910..69f2d7f01 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -93,8 +93,9 @@ export type ApiEnv = Env & { * 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; the tail Worker keeps only the - * dispatcher items from those batches. + * 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/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/projects.ts b/platform/packages/api/src/routes/projects.ts index faf0153bc..053e42597 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'; @@ -2683,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/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-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 532c78674..e08c49c53 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -3169,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/tail/README.md b/platform/packages/tail/README.md index 83c3f4e01..82b83bddb 100644 --- a/platform/packages/tail/README.md +++ b/platform/packages/tail/README.md @@ -9,9 +9,10 @@ Tail consumer for deployed user workers. - `projectKey`: `user-{slug}` - `deployId`: `dpl_*` when present - Writes one Analytics Engine row per trace event. -- Skips user Worker items in a batch that also holds a dispatch Worker item. - That batch comes from the dispatch Worker's consumer; each user Worker's own - `tail_consumers` entry already delivers those items. +- 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. @@ -35,7 +36,6 @@ Versioned deploys use script names like `user-my-app-dpl_abc123`. Legacy unversi ```text src/index.ts # tail() handler + parseScriptName() test/parse-script-name.test.ts -test/tail-dedupe.test.ts ``` ## Configuration and Deployment diff --git a/platform/packages/tail/src/index.ts b/platform/packages/tail/src/index.ts index 5c61a555e..446c0435c 100644 --- a/platform/packages/tail/src/index.ts +++ b/platform/packages/tail/src/index.ts @@ -39,26 +39,16 @@ export function parseScriptName( return null; } -function isDispatchKey(projectKey: string): boolean { - return projectKey.startsWith('__platform-'); -} - export default { async tail(events: ReadonlyArray, env: Env) { - const items = events.flatMap((event) => { - const parsed = event.scriptName - ? parseScriptName(event.scriptName, env.PLATFORM_DISPATCH_WORKER_NAME) - : null; - return parsed ? [{ event, parsed }] : []; - }); - // A batch from the dispatch Worker's consumer holds the dispatcher item - // plus the user Worker items it invoked. Each user Worker also has its own - // tail consumer, which delivers those same items, so keep only the - // dispatcher items here to avoid writing every user log line twice. - const fromDispatch = items.some(({ parsed }) => isDispatchKey(parsed.projectKey)); + for (const event of events) { + const scriptName = event.scriptName; + if (!scriptName) { + continue; + } - for (const { event, parsed } of items) { - if (fromDispatch && !isDispatchKey(parsed.projectKey)) { + const parsed = parseScriptName(scriptName, env.PLATFORM_DISPATCH_WORKER_NAME); + if (!parsed) { continue; } diff --git a/platform/packages/tail/test/tail-dedupe.test.ts b/platform/packages/tail/test/tail-dedupe.test.ts deleted file mode 100644 index f2e40b00a..000000000 --- a/platform/packages/tail/test/tail-dedupe.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, vi } from 'vite-plus/test'; -import worker from '../src/index.ts'; - -function traceItem(scriptName: string, url: string): TraceItem { - return { - scriptName, - outcome: 'ok', - eventTimestamp: 1, - event: { request: { url, method: 'GET' }, response: { status: 200 } }, - logs: [], - exceptions: [], - } as unknown as TraceItem; -} - -function run(events: Array, dispatchName?: string) { - const writeDataPoint = vi.fn(); - const env = { - ANALYTICS: { writeDataPoint } as unknown as AnalyticsEngineDataset, - PLATFORM_DISPATCH_WORKER_NAME: dispatchName, - }; - return worker.tail(events, env).then(() => writeDataPoint.mock.calls.map((call) => call[0])); -} - -describe('tail batch dedupe', () => { - it('writes only the dispatcher item when the batch comes from the dispatch Worker', async () => { - const points = await run( - [ - traceItem('void-dispatch-e2e2', 'https://app.example.com/'), - traceItem('user-app-dpl_abc', 'https://app.example.com/'), - ], - 'void-dispatch-e2e2', - ); - expect(points.map((point) => point.indexes[0])).toEqual(['__platform-dispatch']); - }); - - it('recognizes the default dispatch Worker names', async () => { - const points = await run([ - traceItem('void-dispatch-staging', 'https://app.example.com/'), - traceItem('user-app-dpl_abc', 'https://app.example.com/'), - ]); - expect(points.map((point) => point.indexes[0])).toEqual(['__platform-dispatch-staging']); - }); - - it('writes a user item delivered by its own tail consumer', async () => { - const points = await run( - [traceItem('user-app-dpl_abc', 'https://app.example.com/')], - 'void-dispatch-e2e2', - ); - expect(points.map((point) => point.indexes[0])).toEqual(['user-app']); - }); - - it('writes every user item when the batch has no dispatcher item', async () => { - const points = await run( - [ - traceItem('user-app-dpl_abc', 'https://app.example.com/a'), - traceItem('user-other-dpl_def', 'https://other.example.com/b'), - ], - 'void-dispatch-e2e2', - ); - expect(points.map((point) => point.indexes[0])).toEqual(['user-app', 'user-other']); - }); -}); From edd5b17e5dca6909c24b36f8bfe3b23f4c77d2e4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 11:23:20 +0800 Subject: [PATCH 48/49] Rename platform uninstall --purge-all to --delete-resources The flag does not delete everything: zones, adopted resources, and external PostgreSQL stay. The new name says what it does and matches --delete-zone. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 18 +- docs/reference/cli/platform-installation.md | 4 +- packages/void/src/cli/help.ts | 2 +- packages/void/src/cli/platform-args.ts | 9 +- .../src/cli/platform-installer-database.ts | 2 +- .../void/src/cli/platform-installer-types.ts | 2 +- packages/void/src/cli/platform-management.ts | 129 +++++----- packages/void/src/cli/platform-teardown.ts | 144 ++++++----- packages/void/test/unit/platform-args.test.ts | 12 +- .../unit/platform-management-upgrade.test.ts | 2 +- .../test/unit/platform-management.test.ts | 2 +- .../void/test/unit/platform-teardown.test.ts | 235 +++++++++++------- platform/packages/api/src/routes/projects.ts | 2 +- .../integration/zero-trust-uninstall.test.ts | 2 +- 14 files changed, 312 insertions(+), 253 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index 0af62d969..97141c20c 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -33,7 +33,7 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -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-all` 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-all`. +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; `--delete-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 `--delete-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. @@ -43,37 +43,37 @@ 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, or use `--purge-all`. +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 `--delete-resources`. ### Remove everything Void created To delete every Cloudflare resource the installation created: ```sh -void platform uninstall [id] --purge-all --plan -void platform uninstall [id] --purge-all +void platform uninstall [id] --delete-resources --plan +void platform uninstall [id] --delete-resources ``` -`--purge-all` 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. +`--delete-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-all` stops if: +Before it changes anything, `--delete-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-all` 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 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 `--delete-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-all` to also delete what it kept. Once an uninstall finishes, use the manual steps below instead. +If a plain or `--purge-data` uninstall stopped before it finished, rerun it with `--delete-resources` to also delete what it kept. Once an uninstall finishes, use the manual steps below instead. ### Remove retained resources -After an uninstall without `--purge-all`, 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 `--delete-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. 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. diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 0767a75f7..053947064 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 | --purge-all] [--keep-zone] [--yes] +void platform uninstall [id] [--plan] [--purge-data | --delete-resources] [--keep-zone] [--yes] ``` `discover --installation` limits recovery and endpoint verification to one installation in a shared Cloudflare account. @@ -139,6 +139,6 @@ An upgrade completes after the new Workers pass health checks. If rollout fails, 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-all` 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. +`--delete-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/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 7c5107ae3..06d187f5f 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -581,7 +581,7 @@ const HELP_TREE: HelpTree = { description: 'Delete installer-owned data even when it contains apps', }, { - command: '--purge-all', + command: '--delete-resources', description: 'Delete every resource the installation created (includes --purge-data); keeps zones, adopted resources, and PostgreSQL', }, diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index b8f650d0f..4269663ce 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -116,7 +116,7 @@ export type PlatformArgs = plan: boolean; purgeData: boolean; /** Delete every resource the installation created; implies purgeData. */ - purgeAll: boolean; + deleteResources: boolean; deleteZone: boolean | null; yes: boolean; }; @@ -336,7 +336,8 @@ export function parsePlatformArgs(args: Array): PlatformArgs { if (subcommand === 'uninstall') { const parsed = parseSelector( rest.filter( - (arg) => !['--purge-data', '--purge-all', '--delete-zone', '--keep-zone'].includes(arg), + (arg) => + !['--purge-data', '--delete-resources', '--delete-zone', '--keep-zone'].includes(arg), ), subcommand, { allowYes: true }, @@ -348,8 +349,8 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand, selector: parsed.selector, plan: parsed.plan, - purgeData: rest.includes('--purge-data') || rest.includes('--purge-all'), - purgeAll: rest.includes('--purge-all'), + purgeData: rest.includes('--purge-data') || rest.includes('--delete-resources'), + deleteResources: rest.includes('--delete-resources'), yes: parsed.yes, deleteZone: rest.includes('--delete-zone') ? true diff --git a/packages/void/src/cli/platform-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index 57ee480d7..719275253 100644 --- a/packages/void/src/cli/platform-installer-database.ts +++ b/packages/void/src/cli/platform-installer-database.ts @@ -275,7 +275,7 @@ export async function readPlatformProjectZeroTrustState( } } -/** Read project slugs for a --purge-all plan, which holds no lifecycle lock. */ +/** Read project slugs for a --delete-resources plan, which holds no lifecycle lock. */ export async function readPlatformProjectSlugs( databaseUrl: string, installationId: string, diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index 950f66cce..2d9f1e7b4 100644 --- a/packages/void/src/cli/platform-installer-types.ts +++ b/packages/void/src/cli/platform-installer-types.ts @@ -115,7 +115,7 @@ export type PlatformDatabaseLifecycleLock = (() => Promise) & { ) => Promise; /** Project Zero Trust state that decides whether uninstall may start. */ readProjectZeroTrust?: () => Promise; - /** Project slugs, which --purge-all requires to be empty. */ + /** Project slugs, which --delete-resources requires to be empty. */ readProjects?: () => Promise>; /** Block Zero Trust writers in the API until the returned release runs. */ lockProjectZeroTrust?: () => Promise<() => Promise>; diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 2805a7c55..1c96849fa 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -61,22 +61,22 @@ import { } from './platform-registry.ts'; import { assertNoPlatformProjects, - checkPurgeAllCredentials, - describePurgeAllPlan, + checkDeleteResourcesCredentials, + describeDeleteResourcesPlan, isPlatformTailWorker, - isPurgeAllCandidate, - isPurgedAccessResource, + isDeleteResourcesCandidate, + isDeletedAccessResource, PlatformResourceChangedError, - purgeAllAlwaysKept, - purgeAllHandlesEmailRouting, - purgeAllKeptReason, - purgeAllKeptResources, - purgeAllPermissions, - purgeAllResumeCommand, - PURGE_ALL_STEP_PREFIX, - runPurgeAllTeardown, - type PurgeAllClient, - type PurgeAllCredential, + deleteResourcesAlwaysKept, + deleteResourcesHandlesEmailRouting, + deleteResourcesKeptReason, + deleteResourcesKeptResources, + deleteResourcesPermissions, + deleteResourcesResumeCommand, + DELETE_RESOURCES_STEP_PREFIX, + runDeleteResourcesTeardown, + type DeleteResourcesClient, + type DeleteResourcesCredential, } from './platform-teardown.ts'; import { trackedPlatformWorkerTraffic } from './platform-traffic.ts'; import { @@ -107,7 +107,7 @@ export type PlatformManagementClient = Pick< export type PlatformUninstallClient = PlatformManagementClient & Pick & - Partial; + Partial; export type PlatformOwnershipClient = Pick< PlatformManagementClient, @@ -180,10 +180,10 @@ export type PlatformManagementDependencies = Readonly<{ fenceProjectZeroTrust?: () => Promise; /** Removes that fence when uninstall stops before it disables traffic. */ liftProjectZeroTrustFence?: () => Promise; - /** Project slugs; --purge-all refuses to start while any remain or when this cannot read them. */ + /** Project slugs; --delete-resources refuses to start while any remain or when this cannot read them. */ readProjects?: () => Promise>; - /** Where the operator credential came from; --purge-all checks it before any change. */ - credential?: PurgeAllCredential; + /** Where the operator credential came from; --delete-resources checks it before any change. */ + credential?: DeleteResourcesCredential; }>; export function selectAuthoritativePlatformManifest( @@ -901,12 +901,14 @@ export async function uninstallPlatform( dependencies.checkpoint, ); const retainData = !args.purgeData; - const purgeAll = args.purgeAll; + const deleteResources = args.deleteResources; const routingKv = manifest.resources.find((resource) => resource.key === 'routing-kv'); - // --purge-all also uses routing KV that an uninstall which stopped before it + // --delete-resources also uses routing KV that an uninstall which stopped before it // finished recorded as retained. const reopenedRouting = - purgeAll && routingKv?.state === 'retained' && isPurgeAllCandidate(manifest, routingKv); + deleteResources && + routingKv?.state === 'retained' && + isDeleteResourcesCandidate(manifest, routingKv); const hasRoutingCheckpoint = routingKv?.state === 'ready' || reopenedRouting; const createdZone = manifest.resources.find( (resource) => resource.key === 'zone' && resource.ownership === 'created', @@ -919,7 +921,7 @@ export async function uninstallPlatform( ); } await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); - if (purgeAll) { + if (deleteResources) { await assertNoPlatformProjects(dependencies.readProjects); } const dataMessage = retainData @@ -927,10 +929,10 @@ export async function uninstallPlatform( : 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 purgeClient = purgeAll ? requirePurgeAllClient(client) : null; + const deleteResourcesClient = deleteResources ? requireDeleteResourcesClient(client) : null; note( - purgeAll - ? purgeAllPlanText(manifest) + deleteResources + ? deleteResourcesPlanText(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.` @@ -945,9 +947,9 @@ export async function uninstallPlatform( .join('')}`, 'Uninstall plan', ); - if (purgeClient) { - const credentialCheck = checkPurgeAllCredentials( - purgeClient, + if (deleteResourcesClient) { + const credentialCheck = checkDeleteResourcesCredentials( + deleteResourcesClient, manifest, dependencies.credential ?? { source: 'environment', aiGatewayToken: false }, ); @@ -973,7 +975,7 @@ export async function uninstallPlatform( } if (interactive && !args.yes) { const proceed = await confirmAction( - purgeAll + deleteResources ? 'Permanently delete every Cloudflare resource this installation created?' : args.purgeData ? 'Permanently purge installer-owned platform data?' @@ -986,26 +988,29 @@ export async function uninstallPlatform( } } - const purgeAllKept = new Set( - purgeAll ? purgeAllKeptResources(manifest).map((resource) => resource.key) : [], + const deleteResourcesKept = new Set( + deleteResources ? deleteResourcesKeptResources(manifest).map((resource) => resource.key) : [], ); const resourcesToDelete = new Set( manifest.resources .filter( (resource) => (shouldDeleteResource(resource, retainData) || - (purgeAll && + (deleteResources && // Includes what an uninstall that stopped before it finished // recorded as retained, so a replaced one stops the run here too. - (resource.state === 'ready' || isPurgeAllCandidate(manifest, resource)) && - !purgeAllKept.has(resource.key) && + (resource.state === 'ready' || isDeleteResourcesCandidate(manifest, resource)) && + !deleteResourcesKept.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_ALL_STEP_PREFIX}r2-marker:${resource.key}`))) && + !hasLifecycleStep( + manifest, + `${DELETE_RESOURCES_STEP_PREFIX}r2-marker:${resource.key}`, + ))) && (hasRoutingCheckpoint || resource.kind === 'zone') && !( resource.kind === 'dns-record' && @@ -1023,7 +1028,7 @@ export async function uninstallPlatform( ...resourcesToDelete, ]); // Ownership checks only look at ready resources. Prove retained ones that - // --purge-all reopens the same way, without recording them as ready again. + // --delete-resources reopens the same way, without recording them as ready again. const proofManifest = { ...manifest, resources: manifest.resources.map((resource) => @@ -1078,7 +1083,7 @@ export async function uninstallPlatform( let next = manifest; try { await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); - if (purgeAll) { + if (deleteResources) { await assertNoPlatformProjects(dependencies.readProjects); } fenced = true; @@ -1137,10 +1142,10 @@ export async function uninstallPlatform( } } } - if (purgeClient && hasRoutingCheckpoint) { - next = await runPurgeAllTeardown( + if (deleteResourcesClient && hasRoutingCheckpoint) { + next = await runDeleteResourcesTeardown( { - client: purgeClient, + client: deleteResourcesClient, checkpoint: async (checkpointManifest) => { // Keep `next` current so a failure records the progress made. next = checkpointManifest; @@ -1162,7 +1167,7 @@ export async function uninstallPlatform( ? 'unknown' : r2BucketConfigHash(bucket.name, live.fingerprint); }, - // --purge-all already refused unless the project list could be read. + // --delete-resources already refused unless the project list could be read. readProjects: () => dependencies.readProjects!(), }, next, @@ -1258,10 +1263,10 @@ export async function uninstallPlatform( next.resources.filter((resource) => resource.state === 'retained'), 'Cloudflare resources:', 'Access resources:', - purgeAll && purgeAllHandlesEmailRouting(next), + deleteResources && deleteResourcesHandlesEmailRouting(next), ); - if (purgeAll) { - kept.push('Always kept:', ...purgeAllAlwaysKept()); + if (deleteResources) { + kept.push('Always kept:', ...deleteResourcesAlwaysKept()); } if (kept.length) { note(kept.join('\n'), 'Kept for manual cleanup'); @@ -1408,40 +1413,40 @@ function keptResourceRank( : KEPT_RESOURCE_ORDER.indexOf(resource.kind); } -function purgeAllPlanText(manifest: PlatformInstallationManifest): string { - const permissions = purgeAllPermissions(manifest); +function deleteResourcesPlanText(manifest: PlatformInstallationManifest): string { + const permissions = deleteResourcesPermissions(manifest); // Created Access resources are deleted unless an earlier run kept them. const keepManifest = { ...manifest, authenticationResources: (manifest.authenticationResources ?? []).filter( (resource) => resource.ownership !== 'created' || - purgeAllKeptReason(manifest, `access:${resource.key}`) !== null, + deleteResourcesKeptReason(manifest, `access:${resource.key}`) !== null, ), }; return [ 'Disable all platform traffic through installation-owned routing storage.', - ...describePurgeAllPlan(manifest), + ...describeDeleteResourcesPlan(manifest), ...describeKeptResources( keepManifest, - purgeAllKeptResources(manifest), + deleteResourcesKeptResources(manifest), 'Keep for manual cleanup:', 'Keep Access resources you connected:', - purgeAllHandlesEmailRouting(manifest), + deleteResourcesHandlesEmailRouting(manifest), ), 'Always kept:', - ...purgeAllAlwaysKept(), + ...deleteResourcesAlwaysKept(), ...(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: ${purgeAllResumeCommand(manifest)}`, + `If it stops, fix the reported problem and rerun: ${deleteResourcesResumeCommand(manifest)}`, ].join('\n'); } -function requirePurgeAllClient(client: PlatformUninstallClient): PurgeAllClient { - const required: ReadonlyArray = [ +function requireDeleteResourcesClient(client: PlatformUninstallClient): DeleteResourcesClient { + const required: ReadonlyArray = [ 'deleteAccessResource', 'deleteDispatchNamespaceScript', 'deleteEmailRoutingRule', @@ -1461,9 +1466,9 @@ function requirePurgeAllClient(client: PlatformUninstallClient): PurgeAllClient 'resetEmailRoutingCatchAll', ]; if (required.some((method) => typeof client[method] !== 'function')) { - throw new Error('platform: This Cloudflare client cannot run --purge-all.'); + throw new Error('platform: This Cloudflare client cannot run --delete-resources.'); } - return client as PurgeAllClient; + return client as DeleteResourcesClient; } function describeKeptResources( @@ -1471,7 +1476,7 @@ function describeKeptResources( resources: ReadonlyArray, heading: string, accessHeading: string, - /** --purge-all removes the Email Routing rules that send mail to the gateway Worker. */ + /** --delete-resources removes the Email Routing rules that send mail to the gateway Worker. */ emailRulesRemoved = false, ): Array { const sorted = [...resources].sort( @@ -1480,7 +1485,7 @@ function describeKeptResources( const isTraffic = (resource: InstallationResource) => KEPT_RESOURCE_ORDER.indexOf(resource.kind) < KEPT_RESOURCE_ORDER.indexOf('queue'); const why = (key: string) => { - const reason = purgeAllKeptReason(manifest, key); + const reason = deleteResourcesKeptReason(manifest, key); return reason === null ? '' : `, kept because ${reason}`; }; const line = (resource: InstallationResource) => @@ -1504,14 +1509,14 @@ function describeKeptResources( ] : []; const remainingAccess = (manifest.authenticationResources ?? []).filter( - (resource) => !isPurgedAccessResource(manifest, resource), + (resource) => !isDeletedAccessResource(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-all kept because they changed are listed with the + // Access resources --delete-resources kept because they changed are listed with the // other kept resources, not as ones the operator connected. const keptAccess = (resource: (typeof remainingAccess)[number]) => - purgeAllKeptReason(manifest, `access:${resource.key}`) !== null; + deleteResourcesKeptReason(manifest, `access:${resource.key}`) !== null; const kept = [ ...sorted.filter(isTraffic).map(line), ...remainingAccess.filter(keptAccess).map(accessLine), @@ -1922,7 +1927,7 @@ export function isPlatformManifestMonotonicExtension( next.ownership === resource.ownership && (next.state === resource.state || (resource.state === 'ready' && (next.state === 'deleted' || next.state === 'retained')) || - // --purge-all deletes routes and custom domains recorded as retained. + // --delete-resources deletes routes and custom domains recorded as retained. (resource.state === 'retained' && next.state === 'deleted')) ); }); diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index 7be1205f2..c36d43670 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -12,7 +12,7 @@ import { import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; /** - * `void platform uninstall --purge-all` deletes every Cloudflare resource the + * `void platform uninstall --delete-resources` deletes every Cloudflare resource the * installation created, in an order Cloudflare accepts: * * traffic (routes, custom domains, DNS) -> Access -> Email Routing rules @@ -31,13 +31,13 @@ import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; * stopped. The routing KV namespace holds that checkpoint, so uninstall * deletes it last. * - * An uninstall without --purge-all records what it leaves as retained. If it - * stopped before it finished, --purge-all considers those resources again. + * An uninstall without --delete-resources records what it leaves as retained. If it + * stopped before it finished, --delete-resources considers those resources again. */ -export const PURGE_ALL_STEP_PREFIX = 'uninstall:purge-all:'; +export const DELETE_RESOURCES_STEP_PREFIX = 'uninstall:delete-resources:'; -export type PurgeAllClient = Pick< +export type DeleteResourcesClient = Pick< PlatformCloudflareClient, | 'deleteAccessResource' | 'deleteDispatchNamespaceScript' @@ -67,7 +67,7 @@ export type PurgeAllClient = Pick< | 'resetEmailRoutingCatchAll' >; -export type PurgeAllCredential = Readonly<{ +export type DeleteResourcesCredential = Readonly<{ source: 'environment' | 'saved' | 'oauth'; /** The runtime token is available for AI Gateway calls. */ aiGatewayToken: boolean; @@ -123,16 +123,16 @@ export function isPlatformTailWorker( ); } -export function purgeAllResumeCommand(manifest: PlatformInstallationManifest): string { - return `void platform uninstall ${manifest.id} --purge-all`; +export function deleteResourcesResumeCommand(manifest: PlatformInstallationManifest): string { + return `void platform uninstall ${manifest.id} --delete-resources`; } function accessStepKey(access: AccessResource): string { - return `${PURGE_ALL_STEP_PREFIX}access:${access.key}`; + return `${DELETE_RESOURCES_STEP_PREFIX}access:${access.key}`; } -/** Access resources that --purge-all already deleted. */ -export function isPurgedAccessResource( +/** Access resources that --delete-resources already deleted. */ +export function isDeletedAccessResource( manifest: PlatformInstallationManifest, access: AccessResource, ): boolean { @@ -140,7 +140,7 @@ export function isPurgedAccessResource( } function hasStep(manifest: PlatformInstallationManifest, key: string): boolean { - return manifest.steps.some((step) => step.key === `${PURGE_ALL_STEP_PREFIX}${key}`); + return manifest.steps.some((step) => step.key === `${DELETE_RESOURCES_STEP_PREFIX}${key}`); } function keptStepKey(key: string): string { @@ -148,16 +148,16 @@ function keptStepKey(key: string): string { } /** - * Why --purge-all kept a resource it was going to delete, for example because + * Why --delete-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 purgeAllKeptReason( +export function deleteResourcesKeptReason( manifest: PlatformInstallationManifest, key: string, ): string | null { return ( - manifest.steps.find((step) => step.key === `${PURGE_ALL_STEP_PREFIX}${keptStepKey(key)}`) + manifest.steps.find((step) => step.key === `${DELETE_RESOURCES_STEP_PREFIX}${keptStepKey(key)}`) ?.resultHash ?? null ); } @@ -168,12 +168,12 @@ function isReopenable(manifest: PlatformInstallationManifest): boolean { } /** - * A resource the installation created that --purge-all may still delete: one + * A resource the installation created that --delete-resources may still delete: one * that is ready, or one that an uninstall which stopped before it finished - * recorded as retained. Resources --purge-all itself kept, and earlier + * recorded as retained. Resources --delete-resources itself kept, and earlier * versions of a resource kept when it was replaced, are never reconsidered. */ -export function isPurgeAllCandidate( +export function isDeleteResourcesCandidate( manifest: PlatformInstallationManifest, resource: InstallationResource, ): boolean { @@ -183,7 +183,7 @@ export function isPurgeAllCandidate( (resource.state === 'retained' && isReopenable(manifest) && !resource.key.startsWith('retained:') && - purgeAllKeptReason(manifest, resource.key) === null)) + deleteResourcesKeptReason(manifest, resource.key) === null)) ); } @@ -198,7 +198,7 @@ function usableZone(manifest: PlatformInstallationManifest): InstallationResourc /** * A recorded resource whose live configuration no longer matches the - * installation's record. --purge-all keeps such a DNS record and continues. + * installation's record. --delete-resources keeps such a DNS record and continues. */ export class PlatformResourceChangedError extends Error { constructor( @@ -218,11 +218,12 @@ function emailRoutingZone(manifest: PlatformInstallationManifest): string | null : null; } -/** Everything --purge-all will delete, in deletion order. */ -function purgeAllTargets(manifest: PlatformInstallationManifest): Array { +/** Everything --delete-resources will delete, in deletion order. */ +function deleteResourcesTargets(manifest: PlatformInstallationManifest): Array { const resources = manifest.resources; const routing = resources.find((resource) => resource.key === 'routing-kv'); - const candidate = (resource: InstallationResource) => isPurgeAllCandidate(manifest, resource); + const candidate = (resource: InstallationResource) => + isDeleteResourcesCandidate(manifest, resource); // Without the remote checkpoint nothing can be proven, so nothing is deleted. if (!routing || (routing.state !== 'ready' && !candidate(routing))) { return []; @@ -235,7 +236,7 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array (resource.kind !== 'worker-route' && resource.kind !== 'worker-custom-domain') || (resource.state !== 'ready' && resource.state !== 'retained') || (resource.kind === 'worker-route' && !zone) || - purgeAllKeptReason(manifest, resource.key) !== null + deleteResourcesKeptReason(manifest, resource.key) !== null ) { continue; } @@ -253,8 +254,8 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array const access = (manifest.authenticationResources ?? []).filter( (entry) => entry.ownership === 'created' && - !isPurgedAccessResource(manifest, entry) && - purgeAllKeptReason(manifest, `access:${entry.key}`) === null, + !isDeletedAccessResource(manifest, entry) && + deleteResourcesKeptReason(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) { @@ -313,20 +314,22 @@ function purgeAllTargets(manifest: PlatformInstallationManifest): Array return targets; } -/** Whether --purge-all removes, or has removed, the rules that send mail to the gateway Worker. */ -export function purgeAllHandlesEmailRouting(manifest: PlatformInstallationManifest): boolean { +/** Whether --delete-resources removes, or has removed, the rules that send mail to the gateway Worker. */ +export function deleteResourcesHandlesEmailRouting( + manifest: PlatformInstallationManifest, +): boolean { return ( hasStep(manifest, 'email-routing') || - purgeAllTargets(manifest).some((target) => target.phase === 'email') + deleteResourcesTargets(manifest).some((target) => target.phase === 'email') ); } -/** Manifest resources that --purge-all leaves in the account. */ -export function purgeAllKeptResources( +/** Manifest resources that --delete-resources leaves in the account. */ +export function deleteResourcesKeptResources( manifest: PlatformInstallationManifest, ): Array { const targets = new Set( - purgeAllTargets(manifest).flatMap((target) => + deleteResourcesTargets(manifest).flatMap((target) => 'resource' in target ? [target.resource.key] : [], ), ); @@ -340,9 +343,9 @@ function resourceLabel(resource: InstallationResource): string { return `${resource.kind}: ${resource.name} (${resource.id})`; } -/** Plan lines for --purge-all: what is deleted, in order, then what is kept. */ -export function describePurgeAllPlan(manifest: PlatformInstallationManifest): Array { - const targets = purgeAllTargets(manifest); +/** Plan lines for --delete-resources: what is deleted, in order, then what is kept. */ +export function describeDeleteResourcesPlan(manifest: PlatformInstallationManifest): Array { + const targets = deleteResourcesTargets(manifest); const lines: Array = []; let step = 0; let previous: Target['phase'] | null = null; @@ -394,12 +397,15 @@ export function describePurgeAllPlan(manifest: PlatformInstallationManifest): Ar } } return lines.length - ? ['Delete every resource this installation created (--purge-all), in this order:', ...lines] - : ['--purge-all finds no resource it can prove this installation created.']; + ? [ + 'Delete every resource this installation created (--delete-resources), in this order:', + ...lines, + ] + : ['--delete-resources finds no resource it can prove this installation created.']; } -/** Lines naming what --purge-all never removes. */ -export function purgeAllAlwaysKept(): Array { +/** Lines naming what --delete-resources never removes. */ +export function deleteResourcesAlwaysKept(): Array { return [ ' The external PostgreSQL database and its data.', ' Analytics Engine datasets; Cloudflare has no API to delete them.', @@ -408,9 +414,9 @@ export function purgeAllAlwaysKept(): Array { ]; } -/** Token permissions --purge-all needs for this manifest. */ -export function purgeAllPermissions(manifest: PlatformInstallationManifest): Array { - const targets = purgeAllTargets(manifest); +/** Token permissions --delete-resources needs for this manifest. */ +export function deleteResourcesPermissions(manifest: PlatformInstallationManifest): Array { + const targets = deleteResourcesTargets(manifest); const needed = new Set(); for (const target of targets) { if (target.phase === 'access') { @@ -461,12 +467,12 @@ function deniedStatus(error: unknown): boolean { * 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 checkPurgeAllCredentials( - client: PurgeAllClient, +export async function checkDeleteResourcesCredentials( + client: DeleteResourcesClient, manifest: PlatformInstallationManifest, - credential: PurgeAllCredential, + credential: DeleteResourcesCredential, ): Promise { - const targets = purgeAllTargets(manifest); + const targets = deleteResourcesTargets(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']) => @@ -489,7 +495,7 @@ export async function checkPurgeAllCredentials( ]; if (browserMissing.length) { throw new Error( - `platform: Cloudflare browser login cannot delete every resource --purge-all plans. Set CLOUDFLARE_API_TOKEN to a token with ${purgeAllPermissions(manifest).join(', ')}, scoped to ${scope}, then rerun. Browser login is missing: ${browserMissing.join(', ')}. No Cloudflare resource was changed.`, + `platform: Cloudflare browser login cannot delete every resource --delete-resources plans. Set CLOUDFLARE_API_TOKEN to a token with ${deleteResourcesPermissions(manifest).join(', ')}, scoped to ${scope}, then rerun. Browser login is missing: ${browserMissing.join(', ')}. No Cloudflare resource was changed.`, ); } } @@ -550,7 +556,7 @@ export async function checkPurgeAllCredentials( } if (missing.size) { throw new Error( - `platform: The Cloudflare credential cannot read every resource --purge-all would delete. Add ${[...missing].join(', ')} for ${scope}, then rerun. No Cloudflare resource was changed.`, + `platform: The Cloudflare credential cannot read every resource --delete-resources would delete. Add ${[...missing].join(', ')} for ${scope}, then rerun. No Cloudflare resource was changed.`, ); } } @@ -582,7 +588,7 @@ export async function readPlatformProjectSlugsConnection( /** * Projects own Workers, storage, and domains that only the running platform - * can delete. --purge-all deletes the platform, so it refuses before any + * can delete. --delete-resources deletes the platform, so it refuses before any * change while a project remains. */ export async function assertNoPlatformProjects( @@ -600,14 +606,14 @@ export async function assertNoPlatformProjects( '', ); throw new Error( - `platform: Refusing --purge-all 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.`, + `platform: Refusing --delete-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-all while the platform still has ${slugs.length === 1 ? '1 project' : `${slugs.length} projects`}.`, - "--purge-all deletes the platform, and only the running platform can delete each project's Workers, storage, and domains.", + `platform: Refusing --delete-resources while the platform still has ${slugs.length === 1 ? '1 project' : `${slugs.length} projects`}.`, + "--delete-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 @@ -618,8 +624,8 @@ export async function assertNoPlatformProjects( } } -export type PurgeAllContext = Readonly<{ - client: PurgeAllClient; +export type DeleteResourcesContext = Readonly<{ + client: DeleteResourcesClient; /** 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. */ @@ -671,7 +677,7 @@ function ownershipRefusal( 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: ${purgeAllResumeCommand(manifest)}`, + `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: ${deleteResourcesResumeCommand(manifest)}`, ); } @@ -685,7 +691,7 @@ function cloudflareRefusal( return error; } return new Error( - `platform: Cloudflare refused to delete ${description}: ${error.message}\n${hint}\nProgress is saved. Rerun: ${purgeAllResumeCommand(manifest)}`, + `platform: Cloudflare refused to delete ${description}: ${error.message}\n${hint}\nProgress is saved. Rerun: ${deleteResourcesResumeCommand(manifest)}`, { cause: error }, ); } @@ -694,8 +700,8 @@ function cloudflareRefusal( * 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 runPurgeAllTeardown( - context: PurgeAllContext, +export async function runDeleteResourcesTeardown( + context: DeleteResourcesContext, initial: PlatformInstallationManifest, ): Promise { const { client } = context; @@ -716,7 +722,7 @@ export async function runPurgeAllTeardown( await save( recordInstallationStep( next, - { completedAt: now, key: `${PURGE_ALL_STEP_PREFIX}${key}`, resultHash }, + { completedAt: now, key: `${DELETE_RESOURCES_STEP_PREFIX}${key}`, resultHash }, now, ), ); @@ -743,7 +749,7 @@ export async function runPurgeAllTeardown( * 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-all is opt-in and its + * That one-request window is accepted because --delete-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. */ @@ -790,7 +796,9 @@ export async function runPurgeAllTeardown( const workerTarget = (name: string) => next.resources.find( (resource) => - resource.kind === 'worker' && resource.name === name && isPurgeAllCandidate(next, resource), + resource.kind === 'worker' && + resource.name === name && + isDeleteResourcesCandidate(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.'; @@ -848,7 +856,7 @@ export async function runPurgeAllTeardown( await markDeleted(resource.key); }; - for (const target of purgeAllTargets(initial)) { + for (const target of deleteResourcesTargets(initial)) { if (target.phase === 'access') { const { access } = target; const live = await client.getAccessResource(access.accountId, access.kind, access.id); @@ -879,15 +887,15 @@ export async function runPurgeAllTeardown( if ( !current || current.state === 'deleted' || - purgeAllKeptReason(next, current.key) !== null || - (target.phase !== 'traffic' && !isPurgeAllCandidate(next, current)) + deleteResourcesKeptReason(next, current.key) !== null || + (target.phase !== 'traffic' && !isDeleteResourcesCandidate(next, current)) ) { continue; } switch (target.phase) { case 'traffic': { const worker = next.resources.find((resource) => resource.key === target.worker.key); - if (!worker || !isPurgeAllCandidate(next, worker) || !(await prove(worker))) { + if (!worker || !isDeleteResourcesCandidate(next, worker) || !(await prove(worker))) { // The Worker it served is gone, so the attachment cannot be proven. break; } @@ -999,7 +1007,7 @@ export async function runPurgeAllTeardown( 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-all 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: ${purgeAllResumeCommand(next)}`, + `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. --delete-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: ${deleteResourcesResumeCommand(next)}`, ); } for (const script of await client.listDispatchNamespaceScripts(accountId, current.name)) { @@ -1083,7 +1091,9 @@ export async function runPurgeAllTeardown( 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_ALL_STEP_PREFIX}${markerStep}`); + const saved = next.steps.find( + (step) => step.key === `${DELETE_RESOURCES_STEP_PREFIX}${markerStep}`, + ); const generation = await context.bucketGeneration(next, bucket); if (generation === null) { await markDeleted(bucket.key); diff --git a/packages/void/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index fcab0c0c6..8c1e17290 100644 --- a/packages/void/test/unit/platform-args.test.ts +++ b/packages/void/test/unit/platform-args.test.ts @@ -172,26 +172,26 @@ describe('platform args', () => { selector: '', plan: false, purgeData: true, - purgeAll: false, + deleteResources: false, deleteZone: false, yes: false, }); expect(() => parsePlatformArgs(['uninstall', '--delete-zone', '--keep-zone'])).toThrow(); }); - it('treats --purge-all as a purge of data and every other created resource', () => { - expect(parsePlatformArgs(['uninstall', 'team', '--purge-all', '--plan'])).toEqual({ + it('treats --delete-resources as a purge of data and every other created resource', () => { + expect(parsePlatformArgs(['uninstall', 'team', '--delete-resources', '--plan'])).toEqual({ subcommand: 'uninstall', selector: 'team', plan: true, purgeData: true, - purgeAll: true, + deleteResources: true, deleteZone: null, yes: false, }); - expect(parsePlatformArgs(['uninstall', '--purge-data', '--purge-all'])).toMatchObject({ + expect(parsePlatformArgs(['uninstall', '--purge-data', '--delete-resources'])).toMatchObject({ purgeData: true, - purgeAll: true, + deleteResources: true, }); }); diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index f108908b0..a94905cea 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -660,7 +660,7 @@ describe('installed platform upgrade', () => { deleteZone: null, plan, purgeData: true, - purgeAll: false, + deleteResources: 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 2220e291c..a9a4c4c78 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -331,7 +331,7 @@ const uninstallArgs = { selector: '', plan: false, purgeData: false, - purgeAll: false, + deleteResources: false, deleteZone: null, yes: true, }; diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index 77668c474..433836321 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -78,7 +78,7 @@ const emailDeployment = createPlatformDeploymentManifest({ }); const INSTALLATION_ID = 'example-a1b2c3d4'; const FINGERPRINT = '2026-09-14T00:00:00.000Z'; -const RESUME = `void platform uninstall ${INSTALLATION_ID} --purge-all`; +const RESUME = `void platform uninstall ${INSTALLATION_ID} --delete-resources`; // The installer records its intent right before it creates the AI Gateway. const AI_GATEWAY_INTENT_AT = '2026-09-14T00:00:01.000Z'; @@ -560,12 +560,12 @@ function cloudflare(manifest: PlatformInstallationManifest) { return fake; } -const purgeAllArgs = { +const deleteResourcesArgs = { subcommand: 'uninstall' as const, selector: '', plan: false, purgeData: true, - purgeAll: true, + deleteResources: true, deleteZone: null, yes: true, }; @@ -637,17 +637,17 @@ const FULL_ORDER = [ `DELETE kv-namespace ${names.routingKv}`, ]; -describe('uninstall --purge-all', () => { +describe('uninstall --delete-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, { ...purgeAllArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, dependencies()); const plan = noteText('Uninstall plan'); expectInOrder(plan, [ - 'Delete every resource this installation created (--purge-all), in this order:', + 'Delete every resource this installation created (--delete-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}`, @@ -693,7 +693,7 @@ describe('uninstall --purge-all', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(deletes(fake)).toEqual(FULL_ORDER); expect(fake.calls.indexOf('PUT disabled')).toBeLessThan(fake.calls.indexOf(FULL_ORDER[0]!)); @@ -751,7 +751,7 @@ describe('uninstall --purge-all', () => { }).listQueueConsumers(account, queue)), ]; - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(fake.live.consumers.get('cron-id')).toEqual([]); expect(fake.live.consumers.get('usage-id')).toEqual([ @@ -768,15 +768,15 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.failures.set(`DELETE queue ${names.usageQueue}`, new Error('temporary failure')); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - 'temporary failure', - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, 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, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); // The failed DELETE is retried once; nothing else is sent twice. const sent = deletes(fake); @@ -795,12 +795,17 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - 'temporary failure', - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + ).rejects.toThrow('temporary failure'); expect(fake.live.buckets.get(names.assetsBucket)?.objects.size).toBe(0); - await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, dependencies()); + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + deleteResourcesArgs, + dependencies(), + ); expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); }); @@ -810,16 +815,16 @@ describe('uninstall --purge-all', () => { persist(manifest); const fake = cloudflare(manifest); fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - 'temporary failure', - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, 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]!, purgeAllArgs, dependencies()), + uninstallPlatform(fake, listInstallationManifests()[0]!, deleteResourcesArgs, dependencies()), ).rejects.toThrow(`Refusing to delete R2 bucket ${names.assetsBucket}`); expect(fake.live.buckets.get(names.assetsBucket)?.objects.has('someone-else.txt')).toBe(true); }); @@ -837,9 +842,9 @@ describe('uninstall --purge-all', () => { ), ); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - 'belongs to another installation', - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + ).rejects.toThrow('belongs to another installation'); expect(changes(fake)).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('installed'); }); @@ -852,9 +857,9 @@ describe('uninstall --purge-all', () => { // A later, unrelated stop makes the run resume. fake.live.foreignTailProducer = true; - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - `Cloudflare refused to delete worker ${names.tailWorker}`, - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, 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, @@ -862,7 +867,7 @@ describe('uninstall --purge-all', () => { expect(deletes(fake)).toContain(`DELETE worker ${names.apiWorker}`); fake.live.foreignTailProducer = false; - await uninstallPlatform(fake, stopped, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE worker-route route-2'); expect(fake.live.routes).toEqual([ @@ -880,7 +885,7 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.live.domains[0]!.service = 'someone-else'; - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE worker-custom-domain domain-1'); expect(fake.live.domains).toHaveLength(1); @@ -899,7 +904,7 @@ describe('uninstall --purge-all', () => { .filter((entry) => entry.name === name) .map((entry) => ({ ...entry, content: '192.0.2.1', proxied: false, type: 'A' })); - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE dns-record *.apps.example.com'); expect(fake.live.dns).toHaveLength(1); @@ -919,7 +924,7 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.live.access.set('access-app', { accountId: 'account', name: 'renamed' }); - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE access application access-app'); expect(deletes(fake)).toContain('DELETE access service-token access-token'); @@ -943,13 +948,13 @@ describe('uninstall --purge-all', () => { ); fake.live.catchAll = { enabled: true, workerName: gateway }; - await uninstallPlatform(fake, manifest, { ...purgeAllArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, 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, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expectInOrder(changes(fake).join('\n'), [ 'DELETE access service-token access-token', @@ -971,7 +976,7 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.live.catchAll = { enabled: true, workerName: 'someone-else' }; - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(fake.calls).not.toContain('RESET email-catch-all mail-zone'); expect(fake.live.catchAll).toEqual({ enabled: true, workerName: 'someone-else' }); @@ -986,7 +991,9 @@ describe('uninstall --purge-all', () => { throw new PlatformCloudflareApiError('Authentication error', 403); }; - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, 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([]); @@ -997,21 +1004,26 @@ describe('uninstall --purge-all', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, { ...purgeAllArgs, plan: true }, dependencies()); - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, 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-all what a stopped --purge-data uninstall kept', async () => { + it('finishes with --delete-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, { ...purgeAllArgs, purgeAll: false }, dependencies()), + uninstallPlatform( + fake, + manifest, + { ...deleteResourcesArgs, deleteResources: false }, + dependencies(), + ), ).rejects.toThrow('Namespace has scripts.'); const stopped = listInstallationManifests()[0]!; expect(stopped.status).toBe('failed'); @@ -1019,7 +1031,7 @@ describe('uninstall --purge-all', () => { 'retained', ); - await uninstallPlatform(fake, stopped, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); expect(new Set(deletes(fake))).toEqual(new Set(FULL_ORDER)); const final = listInstallationManifests()[0]!; @@ -1031,7 +1043,7 @@ describe('uninstall --purge-all', () => { ).toEqual(['customer-kv:retained', 'zone:retained']); }); - it('finishes with --purge-all what a stopped plain uninstall kept, including routing KV', async () => { + it('finishes with --delete-resources what a stopped plain uninstall kept, including routing KV', async () => { const manifest = installedManifest(); persist(manifest); const fake = cloudflare(manifest); @@ -1045,7 +1057,7 @@ describe('uninstall --purge-all', () => { } return putKvValue(account, namespace, key, value); }; - const plain = { ...purgeAllArgs, purgeAll: false, purgeData: false }; + const plain = { ...deleteResourcesArgs, deleteResources: false, purgeData: false }; await expect(uninstallPlatform(fake, manifest, plain, dependencies())).rejects.toThrow( 'checkpoint write failed', @@ -1057,7 +1069,7 @@ describe('uninstall --purge-all', () => { ); expect(deletes(fake)).toEqual([]); - await uninstallPlatform(fake, stopped, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); expect(deletes(fake)).toEqual(FULL_ORDER); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1081,7 +1093,7 @@ describe('uninstall --purge-all', () => { uninstallPlatform( fake, manifest, - { ...purgeAllArgs, purgeAll: false, purgeData: false }, + { ...deleteResourcesArgs, deleteResources: false, purgeData: false }, dependencies(), ), ).rejects.toThrow('checkpoint write failed'); @@ -1099,9 +1111,9 @@ describe('uninstall --purge-all', () => { ); const before = changes(fake).length; - await expect(uninstallPlatform(fake, stopped, purgeAllArgs, dependencies())).rejects.toThrow( - 'belongs to another installation', - ); + await expect( + uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()), + ).rejects.toThrow('belongs to another installation'); expect(changes(fake).slice(before)).toEqual([]); const final = listInstallationManifests()[0]!; expect(final.status).toBe('failed'); @@ -1111,19 +1123,19 @@ describe('uninstall --purge-all', () => { ); }); - it('lists an Access resource an earlier --purge-all kept with the other kept resources, not as connected', async () => { + it('lists an Access resource an earlier --delete-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, purgeAllArgs, dependencies())).rejects.toThrow( - `Cloudflare refused to delete worker ${names.tailWorker}`, - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + ).rejects.toThrow(`Cloudflare refused to delete worker ${names.tailWorker}`); const stopped = listInstallationManifests()[0]!; - await uninstallPlatform(fake, stopped, { ...purgeAllArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, stopped, { ...deleteResourcesArgs, 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`; @@ -1145,18 +1157,18 @@ describe('uninstall --purge-all', () => { await uninstallPlatform( fake, manifest, - { ...purgeAllArgs, purgeAll: false, purgeData: false }, + { ...deleteResourcesArgs, deleteResources: false, purgeData: false }, dependencies(), ); const finished = listInstallationManifests()[0]!; expect(finished.status).toBe('uninstalled'); const before = changes(fake).length; - await uninstallPlatform(fake, finished, { ...purgeAllArgs, plan: true }, dependencies()); - expect(noteText('Uninstall plan')).toContain('--purge-all finds no resource'); - await expect(uninstallPlatform(fake, finished, purgeAllArgs, dependencies())).rejects.toThrow( - 'Invalid installation transition: uninstalled -> uninstalling.', - ); + await uninstallPlatform(fake, finished, { ...deleteResourcesArgs, plan: true }, dependencies()); + expect(noteText('Uninstall plan')).toContain('--delete-resources finds no resource'); + await expect( + uninstallPlatform(fake, finished, deleteResourcesArgs, dependencies()), + ).rejects.toThrow('Invalid installation transition: uninstalled -> uninstalling.'); expect(changes(fake).slice(before)).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1169,9 +1181,12 @@ describe('uninstall --purge-all', () => { fake.live.foreignWorkers.add('customer-worker'); fake.live.serviceBindings.push({ from: 'customer-worker', to: names.proxyWorker as string }); - const error = await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()).catch( - (caught: unknown) => caught as Error, - ); + const error = await uninstallPlatform( + fake, + manifest, + deleteResourcesArgs, + dependencies(), + ).catch((caught: unknown) => caught as Error); expect(error?.message).toContain( `Cloudflare refused to delete worker ${names.proxyWorker}: Cloudflare API error: Cannot delete service`, ); @@ -1185,7 +1200,12 @@ describe('uninstall --purge-all', () => { expect(listInstallationManifests()[0]?.status).toBe('failed'); fake.live.serviceBindings.pop(); - await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, dependencies()); + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + deleteResourcesArgs, + dependencies(), + ); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); }); @@ -1210,9 +1230,9 @@ describe('uninstall --purge-all', () => { return deleteResource(...args); }; - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - `worker ${names.apiWorker} belongs to another installation`, - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, 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); }); @@ -1228,9 +1248,12 @@ describe('uninstall --purge-all', () => { persist(manifest); const fake = cloudflare(recorded); - const error = await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()).catch( - (caught: unknown) => caught as Error, - ); + const error = await uninstallPlatform( + fake, + manifest, + deleteResourcesArgs, + dependencies(), + ).catch((caught: unknown) => caught as Error); expect(error?.message).toContain( `Workers outside this installation still use it: ${names.apiWorker}.`, ); @@ -1246,9 +1269,12 @@ describe('uninstall --purge-all', () => { throw new PlatformCloudflareApiError('Authentication error (code 10000)', 403); }; - const error = await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()).catch( - (caught: unknown) => caught as Error, - ); + const error = await uninstallPlatform( + fake, + manifest, + deleteResourcesArgs, + 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); @@ -1260,9 +1286,12 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.live.foreignTailProducer = true; - const error = await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()).catch( - (caught: unknown) => caught as Error, - ); + const error = await uninstallPlatform( + fake, + manifest, + deleteResourcesArgs, + 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)`, ); @@ -1275,7 +1304,12 @@ describe('uninstall --purge-all', () => { expect(listInstallationManifests()[0]?.status).toBe('failed'); fake.live.foreignTailProducer = false; - await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, dependencies()); + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + deleteResourcesArgs, + dependencies(), + ); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); }); @@ -1285,24 +1319,24 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); const withProjects = dependencies({ readProjects: async () => ['blog', 'shop'] }); const message = [ - 'platform: Refusing --purge-all while the platform still has 2 projects.', - "--purge-all deletes the platform, and only the running platform can delete each project's Workers, storage, and domains.", + 'platform: Refusing --delete-resources while the platform still has 2 projects.', + "--delete-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, { ...purgeAllArgs, plan: true }, withProjects), + uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, withProjects), + ).rejects.toThrow(message); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, withProjects), ).rejects.toThrow(message); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, withProjects)).rejects.toThrow( - message, - ); await expect( uninstallPlatform( fake, manifest, - purgeAllArgs, + deleteResourcesArgs, dependencies({ readProjects: async () => { throw new Error('connection refused'); @@ -1334,14 +1368,14 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); const oauth = dependencies({ credential: { source: 'oauth', aiGatewayToken: true } }); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, oauth)).rejects.toThrow( + await expect(uninstallPlatform(fake, manifest, deleteResourcesArgs, 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, { ...purgeAllArgs, plan: true }, oauth); + await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, 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', @@ -1356,8 +1390,10 @@ describe('uninstall --purge-all', () => { throw new PlatformCloudflareApiError('Authentication error', 403); }; - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - 'cannot read every resource --purge-all would delete. Add Zone → DNS → Edit for account account and zone example.com, then rerun. No Cloudflare resource was changed.', + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + ).rejects.toThrow( + 'cannot read every resource --delete-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([]); }); @@ -1367,8 +1403,8 @@ describe('uninstall --purge-all', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, { ...purgeAllArgs, plan: true }, dependencies()); - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(fake.calls.filter((call) => /dispatch-(namespace|script)/.test(call))).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1385,7 +1421,7 @@ describe('uninstall --purge-all', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(fake.live.access.has('access-token')).toBe(false); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1409,7 +1445,9 @@ describe('uninstall --purge-all', () => { return getAccessResource(account, kind, id); }; - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + ).rejects.toThrow( 'Add Account → Access: Service Tokens → Edit for accounts account and identity and zone example.com, then rerun.', ); expect(changes(fake)).toEqual([]); @@ -1422,7 +1460,7 @@ describe('uninstall --purge-all', () => { // 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, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain(`DELETE ai-gateway ${names.aiGateway}`); expect(fake.live.aiGateways.map((gateway) => gateway.id)).toEqual([names.aiGateway]); @@ -1442,7 +1480,7 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); delete fake.live.aiGateways[0]!.createdAt; - await uninstallPlatform(fake, manifest, purgeAllArgs, dependencies()); + await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain(`DELETE ai-gateway ${names.aiGateway}`); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1457,7 +1495,7 @@ describe('uninstall --purge-all', () => { const readProjects = async () => (++reads > 2 ? ['late-project'] : []); await expect( - uninstallPlatform(fake, manifest, purgeAllArgs, dependencies({ readProjects })), + uninstallPlatform(fake, manifest, deleteResourcesArgs, 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.`, ); @@ -1478,12 +1516,17 @@ describe('uninstall --purge-all', () => { const fake = cloudflare(manifest); fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); - await expect(uninstallPlatform(fake, manifest, purgeAllArgs, dependencies())).rejects.toThrow( - 'temporary failure', - ); + await expect( + uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + ).rejects.toThrow('temporary failure'); expect(fake.live.buckets.get(names.assetsBucket)?.objects.size).toBe(0); - await uninstallPlatform(fake, listInstallationManifests()[0]!, purgeAllArgs, dependencies()); + await uninstallPlatform( + fake, + listInstallationManifests()[0]!, + deleteResourcesArgs, + dependencies(), + ); expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); }); diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 053e42597..b8d257474 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -744,7 +744,7 @@ const app = new Hono() const userId = context.get('user').id; try { - // `void platform uninstall --purge-all` holds this lock across its last + // `void platform uninstall --delete-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) => { 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 f23cafc82..49877bae4 100644 --- a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts +++ b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts @@ -267,7 +267,7 @@ describe('platform uninstall check against the API database', () => { }); it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( - 'keeps a project created during the purge-all final check from committing after it', + 'keeps a project created during the delete-resources final check from committing after it', async () => { const owner = await createTestUser(env.db, 'racing-creator'); const platform = bindings(); From 277617d2fe730ffdb10639d5e32c6389d00fccaf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 11:52:06 +0800 Subject: [PATCH 49/49] Rename platform uninstall --delete-resources to --purge-resources Both destructive uninstall flags now use the same verb: --purge-data and --purge-resources. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/platform/installation/uninstall.md | 18 +- docs/reference/cli/platform-installation.md | 4 +- packages/void/src/cli/help.ts | 2 +- packages/void/src/cli/platform-args.ts | 8 +- .../src/cli/platform-installer-database.ts | 2 +- .../void/src/cli/platform-installer-types.ts | 2 +- packages/void/src/cli/platform-management.ts | 126 +++++++------- packages/void/src/cli/platform-teardown.ts | 136 ++++++++------- packages/void/test/unit/platform-args.test.ts | 12 +- .../unit/platform-management-upgrade.test.ts | 2 +- .../test/unit/platform-management.test.ts | 2 +- .../void/test/unit/platform-teardown.test.ts | 160 ++++++++---------- platform/packages/api/src/routes/projects.ts | 2 +- .../integration/zero-trust-uninstall.test.ts | 2 +- 14 files changed, 232 insertions(+), 246 deletions(-) diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index 97141c20c..d3f70d8cf 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -33,7 +33,7 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -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; `--delete-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 `--delete-resources`. +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. @@ -43,37 +43,37 @@ 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, or use `--delete-resources`. +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] --delete-resources --plan -void platform uninstall [id] --delete-resources +void platform uninstall [id] --purge-resources --plan +void platform uninstall [id] --purge-resources ``` -`--delete-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. +`--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, `--delete-resources` stops if: +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 `--delete-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 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 `--delete-resources` to also delete what it kept. Once an uninstall finishes, use the manual steps below instead. +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 -After an uninstall without `--delete-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. 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. diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index 053947064..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 | --delete-resources] [--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. @@ -139,6 +139,6 @@ An upgrade completes after the new Workers pass health checks. If rollout fails, 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. -`--delete-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. +`--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/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 06d187f5f..f339da09d 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -581,7 +581,7 @@ const HELP_TREE: HelpTree = { description: 'Delete installer-owned data even when it contains apps', }, { - command: '--delete-resources', + command: '--purge-resources', description: 'Delete every resource the installation created (includes --purge-data); keeps zones, adopted resources, and PostgreSQL', }, diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index 4269663ce..8bd8f4dd2 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -116,7 +116,7 @@ export type PlatformArgs = plan: boolean; purgeData: boolean; /** Delete every resource the installation created; implies purgeData. */ - deleteResources: boolean; + purgeResources: boolean; deleteZone: boolean | null; yes: boolean; }; @@ -337,7 +337,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { const parsed = parseSelector( rest.filter( (arg) => - !['--purge-data', '--delete-resources', '--delete-zone', '--keep-zone'].includes(arg), + !['--purge-data', '--purge-resources', '--delete-zone', '--keep-zone'].includes(arg), ), subcommand, { allowYes: true }, @@ -349,8 +349,8 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand, selector: parsed.selector, plan: parsed.plan, - purgeData: rest.includes('--purge-data') || rest.includes('--delete-resources'), - deleteResources: rest.includes('--delete-resources'), + 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-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index 719275253..d8bb3a0e4 100644 --- a/packages/void/src/cli/platform-installer-database.ts +++ b/packages/void/src/cli/platform-installer-database.ts @@ -275,7 +275,7 @@ export async function readPlatformProjectZeroTrustState( } } -/** Read project slugs for a --delete-resources plan, which holds no lifecycle lock. */ +/** Read project slugs for a --purge-resources plan, which holds no lifecycle lock. */ export async function readPlatformProjectSlugs( databaseUrl: string, installationId: string, diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index 2d9f1e7b4..0b1a43b7e 100644 --- a/packages/void/src/cli/platform-installer-types.ts +++ b/packages/void/src/cli/platform-installer-types.ts @@ -115,7 +115,7 @@ export type PlatformDatabaseLifecycleLock = (() => Promise) & { ) => Promise; /** Project Zero Trust state that decides whether uninstall may start. */ readProjectZeroTrust?: () => Promise; - /** Project slugs, which --delete-resources requires to be empty. */ + /** 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>; diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 1c96849fa..d8ac6e014 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -61,22 +61,22 @@ import { } from './platform-registry.ts'; import { assertNoPlatformProjects, - checkDeleteResourcesCredentials, - describeDeleteResourcesPlan, + checkPurgeResourcesCredentials, + describePurgeResourcesPlan, isPlatformTailWorker, - isDeleteResourcesCandidate, - isDeletedAccessResource, + isPurgeResourcesCandidate, + isPurgedAccessResource, PlatformResourceChangedError, - deleteResourcesAlwaysKept, - deleteResourcesHandlesEmailRouting, - deleteResourcesKeptReason, - deleteResourcesKeptResources, - deleteResourcesPermissions, - deleteResourcesResumeCommand, - DELETE_RESOURCES_STEP_PREFIX, - runDeleteResourcesTeardown, - type DeleteResourcesClient, - type DeleteResourcesCredential, + 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 { @@ -107,7 +107,7 @@ export type PlatformManagementClient = Pick< export type PlatformUninstallClient = PlatformManagementClient & Pick & - Partial; + Partial; export type PlatformOwnershipClient = Pick< PlatformManagementClient, @@ -180,10 +180,10 @@ export type PlatformManagementDependencies = Readonly<{ fenceProjectZeroTrust?: () => Promise; /** Removes that fence when uninstall stops before it disables traffic. */ liftProjectZeroTrustFence?: () => Promise; - /** Project slugs; --delete-resources refuses to start while any remain or when this cannot read them. */ + /** Project slugs; --purge-resources refuses to start while any remain or when this cannot read them. */ readProjects?: () => Promise>; - /** Where the operator credential came from; --delete-resources checks it before any change. */ - credential?: DeleteResourcesCredential; + /** Where the operator credential came from; --purge-resources checks it before any change. */ + credential?: PurgeResourcesCredential; }>; export function selectAuthoritativePlatformManifest( @@ -901,14 +901,14 @@ export async function uninstallPlatform( dependencies.checkpoint, ); const retainData = !args.purgeData; - const deleteResources = args.deleteResources; + const purgeResources = args.purgeResources; const routingKv = manifest.resources.find((resource) => resource.key === 'routing-kv'); - // --delete-resources also uses routing KV that an uninstall which stopped before it + // --purge-resources also uses routing KV that an uninstall which stopped before it // finished recorded as retained. const reopenedRouting = - deleteResources && + purgeResources && routingKv?.state === 'retained' && - isDeleteResourcesCandidate(manifest, routingKv); + isPurgeResourcesCandidate(manifest, routingKv); const hasRoutingCheckpoint = routingKv?.state === 'ready' || reopenedRouting; const createdZone = manifest.resources.find( (resource) => resource.key === 'zone' && resource.ownership === 'created', @@ -921,7 +921,7 @@ export async function uninstallPlatform( ); } await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); - if (deleteResources) { + if (purgeResources) { await assertNoPlatformProjects(dependencies.readProjects); } const dataMessage = retainData @@ -929,10 +929,10 @@ export async function uninstallPlatform( : 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 deleteResourcesClient = deleteResources ? requireDeleteResourcesClient(client) : null; + const purgeResourcesClient = purgeResources ? requirePurgeResourcesClient(client) : null; note( - deleteResources - ? deleteResourcesPlanText(manifest) + 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.` @@ -947,9 +947,9 @@ export async function uninstallPlatform( .join('')}`, 'Uninstall plan', ); - if (deleteResourcesClient) { - const credentialCheck = checkDeleteResourcesCredentials( - deleteResourcesClient, + if (purgeResourcesClient) { + const credentialCheck = checkPurgeResourcesCredentials( + purgeResourcesClient, manifest, dependencies.credential ?? { source: 'environment', aiGatewayToken: false }, ); @@ -975,7 +975,7 @@ export async function uninstallPlatform( } if (interactive && !args.yes) { const proceed = await confirmAction( - deleteResources + purgeResources ? 'Permanently delete every Cloudflare resource this installation created?' : args.purgeData ? 'Permanently purge installer-owned platform data?' @@ -988,19 +988,19 @@ export async function uninstallPlatform( } } - const deleteResourcesKept = new Set( - deleteResources ? deleteResourcesKeptResources(manifest).map((resource) => resource.key) : [], + const purgeResourcesKept = new Set( + purgeResources ? purgeResourcesKeptResources(manifest).map((resource) => resource.key) : [], ); const resourcesToDelete = new Set( manifest.resources .filter( (resource) => (shouldDeleteResource(resource, retainData) || - (deleteResources && + (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' || isDeleteResourcesCandidate(manifest, resource)) && - !deleteResourcesKept.has(resource.key) && + (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. @@ -1009,7 +1009,7 @@ export async function uninstallPlatform( // by its creation fingerprint instead. !hasLifecycleStep( manifest, - `${DELETE_RESOURCES_STEP_PREFIX}r2-marker:${resource.key}`, + `${PURGE_RESOURCES_STEP_PREFIX}r2-marker:${resource.key}`, ))) && (hasRoutingCheckpoint || resource.kind === 'zone') && !( @@ -1028,7 +1028,7 @@ export async function uninstallPlatform( ...resourcesToDelete, ]); // Ownership checks only look at ready resources. Prove retained ones that - // --delete-resources reopens the same way, without recording them as ready again. + // --purge-resources reopens the same way, without recording them as ready again. const proofManifest = { ...manifest, resources: manifest.resources.map((resource) => @@ -1083,7 +1083,7 @@ export async function uninstallPlatform( let next = manifest; try { await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); - if (deleteResources) { + if (purgeResources) { await assertNoPlatformProjects(dependencies.readProjects); } fenced = true; @@ -1142,10 +1142,10 @@ export async function uninstallPlatform( } } } - if (deleteResourcesClient && hasRoutingCheckpoint) { - next = await runDeleteResourcesTeardown( + if (purgeResourcesClient && hasRoutingCheckpoint) { + next = await runPurgeResourcesTeardown( { - client: deleteResourcesClient, + client: purgeResourcesClient, checkpoint: async (checkpointManifest) => { // Keep `next` current so a failure records the progress made. next = checkpointManifest; @@ -1167,7 +1167,7 @@ export async function uninstallPlatform( ? 'unknown' : r2BucketConfigHash(bucket.name, live.fingerprint); }, - // --delete-resources already refused unless the project list could be read. + // --purge-resources already refused unless the project list could be read. readProjects: () => dependencies.readProjects!(), }, next, @@ -1263,10 +1263,10 @@ export async function uninstallPlatform( next.resources.filter((resource) => resource.state === 'retained'), 'Cloudflare resources:', 'Access resources:', - deleteResources && deleteResourcesHandlesEmailRouting(next), + purgeResources && purgeResourcesHandlesEmailRouting(next), ); - if (deleteResources) { - kept.push('Always kept:', ...deleteResourcesAlwaysKept()); + if (purgeResources) { + kept.push('Always kept:', ...purgeResourcesAlwaysKept()); } if (kept.length) { note(kept.join('\n'), 'Kept for manual cleanup'); @@ -1413,40 +1413,40 @@ function keptResourceRank( : KEPT_RESOURCE_ORDER.indexOf(resource.kind); } -function deleteResourcesPlanText(manifest: PlatformInstallationManifest): string { - const permissions = deleteResourcesPermissions(manifest); +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' || - deleteResourcesKeptReason(manifest, `access:${resource.key}`) !== null, + purgeResourcesKeptReason(manifest, `access:${resource.key}`) !== null, ), }; return [ 'Disable all platform traffic through installation-owned routing storage.', - ...describeDeleteResourcesPlan(manifest), + ...describePurgeResourcesPlan(manifest), ...describeKeptResources( keepManifest, - deleteResourcesKeptResources(manifest), + purgeResourcesKeptResources(manifest), 'Keep for manual cleanup:', 'Keep Access resources you connected:', - deleteResourcesHandlesEmailRouting(manifest), + purgeResourcesHandlesEmailRouting(manifest), ), 'Always kept:', - ...deleteResourcesAlwaysKept(), + ...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: ${deleteResourcesResumeCommand(manifest)}`, + `If it stops, fix the reported problem and rerun: ${purgeResourcesResumeCommand(manifest)}`, ].join('\n'); } -function requireDeleteResourcesClient(client: PlatformUninstallClient): DeleteResourcesClient { - const required: ReadonlyArray = [ +function requirePurgeResourcesClient(client: PlatformUninstallClient): PurgeResourcesClient { + const required: ReadonlyArray = [ 'deleteAccessResource', 'deleteDispatchNamespaceScript', 'deleteEmailRoutingRule', @@ -1466,9 +1466,9 @@ function requireDeleteResourcesClient(client: PlatformUninstallClient): DeleteRe 'resetEmailRoutingCatchAll', ]; if (required.some((method) => typeof client[method] !== 'function')) { - throw new Error('platform: This Cloudflare client cannot run --delete-resources.'); + throw new Error('platform: This Cloudflare client cannot run --purge-resources.'); } - return client as DeleteResourcesClient; + return client as PurgeResourcesClient; } function describeKeptResources( @@ -1476,7 +1476,7 @@ function describeKeptResources( resources: ReadonlyArray, heading: string, accessHeading: string, - /** --delete-resources removes the Email Routing rules that send mail to the gateway Worker. */ + /** --purge-resources removes the Email Routing rules that send mail to the gateway Worker. */ emailRulesRemoved = false, ): Array { const sorted = [...resources].sort( @@ -1485,7 +1485,7 @@ function describeKeptResources( const isTraffic = (resource: InstallationResource) => KEPT_RESOURCE_ORDER.indexOf(resource.kind) < KEPT_RESOURCE_ORDER.indexOf('queue'); const why = (key: string) => { - const reason = deleteResourcesKeptReason(manifest, key); + const reason = purgeResourcesKeptReason(manifest, key); return reason === null ? '' : `, kept because ${reason}`; }; const line = (resource: InstallationResource) => @@ -1509,14 +1509,14 @@ function describeKeptResources( ] : []; const remainingAccess = (manifest.authenticationResources ?? []).filter( - (resource) => !isDeletedAccessResource(manifest, resource), + (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 --delete-resources kept because they changed are listed with the + // 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]) => - deleteResourcesKeptReason(manifest, `access:${resource.key}`) !== null; + purgeResourcesKeptReason(manifest, `access:${resource.key}`) !== null; const kept = [ ...sorted.filter(isTraffic).map(line), ...remainingAccess.filter(keptAccess).map(accessLine), @@ -1927,7 +1927,7 @@ export function isPlatformManifestMonotonicExtension( next.ownership === resource.ownership && (next.state === resource.state || (resource.state === 'ready' && (next.state === 'deleted' || next.state === 'retained')) || - // --delete-resources deletes routes and custom domains recorded as retained. + // --purge-resources deletes routes and custom domains recorded as retained. (resource.state === 'retained' && next.state === 'deleted')) ); }); diff --git a/packages/void/src/cli/platform-teardown.ts b/packages/void/src/cli/platform-teardown.ts index c36d43670..f9bd4da29 100644 --- a/packages/void/src/cli/platform-teardown.ts +++ b/packages/void/src/cli/platform-teardown.ts @@ -12,7 +12,7 @@ import { import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; /** - * `void platform uninstall --delete-resources` deletes every Cloudflare resource the + * `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 @@ -31,13 +31,13 @@ import { platformTrafficAttachmentWorker } from './platform-traffic.ts'; * stopped. The routing KV namespace holds that checkpoint, so uninstall * deletes it last. * - * An uninstall without --delete-resources records what it leaves as retained. If it - * stopped before it finished, --delete-resources considers those resources again. + * 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 DELETE_RESOURCES_STEP_PREFIX = 'uninstall:delete-resources:'; +export const PURGE_RESOURCES_STEP_PREFIX = 'uninstall:purge-resources:'; -export type DeleteResourcesClient = Pick< +export type PurgeResourcesClient = Pick< PlatformCloudflareClient, | 'deleteAccessResource' | 'deleteDispatchNamespaceScript' @@ -67,7 +67,7 @@ export type DeleteResourcesClient = Pick< | 'resetEmailRoutingCatchAll' >; -export type DeleteResourcesCredential = Readonly<{ +export type PurgeResourcesCredential = Readonly<{ source: 'environment' | 'saved' | 'oauth'; /** The runtime token is available for AI Gateway calls. */ aiGatewayToken: boolean; @@ -123,16 +123,16 @@ export function isPlatformTailWorker( ); } -export function deleteResourcesResumeCommand(manifest: PlatformInstallationManifest): string { - return `void platform uninstall ${manifest.id} --delete-resources`; +export function purgeResourcesResumeCommand(manifest: PlatformInstallationManifest): string { + return `void platform uninstall ${manifest.id} --purge-resources`; } function accessStepKey(access: AccessResource): string { - return `${DELETE_RESOURCES_STEP_PREFIX}access:${access.key}`; + return `${PURGE_RESOURCES_STEP_PREFIX}access:${access.key}`; } -/** Access resources that --delete-resources already deleted. */ -export function isDeletedAccessResource( +/** Access resources that --purge-resources already deleted. */ +export function isPurgedAccessResource( manifest: PlatformInstallationManifest, access: AccessResource, ): boolean { @@ -140,7 +140,7 @@ export function isDeletedAccessResource( } function hasStep(manifest: PlatformInstallationManifest, key: string): boolean { - return manifest.steps.some((step) => step.key === `${DELETE_RESOURCES_STEP_PREFIX}${key}`); + return manifest.steps.some((step) => step.key === `${PURGE_RESOURCES_STEP_PREFIX}${key}`); } function keptStepKey(key: string): string { @@ -148,16 +148,16 @@ function keptStepKey(key: string): string { } /** - * Why --delete-resources kept a resource it was going to delete, for example because + * 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 deleteResourcesKeptReason( +export function purgeResourcesKeptReason( manifest: PlatformInstallationManifest, key: string, ): string | null { return ( - manifest.steps.find((step) => step.key === `${DELETE_RESOURCES_STEP_PREFIX}${keptStepKey(key)}`) + manifest.steps.find((step) => step.key === `${PURGE_RESOURCES_STEP_PREFIX}${keptStepKey(key)}`) ?.resultHash ?? null ); } @@ -168,12 +168,12 @@ function isReopenable(manifest: PlatformInstallationManifest): boolean { } /** - * A resource the installation created that --delete-resources may still delete: one + * 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 --delete-resources itself kept, and earlier + * recorded as retained. Resources --purge-resources itself kept, and earlier * versions of a resource kept when it was replaced, are never reconsidered. */ -export function isDeleteResourcesCandidate( +export function isPurgeResourcesCandidate( manifest: PlatformInstallationManifest, resource: InstallationResource, ): boolean { @@ -183,7 +183,7 @@ export function isDeleteResourcesCandidate( (resource.state === 'retained' && isReopenable(manifest) && !resource.key.startsWith('retained:') && - deleteResourcesKeptReason(manifest, resource.key) === null)) + purgeResourcesKeptReason(manifest, resource.key) === null)) ); } @@ -198,7 +198,7 @@ function usableZone(manifest: PlatformInstallationManifest): InstallationResourc /** * A recorded resource whose live configuration no longer matches the - * installation's record. --delete-resources keeps such a DNS record and continues. + * installation's record. --purge-resources keeps such a DNS record and continues. */ export class PlatformResourceChangedError extends Error { constructor( @@ -218,12 +218,12 @@ function emailRoutingZone(manifest: PlatformInstallationManifest): string | null : null; } -/** Everything --delete-resources will delete, in deletion order. */ -function deleteResourcesTargets(manifest: PlatformInstallationManifest): Array { +/** 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) => - isDeleteResourcesCandidate(manifest, resource); + isPurgeResourcesCandidate(manifest, resource); // Without the remote checkpoint nothing can be proven, so nothing is deleted. if (!routing || (routing.state !== 'ready' && !candidate(routing))) { return []; @@ -236,7 +236,7 @@ function deleteResourcesTargets(manifest: PlatformInstallationManifest): Array entry.ownership === 'created' && - !isDeletedAccessResource(manifest, entry) && - deleteResourcesKeptReason(manifest, `access:${entry.key}`) === null, + !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) { @@ -314,22 +314,20 @@ function deleteResourcesTargets(manifest: PlatformInstallationManifest): Array target.phase === 'email') + purgeResourcesTargets(manifest).some((target) => target.phase === 'email') ); } -/** Manifest resources that --delete-resources leaves in the account. */ -export function deleteResourcesKeptResources( +/** Manifest resources that --purge-resources leaves in the account. */ +export function purgeResourcesKeptResources( manifest: PlatformInstallationManifest, ): Array { const targets = new Set( - deleteResourcesTargets(manifest).flatMap((target) => + purgeResourcesTargets(manifest).flatMap((target) => 'resource' in target ? [target.resource.key] : [], ), ); @@ -343,9 +341,9 @@ function resourceLabel(resource: InstallationResource): string { return `${resource.kind}: ${resource.name} (${resource.id})`; } -/** Plan lines for --delete-resources: what is deleted, in order, then what is kept. */ -export function describeDeleteResourcesPlan(manifest: PlatformInstallationManifest): Array { - const targets = deleteResourcesTargets(manifest); +/** 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; @@ -398,14 +396,14 @@ export function describeDeleteResourcesPlan(manifest: PlatformInstallationManife } return lines.length ? [ - 'Delete every resource this installation created (--delete-resources), in this order:', + 'Delete every resource this installation created (--purge-resources), in this order:', ...lines, ] - : ['--delete-resources finds no resource it can prove this installation created.']; + : ['--purge-resources finds no resource it can prove this installation created.']; } -/** Lines naming what --delete-resources never removes. */ -export function deleteResourcesAlwaysKept(): Array { +/** 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.', @@ -414,9 +412,9 @@ export function deleteResourcesAlwaysKept(): Array { ]; } -/** Token permissions --delete-resources needs for this manifest. */ -export function deleteResourcesPermissions(manifest: PlatformInstallationManifest): Array { - const targets = deleteResourcesTargets(manifest); +/** 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') { @@ -467,12 +465,12 @@ function deniedStatus(error: unknown): boolean { * 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 checkDeleteResourcesCredentials( - client: DeleteResourcesClient, +export async function checkPurgeResourcesCredentials( + client: PurgeResourcesClient, manifest: PlatformInstallationManifest, - credential: DeleteResourcesCredential, + credential: PurgeResourcesCredential, ): Promise { - const targets = deleteResourcesTargets(manifest); + 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']) => @@ -495,7 +493,7 @@ export async function checkDeleteResourcesCredentials( ]; if (browserMissing.length) { throw new Error( - `platform: Cloudflare browser login cannot delete every resource --delete-resources plans. Set CLOUDFLARE_API_TOKEN to a token with ${deleteResourcesPermissions(manifest).join(', ')}, scoped to ${scope}, then rerun. Browser login is missing: ${browserMissing.join(', ')}. No Cloudflare resource was changed.`, + `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.`, ); } } @@ -556,7 +554,7 @@ export async function checkDeleteResourcesCredentials( } if (missing.size) { throw new Error( - `platform: The Cloudflare credential cannot read every resource --delete-resources would delete. Add ${[...missing].join(', ')} for ${scope}, then rerun. No Cloudflare resource was changed.`, + `platform: The Cloudflare credential cannot read every resource --purge-resources would delete. Add ${[...missing].join(', ')} for ${scope}, then rerun. No Cloudflare resource was changed.`, ); } } @@ -588,7 +586,7 @@ export async function readPlatformProjectSlugsConnection( /** * Projects own Workers, storage, and domains that only the running platform - * can delete. --delete-resources deletes the platform, so it refuses before any + * can delete. --purge-resources deletes the platform, so it refuses before any * change while a project remains. */ export async function assertNoPlatformProjects( @@ -606,14 +604,14 @@ export async function assertNoPlatformProjects( '', ); throw new Error( - `platform: Refusing --delete-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.`, + `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 --delete-resources while the platform still has ${slugs.length === 1 ? '1 project' : `${slugs.length} projects`}.`, - "--delete-resources deletes the platform, and only the running platform can delete each project's Workers, storage, and domains.", + `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 @@ -624,8 +622,8 @@ export async function assertNoPlatformProjects( } } -export type DeleteResourcesContext = Readonly<{ - client: DeleteResourcesClient; +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. */ @@ -677,7 +675,7 @@ function ownershipRefusal( 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: ${deleteResourcesResumeCommand(manifest)}`, + `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)}`, ); } @@ -691,7 +689,7 @@ function cloudflareRefusal( return error; } return new Error( - `platform: Cloudflare refused to delete ${description}: ${error.message}\n${hint}\nProgress is saved. Rerun: ${deleteResourcesResumeCommand(manifest)}`, + `platform: Cloudflare refused to delete ${description}: ${error.message}\n${hint}\nProgress is saved. Rerun: ${purgeResourcesResumeCommand(manifest)}`, { cause: error }, ); } @@ -700,8 +698,8 @@ function cloudflareRefusal( * 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 runDeleteResourcesTeardown( - context: DeleteResourcesContext, +export async function runPurgeResourcesTeardown( + context: PurgeResourcesContext, initial: PlatformInstallationManifest, ): Promise { const { client } = context; @@ -722,7 +720,7 @@ export async function runDeleteResourcesTeardown( await save( recordInstallationStep( next, - { completedAt: now, key: `${DELETE_RESOURCES_STEP_PREFIX}${key}`, resultHash }, + { completedAt: now, key: `${PURGE_RESOURCES_STEP_PREFIX}${key}`, resultHash }, now, ), ); @@ -749,7 +747,7 @@ export async function runDeleteResourcesTeardown( * 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 --delete-resources is opt-in and its + * 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. */ @@ -798,7 +796,7 @@ export async function runDeleteResourcesTeardown( (resource) => resource.kind === 'worker' && resource.name === name && - isDeleteResourcesCandidate(next, resource), + 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.'; @@ -856,7 +854,7 @@ export async function runDeleteResourcesTeardown( await markDeleted(resource.key); }; - for (const target of deleteResourcesTargets(initial)) { + for (const target of purgeResourcesTargets(initial)) { if (target.phase === 'access') { const { access } = target; const live = await client.getAccessResource(access.accountId, access.kind, access.id); @@ -887,15 +885,15 @@ export async function runDeleteResourcesTeardown( if ( !current || current.state === 'deleted' || - deleteResourcesKeptReason(next, current.key) !== null || - (target.phase !== 'traffic' && !isDeleteResourcesCandidate(next, current)) + 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 || !isDeleteResourcesCandidate(next, worker) || !(await prove(worker))) { + if (!worker || !isPurgeResourcesCandidate(next, worker) || !(await prove(worker))) { // The Worker it served is gone, so the attachment cannot be proven. break; } @@ -1007,7 +1005,7 @@ export async function runDeleteResourcesTeardown( 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. --delete-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: ${deleteResourcesResumeCommand(next)}`, + `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)) { @@ -1092,7 +1090,7 @@ export async function runDeleteResourcesTeardown( await client.deleteR2Object(accountId, bucket.name, PLATFORM_R2_OWNERSHIP_MARKER_KEY); } else { const saved = next.steps.find( - (step) => step.key === `${DELETE_RESOURCES_STEP_PREFIX}${markerStep}`, + (step) => step.key === `${PURGE_RESOURCES_STEP_PREFIX}${markerStep}`, ); const generation = await context.bucketGeneration(next, bucket); if (generation === null) { diff --git a/packages/void/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index 8c1e17290..84330b149 100644 --- a/packages/void/test/unit/platform-args.test.ts +++ b/packages/void/test/unit/platform-args.test.ts @@ -172,26 +172,26 @@ describe('platform args', () => { selector: '', plan: false, purgeData: true, - deleteResources: false, + purgeResources: false, deleteZone: false, yes: false, }); expect(() => parsePlatformArgs(['uninstall', '--delete-zone', '--keep-zone'])).toThrow(); }); - it('treats --delete-resources as a purge of data and every other created resource', () => { - expect(parsePlatformArgs(['uninstall', 'team', '--delete-resources', '--plan'])).toEqual({ + 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, - deleteResources: true, + purgeResources: true, deleteZone: null, yes: false, }); - expect(parsePlatformArgs(['uninstall', '--purge-data', '--delete-resources'])).toMatchObject({ + expect(parsePlatformArgs(['uninstall', '--purge-data', '--purge-resources'])).toMatchObject({ purgeData: true, - deleteResources: true, + purgeResources: true, }); }); diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index a94905cea..b5232c285 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -660,7 +660,7 @@ describe('installed platform upgrade', () => { deleteZone: null, plan, purgeData: true, - deleteResources: false, + 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 a9a4c4c78..962af48cb 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -331,7 +331,7 @@ const uninstallArgs = { selector: '', plan: false, purgeData: false, - deleteResources: false, + purgeResources: false, deleteZone: null, yes: true, }; diff --git a/packages/void/test/unit/platform-teardown.test.ts b/packages/void/test/unit/platform-teardown.test.ts index 433836321..ea8ff88e2 100644 --- a/packages/void/test/unit/platform-teardown.test.ts +++ b/packages/void/test/unit/platform-teardown.test.ts @@ -78,7 +78,7 @@ const emailDeployment = createPlatformDeploymentManifest({ }); const INSTALLATION_ID = 'example-a1b2c3d4'; const FINGERPRINT = '2026-09-14T00:00:00.000Z'; -const RESUME = `void platform uninstall ${INSTALLATION_ID} --delete-resources`; +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'; @@ -560,12 +560,12 @@ function cloudflare(manifest: PlatformInstallationManifest) { return fake; } -const deleteResourcesArgs = { +const purgeResourcesArgs = { subcommand: 'uninstall' as const, selector: '', plan: false, purgeData: true, - deleteResources: true, + purgeResources: true, deleteZone: null, yes: true, }; @@ -637,17 +637,17 @@ const FULL_ORDER = [ `DELETE kv-namespace ${names.routingKv}`, ]; -describe('uninstall --delete-resources', () => { +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, { ...deleteResourcesArgs, plan: true }, dependencies()); + await uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, dependencies()); const plan = noteText('Uninstall plan'); expectInOrder(plan, [ - 'Delete every resource this installation created (--delete-resources), in this order:', + '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}`, @@ -693,7 +693,7 @@ describe('uninstall --delete-resources', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + 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]!)); @@ -751,7 +751,7 @@ describe('uninstall --delete-resources', () => { }).listQueueConsumers(account, queue)), ]; - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); expect(fake.live.consumers.get('cron-id')).toEqual([]); expect(fake.live.consumers.get('usage-id')).toEqual([ @@ -769,14 +769,14 @@ describe('uninstall --delete-resources', () => { fake.failures.set(`DELETE queue ${names.usageQueue}`, new Error('temporary failure')); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + 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, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); // The failed DELETE is retried once; nothing else is sent twice. const sent = deletes(fake); @@ -796,14 +796,14 @@ describe('uninstall --delete-resources', () => { fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + 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]!, - deleteResourcesArgs, + purgeResourcesArgs, dependencies(), ); expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); @@ -816,7 +816,7 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), ).rejects.toThrow('temporary failure'); fake.live.buckets.set(names.assetsBucket, { fingerprint: '2026-10-01T00:00:00.000Z', @@ -824,7 +824,7 @@ describe('uninstall --delete-resources', () => { }); await expect( - uninstallPlatform(fake, listInstallationManifests()[0]!, deleteResourcesArgs, dependencies()), + 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); }); @@ -843,7 +843,7 @@ describe('uninstall --delete-resources', () => { ); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), ).rejects.toThrow('belongs to another installation'); expect(changes(fake)).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('installed'); @@ -858,7 +858,7 @@ describe('uninstall --delete-resources', () => { fake.live.foreignTailProducer = true; await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), ).rejects.toThrow(`Cloudflare refused to delete worker ${names.tailWorker}`); const stopped = listInstallationManifests()[0]!; expect( @@ -867,7 +867,7 @@ describe('uninstall --delete-resources', () => { expect(deletes(fake)).toContain(`DELETE worker ${names.apiWorker}`); fake.live.foreignTailProducer = false; - await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE worker-route route-2'); expect(fake.live.routes).toEqual([ @@ -885,7 +885,7 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); fake.live.domains[0]!.service = 'someone-else'; - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE worker-custom-domain domain-1'); expect(fake.live.domains).toHaveLength(1); @@ -904,7 +904,7 @@ describe('uninstall --delete-resources', () => { .filter((entry) => entry.name === name) .map((entry) => ({ ...entry, content: '192.0.2.1', proxied: false, type: 'A' })); - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain('DELETE dns-record *.apps.example.com'); expect(fake.live.dns).toHaveLength(1); @@ -924,7 +924,7 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); fake.live.access.set('access-app', { accountId: 'account', name: 'renamed' }); - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + 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'); @@ -948,13 +948,13 @@ describe('uninstall --delete-resources', () => { ); fake.live.catchAll = { enabled: true, workerName: gateway }; - await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, dependencies()); + 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, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); expectInOrder(changes(fake).join('\n'), [ 'DELETE access service-token access-token', @@ -976,7 +976,7 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); fake.live.catchAll = { enabled: true, workerName: 'someone-else' }; - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + 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' }); @@ -992,7 +992,7 @@ describe('uninstall --delete-resources', () => { }; await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + 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.', ); @@ -1004,14 +1004,14 @@ describe('uninstall --delete-resources', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, dependencies()); - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + 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 --delete-resources what a stopped --purge-data uninstall kept', async () => { + it('finishes with --purge-resources what a stopped --purge-data uninstall kept', async () => { const manifest = installedManifest(); persist(manifest); const fake = cloudflare(manifest); @@ -1021,7 +1021,7 @@ describe('uninstall --delete-resources', () => { uninstallPlatform( fake, manifest, - { ...deleteResourcesArgs, deleteResources: false }, + { ...purgeResourcesArgs, purgeResources: false }, dependencies(), ), ).rejects.toThrow('Namespace has scripts.'); @@ -1031,7 +1031,7 @@ describe('uninstall --delete-resources', () => { 'retained', ); - await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); expect(new Set(deletes(fake))).toEqual(new Set(FULL_ORDER)); const final = listInstallationManifests()[0]!; @@ -1043,7 +1043,7 @@ describe('uninstall --delete-resources', () => { ).toEqual(['customer-kv:retained', 'zone:retained']); }); - it('finishes with --delete-resources what a stopped plain uninstall kept, including routing KV', async () => { + it('finishes with --purge-resources what a stopped plain uninstall kept, including routing KV', async () => { const manifest = installedManifest(); persist(manifest); const fake = cloudflare(manifest); @@ -1057,7 +1057,7 @@ describe('uninstall --delete-resources', () => { } return putKvValue(account, namespace, key, value); }; - const plain = { ...deleteResourcesArgs, deleteResources: false, purgeData: false }; + const plain = { ...purgeResourcesArgs, purgeResources: false, purgeData: false }; await expect(uninstallPlatform(fake, manifest, plain, dependencies())).rejects.toThrow( 'checkpoint write failed', @@ -1069,7 +1069,7 @@ describe('uninstall --delete-resources', () => { ); expect(deletes(fake)).toEqual([]); - await uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()); expect(deletes(fake)).toEqual(FULL_ORDER); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1093,7 +1093,7 @@ describe('uninstall --delete-resources', () => { uninstallPlatform( fake, manifest, - { ...deleteResourcesArgs, deleteResources: false, purgeData: false }, + { ...purgeResourcesArgs, purgeResources: false, purgeData: false }, dependencies(), ), ).rejects.toThrow('checkpoint write failed'); @@ -1112,7 +1112,7 @@ describe('uninstall --delete-resources', () => { const before = changes(fake).length; await expect( - uninstallPlatform(fake, stopped, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, stopped, purgeResourcesArgs, dependencies()), ).rejects.toThrow('belongs to another installation'); expect(changes(fake).slice(before)).toEqual([]); const final = listInstallationManifests()[0]!; @@ -1123,7 +1123,7 @@ describe('uninstall --delete-resources', () => { ); }); - it('lists an Access resource an earlier --delete-resources kept with the other kept resources, not as connected', async () => { + 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); @@ -1131,11 +1131,11 @@ describe('uninstall --delete-resources', () => { // A later, unrelated stop makes the run resume. fake.live.foreignTailProducer = true; await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), ).rejects.toThrow(`Cloudflare refused to delete worker ${names.tailWorker}`); const stopped = listInstallationManifests()[0]!; - await uninstallPlatform(fake, stopped, { ...deleteResourcesArgs, plan: true }, dependencies()); + 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`; @@ -1157,17 +1157,17 @@ describe('uninstall --delete-resources', () => { await uninstallPlatform( fake, manifest, - { ...deleteResourcesArgs, deleteResources: false, purgeData: false }, + { ...purgeResourcesArgs, purgeResources: false, purgeData: false }, dependencies(), ); const finished = listInstallationManifests()[0]!; expect(finished.status).toBe('uninstalled'); const before = changes(fake).length; - await uninstallPlatform(fake, finished, { ...deleteResourcesArgs, plan: true }, dependencies()); - expect(noteText('Uninstall plan')).toContain('--delete-resources finds no resource'); + await uninstallPlatform(fake, finished, { ...purgeResourcesArgs, plan: true }, dependencies()); + expect(noteText('Uninstall plan')).toContain('--purge-resources finds no resource'); await expect( - uninstallPlatform(fake, finished, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, finished, purgeResourcesArgs, dependencies()), ).rejects.toThrow('Invalid installation transition: uninstalled -> uninstalling.'); expect(changes(fake).slice(before)).toEqual([]); @@ -1181,12 +1181,9 @@ describe('uninstall --delete-resources', () => { fake.live.foreignWorkers.add('customer-worker'); fake.live.serviceBindings.push({ from: 'customer-worker', to: names.proxyWorker as string }); - const error = await uninstallPlatform( - fake, - manifest, - deleteResourcesArgs, - dependencies(), - ).catch((caught: unknown) => caught as Error); + 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`, ); @@ -1203,7 +1200,7 @@ describe('uninstall --delete-resources', () => { await uninstallPlatform( fake, listInstallationManifests()[0]!, - deleteResourcesArgs, + purgeResourcesArgs, dependencies(), ); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1231,7 +1228,7 @@ describe('uninstall --delete-resources', () => { }; await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + 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); @@ -1248,12 +1245,9 @@ describe('uninstall --delete-resources', () => { persist(manifest); const fake = cloudflare(recorded); - const error = await uninstallPlatform( - fake, - manifest, - deleteResourcesArgs, - dependencies(), - ).catch((caught: unknown) => caught as Error); + 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}.`, ); @@ -1269,12 +1263,9 @@ describe('uninstall --delete-resources', () => { throw new PlatformCloudflareApiError('Authentication error (code 10000)', 403); }; - const error = await uninstallPlatform( - fake, - manifest, - deleteResourcesArgs, - dependencies(), - ).catch((caught: unknown) => caught as Error); + 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); @@ -1286,12 +1277,9 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); fake.live.foreignTailProducer = true; - const error = await uninstallPlatform( - fake, - manifest, - deleteResourcesArgs, - dependencies(), - ).catch((caught: unknown) => caught as Error); + 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)`, ); @@ -1307,7 +1295,7 @@ describe('uninstall --delete-resources', () => { await uninstallPlatform( fake, listInstallationManifests()[0]!, - deleteResourcesArgs, + purgeResourcesArgs, dependencies(), ); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1319,24 +1307,24 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); const withProjects = dependencies({ readProjects: async () => ['blog', 'shop'] }); const message = [ - 'platform: Refusing --delete-resources while the platform still has 2 projects.', - "--delete-resources deletes the platform, and only the running platform can delete each project's Workers, storage, and domains.", + '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, { ...deleteResourcesArgs, plan: true }, withProjects), + uninstallPlatform(fake, manifest, { ...purgeResourcesArgs, plan: true }, withProjects), ).rejects.toThrow(message); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, withProjects), + uninstallPlatform(fake, manifest, purgeResourcesArgs, withProjects), ).rejects.toThrow(message); await expect( uninstallPlatform( fake, manifest, - deleteResourcesArgs, + purgeResourcesArgs, dependencies({ readProjects: async () => { throw new Error('connection refused'); @@ -1368,14 +1356,14 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); const oauth = dependencies({ credential: { source: 'oauth', aiGatewayToken: true } }); - await expect(uninstallPlatform(fake, manifest, deleteResourcesArgs, oauth)).rejects.toThrow( + 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, { ...deleteResourcesArgs, plan: true }, oauth); + 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', @@ -1391,9 +1379,9 @@ describe('uninstall --delete-resources', () => { }; await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), ).rejects.toThrow( - 'cannot read every resource --delete-resources would delete. Add Zone → DNS → Edit for account account and zone example.com, then rerun. No Cloudflare resource was changed.', + '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([]); }); @@ -1403,8 +1391,8 @@ describe('uninstall --delete-resources', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, { ...deleteResourcesArgs, plan: true }, dependencies()); - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + 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'); @@ -1421,7 +1409,7 @@ describe('uninstall --delete-resources', () => { persist(manifest); const fake = cloudflare(manifest); - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); expect(fake.live.access.has('access-token')).toBe(false); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1446,7 +1434,7 @@ describe('uninstall --delete-resources', () => { }; await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()), ).rejects.toThrow( 'Add Account → Access: Service Tokens → Edit for accounts account and identity and zone example.com, then rerun.', ); @@ -1460,7 +1448,7 @@ describe('uninstall --delete-resources', () => { // 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, deleteResourcesArgs, dependencies()); + 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]); @@ -1480,7 +1468,7 @@ describe('uninstall --delete-resources', () => { const fake = cloudflare(manifest); delete fake.live.aiGateways[0]!.createdAt; - await uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()); + await uninstallPlatform(fake, manifest, purgeResourcesArgs, dependencies()); expect(deletes(fake)).not.toContain(`DELETE ai-gateway ${names.aiGateway}`); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1495,7 +1483,7 @@ describe('uninstall --delete-resources', () => { const readProjects = async () => (++reads > 2 ? ['late-project'] : []); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies({ readProjects })), + 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.`, ); @@ -1517,14 +1505,14 @@ describe('uninstall --delete-resources', () => { fake.failures.set(`DELETE r2-bucket ${names.assetsBucket}`, new Error('temporary failure')); await expect( - uninstallPlatform(fake, manifest, deleteResourcesArgs, dependencies()), + 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]!, - deleteResourcesArgs, + purgeResourcesArgs, dependencies(), ); expect(fake.live.buckets.has(names.assetsBucket)).toBe(false); diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index b8d257474..d385fb5e6 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -744,7 +744,7 @@ const app = new Hono() const userId = context.get('user').id; try { - // `void platform uninstall --delete-resources` holds this lock across its last + // `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) => { 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 49877bae4..8c4c55d59 100644 --- a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts +++ b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts @@ -267,7 +267,7 @@ describe('platform uninstall check against the API database', () => { }); it.runIf(Boolean(process.env.VOID_TEST_DATABASE_URL))( - 'keeps a project created during the delete-resources final check from committing after it', + '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();