From 64bd3220e076a03ffdccf11f647042c5f12685d7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 2 Oct 2026 09:30:26 +0800 Subject: [PATCH 1/5] Fix native secret draft deployment and document email configuration --- docs/guide/email/domains.md | 4 +- docs/guide/platform/installation/domains.md | 11 +- docs/reference/config.md | 2 +- packages/deploy-cloudflare/src/existing.ts | 22 +- packages/deploy-cloudflare/src/index.ts | 1 + packages/deploy-cloudflare/src/metadata.ts | 6 +- packages/deploy-cloudflare/src/operations.ts | 2 +- .../deploy-cloudflare/src/secret-draft.ts | 104 +++++ packages/deploy-cloudflare/src/version.ts | 195 ++++++++- .../test/secret-draft-fixture.ts | 33 ++ .../test/unit/existing.test.ts | 114 +++++ .../test/unit/metadata.test.ts | 6 + .../test/unit/secret-draft.test.ts | 84 ++++ .../test/unit/version.test.ts | 394 +++++++++++++++++- packages/void/src/cli/cf-version-runner.ts | 22 +- .../src/cli/cloudflare-operation-process.ts | 32 +- packages/void/src/cli/deploy.ts | 24 +- .../void/test/unit/cf-version-runner.test.ts | 62 +++ .../void/test/unit/cloudflare-deploy.test.ts | 93 ++++- .../unit/cloudflare-operation-process.test.ts | 63 +++ platform/packages/dashboard/README.md | 40 ++ 21 files changed, 1260 insertions(+), 54 deletions(-) create mode 100644 packages/deploy-cloudflare/test/secret-draft-fixture.ts create mode 100644 packages/deploy-cloudflare/test/unit/secret-draft.test.ts diff --git a/docs/guide/email/domains.md b/docs/guide/email/domains.md index 7c08a5199..facc88fac 100644 --- a/docs/guide/email/domains.md +++ b/docs/guide/email/domains.md @@ -50,7 +50,9 @@ Use a mail subdomain to keep existing mail on `acme.com` with its current provid Sign in with `void cloudflare login`, or use a `CLOUDFLARE_API_TOKEN` with **Email Routing Edit** and **Email Sending Edit** in addition to deploy permissions. If your browser session lacks email permissions, log out and sign in again. Global API Keys aren't supported. -Run `void deploy --platform cloudflare`. Void shows the domain, routing, sending, DNS, and handler addresses it would configure. Accept to set up email and deploy. Later deploys reuse the setup. Without `email.from`, or if you decline the initial setup, the app deploys without email. +Run `void deploy --platform cloudflare`. Void shows the domain, routing, sending, DNS, and handler addresses it would configure. Accept to set up email and deploy. Later deploys reuse the setup. If you decline the initial setup, the app deploys without email. + +Without `email.from`, Void skips automatic setup and preserves authored `cloudflare.send_email` bindings and `cloudflare.addresses`; check their readiness yourself. `--require-email` still refuses deployment when automatic setup cannot be verified. If subdomain setup needs a dashboard step, follow the checklist under **Email → Settings → Subdomains**, then retry. After DNS changes, check readiness with: diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index 843a5c6d0..29a728b32 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -66,9 +66,14 @@ void platform repair --dashboard-url https://dash.example.com The origin must contain no credentials, path, query, or fragment. Void saves it for login callbacks and keeps it across upgrades and repairs. The command does -not create a dashboard Worker or DNS records; deploy that app separately. Omit -the option to keep the saved origin. The dashboard provides sign-in, linked -login methods, and sign-out; use the CLI for user project and team management. +not create a dashboard Worker or DNS records. Follow the source app’s +[standalone dashboard preparation](https://github.com/voidzero-dev/void/tree/main/platform/packages/dashboard#standalone-deployment) +to deploy your own copy with an unused Worker name, your dashboard hostname, +and the installed API Worker’s service binding. Set its `API_URL` to the public +API origin, `DASHBOARD_URL` to the dashboard origin, and `SITE_DOMAIN` to the +installation’s application domain. Omit the option to keep the saved origin. +The dashboard provides sign-in, linked login methods, and sign-out; use the CLI +for user project and team management. If Access protects the platform, its application must cover this dashboard origin too. Configure the origin when installing protection. To change it on an diff --git a/docs/reference/config.md b/docs/reference/config.md index fad7b776a..ebd29022b 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -156,7 +156,7 @@ Configuration for the [email integration](../guide/email.md). During development, `email.from` is the default sender in the local inbox. On a Void platform, use your project's shared sender or registered domain. -For direct Cloudflare deployment, this address selects the mail domain and default sender. Use a zone or subdomain in your account; an existing mail provider's MX records are preserved. See [Email setup](../guide/email/domains.md#your-own-cloudflare-account). +For direct Cloudflare deployment, this address selects the mail domain and default sender. Use a zone or subdomain in your account; an existing mail provider's MX records are preserved. Without `email.from`, Void skips automatic setup and preserves authored `cloudflare.send_email` bindings and `cloudflare.addresses`; check their readiness yourself. `--require-email` still refuses deployment when automatic setup cannot be verified. See [Email setup](../guide/email/domains.md#your-own-cloudflare-account). ### `head` diff --git a/packages/deploy-cloudflare/src/existing.ts b/packages/deploy-cloudflare/src/existing.ts index e35733445..c3923ae61 100644 --- a/packages/deploy-cloudflare/src/existing.ts +++ b/packages/deploy-cloudflare/src/existing.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util'; import { parseRootWranglerConfig } from './config.ts'; import type { CloudflareCommandRunner } from './execute.ts'; import type { CloudflareOperation } from './operations.ts'; -import { isWranglerSecretOnlyDraft } from './secret-draft.ts'; +import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; export type ExistingCloudflareWorker = { versionId: string; @@ -55,6 +55,7 @@ export async function readExistingCloudflareWorker(options: { async function readCloudflareWorker( options: ExistingCloudflareWorkerOptions, allowMissing: boolean, + proveSecretDraft = false, ): Promise { const target = { configPath: options.configPath, workerName: options.workerName }; const read = async ( @@ -124,6 +125,19 @@ async function readCloudflareWorker( ) { throw new Error('Cloudflare did not return the existing Worker configuration.'); } + let isSecretOnlyDraft = isWranglerSecretOnlyDraft(version); + if ( + proveSecretDraft && + !isSecretOnlyDraft && + record(version.annotations) && + Object.keys(version.annotations).length === 1 && + version.annotations['workers/triggered_by'] === 'secret' + ) { + const source = await read({ type: 'versions.view', versionId, includeSource: true, ...target }); + if (source?.id === versionId) { + isSecretOnlyDraft = isVoidSecretOnlyDraft(source); + } + } const after = options.versionId ? deployment : await read({ type: 'deployments.status', ...target }); @@ -139,7 +153,7 @@ async function readCloudflareWorker( runtime: resources.script_runtime, script: resources.script, }, - isSecretOnlyDraft: isWranglerSecretOnlyDraft(version), + isSecretOnlyDraft, }; } @@ -265,7 +279,7 @@ export async function assertCloudflareDurableObjectLifecycleSupported(options: { if (!migrations.some((migration) => migration.changesClasses)) { return; } - const existing = await readCloudflareWorker(options, true); + const existing = await readCloudflareWorker(options, true, true); if (!existing) { return; } @@ -277,7 +291,7 @@ export async function assertCloudflareDurableObjectLifecycleSupported(options: { workerName: options.workerName, }; const activeTag = await readMigrationTag(migrationOptions); - const after = await readCloudflareWorker(options, false); + const after = await readCloudflareWorker(options, false, true); const afterTag = await readMigrationTag(migrationOptions); if ( !after || diff --git a/packages/deploy-cloudflare/src/index.ts b/packages/deploy-cloudflare/src/index.ts index d54923e7b..3a6ea3f5f 100644 --- a/packages/deploy-cloudflare/src/index.ts +++ b/packages/deploy-cloudflare/src/index.ts @@ -52,6 +52,7 @@ export { type CloudflareVersionMetadata, } from './version.ts'; export { createCloudflareMetadataRunner } from './metadata.ts'; +export { VOID_SECRET_DRAFT_SOURCE } from './secret-draft.ts'; export type { CloudflareOperation, diff --git a/packages/deploy-cloudflare/src/metadata.ts b/packages/deploy-cloudflare/src/metadata.ts index 7a1d2fe6f..cf7e9926c 100644 --- a/packages/deploy-cloudflare/src/metadata.ts +++ b/packages/deploy-cloudflare/src/metadata.ts @@ -41,7 +41,11 @@ export function createCloudflareMetadataRunner(options: { } else if (operation.type === 'versions.list') { kind = 'versions'; resource = '/versions?deployable=true'; - } else if (operation.type === 'versions.view' && /^[0-9a-f-]{36}$/i.test(operation.versionId)) { + } else if ( + operation.type === 'versions.view' && + !operation.includeSource && + /^[0-9a-f-]{36}$/i.test(operation.versionId) + ) { kind = 'version'; resource = '/versions/' + encodeURIComponent(operation.versionId); } diff --git a/packages/deploy-cloudflare/src/operations.ts b/packages/deploy-cloudflare/src/operations.ts index 2481ebcfc..799dfff32 100644 --- a/packages/deploy-cloudflare/src/operations.ts +++ b/packages/deploy-cloudflare/src/operations.ts @@ -27,7 +27,7 @@ export type CloudflareOperation = | ({ type: 'secret.list' | 'secret.bulk' } & WorkerTarget) | ({ type: 'secret.put' | 'secret.delete'; name: string } & WorkerTarget) | ({ type: 'deployments.status' | 'deployments.list' | 'versions.list' } & WorkerTarget) - | ({ type: 'versions.view'; versionId: string } & WorkerTarget) + | ({ type: 'versions.view'; versionId: string; includeSource?: boolean } & WorkerTarget) | ({ type: 'versions.deploy'; versions: Array<{ versionId: string; percentage: number }>; diff --git a/packages/deploy-cloudflare/src/secret-draft.ts b/packages/deploy-cloudflare/src/secret-draft.ts index 4e6324d03..e0be322bb 100644 --- a/packages/deploy-cloudflare/src/secret-draft.ts +++ b/packages/deploy-cloudflare/src/secret-draft.ts @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from 'node:util'; + /** * `wrangler secret put` creates a fetch-only draft before attaching a first secret to a Worker. * Accept that draft as first-deployment proof only when its complete deployable shape contains no @@ -57,6 +59,108 @@ export function isWranglerSecretOnlyDraft(entry: Record): boole ); } +// Keep these bytes aligned with the private placeholder written by Void's +// Cloudflare secret transport. Unlike the legacy Wrangler placeholder, cf +// uploads include a compatibility date, so metadata alone cannot distinguish +// them from an existing fetch-only application after a secret update. +export const VOID_SECRET_DRAFT_SOURCE = + 'export default { fetch() { return new Response(""); } };\n'; + +/** Prove a cf-created placeholder from the exact immutable version's source. */ +export function isVoidSecretOnlyDraft(entry: Record): boolean { + const metadataFields = new Set([ + 'id', + 'number', + 'startup_time_ms', + 'created_on', + 'source', + 'author_id', + 'author_email', + 'urls', + 'annotations', + 'compatibility_date', + 'usage_model', + 'main_module', + 'modules', + 'bindings', + 'env', + // The cf adapter adds this compatibility view to the beta response. + 'resources', + ]); + if ( + Object.keys(entry).some((key) => !metadataFields.has(key)) || + typeof entry.id !== 'string' || + !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(entry.id) || + !Number.isSafeInteger(entry.number) || + (entry.number as number) < 1 || + entry.source !== 'cf_cli' || + entry.usage_model !== 'standard' || + typeof entry.compatibility_date !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(entry.compatibility_date) || + !isRecord(entry.annotations) || + Object.keys(entry.annotations).length !== 1 || + entry.annotations['workers/triggered_by'] !== 'secret' || + entry.main_module !== 'index.js' || + !Array.isArray(entry.urls) || + entry.urls.length !== 0 || + !Array.isArray(entry.modules) || + entry.modules.length !== 1 || + !Array.isArray(entry.bindings) || + entry.bindings.length === 0 + ) { + return false; + } + const module = entry.modules[0]; + if ( + !isRecord(module) || + Object.keys(module).length !== 3 || + module.name !== 'index.js' || + module.content_type !== 'application/javascript+module' || + module.content_base64 !== Buffer.from(VOID_SECRET_DRAFT_SOURCE).toString('base64') + ) { + return false; + } + const names = new Set(); + for (const binding of entry.bindings) { + if ( + !isRecord(binding) || + Object.keys(binding).length !== 2 || + typeof binding.name !== 'string' || + binding.name.length === 0 || + names.has(binding.name) || + binding.type !== 'secret_text' + ) { + return false; + } + names.add(binding.name); + } + if ( + entry.env !== undefined && + (!isRecord(entry.env) || + Object.keys(entry.env).length !== names.size || + Object.entries(entry.env).some( + ([name, binding]) => + !names.has(name) || + !isRecord(binding) || + Object.keys(binding).length !== 1 || + binding.type !== 'secret_text', + )) + ) { + return false; + } + // Never use contradictory adapter metadata as proof of an empty application. + if (entry.resources !== undefined) { + if (!isRecord(entry.resources) || Object.keys(entry.resources).length !== 1) { + return false; + } + const bindings = entry.resources.bindings; + if (!Array.isArray(bindings) || !isDeepStrictEqual(bindings, entry.bindings)) { + return false; + } + } + return true; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 860416130..73092a6dd 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -7,7 +7,7 @@ import { parseRootWranglerConfig } from './config.ts'; import { cloudflareCommandDiagnostic } from './diagnostics.ts'; import type { CloudflareCommandResult, CloudflareCommandRunner } from './execute.ts'; import type { CloudflareUploadResult } from './operations.ts'; -import { isWranglerSecretOnlyDraft } from './secret-draft.ts'; +import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; const DURABLE_MIGRATION_VERSION_ERROR = 10211; @@ -899,6 +899,7 @@ async function activateCloudflareVersionWithSecretsFile( let inheritanceBaseline: Array | undefined; let trustedCandidateChain = false; let activeSecretsPinned = false; + let pinnedActiveSecrets: Array<{ name: string; type: 'secret_key' | 'secret_text' }> | undefined; if (options.expectedPreviousVersionId !== undefined || options.verifyInheritedSecrets === true) { const recent = await readRecentCloudflareVersionIds(options, configPath); if (recent === null) { @@ -964,6 +965,7 @@ async function activateCloudflareVersionWithSecretsFile( }; } activeSecretsPinned = true; + pinnedActiveSecrets = activeSecrets; } } inheritanceBaseline = recent; @@ -1058,24 +1060,64 @@ async function activateCloudflareVersionWithSecretsFile( ); const alias = `void-ready-${Date.now().toString(36)}`; - if (options.atomic && previous.versionId !== null) { - // Pinning the active secrets selects the version-aware uploader for atomic - // publication, including when the active version is the latest upload. - if (!activeSecretsPinned && options.pinActiveSecrets) { - const activeSecrets = await readCloudflareVersionSecretBindings( - options, - configPath, - previous.versionId, - ); - if (activeSecrets?.length) { - await options.pinActiveSecrets(previous.versionId, activeSecrets); + const bootstrapVoidDraft = + !options.preserveTriggers && + !options.requirePreviewForReadiness && + previousVersion?.isVoidSecretOnlyDraft === true; + if (previous.versionId !== null && (options.atomic || bootstrapVoidDraft)) { + // The exact private cf placeholder has no serving preview. Publish its first + // application once through the bootstrap path, pinning its active secrets + // just as for an explicitly atomic release. + if (bootstrapVoidDraft && !activeSecretsPinned && !options.pinActiveSecrets) { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if (bootstrapVoidDraft || (!activeSecretsPinned && options.pinActiveSecrets)) { + let activeSecrets: Awaited>; + try { + activeSecrets = await readCloudflareVersionSecretBindings( + options, + configPath, + previous.versionId, + ); + } catch { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if (activeSecrets === null) { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if ( + bootstrapVoidDraft && + (activeSecrets.length === 0 || + activeSecrets.some((secret) => secret.type !== 'secret_text') || + !arraysEqual( + activeSecrets.map((secret) => secret.name).sort(), + [...previousVersion!.voidDraftSecretNames!].sort(), + ) || + (activeSecretsPinned && + !isDeepStrictEqual( + [...activeSecrets].sort((a, b) => a.name.localeCompare(b.name)), + [...pinnedActiveSecrets!].sort((a, b) => a.name.localeCompare(b.name)), + ))) + ) { + return { ok: false, reason: 'inheritance-source-changed' }; + } + if (!activeSecretsPinned && activeSecrets.length) { + let pinned: boolean; + try { + pinned = await options.pinActiveSecrets!(previous.versionId, activeSecrets); + } catch { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if (!pinned) { + return { ok: false, reason: 'inheritance-source-changed' }; + } } } return bootstrapFirstDeployment( { ...options, message }, configPath, previous.versionId, - true, + options.atomic === true, previousTriggerConfig, ); } @@ -1707,6 +1749,8 @@ async function readCloudflareVersionMetadata( type CloudflareVersionInspection = Readonly<{ metadata: CloudflareVersionMetadata | null; isSecretOnlyDraft: boolean; + isVoidSecretOnlyDraft: boolean; + voidDraftSecretNames: ReadonlyArray | null; }>; async function readCloudflareVersionInspection( @@ -1744,11 +1788,17 @@ async function readCloudflareVersionInspection( if ( annotations?.['workers/triggered_by'] === 'secret' && Object.keys(annotations).length === 1 && - !isRecord(entry.resources) + !isWranglerSecretOnlyDraft(entry) ) { const inspection = await Promise.resolve( options.runWrangler( - { type: 'versions.view', versionId, configPath, workerName: options.workerName }, + { + type: 'versions.view', + versionId, + configPath, + workerName: options.workerName, + includeSource: true, + }, { capture: true }, ), ); @@ -1759,9 +1809,14 @@ async function readCloudflareVersionInspection( } } } + const isVoidDraft = isVoidSecretOnlyDraft(draft); return { metadata: typeof message === 'string' ? parseCloudflareVersionMessage(message) : null, - isSecretOnlyDraft: isWranglerSecretOnlyDraft(draft), + isSecretOnlyDraft: isWranglerSecretOnlyDraft(draft) || isVoidSecretOnlyDraft(draft), + isVoidSecretOnlyDraft: isVoidDraft, + voidDraftSecretNames: isVoidDraft + ? (draft.bindings as Array<{ name: string }>).map((binding) => binding.name) + : null, }; } } catch { @@ -1901,12 +1956,19 @@ async function bootstrapFirstDeployment( if (!options.verifyUploader()) { return { ok: false, reason: 'uploader-changed' }; } - if (atomic) { + { const current = await readCurrentProductionVersion(options, configPath); if (current.kind === 'failed') { return { ok: false, reason: 'status-failed', status: current.status }; } - if (current.kind !== 'ok' || current.versionId !== previousVersionId) { + if ( + current.kind !== 'ok' || + current.versionId !== previousVersionId || + !productionVersionPercentagesEqual( + current.versions, + previousVersionId ? [{ versionId: previousVersionId, percentage: 100 }] : [], + ) + ) { return { ok: false, reason: 'deployment-changed', @@ -1974,10 +2036,6 @@ async function bootstrapFirstDeployment( rollback: 'unsafe', }; } - // A version-aware atomic upload activates code without publishing routes. - // Publish only the active owner's snapshot before probing a first Custom - // Domain; enabling an unrequested workers.dev URL would expose the app. - options.onProgress?.('Updating routes and triggers...'); const triggerOptions = { runWrangler: options.runWrangler, workerName: options.workerName, @@ -1993,6 +2051,99 @@ async function bootstrapFirstDeployment( publishEmptyCustomDomains: options.publishEmptyCustomDomains, publishEmptyCrons: options.publishEmptyCrons, }; + const verifyPublishedOwner = async (): Promise => { + const owner = await readCurrentProductionVersion(options, configPath); + if (owner.kind === 'failed') { + return { + ok: false, + reason: 'status-failed', + status: owner.status, + versionId, + previousVersionId, + rollback: 'unsafe', + }; + } + if ( + owner.kind !== 'ok' || + owner.versionId !== versionId || + !productionVersionPercentagesEqual(owner.versions, [{ versionId, percentage: 100 }]) + ) { + // Another deployment may have published its code while this uploader was + // still applying bootstrap settings. Repair only that owner's immutable + // snapshot. If our code regains ownership, preserve all triggers until + // its upload validation and checkpoint have completed. + const repair = + owner.kind === 'ok' && + owner.versionId && + owner.versionId !== versionId && + options.verifyUploader() + ? await stabilizeCloudflareVersionTriggers({ + ...triggerOptions, + targetTriggerConfig: { ...desiredTriggerConfig, preserve: true }, + }) + : null; + return { + ok: false, + reason: 'deployment-changed', + versionId, + previousVersionId, + activeVersionId: repair?.activeVersionId ?? (owner.kind === 'ok' ? owner.versionId : null), + rollback: 'not-needed', + ...(repair + ? { + triggerRepair: + repair.ok && !repair.targetOwnsTraffic + ? 'succeeded' + : !repair.ok && repair.reason !== 'metadata-unavailable' + ? 'failed' + : 'unavailable', + } + : {}), + }; + } + return null; + }; + const publishedOwnerFailure = await verifyPublishedOwner(); + if (publishedOwnerFailure) { + return publishedOwnerFailure; + } + if (options.validateUploadedVersion || options.recordVerifiedCandidate) { + options.onProgress?.('Verifying the uploaded Worker...'); + try { + await options.validateUploadedVersion?.(versionId); + const validatedOwnerFailure = await verifyPublishedOwner(); + if (validatedOwnerFailure) { + return validatedOwnerFailure; + } + if (options.recordVerifiedCandidate) { + if ( + !options.validateUploadedVersion && + !options.verifyInheritedSecrets && + options.expectedPreviousVersionId === undefined + ) { + throw new Error('The bootstrap upload has no verified candidate checkpoint.'); + } + await options.recordVerifiedCandidate(versionId, previousVersionId); + } + } catch { + return { + ok: false, + reason: atomic ? 'atomic-failed' : 'bootstrap-failed', + versionId, + previousVersionId, + rollback: 'unavailable', + diagnostic: `Worker version ${versionId} was published, but its upload validation or candidate checkpoint failed. Inspect the current Worker deployment before retrying.`, + }; + } + const checkpointOwnerFailure = await verifyPublishedOwner(); + if (checkpointOwnerFailure) { + return checkpointOwnerFailure; + } + } + // A version-aware atomic upload activates code without publishing routes. + // Publish only the active owner's snapshot before probing a first Custom + // Domain; enabling an unrequested workers.dev URL would expose the app. + options.onProgress?.('Updating routes and triggers...'); const triggers = await stabilizeCloudflareVersionTriggers(triggerOptions); if (!triggers.ok || !triggers.targetOwnsTraffic) { const superseded = diff --git a/packages/deploy-cloudflare/test/secret-draft-fixture.ts b/packages/deploy-cloudflare/test/secret-draft-fixture.ts new file mode 100644 index 000000000..62f52b0a0 --- /dev/null +++ b/packages/deploy-cloudflare/test/secret-draft-fixture.ts @@ -0,0 +1,33 @@ +/** Sanitized beta shape observed on an immutable cf-created secret placeholder. */ +export function voidSecretDraftVersion(): Record & { + id: string; + annotations: Record; + bindings: Array<{ name: string; type: string }>; + modules: Array<{ name: string; content_type: string; content_base64: string }>; + compatibility_date: string; +} { + return { + id: '11111111-1111-4111-8111-111111111111', + number: 2, + startup_time_ms: 2, + compatibility_date: '2026-10-01', + main_module: 'index.js', + annotations: { 'workers/triggered_by': 'secret' }, + usage_model: 'standard', + urls: [], + source: 'cf_cli', + created_on: '2026-10-01T23:16:24.453175Z', + modules: [ + { + name: 'index.js', + content_type: 'application/javascript+module', + content_base64: Buffer.from( + 'export default { fetch() { return new Response(""); } };\n', + ).toString('base64'), + }, + ], + bindings: [{ name: 'TOKEN', type: 'secret_text' }], + env: { TOKEN: { type: 'secret_text' } }, + author_id: 'synthetic-author', + }; +} diff --git a/packages/deploy-cloudflare/test/unit/existing.test.ts b/packages/deploy-cloudflare/test/unit/existing.test.ts index d9229d5f3..a3b411665 100644 --- a/packages/deploy-cloudflare/test/unit/existing.test.ts +++ b/packages/deploy-cloudflare/test/unit/existing.test.ts @@ -13,7 +13,9 @@ import { assertCloudflareBindingsPreserved, type ExistingCloudflareWorker, } from '../../src/existing.ts'; +import type { CloudflareOperation } from '../../src/operations.ts'; import { operationFixture } from '../operation-fixture.ts'; +import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; const worker: ExistingCloudflareWorker = { versionId: 'original', @@ -503,6 +505,118 @@ describe('existing Worker preservation', () => { } }); + it.each(['placeholder', 'real-code', 'owner-drift'] as const)( + 'verifies immutable SDK secret-draft proof before its first DO migration: %s', + async (scenario) => { + const root = mkdtempSync(join(tmpdir(), 'void-sdk-draft-lifecycle-')); + const configPath = join(root, 'wrangler.jsonc'); + writeFileSync( + configPath, + JSON.stringify({ migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }), + ); + const source = voidSecretDraftVersion(); + if (scenario === 'real-code') { + source.modules[0]!.content_base64 = Buffer.from( + 'export default { fetch() { return new Response("app"); } };', + ).toString('base64'); + } + let owner = source.id; + const runWrangler = vi.fn( + async (operation: CloudflareOperation): Promise => { + if (operation.type === 'deployments.status') { + return { + status: 0, + stdout: '', + stderr: '', + data: { versions: [{ version_id: owner, percentage: 100 }] }, + }; + } + if (operation.type === 'versions.view') { + if (operation.includeSource) { + if (scenario === 'owner-drift') { + owner = '22222222-2222-4222-8222-222222222222'; + } + return { status: 0, stdout: '', stderr: '', data: source }; + } + return { + status: 0, + stdout: '', + stderr: '', + data: { + id: operation.versionId, + annotations: source.annotations, + resources: { + bindings: source.bindings, + script: { handlers: ['fetch'] }, + script_runtime: { + compatibility_date: source.compatibility_date, + usage_model: 'standard', + }, + }, + }, + }; + } + throw new Error(`Unexpected read-only lifecycle operation: ${operation.type}`); + }, + ); + try { + const inspection = assertCloudflareDurableObjectLifecycleSupported({ + runWrangler, + configPath, + workerName: 'app', + accountId: 'account', + apiBaseUrl: 'https://api.cloudflare.test/client/v4', + readMigrationTag: async () => null, + }); + if (scenario === 'placeholder') { + await expect(inspection).resolves.toBeUndefined(); + } else { + await expect(inspection).rejects.toThrow( + scenario === 'owner-drift' ? 'production changed' : 'atomic Cloudflare deployment', + ); + } + expect( + runWrangler.mock.calls.filter( + ([operation]) => operation.type === 'versions.view' && operation.includeSource, + ), + ).toHaveLength(scenario === 'owner-drift' ? 1 : 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it('does not request source for an ordinary existing Worker read', async () => { + const runWrangler = vi.fn( + async (operation: CloudflareOperation): Promise => ({ + status: 0, + stdout: '', + stderr: '', + data: + operation.type === 'deployments.status' + ? { versions: [{ version_id: worker.versionId, percentage: 100 }] } + : { + id: worker.versionId, + resources: { + bindings: worker.bindings, + script: worker.script, + script_runtime: worker.runtime, + }, + }, + }), + ); + await expect( + readExistingCloudflareWorker({ + runWrangler, + configPath: '/app/wrangler.jsonc', + workerName: 'app', + }), + ).resolves.toEqual(worker); + expect( + runWrangler.mock.calls.some(([op]) => op.type === 'versions.view' && op.includeSource), + ).toBe(false); + }); + it.each>([ { signal: 'SIGTERM' }, { error: 'provider process failed' }, diff --git a/packages/deploy-cloudflare/test/unit/metadata.test.ts b/packages/deploy-cloudflare/test/unit/metadata.test.ts index 02d22a618..2545423ca 100644 --- a/packages/deploy-cloudflare/test/unit/metadata.test.ts +++ b/packages/deploy-cloudflare/test/unit/metadata.test.ts @@ -86,6 +86,12 @@ describe('Cloudflare metadata transport', () => { }); it.each([ + { + type: 'versions.view', + versionId: '11111111-1111-4111-8111-111111111111', + includeSource: true, + ...suffix, + }, { type: 'versions.upload', ...suffix }, { type: 'versions.deploy', versions: [], ...suffix }, { type: 'deployments.status', configPath: 'another.json', workerName: 'worker' }, diff --git a/packages/deploy-cloudflare/test/unit/secret-draft.test.ts b/packages/deploy-cloudflare/test/unit/secret-draft.test.ts new file mode 100644 index 000000000..a5b733a89 --- /dev/null +++ b/packages/deploy-cloudflare/test/unit/secret-draft.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from '../../src/secret-draft.ts'; +import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; + +describe('immutable SDK secret draft proof', () => { + it('recognizes the actual cf placeholder without treating its compatibility date as proof', () => { + const draft = voidSecretDraftVersion(); + expect(isWranglerSecretOnlyDraft(draft)).toBe(false); + expect(isVoidSecretOnlyDraft(draft)).toBe(true); + expect(isVoidSecretOnlyDraft({ ...draft, modules: undefined })).toBe(false); + }); + + it.each([ + [ + 'application code', + { + modules: [ + { + name: 'index.js', + content_type: 'application/javascript+module', + content_base64: Buffer.from( + 'export default { fetch() { return new Response("application"); } };\n', + ).toString('base64'), + }, + ], + }, + ], + [ + 'another module', + { + modules: [ + ...voidSecretDraftVersion().modules, + { name: 'data.txt', content_type: 'text/plain', content_base64: '' }, + ], + }, + ], + ['different main', { main_module: 'other.js' }], + ['stateful exports', { exports: { Room: { type: 'durable-object' } } }], + ['assets', { assets: {} }], + ['settings', { compatibility_flags: ['nodejs_compat'] }], + [ + 'a different annotation', + { annotations: { 'workers/triggered_by': 'secret', 'workers/message': 'an application' } }, + ], + [ + 'a non-secret resource', + { + bindings: [ + ...voidSecretDraftVersion().bindings, + { name: 'KV', type: 'kv_namespace', namespace_id: 'foreign' }, + ], + }, + ], + ['a secret key', { bindings: [{ name: 'TOKEN', type: 'secret_key' }] }], + ['a secret value', { bindings: [{ name: 'TOKEN', type: 'secret_text', text: 'private' }] }], + [ + 'duplicate bindings', + { bindings: [...voidSecretDraftVersion().bindings, ...voidSecretDraftVersion().bindings] }, + ], + [ + 'a contradictory env', + { env: { TOKEN: { type: 'secret_text' }, OTHER: { type: 'text', value: 'private' } } }, + ], + ['a public version URL', { urls: ['https://version.example.workers.dev'] }], + ['an invalid UUID', { id: 'latest' }], + ['an invalid version number', { number: 0 }], + ['an unknown uploader', { source: 'api' }], + [ + 'contradictory adapter bindings', + { resources: { bindings: [{ name: 'OTHER', type: 'secret_text' }] } }, + ], + ])('refuses %s', (_name, patch) => { + expect(isVoidSecretOnlyDraft({ ...voidSecretDraftVersion(), ...patch })).toBe(false); + }); + + it('accepts only the adapter compatibility binding view, independent of object key order', () => { + expect( + isVoidSecretOnlyDraft({ + ...voidSecretDraftVersion(), + resources: { bindings: [{ type: 'secret_text', name: 'TOKEN' }] }, + }), + ).toBe(true); + }); +}); diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index b4e044648..b8547d112 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -12,13 +12,149 @@ import { parseCloudflareVersionMessage, readCloudflareTriggerConfig, type CloudflareCommandResult, + type CloudflareOperation, } from '../../src/index.ts'; import { operationFixture } from '../operation-fixture.ts'; +import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; const CURRENT = '11111111-1111-4111-8111-111111111111'; const NEXT = '22222222-2222-4222-8222-222222222222'; const CONCURRENT = '33333333-3333-4333-8333-333333333333'; +function sdkDraftActivation(source: Record = voidSecretDraftVersion()) { + const events: Array = []; + const operations: Array = []; + const triggerConfig = { + workers_dev: true, + preview_urls: true, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }; + const message = encodeCloudflareVersionMessage([], triggerConfig); + let traffic = [{ version_id: CURRENT, percentage: 100 }]; + let uploaded = false; + let published = false; + let triggers = false; + let ordinary = false; + let readSource = async () => source; + const runWrangler = vi.fn( + async (operation: CloudflareOperation): Promise => { + operations.push(operation); + if (operation.type === 'deployments.status') { + return { ...ok(), data: { versions: traffic } }; + } + if (operation.type === 'versions.list') { + return { + ...ok(), + data: [ + { + id: CURRENT, + number: 2, + annotations: ordinary + ? { 'workers/message': message } + : { 'workers/triggered_by': 'secret' }, + }, + ...(uploaded + ? [{ id: NEXT, number: 3, annotations: { 'workers/message': message } }] + : []), + ], + }; + } + if (operation.type === 'versions.view') { + if (operation.includeSource) { + events.push('source'); + return { ...ok(), data: await readSource() }; + } + return { + ...ok(), + data: { + id: operation.versionId, + resources: { bindings: [{ name: 'TOKEN', type: 'secret_text' }] }, + }, + }; + } + if (operation.type === 'versions.upload') { + events.push('upload'); + uploaded = true; + return { + ...ok(), + data: { + versionId: NEXT, + previewUrl: `https://${NEXT.slice(0, 8)}-app.example.workers.dev`, + }, + }; + } + if (operation.type === 'worker.deploy') { + events.push('publish'); + uploaded = true; + published = true; + traffic = [{ version_id: NEXT, percentage: 100 }]; + return { + ...ok(), + data: { versionId: NEXT, workersDevUrl: 'https://app.example.workers.dev' }, + }; + } + if (operation.type === 'versions.deploy') { + events.push('activate'); + traffic = operation.versions.map(({ versionId, percentage }) => ({ + version_id: versionId, + percentage, + })); + return ok(); + } + if (operation.type.startsWith('triggers.')) { + events.push('triggers'); + triggers = true; + return ok(); + } + throw new Error(`Unexpected SDK draft operation: ${operation.type}`); + }, + ); + const pinActiveSecrets = vi.fn(async (id: string, bindings: unknown) => { + expect(id).toBe(CURRENT); + expect(bindings).toEqual([{ name: 'TOKEN', type: 'secret_text' }]); + events.push('pin'); + return true; + }); + const fetcher = vi.fn(async (url: string | URL | Request) => { + if (published) { + expect(triggers).toBe(true); + expect(String(url)).toBe('https://app.example.workers.dev/__void/ready'); + } + events.push('ready'); + return Response.json({ ready: true, versionId: NEXT }); + }); + return { + events, + operations, + runWrangler, + pinActiveSecrets, + fetcher, + changeOwner: (versions = [{ version_id: CONCURRENT, percentage: 100 }]) => { + traffic = versions; + }, + sourceRead: (read: () => Promise>) => { + readSource = read; + }, + ordinary: () => { + ordinary = true; + }, + options: { + runWrangler, + pinActiveSecrets, + fetch: fetcher, + workerName: 'app', + message, + verifyUploader: () => true, + verifyInheritedSecrets: true, + requireVersionMetadata: true, + readinessAttempts: 1, + }, + }; +} + // The bundled uploader rejects locally without an API error code; keep its actual diagnostic. const LOCAL_DURABLE_MIGRATION_ERROR = 'This Worker has a pending Durable Object migration, which cannot be applied by `wrangler versions upload`. Durable Object migrations must be applied with `wrangler deploy`. Run `wrangler deploy` to apply the migration, then retry `wrangler versions upload`.'; @@ -159,6 +295,251 @@ function runner(overrides?: { } describe('Cloudflare version activation', () => { + it('publishes an exact private SDK draft once, proving secrets and callbacks before readiness', async () => { + const fixture = sdkDraftActivation(); + const validateUploadedVersion = vi.fn(async (id: string) => { + expect(id).toBe(NEXT); + fixture.events.push('validate'); + }); + const recordVerifiedCandidate = vi.fn(async (id: string, parent: string | null) => { + expect([id, parent]).toEqual([NEXT, CURRENT]); + fixture.events.push('checkpoint'); + }); + const result = await activateCloudflareVersion({ + ...fixture.options, + validateUploadedVersion, + recordVerifiedCandidate, + }); + expect(result).toMatchObject({ ok: true, versionId: NEXT, previousVersionId: CURRENT }); + expect(fixture.events.slice(0, 5)).toEqual([ + 'source', + 'pin', + 'publish', + 'validate', + 'checkpoint', + ]); + expect(fixture.operations.filter((op) => op.type === 'worker.deploy')).toHaveLength(1); + expect( + fixture.operations.some( + (op) => op.type === 'versions.upload' || op.type === 'versions.deploy', + ), + ).toBe(false); + expect(fixture.fetcher).toHaveBeenCalledOnce(); + }); + + it('does not overwrite a deployment that changed during immutable draft source inspection', async () => { + const fixture = sdkDraftActivation(); + const entered = deferred(), + release = deferred>(); + fixture.sourceRead(() => { + entered.resolve(); + return release.promise; + }); + const activation = activateCloudflareVersion(fixture.options); + await entered.promise; + fixture.changeOwner(); + release.resolve(voidSecretDraftVersion()); + expect(await activation).toMatchObject({ + ok: false, + reason: 'deployment-changed', + activeVersionId: CONCURRENT, + }); + expect( + fixture.operations.some((op) => + ['worker.deploy', 'versions.upload', 'versions.deploy', 'triggers.deploy'].includes( + op.type, + ), + ), + ).toBe(false); + expect(fixture.fetcher).not.toHaveBeenCalled(); + }); + + it('refuses extra zero-percent traffic before publishing a draft', async () => { + const fixture = sdkDraftActivation(); + fixture.sourceRead(async () => { + fixture.changeOwner([ + { version_id: CURRENT, percentage: 100 }, + { version_id: CONCURRENT, percentage: 0 }, + ]); + return voidSecretDraftVersion(); + }); + expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ + ok: false, + reason: 'deployment-changed', + }); + expect(fixture.events).not.toContain('publish'); + }); + + it.each(['missing', 'refused', 'unreadable', 'failed'] as const)( + 'refuses %s required secret pinning before publishing an SDK draft', + async (kind) => { + const fixture = sdkDraftActivation(); + if (kind === 'refused') { + fixture.pinActiveSecrets.mockResolvedValue(false); + } + if (kind === 'failed') { + fixture.pinActiveSecrets.mockRejectedValue(new Error('private synthetic pin detail')); + } + const original = fixture.runWrangler.getMockImplementation()!; + if (kind === 'unreadable') { + fixture.runWrangler.mockImplementation(async (op) => + op.type === 'versions.view' && !op.includeSource + ? { status: 1, stdout: '', stderr: 'synthetic failure' } + : original(op), + ); + } + const result = await activateCloudflareVersion({ + ...fixture.options, + pinActiveSecrets: kind === 'missing' ? undefined : fixture.pinActiveSecrets, + }); + expect(result).toMatchObject({ + ok: false, + reason: + kind === 'refused' ? 'inheritance-source-changed' : 'inheritance-source-unavailable', + }); + expect(fixture.events).not.toContain('publish'); + expect(fixture.fetcher).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { bindings: [] }, + { bindings: [{ name: 'OTHER', type: 'secret_text' }] }, + { bindings: [{ name: 'TOKEN', type: 'secret_key' }] }, + { + bindings: [ + { name: 'TOKEN', type: 'secret_text' }, + { name: 'TOKEN', type: 'secret_text' }, + ], + }, + ])('refuses active secrets that contradict the immutable SDK draft: %j', async ({ bindings }) => { + const fixture = sdkDraftActivation(); + const original = fixture.runWrangler.getMockImplementation()!; + fixture.runWrangler.mockImplementation(async (op) => + op.type === 'versions.view' && !op.includeSource + ? { ...ok(), data: { id: CURRENT, resources: { bindings } } } + : original(op), + ); + expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ + ok: false, + reason: 'inheritance-source-changed', + }); + expect(fixture.pinActiveSecrets).not.toHaveBeenCalled(); + expect(fixture.events).not.toContain('publish'); + expect(fixture.fetcher).not.toHaveBeenCalled(); + }); + + it('honors a refused active-secret pin for an atomic existing app', async () => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + fixture.pinActiveSecrets.mockResolvedValue(false); + expect(await activateCloudflareVersion({ ...fixture.options, atomic: true })).toMatchObject({ + ok: false, + reason: 'inheritance-source-changed', + }); + expect(fixture.events).not.toContain('publish'); + }); + + it.each(['validation', 'checkpoint'] as const)( + 'reports a published UUID and stops triggers/readiness after %s fails', + async (stage) => { + const fixture = sdkDraftActivation(); + const validateUploadedVersion = vi.fn(async () => { + if (stage === 'validation') { + throw new Error('private synthetic detail'); + } + }); + const recordVerifiedCandidate = vi.fn(async () => { + throw new Error('private synthetic detail'); + }); + const result = await activateCloudflareVersion({ + ...fixture.options, + validateUploadedVersion, + recordVerifiedCandidate, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'bootstrap-failed', + versionId: NEXT, + rollback: 'unavailable', + }); + expect(result.ok ? '' : result.diagnostic).toContain(NEXT); + expect(result.ok ? '' : result.diagnostic).not.toContain('private synthetic detail'); + expect(fixture.events).toContain('publish'); + expect(fixture.events).not.toContain('triggers'); + expect(fixture.fetcher).not.toHaveBeenCalled(); + expect(recordVerifiedCandidate).toHaveBeenCalledTimes(stage === 'validation' ? 0 : 1); + }, + ); + + it('does not record a checkpoint or publish triggers after validation observes a newer owner', async () => { + const fixture = sdkDraftActivation(); + const recordVerifiedCandidate = vi.fn(); + const result = await activateCloudflareVersion({ + ...fixture.options, + validateUploadedVersion: async () => { + fixture.changeOwner(); + }, + recordVerifiedCandidate, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'deployment-changed', + activeVersionId: CONCURRENT, + }); + expect(recordVerifiedCandidate).not.toHaveBeenCalled(); + expect(fixture.events).not.toContain('triggers'); + expect(fixture.fetcher).not.toHaveBeenCalled(); + }); + + it.each([ + { + modules: [ + { + ...voidSecretDraftVersion().modules[0], + content_base64: Buffer.from( + 'export default { fetch() { return new Response("real app"); } };', + ).toString('base64'), + }, + ], + }, + { + bindings: [ + ...voidSecretDraftVersion().bindings, + { name: 'KV', type: 'kv_namespace', namespace_id: 'foreign' }, + ], + }, + { assets: {} }, + ])('retains preview-first activation for a secret-triggered real app with %j', async (patch) => { + const fixture = sdkDraftActivation({ ...voidSecretDraftVersion(), ...patch }); + expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ + ok: true, + versionId: NEXT, + }); + expect(fixture.events).not.toContain('publish'); + expect(fixture.events).toContain('upload'); + }); + + it('does not inspect source or bootstrap an ordinary version', async () => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ + ok: true, + versionId: NEXT, + }); + expect(fixture.events).not.toContain('source'); + expect(fixture.events).not.toContain('publish'); + }); + + it('never uses the draft bootstrap to bypass an explicit preview requirement', async () => { + const fixture = sdkDraftActivation(); + expect( + await activateCloudflareVersion({ ...fixture.options, requirePreviewForReadiness: true }), + ).toMatchObject({ ok: true }); + expect(fixture.events).not.toContain('publish'); + expect(fixture.events).toContain('upload'); + }); + it('publishes explicitly atomic Durable Object deployments without a candidate stage', async () => { const runWrangler = runner({ bootstrap: ok(`Worker Version ID: ${NEXT}\nhttps://my-worker.example.workers.dev`), @@ -2338,8 +2719,10 @@ describe('Cloudflare version activation', () => { expect(runWrangler.mock.calls.map(([args]) => args[0])).toEqual([ 'deployments', 'versions', + 'deployments', 'deploy', 'deployments', + 'deployments', 'triggers', 'deployments', 'deployments', @@ -3133,15 +3516,18 @@ describe('Cloudflare version activation', () => { expect(runWrangler.mock.calls.map(([args]) => args.slice(0, 2))).toEqual([ ['deployments', 'status'], ['versions', 'upload'], + ['deployments', 'status'], ['deploy', '-c'], ['deployments', 'status'], + ['deployments', 'status'], ['triggers', 'deploy'], ['deployments', 'status'], ['deployments', 'status'], ]); - expect(runWrangler.mock.calls[2]![0]).toContain('--autoconfig=false'); - expect(runWrangler.mock.calls[2]![0]).toContain('--assets'); - expect(runWrangler.mock.calls[2]![0]).toContain('/app/.void/deploy/cloudflare/assets'); + const deployArgs = runWrangler.mock.calls.find(([args]) => args[0] === 'deploy')![0]; + expect(deployArgs).toContain('--autoconfig=false'); + expect(deployArgs).toContain('--assets'); + expect(deployArgs).toContain('/app/.void/deploy/cloudflare/assets'); expect(secretPayloads).toEqual([ { API_TOKEN: 'first-secret' }, { API_TOKEN: 'first-secret' }, @@ -3205,8 +3591,10 @@ describe('Cloudflare version activation', () => { expect(runWrangler.mock.calls.map(([args]) => args[0])).toEqual([ 'deployments', 'versions', + 'deployments', 'deploy', 'deployments', + 'deployments', 'triggers', 'deployments', 'deployments', diff --git a/packages/void/src/cli/cf-version-runner.ts b/packages/void/src/cli/cf-version-runner.ts index 122b5e376..81ec261fb 100644 --- a/packages/void/src/cli/cf-version-runner.ts +++ b/packages/void/src/cli/cf-version-runner.ts @@ -589,10 +589,24 @@ export function createCfVersionRunner(options: { } if (operation.type === 'versions.view') { - const result = await run( - ['workers', 'versions', 'get', operation.versionId, '--worker-id', workerName], - true, - ); + const args = [ + 'workers', + 'versions', + 'get', + operation.versionId, + '--worker-id', + workerName, + ...(operation.includeSource ? ['--include', 'modules'] : []), + ]; + const called = operation.includeSource + ? await runCf(args, { + cwd: output.directory, + capture: true, + timeoutMs: 30_000, + maxOutputBytes: 1024 * 1024, + }) + : await run(args, true); + const result = { ...called, data: called.data ?? parseJson(called.stdout) }; if (result.status !== 0 || result.signal || result.error) { return result; } diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index f540c5cb7..fd0f42b1d 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -84,7 +84,11 @@ function result(status: number, stdout = '', stderr = '', data?: unknown): Resul }; } -function runCf(command: Array, cwd = process.cwd()): Result { +function runCf( + command: Array, + cwd = process.cwd(), + bounds?: { timeoutMs: number; maxOutputBytes: number }, +): Result { const env = cfEnvironment(); env.CF_QUIET = '1'; const child = spawnSync(process.execPath, [cf, ...command], { @@ -93,7 +97,8 @@ function runCf(command: Array, cwd = process.cwd()): Result { encoding: 'utf8', shell: false, windowsHide: true, - maxBuffer: 32 * 1024 * 1024, + maxBuffer: bounds?.maxOutputBytes ?? 32 * 1024 * 1024, + ...(bounds ? { timeout: bounds.timeoutMs } : {}), stdio: ['ignore', 'pipe', 'pipe'], }); const stderr = [ @@ -187,10 +192,8 @@ async function withBuildOutput( if (sourceMain) { copyFileSync(sourceMain, join(scratch, main)); } else { - writeFileSync( - join(scratch, main), - 'export default { fetch() { return new Response(""); } };\n', - ); + const { VOID_SECRET_DRAFT_SOURCE } = await import('@void/deploy-cloudflare'); + writeFileSync(join(scratch, main), VOID_SECRET_DRAFT_SOURCE); } writeFileSync( join(scratch, 'wrangler.json'), @@ -925,7 +928,19 @@ async function workerHistory(): Promise { }); case 'versions.view': { const versionId = operation.versionId; - const called = runCf(['workers', 'versions', 'get', versionId, '--worker-id', worker]); + const called = runCf( + [ + 'workers', + 'versions', + 'get', + versionId, + '--worker-id', + worker, + ...(operation.includeSource ? ['--include', 'modules'] : []), + ], + process.cwd(), + operation.includeSource ? { timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 } : undefined, + ); if (called.status !== 0) { return called; } @@ -933,6 +948,9 @@ async function workerHistory(): Promise { if (version.id !== versionId || !Array.isArray(version.bindings)) { throw new Error('Worker version bindings were malformed.'); } + if (operation.includeSource) { + return result(0, '', called.stderr, version); + } const resources = await readVersionResources(worker, versionId); return result(0, '', called.stderr, { ...version, resources }); } diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 7f33f2133..a6e6f3947 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -4011,12 +4011,24 @@ async function runCloudflareEmailStep( const without: CloudflareEmailStepResult = { satisfied: false, configWritten: false, map: null }; const from = input.voidConfig.email?.from; if (!from) { - log.warn( - [ - 'deploy: this app uses email, but void.config.ts names no sender.', - 'Set email.from to "you@mail.acme.com" in void.config.ts — deploying without email.', - ].join('\n'), - ); + const carriesEmailConfig = + (Array.isArray(input.rootConfig.send_email) && + (input.rootConfig.send_email as Array<{ name?: unknown } | null>).some( + (binding) => binding?.name === SEND_EMAIL_BINDING, + )) || + (Array.isArray(input.rootConfig.addresses) && input.rootConfig.addresses.length > 0); + if (carriesEmailConfig) { + log.info( + 'deploy: email.from is unset; skipping automatic email setup. Existing Cloudflare email configuration is preserved.', + ); + } else { + log.warn( + [ + 'deploy: this app uses email, but void.config.ts names no sender.', + 'Set email.from to "you@mail.acme.com" in void.config.ts — automatic email setup skipped.', + ].join('\n'), + ); + } return without; } const { rootConfig } = input; diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index b80652ba3..9409dc7cd 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -14,6 +14,7 @@ import { } from '@cloudflare/build-output-utils'; import { OutputContainerSchema } from '@cloudflare/config'; import { afterEach, expect, it, vi } from 'vite-plus/test'; +import { isVoidSecretOnlyDraft } from '../../../deploy-cloudflare/src/secret-draft.ts'; import { activateCloudflareVersion, deployCloudflareVersionTriggers, @@ -23,6 +24,7 @@ import { operationFixture, operationFixtureRequest, } from '../../../deploy-cloudflare/test/operation-fixture.ts'; +import { voidSecretDraftVersion } from '../../../deploy-cloudflare/test/secret-draft-fixture.ts'; import { prepareCfBuildOutput } from '../../src/cli/cf-build-output.ts'; import { uploadPinnedCloudflareVersion } from '../../src/cli/cf-version-api.ts'; import { @@ -37,6 +39,66 @@ const roots: Array = []; const pinnedCfVersion = JSON.parse( readFileSync(fileURLToPath(import.meta.resolve('cf/package.json')), 'utf8'), ).version as string; + +it('reads the exact immutable SDK draft source through the real pinned cf HTTP transport', async () => { + const prepared = await output(); + const draft = voidSecretDraftVersion(); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(request.url ?? ''); + request.resume(); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, errors: [], messages: [], result: draft })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const apiBaseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`; + const command = createCfCommand({ + accountId: 'test-account', + apiBaseUrl, + verifyUploader: () => true, + env: () => ({ + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + CI: 'true', + CLOUDFLARE_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'test-account', + CLOUDFLARE_API_BASE_URL: apiBaseUrl, + }), + }); + const commands: Array<{ args: Array; bounds: unknown }> = []; + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + runCf: (args, options) => { + commands.push({ args, bounds: options }); + return command(args, options); + }, + verifyResources: async () => { + throw new Error('A read must not provision or probe resources'); + }, + }); + const result = await runner( + { type: 'versions.view', versionId: draft.id, workerName: 'void-test', includeSource: true }, + { capture: true }, + ); + expect(result.status).toBe(0); + expect(isVoidSecretOnlyDraft(result.data as Record)).toBe(true); + expect(requests).toEqual([ + `/client/v4/accounts/test-account/workers/workers/void-test/versions/${draft.id}?include=modules`, + ]); + expect(commands).toHaveLength(1); + expect(commands[0]!.bounds).toMatchObject({ timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 }); + expect(JSON.stringify(result)).not.toContain('synthetic-token'); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + await prepared.dispose(); + } +}); afterEach(() => { vi.restoreAllMocks(); syncBuiltinESMExports(); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index c87c5f413..7ea807e4a 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -10554,7 +10554,7 @@ describe('runCloudflareDeploy — Mode B email step (3b-ii)', () => { expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); }); - it('no `email.from`: says what to add to void.config.ts and deploys without email (no wrangler email call)', async () => { + it('no `email.from`: explains automatic setup is skipped (no wrangler email call)', async () => { const root = emailProject(PROVISIONED_D1, {}, {}); const { deps, spawns, exitCalls } = emailHarness(); const output = captureOutput(); @@ -10571,6 +10571,97 @@ describe('runCloudflareDeploy — Mode B email step (3b-ii)', () => { expect(spawns.some((s) => s.args[0] === 'deploy')).toBe(true); }); + it('no email.from preserves restricted manual email configuration without claiming email is absent or ready', async () => { + const root = emailProject( + { + ...PROVISIONED_D1, + send_email: [ + { + name: 'SEND_EMAIL', + allowed_sender_addresses: ['support@mail.acme.com'], + allowed_destination_addresses: ['owner@acme.com'], + }, + ], + addresses: ['support@mail.acme.com'], + vars: { __VOID_EMAIL_FROM: 'support@mail.acme.com' }, + }, + {}, + {}, + ); + const before = readRootConfig(root); + const { deps, spawns, fetchCalls, confirms, trace, exitCalls } = emailHarness(); + const output = captureOutput(); + try { + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([]); + expect(confirms).toEqual([]); + expect(fetchCalls).toEqual([]); + expect(spawns.some((spawn) => spawn.args[0] === 'email' || spawn.args[0] === 'auth')).toBe( + false, + ); + expect(trace).toContain('build'); + expect(spawns.some((spawn) => spawn.args[0] === 'deploy')).toBe(true); + const after = readRootConfig(root); + expect(after.send_email).toEqual(before.send_email); + expect(after.addresses).toEqual(before.addresses); + expect(after.vars).toEqual(before.vars); + const printed = output.lines.join('\n'); + expect(printed).toContain('skipping automatic email setup'); + expect(printed).toContain('Existing Cloudflare email configuration is preserved.'); + expect(printed).not.toContain('deploying without email'); + expect(printed).not.toContain('email on mail.acme.com is ready'); + }); + + it('require-email still refuses unverified manual configuration without email.from before build or upload', async () => { + const root = emailProject( + { + ...PROVISIONED_D1, + send_email: [ + { + name: 'SEND_EMAIL', + allowed_sender_addresses: ['support@mail.acme.com'], + allowed_destination_addresses: ['owner@acme.com'], + }, + ], + addresses: ['support@mail.acme.com'], + }, + {}, + {}, + ); + const before = readRootConfig(root); + const { deps, spawns, fetchCalls, confirms, trace, exitCalls } = emailHarness(); + const output = captureOutput(); + try { + await expect( + runCloudflareDeploy( + root, + { platform: 'cloudflare', requireEmail: true }, + silentCliLog, + deps, + ), + ).rejects.toBeInstanceOf(ExitError); + } finally { + output.restore(); + } + expect(exitCalls).toEqual([1]); + expect(confirms).toEqual([]); + expect(fetchCalls).toEqual([]); + expect(spawns.some((spawn) => spawn.args[0] === 'email' || spawn.args[0] === 'auth')).toBe( + false, + ); + expect(trace).not.toContain('build'); + expect(trace).not.toContain('provision'); + expect(spawns.some((spawn) => spawn.args[0] === 'deploy')).toBe(false); + expect(readRootConfig(root)).toEqual(before); + const printed = output.lines.join('\n'); + expect(printed).toContain('Existing Cloudflare email configuration is preserved.'); + expect(printed).toContain('--require-email'); + expect(printed).toContain('Nothing was built or uploaded.'); + }); + it('second deploy, everything ready: no prompt, no write, no bearer write, address map printed', async () => { const root = emailProject({ ...PROVISIONED_D1, diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index 28bd4f94d..765ddffc9 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -9,12 +9,75 @@ import { join } from 'node:path'; import type { Duplex } from 'node:stream'; import { pathToFileURL } from 'node:url'; import { expect, it } from 'vite-plus/test'; +import { isVoidSecretOnlyDraft } from '../../../deploy-cloudflare/src/secret-draft.ts'; +import { voidSecretDraftVersion } from '../../../deploy-cloudflare/test/secret-draft-fixture.ts'; import { decodeCloudflareOperationResult, encodeCloudflareOperation, } from '../../src/cli/cloudflare-process.ts'; import { resolveCloudflareOperationArgv } from '../../src/cli/preset.ts'; +it('requests immutable version source explicitly without loading or provisioning build output', async () => { + const directory = mkdtempSync(join(tmpdir(), 'void-cf-secret-source-')); + const draft = voidSecretDraftVersion(); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(request.url ?? ''); + request.resume(); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, errors: [], messages: [], result: draft })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const configPath = join(directory, 'wrangler.json'); + writeFileSync(configPath, JSON.stringify({ name: 'test-worker', account_id: 'test-account' })); + const [command, ...prefix] = resolveCloudflareOperationArgv(directory); + const captured = await new Promise((resolve, reject) => + execFile( + command!, + [ + ...prefix, + ...encodeCloudflareOperation({ + type: 'versions.view', + versionId: draft.id, + includeSource: true, + configPath, + workerName: 'test-worker', + }), + ], + { + cwd: directory, + encoding: 'utf8', + timeout: 20_000, + env: { + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + CI: 'true', + CLOUDFLARE_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'test-account', + CLOUDFLARE_API_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`, + }, + }, + (error, stdout, stderr) => (error ? reject(new Error(stderr)) : resolve(stdout)), + ), + ); + const result = decodeCloudflareOperationResult({ status: 0, stdout: captured, stderr: '' }); + expect(result.status).toBe(0); + expect(isVoidSecretOnlyDraft(result.data as Record)).toBe(true); + expect(requests).toEqual([ + `/client/v4/accounts/test-account/workers/workers/test-worker/versions/${draft.id}?include=modules`, + ]); + expect(captured).not.toContain('synthetic-token'); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + rmSync(directory, { recursive: true, force: true }); + } +}); + it.each([200, 503])( 'reports HTTP %i without loading the build toolchain when domain cleanup returns non-JSON', async (status) => { diff --git a/platform/packages/dashboard/README.md b/platform/packages/dashboard/README.md index 91c798552..9e644435e 100644 --- a/platform/packages/dashboard/README.md +++ b/platform/packages/dashboard/README.md @@ -61,6 +61,46 @@ See [Platform Development](../../../docs/guide/platform-development.md) for the full setup and source-runtime workflow. The core platform installer does not install the dashboard; running this app locally does not add it to an installation. +## Standalone Deployment + +Use a source checkout to deploy a dashboard for an installed platform. Make a +separate copy in `playground-local/my-dashboard`, keeping the app’s source, +assets, package manifest and TypeScript configuration. Exclude local environment +files, dependencies and generated output. Give the copied package a unique +private name and keep its `build: vp build` script. In the copied Vite config, +keep `tailwind()`, `voidPlugin()` and `voidReact()`, and remove the local API +`persistTo` option. + +Replace the copied `cloudflare` configuration with your deployment settings: + +| Setting | Value | +| ------------------------------ | -------------------------------------------------------------------------------------------------- | +| `name` | An unused dashboard Worker name in your account | +| `account_id` | The installed API Worker’s Cloudflare account ID | +| `compatibility_date` | `2026-10-01` | +| `compatibility_flags` | `['nodejs_compat']` | +| `assets` | `{ directory: 'dist/client' }` | +| `routes` | `[{ pattern: 'dash.example.com', custom_domain: true }]`, using a hostname in your Cloudflare zone | +| `workers_dev` / `preview_urls` | `false` for a dashboard served only at its custom hostname | +| `services` | `[{ binding: 'API', service: '' }]` | +| `vars.API_URL` | The installed API’s public HTTPS origin | +| `vars.DASHBOARD_URL` | The dashboard’s HTTPS origin, such as `https://dash.example.com` | +| `vars.SITE_DOMAIN` | The installation’s application domain, such as `apps.example.com` | + +From the repository root, run `vp install` and `vp run build:core`. Configure the +API’s accepted dashboard origin using the +[optional dashboard instructions](../../../docs/guide/platform/installation/domains.md#optional-dashboard). +Then deploy from the copied app directory with the public Void CLI: + +```sh +vp exec void deploy --platform cloudflare +``` + +The `API` binding sends server requests to your installed API, while +`API_URL` supplies browser login URLs. Open the dashboard’s `/login` page once +deployment reports readiness. If Access protects the platform, configure its +coverage for the dashboard origin as described in the installation guide. + ## Source Layout ```text From a81cf0849dcce18603572657c6e24c0697d063d7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 2 Oct 2026 10:50:13 +0800 Subject: [PATCH 2/5] Fix ordinary native revisions after API secret bootstrap --- packages/deploy-cloudflare/src/version.ts | 67 ++++- .../test/unit/version.test.ts | 160 +++++++++++ packages/void/AGENTS.md | 2 +- .../void/test/unit/cf-version-runner.test.ts | 265 ++++++++++++++++++ 4 files changed, 492 insertions(+), 2 deletions(-) diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index 73092a6dd..7060b9e9f 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -805,7 +805,7 @@ export type CloudflareVersionActivationOptions = { requirePreviewForReadiness?: boolean; /** Verify that omitted remote secrets come from the active Worker Version before uploading. */ verifyInheritedSecrets?: boolean; - /** Pin opaque secret bindings to the live version when an unpublished upload is newer. */ + /** Pin inherited opaque secret bindings to the inspected live version before uploading. */ pinActiveSecrets?: ( activeVersionId: string, bindings: ReadonlyArray<{ name: string; type: 'secret_text' | 'secret_key' }>, @@ -1121,6 +1121,71 @@ async function activateCloudflareVersionWithSecretsFile( previousTriggerConfig, ); } + // The ordinary cf uploader refuses a non-interactive revision of an API-origin + // Worker. Keep inherited secrets on the same UUID-pinned upload path used by + // bootstrap, rather than bypassing cf's general overwrite confirmation. An + // explicitly supplied replacement retains its existing upload semantics. + if ( + previous.versionId !== null && + !activeSecretsPinned && + options.pinActiveSecrets && + options.candidateSecretsFilePath === undefined + ) { + let activeSecrets: Awaited>; + try { + activeSecrets = await readCloudflareVersionSecretBindings( + options, + configPath, + previous.versionId, + ); + } catch { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if (activeSecrets === null) { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if ( + activeSecrets.some((secret) => !secret.name.trim() || secret.name.trim() !== secret.name) || + new Set(activeSecrets.map((secret) => secret.name)).size !== activeSecrets.length + ) { + return { ok: false, reason: 'inheritance-source-changed' }; + } + if (activeSecrets.length) { + let pinned: boolean; + try { + pinned = await options.pinActiveSecrets(previous.versionId, activeSecrets); + } catch { + return { ok: false, reason: 'inheritance-source-unavailable' }; + } + if (!pinned) { + return { ok: false, reason: 'inheritance-source-changed' }; + } + activeSecretsPinned = true; + } + } + if (activeSecretsPinned) { + if (!options.verifyUploader()) { + return { ok: false, reason: 'uploader-changed' }; + } + const owner = await readCurrentProductionVersion(options, configPath); + if (owner.kind === 'failed') { + return { ok: false, reason: 'status-failed', status: owner.status }; + } + if ( + owner.kind !== 'ok' || + owner.versionId !== previous.versionId || + !productionVersionPercentagesEqual(owner.versions, [ + { versionId: previous.versionId!, percentage: 100 }, + ]) + ) { + return { + ok: false, + reason: 'deployment-changed', + activeVersionId: owner.kind === 'ok' ? owner.versionId : null, + rollback: 'not-needed', + }; + } + } options.onProgress?.('Uploading the Worker and assets...'); const upload = await Promise.resolve( options.runWrangler( diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index b8547d112..9791abab7 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -295,6 +295,166 @@ function runner(overrides?: { } describe('Cloudflare version activation', () => { + it('pins ordinary revision secrets to the inspected active UUID before upload and readiness', async () => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ ok: true, versionId: NEXT, previousVersionId: CURRENT }); + expect(fixture.pinActiveSecrets).toHaveBeenCalledExactlyOnceWith(CURRENT, [ + { name: 'TOKEN', type: 'secret_text' }, + ]); + expect(fixture.events.indexOf('pin')).toBeLessThan(fixture.events.indexOf('upload')); + expect(fixture.events.indexOf('upload')).toBeLessThan(fixture.events.indexOf('ready')); + expect(fixture.events.indexOf('ready')).toBeLessThan(fixture.events.indexOf('activate')); + expect(fixture.operations.filter((op) => op.type === 'versions.upload')).toHaveLength(1); + expect( + fixture.operations.some( + (op) => op.type === 'worker.deploy' || (op.type === 'versions.view' && op.includeSource), + ), + ).toBe(false); + }); + + it.each(['refused', 'thrown', 'unreadable', 'wrong-version'] as const)( + 'does not upload an ordinary revision when active-secret pinning is %s', + async (kind) => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + if (kind === 'refused') { + fixture.pinActiveSecrets.mockResolvedValue(false); + } + if (kind === 'thrown') { + fixture.pinActiveSecrets.mockRejectedValue(new Error('private synthetic pin detail')); + } + const original = fixture.runWrangler.getMockImplementation()!; + if (kind === 'unreadable' || kind === 'wrong-version') { + fixture.runWrangler.mockImplementation(async (op) => + op.type === 'versions.view' + ? kind === 'unreadable' + ? { status: 1, stdout: '', stderr: 'private synthetic inspection detail' } + : { + ...ok(), + data: { id: CONCURRENT, resources: { bindings: [] } }, + } + : original(op), + ); + } + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ + ok: false, + reason: + kind === 'refused' ? 'inheritance-source-changed' : 'inheritance-source-unavailable', + }); + expect(JSON.stringify(result)).not.toContain('private synthetic'); + expect(fixture.events).not.toContain('upload'); + expect(fixture.events).not.toContain('publish'); + expect(fixture.fetcher).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [{ name: '', type: 'secret_text' }], + [{ name: ' TOKEN ', type: 'secret_text' }], + [ + { name: 'TOKEN', type: 'secret_text' }, + { name: 'TOKEN', type: 'secret_key' }, + ], + ])('rejects ambiguous active secret names before ordinary upload: %j', async (...bindings) => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + const original = fixture.runWrangler.getMockImplementation()!; + fixture.runWrangler.mockImplementation(async (op) => + op.type === 'versions.view' + ? { ...ok(), data: { id: CURRENT, resources: { bindings } } } + : original(op), + ); + expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ + ok: false, + reason: 'inheritance-source-changed', + }); + expect(fixture.pinActiveSecrets).not.toHaveBeenCalled(); + expect(fixture.events).not.toContain('upload'); + }); + + it.each(['replacement', 'companion'] as const)( + 'refuses ordinary upload when a %s deployment appears during secret pinning', + async (kind) => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + const entered = deferred(); + const release = deferred(); + fixture.pinActiveSecrets.mockImplementation(async () => { + entered.resolve(); + return release.promise; + }); + const activation = activateCloudflareVersion(fixture.options); + await entered.promise; + fixture.changeOwner( + kind === 'replacement' + ? [{ version_id: CONCURRENT, percentage: 100 }] + : [ + { version_id: CURRENT, percentage: 100 }, + { version_id: CONCURRENT, percentage: 0 }, + ], + ); + release.resolve(true); + expect(await activation).toMatchObject({ + ok: false, + reason: 'deployment-changed', + rollback: 'not-needed', + }); + expect(fixture.events).not.toContain('upload'); + expect(fixture.events).not.toContain('publish'); + expect(fixture.fetcher).not.toHaveBeenCalled(); + }, + ); + + it('retains ordinary explicit replacement without requiring remote-only secret pinning', async () => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + fixture.pinActiveSecrets.mockResolvedValue(false); + const original = fixture.runWrangler.getMockImplementation()!; + fixture.runWrangler.mockImplementation(async (op) => { + if (op.type === 'versions.upload') { + expect(JSON.parse(readFileSync(op.secretsFilePath!, 'utf8'))).toEqual({ + TOKEN: 'synthetic replacement', + NEW_SECRET: 'synthetic new secret', + }); + } + return original(op); + }); + expect( + await activateCloudflareVersion({ + ...fixture.options, + candidateSecrets: { TOKEN: 'synthetic replacement', NEW_SECRET: 'synthetic new secret' }, + }), + ).toMatchObject({ ok: true, versionId: NEXT }); + expect(fixture.pinActiveSecrets).not.toHaveBeenCalled(); + expect(fixture.operations.filter((op) => op.type === 'versions.upload')).toHaveLength(1); + }); + + it('keeps an ordinary app without remote secrets on the normal uploader', async () => { + const fixture = sdkDraftActivation(); + fixture.ordinary(); + fixture.pinActiveSecrets.mockResolvedValue(false); + const original = fixture.runWrangler.getMockImplementation()!; + fixture.runWrangler.mockImplementation(async (op) => + op.type === 'versions.view' + ? { ...ok(), data: { id: CURRENT, resources: { bindings: [] } } } + : original(op), + ); + expect(await activateCloudflareVersion(fixture.options)).toMatchObject({ + ok: true, + versionId: NEXT, + }); + expect(fixture.pinActiveSecrets).not.toHaveBeenCalled(); + expect(fixture.operations.filter((op) => op.type === 'versions.upload')).toHaveLength(1); + expect( + fixture.operations.some( + (op) => op.type === 'worker.deploy' || (op.type === 'versions.view' && op.includeSource), + ), + ).toBe(false); + }); + it('publishes an exact private SDK draft once, proving secrets and callbacks before readiness', async () => { const fixture = sdkDraftActivation(); const validateUploadedVersion = vi.fn(async (id: string) => { diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 20ccf304c..a48dc65c9 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -17,7 +17,7 @@ Project settings live in `void.config.ts` through `void/config` and `defineConfi Build-output config translation uses the public `@cloudflare/codemods` API pinned to the version used by `cf`. Migration and generated-config evaluation share one disposable, uncredentialed subprocess; unresolved blocking follow-ups must still fail closed. Keep real `cf` prebuilt dry-run coverage. -Unlinked projects with a root `wrangler.jsonc` or `wrangler.json` get a link-and-deploy prompt from `cli/cloudflare-link.ts` before the hosted fallback in `runDeployInner`. Honor explicit platform/project selections and saved destinations. Acceptance verifies the existing Worker, saves Cloudflare, and continues in the same invocation. `.void/cloudflare-link.json` keeps the handoff guard across failed builds; remove it only after successful activation. The handoff reuses resource IDs and remote secrets, blocks unapproved resource additions and schema/secret changes, and skips trigger/settings publication. `@void/deploy-cloudflare` validates the active and uploaded bindings and handler sets. Because bundled Wrangler inherits secrets from the latest upload, the handoff requires the active version to be latest before upload and verifies that no other candidate appeared during upload. Later deployments with a newer inactive upload use the version-aware API to inherit opaque secrets from the active version, and upload assets explicitly; regular deploys still use pinned `cf`. The first handoff still fails closed. Later code-only deployments carry the opaque trigger-preservation marker forward; adding a trigger requires a known traffic snapshot or explicit root `routes`, `workers_dev`, and `preview_urls`. Older versions without complete trigger metadata use traffic-only rollback, retaining current domains and schedules. Existing TOML configs are detected and rejected before generating a competing config; named environments remain unsupported. +Unlinked projects with a root `wrangler.jsonc` or `wrangler.json` get a link-and-deploy prompt from `cli/cloudflare-link.ts` before the hosted fallback in `runDeployInner`. Honor explicit platform/project selections and saved destinations. Acceptance verifies the existing Worker, saves Cloudflare, and continues in the same invocation. `.void/cloudflare-link.json` keeps the handoff guard across failed builds; remove it only after successful activation. The handoff reuses resource IDs and remote secrets, blocks unapproved resource additions and schema/secret changes, and skips trigger/settings publication. `@void/deploy-cloudflare` validates the active and uploaded bindings and handler sets. Because bundled Wrangler inherits secrets from the latest upload, the handoff requires the active version to be latest before upload and verifies that no other candidate appeared during upload. Later deployments with a newer inactive upload and ordinary revisions with inherited active secrets use the version-aware API to inherit opaque secrets from the inspected active version, and upload assets explicitly. The active version must still own all traffic before upload. Deployments without remote secrets and explicit secret replacements retain pinned `cf` uploads. The first handoff still fails closed. Later code-only deployments carry the opaque trigger-preservation marker forward; adding a trigger requires a known traffic snapshot or explicit root `routes`, `workers_dev`, and `preview_urls`. Older versions without complete trigger metadata use traffic-only rollback, retaining current domains and schedules. Existing TOML configs are detected and rejected before generating a competing config; named environments remain unsupported. Assets-only Workers report null or empty handlers alongside `script_runtime.assets`. Accept that shape during handoff; only the static adapter may add its `ASSETS` binding and sole `fetch` handler. Preserve every existing binding and reject name collisions. Missing handler metadata without verified assets must not bypass the handler checks. diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index 9409dc7cd..9ea79f052 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -242,6 +242,271 @@ it('classifies the pinned prebuilt uploader’s missing-Worker diagnostic', asyn } }); +it('reproduces the pinned cf uploader refusing an API-origin revision without confirmation', async () => { + const prepared = await output(); + const requests: Array<{ method: string; path: string }> = []; + const server = createServer((request, response) => { + requests.push({ method: request.method!, path: request.url! }); + request.resume(); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + success: true, + errors: [], + messages: [], + result: { + default_environment: { + environment: 'production', + script: { id: 'void-test', last_deployed_from: 'api' }, + }, + }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const apiBaseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`; + const command = createCfCommand({ + accountId: 'account-id', + apiBaseUrl, + verifyUploader: () => true, + env: () => ({ + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + CI: 'true', + CLOUDFLARE_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'account-id', + CLOUDFLARE_API_BASE_URL: apiBaseUrl, + }), + }); + const result = await command(['workers', 'versions', 'create', '--prebuilt'], { + cwd: prepared.directory, + capture: true, + }); + expect(result).toMatchObject({ status: 1, signal: null, error: null }); + expect(result.stdout.replace(/\s+/g, ' ')).toContain( + 'Aborting the upload operation because of conflicts.', + ); + expect(result.stdout.replace(/\s+/g, ' ')).toContain('last updated via the script API'); + expect(requests).toEqual([ + { method: 'GET', path: '/client/v4/accounts/account-id/workers/services/void-test' }, + ]); + } finally { + prepared.dispose(); + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } +}); + +it('uploads an ordinary API-origin revision with genuine cf reads and UUID-pinned inheritance', async () => { + const current = '11111111-1111-4111-8111-111111111111'; + const next = '22222222-2222-4222-8222-222222222222'; + const triggerConfig = { + workers_dev: true, + preview_urls: true, + routes: [], + crons: [], + queueConsumers: [], + workflows: [], + }; + const message = encodeCloudflareVersionMessage([], triggerConfig); + const prepared = await output({ + secrets: { required: ['AUTH_SECRET'] }, + version_metadata: { binding: 'APP_VERSION' }, + send_email: [ + { + name: 'SEND_EMAIL', + allowed_sender_addresses: ['sender@example.test'], + allowed_destination_addresses: ['receiver@example.test'], + }, + ], + }); + const originalConfig = readFileSync(getWorkerConfigPath(prepared.directory), 'utf8'); + let traffic = [{ version_id: current, percentage: 100 }]; + let uploaded = false; + const events: Array = []; + const requests: Array<{ method: string; path: string; body: Record }> = []; + const server = createServer(async (request, response) => { + const chunks: Array = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + const text = Buffer.concat(chunks).toString('utf8'); + const row = { + method: request.method!, + path: request.url!, + body: text ? JSON.parse(text) : {}, + }; + requests.push(row); + const path = new URL(row.path, 'http://localhost').pathname; + let result: unknown; + if (row.method === 'POST' && path.endsWith('/workers/workers/void-test/versions')) { + uploaded = true; + events.push('upload'); + result = { id: next, urls: [`https://${next.slice(0, 8)}-void-test.example.workers.dev`] }; + } else if (row.method === 'GET' && path.endsWith('/deployments')) { + events.push('owner'); + result = { deployments: [{ versions: traffic }] }; + } else if (row.method === 'GET' && path.endsWith('/versions')) { + result = [ + { id: current, number: 1, annotations: { 'workers/message': message } }, + ...(uploaded ? [{ id: next, number: 2, annotations: { 'workers/message': message } }] : []), + ]; + } else if (row.method === 'GET' && path.endsWith(`/versions/${current}`)) { + events.push('secret-source'); + result = { + id: current, + number: 1, + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + annotations: { 'workers/message': message }, + }; + } else if (row.method === 'GET' && path.endsWith('/services/void-test')) { + result = { + default_environment: { + environment: 'production', + script: { id: 'void-test', last_deployed_from: 'api' }, + }, + }; + } else { + response.writeHead(501); + response.end('Unexpected local test request'); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, errors: [], messages: [], result })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const apiBaseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/client/v4`; + const command = createCfCommand({ + accountId: 'account-id', + apiBaseUrl, + verifyUploader: () => true, + env: () => ({ + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + CI: 'true', + CLOUDFLARE_API_TOKEN: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'account-id', + CLOUDFLARE_API_BASE_URL: apiBaseUrl, + }), + }); + const calls: Array> = []; + const runCf: Parameters[0]['runCf'] = async (args, options) => { + calls.push(args); + // These mutation acknowledgements retain the activation protocol's state; + // all discovery reads and the actual version POST use the real transports. + if (args.slice(0, 3).join(' ') === 'workers deployments create') { + traffic = JSON.parse(args[args.indexOf('--versions') + 1]!); + events.push('activate'); + return { status: 0, stdout: '{}', stderr: '' }; + } + if (args.slice(0, 3).join(' ') === 'workers triggers deploy') { + events.push('triggers'); + return { status: 0, stdout: '{}', stderr: '' }; + } + return command(args, options); + }; + const runner = createCfVersionRunner({ + output: prepared, + workerName: 'void-test', + runCf, + pinnedUpload: { + accountId: 'account-id', + apiBaseUrl, + getToken: async () => 'synthetic-token', + verifyUploader: () => true, + }, + verifyResources: createCfResourceVerifier({ + configPath: join(roots.at(-1)!, 'dist/ssr/wrangler.json'), + runCf, + runWrangler: () => { + throw new Error('An app without paid bindings must not probe paid services.'); + }, + verifyCompatibilityRunner: () => true, + }), + }); + const result = await activateCloudflareVersion({ + runWrangler: runner, + configPath: join(roots.at(-1)!, 'dist/ssr/wrangler.json'), + workerName: 'void-test', + verifyUploader: () => true, + verifyInheritedSecrets: true, + expectedPreviousVersionId: current, + requireVersionMetadata: true, + pinActiveSecrets: createActiveSecretPinner({ + output: prepared, + handoffPending: false, + requiredNames: ['AUTH_SECRET'], + }), + readinessAttempts: 1, + message, + fetch: async (url) => { + expect(String(url)).toBe( + `https://${next.slice(0, 8)}-void-test.example.workers.dev/__void/ready`, + ); + expect(traffic).toEqual([{ version_id: current, percentage: 100 }]); + events.push('ready'); + return Response.json({ ready: true, versionId: next }); + }, + }); + expect(result, JSON.stringify({ result, events, requests })).toMatchObject({ + ok: true, + versionId: next, + previousVersionId: current, + }); + expect(prepared.activeSecrets).toEqual({ + versionId: current, + bindings: [{ name: 'AUTH_SECRET', type: 'secret_text' }], + }); + const posts = requests.filter((row) => row.method === 'POST'); + expect(posts).toHaveLength(1); + expect(posts[0]!.path).toBe( + '/client/v4/accounts/account-id/workers/workers/void-test/versions', + ); + expect(posts[0]!.body.bindings).toContainEqual({ + name: 'AUTH_SECRET', + type: 'inherit', + version_id: current, + }); + expect(posts[0]!.body.bindings).toContainEqual({ + name: 'SEND_EMAIL', + type: 'send_email', + allowed_sender_addresses: ['sender@example.test'], + allowed_destination_addresses: ['receiver@example.test'], + }); + expect(posts[0]!.body.bindings).toContainEqual({ + name: 'APP_VERSION', + type: 'version_metadata', + }); + expect(requests.some((row) => row.path.includes('/services/'))).toBe(false); + expect(calls.some((args) => args.slice(0, 3).join(' ') === 'workers versions create')).toBe( + false, + ); + expect(events.indexOf('upload')).toBeLessThan(events.indexOf('ready')); + expect(events.indexOf('ready')).toBeLessThan(events.indexOf('activate')); + expect(events.indexOf('activate')).toBeLessThan(events.indexOf('triggers')); + expect( + events.slice(0, events.indexOf('upload')).filter((event) => event === 'owner'), + ).toHaveLength(2); + expect(readFileSync(getWorkerConfigPath(prepared.directory), 'utf8')).toBe(originalConfig); + } finally { + prepared.dispose(); + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } +}, 15_000); + it('captures a version list larger than Node’s default child-process buffer', async () => { const root = mkdtempSync(join(tmpdir(), 'void-cf-large-version-list-')); roots.push(root); From 28e4673c36e259816641ce4410512a74c068949f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 2 Oct 2026 11:19:01 +0800 Subject: [PATCH 3/5] docs: clarify native email authentication and delivery checks --- docs/guide/email/domains.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/email/domains.md b/docs/guide/email/domains.md index facc88fac..4252fbc98 100644 --- a/docs/guide/email/domains.md +++ b/docs/guide/email/domains.md @@ -80,6 +80,8 @@ Setup enables the zone's plus addressing so `support+T-42@mail.acme.com` reaches On Workers Free, `sendEmail()` can send to verified destinations listed under **Email Routing → Destination addresses** in Cloudflare. +Configure your sender domain's SPF, DKIM, and DMARC records using [Cloudflare's email authentication guidance](https://developers.cloudflare.com/email-service/concepts/email-authentication/), then check the authentication results in a received message's original headers. A successful `sendEmail()` result confirms provider acceptance; it does not confirm delivery or authentication readiness. + Sending to arbitrary recipients requires [Workers Paid](https://dash.cloudflare.com/?to=/:account/workers/plans) and Email Sending onboarding. After upgrading, run `void email setup --platform cloudflare`. Void doesn't retry onboarding during ordinary deploys. Existing DMARC or bounce-domain records can block onboarding; the checklist identifies conflicts to resolve. Inbound mail remains available. From 44db7ace838a9c832e876a0f6218646b7417fe8d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 2 Oct 2026 22:02:43 +0800 Subject: [PATCH 4/5] Remove redundant secret draft metadata validation --- packages/deploy-cloudflare/src/secret-draft.ts | 7 ------- packages/deploy-cloudflare/test/unit/secret-draft.test.ts | 2 -- packages/deploy-cloudflare/test/unit/version.test.ts | 7 ++++++- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/deploy-cloudflare/src/secret-draft.ts b/packages/deploy-cloudflare/src/secret-draft.ts index e0be322bb..0a9b64d9f 100644 --- a/packages/deploy-cloudflare/src/secret-draft.ts +++ b/packages/deploy-cloudflare/src/secret-draft.ts @@ -89,14 +89,7 @@ export function isVoidSecretOnlyDraft(entry: Record): boolean { ]); if ( Object.keys(entry).some((key) => !metadataFields.has(key)) || - typeof entry.id !== 'string' || - !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(entry.id) || - !Number.isSafeInteger(entry.number) || - (entry.number as number) < 1 || entry.source !== 'cf_cli' || - entry.usage_model !== 'standard' || - typeof entry.compatibility_date !== 'string' || - !/^\d{4}-\d{2}-\d{2}$/.test(entry.compatibility_date) || !isRecord(entry.annotations) || Object.keys(entry.annotations).length !== 1 || entry.annotations['workers/triggered_by'] !== 'secret' || diff --git a/packages/deploy-cloudflare/test/unit/secret-draft.test.ts b/packages/deploy-cloudflare/test/unit/secret-draft.test.ts index a5b733a89..8e94b0c99 100644 --- a/packages/deploy-cloudflare/test/unit/secret-draft.test.ts +++ b/packages/deploy-cloudflare/test/unit/secret-draft.test.ts @@ -62,8 +62,6 @@ describe('immutable SDK secret draft proof', () => { { env: { TOKEN: { type: 'secret_text' }, OTHER: { type: 'text', value: 'private' } } }, ], ['a public version URL', { urls: ['https://version.example.workers.dev'] }], - ['an invalid UUID', { id: 'latest' }], - ['an invalid version number', { number: 0 }], ['an unknown uploader', { source: 'api' }], [ 'contradictory adapter bindings', diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 9791abab7..61ae5c4c1 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -456,7 +456,12 @@ describe('Cloudflare version activation', () => { }); it('publishes an exact private SDK draft once, proving secrets and callbacks before readiness', async () => { - const fixture = sdkDraftActivation(); + const fixture = sdkDraftActivation({ + ...voidSecretDraftVersion(), + number: undefined, + compatibility_date: undefined, + usage_model: undefined, + }); const validateUploadedVersion = vi.fn(async (id: string) => { expect(id).toBe(NEXT); fixture.events.push('validate'); From 08e5588952346505f938696488c4f9e7b2ddc859 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 3 Oct 2026 00:47:35 +0800 Subject: [PATCH 5/5] Stabilize invitation delivery rate limit test clock --- .../test/integration/api-project-team.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index 0cc635dcd..89106c410 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -1,5 +1,14 @@ import { and, count, eq, gt, sql } from 'drizzle-orm'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; import { createGitHubConnection, getBuild, upsertGitHubInstallation } from '../../src/db.ts'; import app from '../../src/index.ts'; import * as projectAccess from '../../src/project-access.ts'; @@ -36,6 +45,10 @@ beforeEach(async () => { vi.restoreAllMocks(); }); +afterEach(() => { + vi.useRealTimers(); +}); + function request( path: string, jwt: string, @@ -630,6 +643,8 @@ describe('project team API', () => { }); it('rate-limits create, refresh, and resend delivery while preserving invitations', async () => { + // All delivery attempts must stay in the same UTC-minute rate-limit window. + vi.setSystemTime(new Date()); const owner = await createTestUser(env.db, 'owner'); const target = await createTestUser(env.db, 'target'); await setEmail(target.id, 'target@example.com');