From 50efd5383b0d168736425d86eb99389ae73c0957 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 23:10:53 +0800 Subject: [PATCH 1/7] fix(platform): address Cloudflare end-to-end deployment failures --- docs/guide/database.md | 2 + .../platform/installation/credentials.md | 2 + packages/deploy-cloudflare/src/diagnostics.ts | 11 ++++ packages/deploy-cloudflare/src/index.ts | 1 + .../void/src/cli/deploy-managed-static.ts | 4 +- .../void/src/cli/platform-installer-errors.ts | 7 ++- packages/void/src/cli/platform-setup.ts | 6 +++ .../void/test/unit/direct-r2-deploy.test.ts | 52 +++++++++++++++++++ .../unit/platform-installer-errors.test.ts | 52 +++++++++++++++++++ .../void/test/unit/platform-setup.test.ts | 29 +++++++++++ .../api/src/email/connection-service.ts | 2 +- .../packages/api/src/operator/email-routes.ts | 2 +- .../api/src/provisioning/cloudflare-api.ts | 21 ++++++-- platform/packages/api/src/routes/domains.ts | 2 +- .../api/test/integration/api-email.test.ts | 42 ++++++++++++--- .../api/test/integration/api-projects.test.ts | 5 +- .../operator-email-domains.test.ts | 30 +++++++++++ .../api/test/unit/cloudflare-api.test.ts | 31 ++++++++++- .../unit/kitchen-sink-sandbox-network.test.ts | 29 +++++++++++ platform/packages/dispatch/src/index.ts | 4 +- .../dispatch/test/unit/fallback-rules.test.ts | 29 +++++++++++ .../packages/proxy/src/email-admission.ts | 14 ++++- .../test/unit/email-admission-send.test.ts | 34 +++++++++++- .../lib/internet-probe.ts | 18 +++++++ .../routes/api/acceptance/sandbox.ts | 31 ++++++----- playground/analog/index.html | 2 - 26 files changed, 419 insertions(+), 43 deletions(-) create mode 100644 packages/void/test/unit/platform-installer-errors.test.ts create mode 100644 platform/packages/api/test/unit/kitchen-sink-sandbox-network.test.ts create mode 100644 platform/scripts/fixtures/kitchen-sink-sandbox/lib/internet-probe.ts diff --git a/docs/guide/database.md b/docs/guide/database.md index 347e8683a..58cb90844 100644 --- a/docs/guide/database.md +++ b/docs/guide/database.md @@ -47,6 +47,8 @@ Choose a database during `void init`, or set `database` in `void.config.ts`: | Managed by | Void | Bring your own database | Bring your own database | | Connection | Automatic D1 binding | Hyperdrive | Hyperdrive | +A Void platform disables Hyperdrive query caching when it creates or updates your database connection, so reads reflect completed writes. + ## Schema Definition Define your tables in `db/schema.ts` using column helpers from `void/schema-d1`, `void/schema-pg`, or `void/schema-mysql`. diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index 54f1c0e6c..c6af9c617 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -48,6 +48,8 @@ When Void configures Access in the installation account, the initial management- The management token also needs **Zone Edit** with authority to create zones if you ask Void to create the zone. If it already exists, use the selected zone with Zone Read and DNS Edit. Nested application domains additionally need **SSL and Certificates: Read** on the management token. A custom runtime that enables custom project domains needs **SSL and Certificates: Edit** on the runtime token; the core runtime does not enable that feature. +If your platform enables Sandbox, its runtime token also needs **Account → Workers Containers → Edit** and **Account → Cloudchamber → Edit**. + Void checks access before provisioning. If it reports a missing permission, update the token's permissions for the selected account or zone and retry. ::: diff --git a/packages/deploy-cloudflare/src/diagnostics.ts b/packages/deploy-cloudflare/src/diagnostics.ts index 6ce8f834b..2d6da6ba1 100644 --- a/packages/deploy-cloudflare/src/diagnostics.ts +++ b/packages/deploy-cloudflare/src/diagnostics.ts @@ -25,6 +25,17 @@ export function cloudflareCommandDiagnostic(result: CloudflareCommandResult): st ); if (box) { message = box[0].trim(); + } else { + const lines = stdout.split('\n'); + const abort = lines.find((line) => + line.startsWith('■ Aborting the upload operation because of conflicts.'), + ); + if (abort) { + const reason = 'You are about to upload a Worker that was last updated via the script API.'; + message = [lines.includes(`▲ ${reason}`) ? reason : '', abort.slice('■ '.length)] + .filter(Boolean) + .join('\n'); + } } } // Upload progress and bindings are printed on stdout. Never echo that whole stream diff --git a/packages/deploy-cloudflare/src/index.ts b/packages/deploy-cloudflare/src/index.ts index 3a6ea3f5f..946e06b39 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 { cloudflareCommandDiagnostic } from './diagnostics.ts'; export { VOID_SECRET_DRAFT_SOURCE } from './secret-draft.ts'; export type { diff --git a/packages/void/src/cli/deploy-managed-static.ts b/packages/void/src/cli/deploy-managed-static.ts index d54d77cb8..d8c0ea040 100644 --- a/packages/void/src/cli/deploy-managed-static.ts +++ b/packages/void/src/cli/deploy-managed-static.ts @@ -104,7 +104,9 @@ export async function runStaticDeploy( process.exit(1); } - if (routing) { + // Full deploys can supply premerged rules; standalone static deploys still + // need file-based rules when no routing config was provided. + if (routing || (!headerRules && !redirectRules && !fallbackRules)) { const merged = mergeAndLintRoutingRules(preset.outputDir, routing); headerRules = merged.headerRules; redirectRules = merged.redirectRules; diff --git a/packages/void/src/cli/platform-installer-errors.ts b/packages/void/src/cli/platform-installer-errors.ts index f378e3dc9..c2e174bdb 100644 --- a/packages/void/src/cli/platform-installer-errors.ts +++ b/packages/void/src/cli/platform-installer-errors.ts @@ -1,4 +1,5 @@ import { + cloudflareCommandDiagnostic, stabilizeCloudflareVersionTriggers, type CloudflareCommandResult, } from '@void/deploy-cloudflare'; @@ -8,8 +9,12 @@ export function assertCleanPlatformWrangler( operation: string, ): void { if (result.status !== 0 || result.signal || result.error) { + const diagnostic = cloudflareCommandDiagnostic({ + ...result, + stderr: result.stderr.trim() || result.errors?.map((error) => error.message).join('\n') || '', + }); throw new Error( - `platform: Wrangler failed to ${operation} (${result.stderr.trim() || `exit ${result.status}`}).`, + `platform: Wrangler failed to ${operation} (${diagnostic || `exit ${result.status}`}).`, ); } } diff --git a/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index d9ea02791..031875e1d 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -187,6 +187,12 @@ export function platformSetupPages(plan: PlatformInstallPlan): { ...(plan.deployment.capabilities.customDomains ? [{ key: 'ssl_and_certificates', type: 'edit' }] : []), + ...(plan.deployment.capabilities.sandbox + ? [ + { key: 'containers', type: 'edit' }, + { key: 'cloudchamber', type: 'edit' }, + ] + : []), // Email domain setup and destination consent use this token. ...(plan.deployment.capabilities.email ? [ diff --git a/packages/void/test/unit/direct-r2-deploy.test.ts b/packages/void/test/unit/direct-r2-deploy.test.ts index 9b7379b67..2a343fe64 100644 --- a/packages/void/test/unit/direct-r2-deploy.test.ts +++ b/packages/void/test/unit/direct-r2-deploy.test.ts @@ -449,6 +449,58 @@ describe('direct-to-R2 deploy flow', () => { expect(body.preflight).toBe('true'); }); + it.each(['static', 'spa'] as const)( + 'includes file-based routing without a routing config (%s)', + async (appType) => { + writeFileSync(join(assetsDir, '_headers'), '/about\n X-Test: file-rule\n'); + writeFileSync(join(assetsDir, '_redirects'), '/old /about 302\n/rewrite /about 200\n'); + const state = installPlatformMocks(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + makeClient(), + { buildCommand: null, outputDir: assetsDir, appType }, + true, + ); + + const body = JSON.parse(state.finalizeCalls[0].init.body as string); + expect(body.manifest.headerRules).toEqual([ + { pattern: '/about', headers: [['X-Test', 'file-rule']] }, + ]); + expect(body.manifest.redirectRules).toEqual([ + { source: '/old', destination: '/about', status: 302 }, + ]); + expect(body.manifest.fallbackRules).toContainEqual({ + source: '/rewrite', + destination: '/about', + status: 200, + }); + }, + ); + + it('preserves routing rules already merged by the full-to-static path', async () => { + writeFileSync(join(assetsDir, '_headers'), '/about\n X-Test: file-rule\n'); + const headerRules = [ + { pattern: '/about', headers: [['X-Test', 'config-rule']] as Array<[string, string]> }, + ]; + const state = installPlatformMocks(); + + await runStaticDeploy( + workdir, + { projectId: 'proj_t', slug: 'test' }, + makeClient(), + { buildCommand: null, outputDir: assetsDir, appType: 'static' }, + true, + undefined, + undefined, + headerRules, + ); + + const body = JSON.parse(state.finalizeCalls[0].init.body as string); + expect(body.manifest.headerRules).toEqual(headerRules); + }); + // `routing.notFound` must reach the STATIC manifest, not only the // worker-with-assets path. `detectPreset` classifies any Vite project with // no backend files as `spa`, so a plain Vite MPA lands here and would diff --git a/packages/void/test/unit/platform-installer-errors.test.ts b/packages/void/test/unit/platform-installer-errors.test.ts new file mode 100644 index 000000000..34aaf5595 --- /dev/null +++ b/packages/void/test/unit/platform-installer-errors.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from 'vite-plus/test'; +import { assertCleanPlatformWrangler } from '../../src/cli/platform-installer-errors.ts'; + +it('reports the cf upload conflict from stdout without binding values or completion output', () => { + const diagnostic = + 'Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag'; + const result = { + status: 1, + stderr: '', + stdout: [ + 'env.PRIVATE_VALUE: "do-not-echo"', + '├ Upload', + '▲ You are about to upload a Worker that was last updated via the script API.', + '│ Edits that have been made via the script API will be overridden by your local code and config.', + `■ ${diagnostic}`, + '│', + '◆ Version upload complete', + '', + ].join('\n'), + }; + + expect(() => assertCleanPlatformWrangler(result, 'upload test-worker')).toThrow( + 'platform: Wrangler failed to upload test-worker (' + + 'You are about to upload a Worker that was last updated via the script API.\n' + + `${diagnostic}).`, + ); +}); + +it('reports structured provider errors when stderr is empty using existing credential redaction', () => { + expect(() => + assertCleanPlatformWrangler( + { + status: 1, + stdout: 'env.PRIVATE_VALUE: "do-not-echo"', + stderr: '', + errors: [{ code: 10000, message: 'Invalid Authorization: Bearer synthetic-token' }], + }, + 'upload test-worker', + ), + ).toThrow( + 'platform: Wrangler failed to upload test-worker (Invalid Authorization: Bearer [redacted]).', + ); +}); + +it('retains the exit status when stdout contains only binding values', () => { + expect(() => + assertCleanPlatformWrangler( + { status: 1, stdout: 'env.PRIVATE_VALUE: "do-not-echo"', stderr: '' }, + 'upload test-worker', + ), + ).toThrow('platform: Wrangler failed to upload test-worker (exit 1).'); +}); diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 489f454d7..ecb311584 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -321,6 +321,35 @@ describe('platform credential setup links', () => { expect(permissions(custom)).toContainEqual({ key: 'ssl_and_certificates', type: 'edit' }); expect(permissions(original)).not.toContainEqual({ key: 'ssl_and_certificates', type: 'edit' }); }); + it.each([false, true])( + 'requests Containers and Cloudchamber only for managed Sandbox, enabled: %s', + (sandbox) => { + const original = plan(); + const selected = { + ...original, + deployment: { + ...original.deployment, + capabilities: { ...original.deployment.capabilities, sandbox }, + }, + }; + const runtimePermissions = (input: PlatformInstallPlan) => + JSON.parse( + new URL(platformSetupPages(input).runtime.url).searchParams.get('permissionGroupKeys')!, + ); + expect(runtimePermissions(selected)).toEqual([ + ...runtimePermissions(original), + ...(sandbox + ? [ + { key: 'containers', type: 'edit' }, + { key: 'cloudchamber', type: 'edit' }, + ] + : []), + ]); + expect(platformManagementSetupPage(selected).url).toBe( + platformManagementSetupPage(original).url, + ); + }, + ); it('requests the email zone and sending permissions only when email is enabled', () => { const original = plan('example.com'); const withEmail = { diff --git a/platform/packages/api/src/email/connection-service.ts b/platform/packages/api/src/email/connection-service.ts index 5034c76e8..408e8d23e 100644 --- a/platform/packages/api/src/email/connection-service.ts +++ b/platform/packages/api/src/email/connection-service.ts @@ -1468,7 +1468,7 @@ export async function requestEmailDomainAdd( : preflight[0].length > 0; if (mxConflict) { throw new EmailOperationError( - `${domain} already has MX records; use an unused mail subdomain`, + `${domain} already has MX records; use an unused domain apex or a platform shared email address`, 409, ); } diff --git a/platform/packages/api/src/operator/email-routes.ts b/platform/packages/api/src/operator/email-routes.ts index 560c858ce..a6fdb6946 100644 --- a/platform/packages/api/src/operator/email-routes.ts +++ b/platform/packages/api/src/operator/email-routes.ts @@ -711,7 +711,7 @@ export async function addOperatorEmailDomain( effect: existing ? `The existing assignment for ${domain} belongs to ${project.slug}; use sync to reconcile it.` : mx === 'subdomain' - ? `${domain} already receives mail; setup will refuse it — name a subdomain such as mail.${domain} instead.` + ? `${domain} already receives mail; setup will refuse it — use an unused domain apex or a platform shared email address instead.` : credentialKind === 'platform' ? `Registers ${domain} for ${project.slug} on the platform's own Cloudflare credential; the zone must be in the platform's account.` : `Registers ${domain} for ${project.slug} with the supplied API token.`, diff --git a/platform/packages/api/src/provisioning/cloudflare-api.ts b/platform/packages/api/src/provisioning/cloudflare-api.ts index 49bac05f1..5912a293e 100644 --- a/platform/packages/api/src/provisioning/cloudflare-api.ts +++ b/platform/packages/api/src/provisioning/cloudflare-api.ts @@ -579,7 +579,7 @@ type HyperdriveOrigin = Readonly<{ type HyperdriveConfigPayload = Readonly<{ name?: string; origin: HyperdriveOrigin; - mtls?: Readonly<{ sslmode: 'require' | 'verify-ca' | 'verify-full' }>; + mtls?: Readonly<{ sslmode: 'require' }>; }>; function decodeUrlPart(value: string): string { @@ -636,6 +636,13 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper ); } + const sslrootcert = url.searchParams.getAll('sslrootcert').at(-1); + if (sslrootcert && sslrootcert !== 'system') { + throw new InvalidHyperdriveConnectionStringError( + 'Void-created Hyperdrive uses public certificate authorities and cannot honor sslrootcert. For a database signed by a public certificate authority, use sslrootcert=system.', + ); + } + const sslmode = url.searchParams.get('sslmode'); let mtls: HyperdriveConfigPayload['mtls']; if (sslmode) { @@ -644,7 +651,10 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper 'Unsupported sslmode. Cloudflare Hyperdrive supports "require", "verify-ca", or "verify-full".', ); } - mtls = { sslmode }; + // Hyperdrive's verify modes require an uploaded CA certificate ID, which a URL + // cannot supply. `require` validates server certificates using public WebPKI; + // direct database clients retain the connection URL's original verification mode. + mtls = { sslmode: 'require' }; } return { @@ -666,7 +676,11 @@ export async function createHyperdrive( name: string, connectionString: string, ): Promise { - const payload = { name, ...parseHyperdriveConnectionString(connectionString) }; + const payload = { + name, + caching: { disabled: true }, + ...parseHyperdriveConnectionString(connectionString), + }; return cfFetch(`${CF_API_BASE}/${accountId}/hyperdrive/configs`, apiToken, { method: 'POST', body: JSON.stringify(payload), @@ -682,6 +696,7 @@ export async function updateHyperdrive( ): Promise { const payload = { ...(name === undefined ? {} : { name }), + caching: { disabled: true }, ...parseHyperdriveConnectionString(connectionString), }; return cfFetch( diff --git a/platform/packages/api/src/routes/domains.ts b/platform/packages/api/src/routes/domains.ts index 9041c1f7f..2cf4f1e02 100644 --- a/platform/packages/api/src/routes/domains.ts +++ b/platform/packages/api/src/routes/domains.ts @@ -68,7 +68,7 @@ async function protectedProjectReachedDomainLimit(db: Db, projectId: string): Pr } const app = new Hono() - .use('*', capabilityMiddleware('customDomains')) + .use('/:id/domains/*', capabilityMiddleware('customDomains')) // Add a custom domain to a project .post('/:id/domains', async (context) => { diff --git a/platform/packages/api/test/integration/api-email.test.ts b/platform/packages/api/test/integration/api-email.test.ts index 13d1808c3..e01f090fd 100644 --- a/platform/packages/api/test/integration/api-email.test.ts +++ b/platform/packages/api/test/integration/api-email.test.ts @@ -2345,19 +2345,13 @@ describe('GET /projects/:id/email/logs', () => { // --------------------------------------------------------------------------- describe('email platform capability', () => { - /** - * Hosted Void Cloud's capability set with ONE switch flipped. Only `email` - * is under test here: `domains.ts` registers its gate as `use('*')`, which - * Hono re-homes to `ALL /projects/*` and runs for every sibling mounted - * after it, so turning `customDomains` off as well would make these routes - * answer 501 `customDomains` before the email gate is reached. - */ + /** Email must remain independent of optional application custom domains. */ function capabilityEnv(email: boolean, bindings: TestEnv = env): TestEnv { return { ...bindings, PLATFORM_CAPABILITIES: JSON.stringify({ ai: true, - customDomains: true, + customDomains: false, email, githubActions: true, githubOAuth: true, @@ -2499,6 +2493,38 @@ describe('email platform capability', () => { expect(await list.json()).toEqual([]); }); + it('allows owner email destinations while custom-domain routes remain disabled', async () => { + const user = await createTestUser(env.db); + await setUserEmail(user.id, 'owner@example.com'); + const project = await createTestProject(env.db, user.id); + mockCfWithEmail(cfAlreadyVerified('cf-owner', 'owner@example.com')); + const bindings = capabilityEnv(true, emailEnv()); + + const destination = await addDestination(project.id, user.jwt, 'owner@example.com', bindings); + expect(destination.status).toBe(201); + expect(await destination.json()).toMatchObject({ + address: 'owner@example.com', + status: 'verified', + }); + + for (const [method, path] of [ + ['GET', `/projects/${project.id}/domains`], + ['POST', `/projects/${project.id}/domains`], + ['GET', `/projects/${project.id}/domains/missing`], + ['DELETE', `/projects/${project.id}/domains/missing`], + ]) { + const response = await app.fetch( + new Request(`http://localhost${path}`, { + method, + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + bindings, + ); + expect(response.status).toBe(501); + expect(await response.json()).toMatchObject({ capability: 'customDomains' }); + } + }); + it('registers no owner destination on project creation, even with a mail domain configured', async () => { const user = await createTestUser(env.db); await setUserEmail(user.id, 'owner@example.com'); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index e0e82597f..b31012666 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -1323,6 +1323,7 @@ describe('PUT /projects/:id/database-url', () => { createHyperdrive: (_url, init) => { expect(JSON.parse(init?.body as string)).toEqual({ name: hyperdriveName, + caching: { disabled: true }, origin: { database: 'appdb', host: 'db.example.com', @@ -1346,7 +1347,7 @@ describe('PUT /projects/:id/database-url', () => { }, body: JSON.stringify({ connectionString: - 'postgresql://void_user:p%40ss@db.example.com:25060/appdb?sslmode=require', + 'postgresql://void_user:p%40ss@db.example.com:25060/appdb?sslmode=verify-full&sslrootcert=system', }), }), env, @@ -1369,6 +1370,7 @@ describe('PUT /projects/:id/database-url', () => { createHyperdrive: (_url, init) => { expect(JSON.parse(init?.body as string)).toEqual({ name: hyperdriveName, + caching: { disabled: true }, origin: { database: 'appdb', host: 'mysql.example.com', @@ -1445,6 +1447,7 @@ describe('PUT /projects/:id/database-url', () => { expect(url).toContain('/hyperdrive/configs/hd-existing'); expect(JSON.parse(init?.body as string)).toEqual({ name: hyperdriveName, + caching: { disabled: true }, origin: { database: 'appdb', host: 'db.example.com', diff --git a/platform/packages/api/test/integration/operator-email-domains.test.ts b/platform/packages/api/test/integration/operator-email-domains.test.ts index 2d4c5b509..357f05564 100644 --- a/platform/packages/api/test/integration/operator-email-domains.test.ts +++ b/platform/packages/api/test/integration/operator-email-domains.test.ts @@ -137,6 +137,36 @@ describe('operator email domain management', () => { expect((await env.db.select().from(emailDomainRoutes))[0]?.desired_state).toBe('removed'); }); + it('previews and refuses foreign MX conflicts with supported alternatives and no provider writes', async () => { + const owner = await createTestUser(env.db, 'mx-preview-owner'); + const project = await createTestProject(env.db, owner.id, 'mx-preview-project'); + const stub = stubCustomerCf({ zones, mx: { 'mail.example.com': ['mx.other.test'] } }); + const response = await request('/email/domains', { + domain: 'mail.example.com', + project: project.slug, + dryRun: true, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + dryRun: true, + effect: + 'mail.example.com already receives mail; setup will refuse it — use an unused domain apex or a platform shared email address instead.', + }); + expect(stub.calls.map((call) => call.tag)).toEqual(['mxLookup']); + const refused = await request('/email/domains', { + domain: 'mail.example.com', + project: project.slug, + }); + expect(refused.status).toBe(409); + expect(await refused.json()).toMatchObject({ + message: + 'mail.example.com already has MX records; use an unused domain apex or a platform shared email address', + }); + expect(stub.calls.every((call) => call.method === 'GET')).toBe(true); + expect(await env.db.select().from(emailDomainRoutes)).toHaveLength(0); + }); + it('leaves a foreign catch-all untouched and reports the refusal', async () => { const owner = await createTestUser(env.db, 'foreign-owner'), project = await createTestProject(env.db, owner.id, 'foreign-project'); diff --git a/platform/packages/api/test/unit/cloudflare-api.test.ts b/platform/packages/api/test/unit/cloudflare-api.test.ts index d96df9831..1fee20c32 100644 --- a/platform/packages/api/test/unit/cloudflare-api.test.ts +++ b/platform/packages/api/test/unit/cloudflare-api.test.ts @@ -305,6 +305,30 @@ describe('Hyperdrive connection strings', () => { }); }); + it.each(['verify-ca', 'verify-full', 'verify-full&sslrootcert=system'])( + 'uses Hyperdrive public-CA TLS verification for a PostgreSQL URL with sslmode=%s', + (sslmode) => { + expect( + parseHyperdriveConnectionString( + `postgresql://user:pass@db.example.com/app_db?sslmode=${sslmode}`, + ).mtls, + ).toEqual({ sslmode: 'require' }); + }, + ); + + it('rejects an explicit private CA before provisioning Hyperdrive', async () => { + fetchMock.mockResolvedValueOnce(cfSuccess({ id: 'hd_1', name: 'proj-hyperdrive' })); + await expect( + createHyperdrive( + ACCOUNT, + TOKEN, + 'proj-hyperdrive', + 'postgresql://user:pass@db.example.com/app?sslmode=verify-full&sslrootcert=/private/company-ca.pem', + ), + ).rejects.toThrow('cannot honor sslrootcert'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('parses MySQL connection strings and defaults to port 3306', () => { expect(parseHyperdriveConnectionString('mysql://user:p%40ss@mysql.example.com/app_db')).toEqual( { @@ -333,6 +357,7 @@ describe('Hyperdrive connection strings', () => { const [, init] = fetchMock.mock.calls[0]; expect(JSON.parse((init as RequestInit).body as string)).toEqual({ name: 'proj-hyperdrive', + caching: { disabled: true }, origin: { database: 'app', host: 'db.example.com', @@ -341,7 +366,7 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgres', user: 'user', }, - mtls: { sslmode: 'verify-full' }, + mtls: { sslmode: 'require' }, }); }); @@ -352,13 +377,14 @@ describe('Hyperdrive connection strings', () => { ACCOUNT, TOKEN, 'hd_1', - 'postgresql://user:pass@db.example.com/app', + 'postgresql://user:pass@db.example.com/app?sslmode=verify-full&sslrootcert=system', 'proj-hyperdrive', ); const [, init] = fetchMock.mock.calls[0]; expect(JSON.parse((init as RequestInit).body as string)).toEqual({ name: 'proj-hyperdrive', + caching: { disabled: true }, origin: { database: 'app', host: 'db.example.com', @@ -367,6 +393,7 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgresql', user: 'user', }, + mtls: { sslmode: 'require' }, }); }); diff --git a/platform/packages/api/test/unit/kitchen-sink-sandbox-network.test.ts b/platform/packages/api/test/unit/kitchen-sink-sandbox-network.test.ts new file mode 100644 index 000000000..c3c2d3aff --- /dev/null +++ b/platform/packages/api/test/unit/kitchen-sink-sandbox-network.test.ts @@ -0,0 +1,29 @@ +import { execFile } from 'node:child_process'; +import { createServer, type Socket } from 'node:net'; +import { promisify } from 'node:util'; +import { expect, it } from 'vite-plus/test'; +import { internetAccessProbe } from '../../../../scripts/fixtures/kitchen-sink-sandbox/lib/internet-probe.ts'; + +it('closes a stalled network connection so the denial probe exits without being killed', async () => { + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Probe server did not bind a port.'); + } + const args = [...internetAccessProbe.slice(1, -2), `https://127.0.0.1:${address.port}/`, '50']; + const result = await promisify(execFile)(process.execPath, args, { timeout: 2_000 }); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + } finally { + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/platform/packages/dispatch/src/index.ts b/platform/packages/dispatch/src/index.ts index 584d89c23..0d7359f5b 100644 --- a/platform/packages/dispatch/src/index.ts +++ b/platform/packages/dispatch/src/index.ts @@ -1598,11 +1598,11 @@ export default { const fallbackOriginPathname = pathname; let resolved = resolveStaticPath(pathname, assetManifest, assetConfig); - // Fallback rules — fire only when nothing matched in static resolution. + // Fallback rules run on an asset miss, before serving a custom 404 page. // Single-pass: re-resolve with rewritten path, no further fallbacks. // `fallbackRules` are status-200-only (enforced at API ingest), so // `applyRoutingRules` only ever yields `type: 'rewrite'` here. - if (!resolved && routing.fallbackRules?.length) { + if ((!resolved || resolved.notFound) && routing.fallbackRules?.length) { const fallback = applyRoutingRules(pathname, routing.fallbackRules, url.hostname); if (fallback?.type === 'rewrite') { // Same trust model as the redirectRules rewrite above — framework + diff --git a/platform/packages/dispatch/test/unit/fallback-rules.test.ts b/platform/packages/dispatch/test/unit/fallback-rules.test.ts index c4b5654ca..6438c6f0d 100644 --- a/platform/packages/dispatch/test/unit/fallback-rules.test.ts +++ b/platform/packages/dispatch/test/unit/fallback-rules.test.ts @@ -206,6 +206,35 @@ describe('SPA app — user fallbacks with not_found_handling: none', () => { }); describe('static app — fallbackRules only, no SPA setting', () => { + it.each([ + ['/proxy/missing', 200, 'proxied'], + ['/other/missing', 404, 'not found'], + ['/proxy/existing.html', 200, 'existing'], + ] as const)('applies fallbacks before a custom 404 page for %s', async (path, status, body) => { + const { env } = createStaticEnv({ + routing: { + staticDeploymentId: 'dep_1', + projectId: 'proj_1', + assetConfig: { not_found_handling: '404-page', html_handling: 'none' }, + fallbackRules: [{ source: '/proxy/*', destination: '/proxied.html', status: 200 }], + }, + manifest: { + '/proxied.html': 'h_proxied', + '/404.html': 'h_404', + '/proxy/existing.html': 'h_existing', + }, + assetBodies: { + '/proxied.html': 'proxied', + '/404.html': 'not found', + '/proxy/existing.html': 'existing', + }, + }); + + const res = await app.fetch(new Request(`https://test.void.app${path}`), env, createCtx()); + expect(res.status).toBe(status); + expect(await res.text()).toBe(body); + }); + it('fires fallbackRules on asset miss', async () => { const { env } = createStaticEnv({ routing: { diff --git a/platform/packages/proxy/src/email-admission.ts b/platform/packages/proxy/src/email-admission.ts index 8a0b6d097..cf49753dd 100644 --- a/platform/packages/proxy/src/email-admission.ts +++ b/platform/packages/proxy/src/email-admission.ts @@ -196,7 +196,15 @@ export function canonicalizeVoidMime(raw: Uint8Array): Uint8Array { // terminating empty line) remains byte-for-byte unchanged. replace(lineStart, nextLine, new Uint8Array()); } else if (isMultipartContentType(raw, lineStart, lineEnd)) { - const line = decoder.decode(raw.subarray(lineStart, lineEnd)); + let headerEnd = lineEnd; + let afterHeader = nextLine; + let line = decoder.decode(raw.subarray(lineStart, headerEnd)); + // The SDK folds longer multipart boundary parameters onto continuation lines. + while (raw[afterHeader] === 32 || raw[afterHeader] === 9) { + headerEnd = findCrlf(raw, afterHeader); + line += ` ${decoder.decode(raw.subarray(afterHeader, headerEnd)).trimStart()}`; + afterHeader = headerEnd === raw.byteLength ? raw.byteLength : headerEnd + 2; + } const normalized = line.replace( /boundary="(=_void_(?:mixed|alternative|related)_[A-Za-z0-9_-]+)"/g, (_, token: string) => { @@ -209,8 +217,10 @@ export function canonicalizeVoidMime(raw: Uint8Array): Uint8Array { }, ); if (normalized !== line) { - replace(lineStart, lineEnd, encoder.encode(normalized)); + replace(lineStart, headerEnd, encoder.encode(normalized)); } + lineStart = afterHeader; + continue; } } else if (raw[lineStart] === 45 && raw[lineStart + 1] === 45) { const tokenStart = lineStart + 2; diff --git a/platform/packages/proxy/test/unit/email-admission-send.test.ts b/platform/packages/proxy/test/unit/email-admission-send.test.ts index fb50411b5..dfc4060b8 100644 --- a/platform/packages/proxy/test/unit/email-admission-send.test.ts +++ b/platform/packages/proxy/test/unit/email-admission-send.test.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { describe, expect, it, vi } from 'vite-plus/test'; +import { buildMime, normalizeEmail } from '../../../../../packages/void/src/runtime/email/mime.ts'; vi.mock('cloudflare:email', () => ({ EmailMessage: class { @@ -100,7 +101,7 @@ function fixture() { await next(); }); app.route('/email', email); - const send = async (key: string, recipients = ['one@example.com']) => + const send = async (key: string, recipients = ['one@example.com'], raw?: Uint8Array) => app.request( '/email/send', { @@ -109,7 +110,11 @@ function fixture() { body: JSON.stringify({ from: 'acme+news@mail.example.com', recipients, - mimeBase64: btoa('From: acme+news@mail.example.com\r\nSubject: hello\r\n\r\nbody'), + mimeBase64: btoa( + raw + ? Array.from(raw, (byte) => String.fromCharCode(byte)).join('') + : 'From: acme+news@mail.example.com\r\nSubject: hello\r\n\r\nbody', + ), idempotencyKey: key, }), }, @@ -119,6 +124,31 @@ function fixture() { } describe('proxy email admission delivery', () => { + it('replays rebuilt SDK multipart MIME without another provider submission', async () => { + const test = fixture(); + const options = { + from: 'acme+news@mail.example.com', + to: 'one@example.com', + subject: 'multipart retry', + text: 'Plain text', + html: '

HTML

', + attachments: [{ filename: 'note.txt', content: 'Attachment', contentType: 'text/plain' }], + }; + const normalized = await normalizeEmail(options); + const firstMime = buildMime(normalized).raw; + const retryMime = buildMime(normalized).raw; + const first = await test.send('multipart', undefined, firstMime); + const retry = await test.send('multipart', undefined, retryMime); + expect(first.status).toBe(200); + expect(retry.status).toBe(200); + expect(await retry.json()).toEqual(await first.json()); + expect(test.sender).toHaveBeenCalledTimes(1); + + const changed = buildMime(await normalizeEmail({ ...options, text: 'Changed text' })).raw; + expect((await test.send('multipart', undefined, changed)).status).toBe(409); + expect(test.sender).toHaveBeenCalledTimes(1); + }); + it('replays a caller key without another provider submission', async () => { const test = fixture(); expect((await test.send('one')).status).toBe(200); diff --git a/platform/scripts/fixtures/kitchen-sink-sandbox/lib/internet-probe.ts b/platform/scripts/fixtures/kitchen-sink-sandbox/lib/internet-probe.ts new file mode 100644 index 000000000..733a9d06b --- /dev/null +++ b/platform/scripts/fixtures/kitchen-sink-sandbox/lib/internet-probe.ts @@ -0,0 +1,18 @@ +// Probe the connection without DNS and explicitly close it on timeout. Both +// Internet-disabled and Internet-enabled cases run this same command. +export const internetAccessProbe = [ + 'node', + '-e', + `const request = require("node:https").get(process.argv[1], response => { + clearTimeout(timer); + response.destroy(); + process.exitCode = 1; + }); + const timer = setTimeout(() => request.destroy(new Error("Internet probe timed out")), Number(process.argv[2])); + request.on("error", () => { + clearTimeout(timer); + process.exitCode = 0; + });`, + 'https://1.1.1.1/cdn-cgi/trace', + '3000', +]; diff --git a/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts b/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts index f750563b1..94e94104d 100644 --- a/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts +++ b/platform/scripts/fixtures/kitchen-sink-sandbox/routes/api/acceptance/sandbox.ts @@ -1,6 +1,7 @@ import { defineHandler } from 'void'; import { env } from 'void/env'; import { getSandbox, SandboxLimitError, type SandboxClient } from 'void/sandbox'; +import { internetAccessProbe } from '../../../lib/internet-probe.ts'; function assert(condition: unknown, message: string): asserts condition { if (!condition) { @@ -155,15 +156,14 @@ export const POST = defineHandler(async (c) => { await server.exitCode; checks.push('HTTP port fetch'); - const blocked = await sandbox.exec( - [ - 'node', - '-e', - 'fetch("https://www.cloudflare.com/cdn-cgi/trace", {signal: AbortSignal.timeout(3000)}).then(() => process.exit(1), () => process.exit(0));', - ], - { signal: AbortSignal.timeout(5_000) }, + const blocked = await sandbox.exec(internetAccessProbe, { + signal: AbortSignal.timeout(10_000), + }); + const blockedOutput = await blocked.output(); + assert( + blockedOutput.exitCode === 0, + `Disabled Internet probe failed (exit ${blockedOutput.exitCode})`, ); - assert((await blocked.output()).exitCode === 0, 'Internet access was allowed while disabled'); checks.push('Internet disabled'); const isolated = await getSandbox(`${id}-other`, { @@ -177,15 +177,14 @@ export const POST = defineHandler(async (c) => { 'A second Sandbox can see the first Sandbox files', ); checks.push('separate ID isolation'); - const internet = await isolated.exec( - [ - 'node', - '-e', - 'fetch("https://www.cloudflare.com/cdn-cgi/trace", {signal: AbortSignal.timeout(10000)}).then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1));', - ], - { signal: AbortSignal.timeout(15_000) }, + const internet = await isolated.exec(internetAccessProbe, { + signal: AbortSignal.timeout(15_000), + }); + const internetOutput = await internet.output(); + assert( + internetOutput.exitCode === 1, + `Enabled Internet probe did not receive an HTTP response (exit ${internetOutput.exitCode})`, ); - assert((await internet.output()).exitCode === 0, 'Internet access failed while enabled'); checks.push('Internet enabled'); await isolated.destroy(); diff --git a/playground/analog/index.html b/playground/analog/index.html index 1f7ab1cf8..92242ebbc 100644 --- a/playground/analog/index.html +++ b/playground/analog/index.html @@ -5,8 +5,6 @@ analog - - From 84706062ddbfa95b0c059e1ba65cb8a0c08a8950 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 23:39:57 +0800 Subject: [PATCH 2/7] fix(platform): keep admin team tables visible --- .../packages/api/src/admin/react/styles.css | 4 ++++ .../test/integration/admin-dashboard.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/platform/packages/api/src/admin/react/styles.css b/platform/packages/api/src/admin/react/styles.css index c22524b5b..0680e369c 100644 --- a/platform/packages/api/src/admin/react/styles.css +++ b/platform/packages/api/src/admin/react/styles.css @@ -251,6 +251,10 @@ body { margin: 0; } .void-admin-team-table-scroll { + --container-padding-inline-start: 0px; + --container-padding-inline-end: 0px; + --container-padding-block-start: 0px; + --container-padding-block-end: 0px; min-width: 0; max-width: 100%; overflow-x: auto; diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 84218c8aa..58d9cc167 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -1100,6 +1100,16 @@ describe('GET /admin/ (dashboard)', () => { admin.id, 'a-project-with-a-long-name-for-truncation', ); + const invited = await createTestUser(env.db, 'team-layout-invitee'); + await env.db.insert(projectInvitations).values({ + id: 'team-layout-invitation', + project_id: project.id, + target_user_id: invited.id, + target_email_snapshot: 'team-layout-invitee@example.com', + role: 'reader', + invited_by_user_id: admin.id, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); const error = 'Worker upload failed: upstream rejected the deployment.\n'.repeat(20); const failed = await createTestDeployment(env.db, project.id, 'failed', 'cli', error); const email = `${'long-email-address-'.repeat(5)}@example.com`; @@ -1162,6 +1172,20 @@ describe('GET /admin/ (dashboard)', () => { page.setDefaultTimeout(5000); await page.goto(`http://localhost${path}`); await page.locator('[data-hydrated="true"]').waitFor(); + if (path === `/admin/projects/${project.id}`) { + const teamTables = page.locator('.void-admin-team-table-scroll'); + expect(await teamTables.count()).toBe(2); + for (const teamTable of await teamTables.all()) { + const rows = await teamTable.evaluate((container) => { + const bounds = container.getBoundingClientRect(); + return Array.from(container.querySelectorAll('tr'), (row) => { + const rect = row.getBoundingClientRect(); + return { top: rect.top - bounds.top, bottom: rect.bottom - bounds.bottom }; + }); + }); + expect(rows.every(({ top, bottom }) => top >= -1 && bottom <= 1)).toBe(true); + } + } const isUsers = path === '/admin/users'; const table = page.locator( isUsers ? '.void-admin-users-table' : '.void-admin-deployments-table', From 0512376421726145ef13cc8aa64a8383d51de122 Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Sat, 10 Oct 2026 08:25:05 +0800 Subject: [PATCH 3/7] Preserve email idempotency across MIME normalization upgrades --- platform/packages/api/src/email/admission.ts | 14 ++- .../test/integration/email-admission.test.ts | 113 ++++++++++++++++++ .../packages/proxy/src/email-admission.ts | 23 +++- platform/packages/proxy/src/email-router.ts | 3 + platform/packages/proxy/src/email.ts | 4 +- .../proxy/test/fixtures/legacy-multipart.ts | 29 +++++ .../test/unit/email-admission-send.test.ts | 34 +++++- .../proxy/test/unit/email-admission.test.ts | 34 +++++- .../proxy/test/unit/email-router.test.ts | 20 ++++ 9 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 platform/packages/proxy/test/fixtures/legacy-multipart.ts diff --git a/platform/packages/api/src/email/admission.ts b/platform/packages/api/src/email/admission.ts index 8b2e24c39..1d766c3db 100644 --- a/platform/packages/api/src/email/admission.ts +++ b/platform/packages/api/src/email/admission.ts @@ -50,6 +50,8 @@ export type EmailAdmissionInput = { kind: EmailActionKind; key: string; digest: string; + /** Computed by the signed proxy from the same MIME, never accepted from a tenant. */ + legacyDigest?: string; from: string; recipients: Array; transport: 'binding' | 'send_raw' | 'native_forward' | 'native_reply'; @@ -308,6 +310,10 @@ export async function admitEmailAction( !input.key || input.key.length > (input.kind === 'direct' ? 128 : 256) || !/^[a-f0-9]{64}$/.test(input.digest) || + (input.legacyDigest !== undefined && + (input.kind !== 'direct' || + typeof input.legacyDigest !== 'string' || + !/^[a-f0-9]{64}$/.test(input.legacyDigest))) || input.recipients.length > (input.kind === 'direct' ? 50 : 100) || !validAddress(input.from) ) { @@ -341,12 +347,18 @@ export async function admitEmailAction( ) .limit(1); if (existing) { - if (existing.payload_digest !== input.digest || existing.kind !== input.kind) { + if ( + (existing.payload_digest !== input.digest && + existing.payload_digest !== input.legacyDigest) || + existing.kind !== input.kind + ) { throw new EmailAdmissionError( 'CONFLICT', 'idempotency key was used for a different action', ); } + // Do not rewrite the stored digest or recipient outcomes on a legacy replay. + // Older proxy executions still need to recognize the original operation. return { operationId: existing.id, replay: true, diff --git a/platform/packages/api/test/integration/email-admission.test.ts b/platform/packages/api/test/integration/email-admission.test.ts index 662791b00..73692458a 100644 --- a/platform/packages/api/test/integration/email-admission.test.ts +++ b/platform/packages/api/test/integration/email-admission.test.ts @@ -1,8 +1,14 @@ import { eq, sql } from 'drizzle-orm'; import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { emailPayloadDigests } from '../../../proxy/src/email-admission.ts'; +import { + legacyMultipartDigest, + legacyMultipartMime, +} from '../../../proxy/test/fixtures/legacy-multipart.ts'; import { deleteEmailDestination } from '../../src/db.ts'; import { emailActionRecipients, + emailActions, emailMonthlyUsage, emailReceipts, emailUnresolvedAttempts, @@ -915,6 +921,113 @@ describe('authoritative email admission', () => { ).rejects.toMatchObject({ code: 'CONFLICT' } satisfies Partial); }); + it.each(['provider_accepted', 'outcome_unknown', 'attempt_started'] as const)( + 'replays a pre-upgrade multipart operation in state %s without changing its ledger', + async (state) => { + const project = await fixture(); + const original = { ...input(project.id, 'upgrade'), digest: legacyMultipartDigest }; + const first = await admitEmailAction(env.db, original); + await transitionEmailRecipients( + env.db, + first.operationId, + original.recipients, + 'attempt_started', + ); + if (state !== 'attempt_started') { + await transitionEmailRecipients(env.db, first.operationId, original.recipients, state, { + providerReference: 'original-provider-id', + }); + } + const ledger = await env.db.select().from(emailActions); + const recipients = await env.db.select().from(emailActionRecipients); + const digests = await emailPayloadDigests( + 'direct', + original.from, + original.recipients, + legacyMultipartMime, + ); + expect(digests.digest).not.toBe(legacyMultipartDigest); + expect(digests.legacyDigest).toBe(legacyMultipartDigest); + const replay = await admitEmailAction(env.db, { ...original, ...digests }); + expect(replay).toMatchObject({ + operationId: first.operationId, + replay: true, + recipients: [{ state }], + }); + expect(await env.db.select().from(emailActions)).toEqual(ledger); + expect(await env.db.select().from(emailActionRecipients)).toEqual(recipients); + expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); + expect((await admitEmailAction(env.db, original)).operationId).toBe(first.operationId); + + for (const [from, to, raw] of [ + [ + original.from, + original.recipients, + new TextEncoder().encode( + new TextDecoder().decode(legacyMultipartMime).replace('Plain text', 'Changed text'), + ), + ], + [original.from, ['two@example.com'], legacyMultipartMime], + ['acme+different@mail.example.com', original.recipients, legacyMultipartMime], + ] as const) { + await expect( + admitEmailAction(env.db, { + ...original, + from, + recipients: [...to], + ...(await emailPayloadDigests('direct', from, [...to], raw)), + }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + } + }, + ); + + it('stores the current digest for new multipart sends and accepts rebuilt boundaries', async () => { + const project = await fixture(); + const original = input(project.id, 'new-multipart'); + const first = await admitEmailAction(env.db, { + ...original, + ...(await emailPayloadDigests( + 'direct', + original.from, + original.recipients, + legacyMultipartMime, + )), + }); + const rebuilt = new TextEncoder().encode( + new TextDecoder() + .decode(legacyMultipartMime) + .replaceAll('=_void_alternative_1573725Jyj8yLCojOVRltQ', '=_void_alternative_rebuilt'), + ); + const digests = await emailPayloadDigests( + 'direct', + original.from, + original.recipients, + rebuilt, + ); + expect(digests.legacyDigest).not.toBe(legacyMultipartDigest); + expect((await env.db.select().from(emailActions))[0]?.payload_digest).toBe(digests.digest); + expect(await admitEmailAction(env.db, { ...original, ...digests })).toMatchObject({ + operationId: first.operationId, + replay: true, + }); + expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); + }); + + it.each(['invalid', 123, null, ['a'.repeat(64)]])( + 'rejects an invalid legacy digest %j', + async (legacyDigest) => { + const project = await fixture(); + await expect( + admitEmailAction(env.db, { + ...input(project.id, 'invalid-legacy'), + legacyDigest: legacyDigest as string, + }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + expect(await env.db.select().from(emailActions)).toHaveLength(0); + }, + ); + it('admits a partial batch under atomic recipient quotas and keeps charges after uncertain outcomes', async () => { const project = await fixture(); await setProjectEmailLimits(env.db, project.id, { burst: 1, monthly: 1 }); diff --git a/platform/packages/proxy/src/email-admission.ts b/platform/packages/proxy/src/email-admission.ts index cf49753dd..99c6aae8c 100644 --- a/platform/packages/proxy/src/email-admission.ts +++ b/platform/packages/proxy/src/email-admission.ts @@ -153,7 +153,10 @@ export function completeRelayedReceipt( } /** Only generated, transport-irrelevant MIME fields are normalized. Body text stays byte-sensitive. */ -export function canonicalizeVoidMime(raw: Uint8Array): Uint8Array { +export function canonicalizeVoidMime( + raw: Uint8Array, + version: 'legacy' | 'current' = 'current', +): Uint8Array { if (findCrlfCrlf(raw) === -1) { return raw; } @@ -200,7 +203,7 @@ export function canonicalizeVoidMime(raw: Uint8Array): Uint8Array { let afterHeader = nextLine; let line = decoder.decode(raw.subarray(lineStart, headerEnd)); // The SDK folds longer multipart boundary parameters onto continuation lines. - while (raw[afterHeader] === 32 || raw[afterHeader] === 9) { + while (version === 'current' && (raw[afterHeader] === 32 || raw[afterHeader] === 9)) { headerEnd = findCrlf(raw, afterHeader); line += ` ${decoder.decode(raw.subarray(afterHeader, headerEnd)).trimStart()}`; afterHeader = headerEnd === raw.byteLength ? raw.byteLength : headerEnd + 2; @@ -340,8 +343,9 @@ export async function emailPayloadDigest( from: string, recipients: Array, raw: Uint8Array, + version: 'legacy' | 'current' = 'current', ): Promise { - const canonical = canonicalizeVoidMime(raw); + const canonical = canonicalizeVoidMime(raw, version); const prefix = encoder.encode( JSON.stringify({ kind, @@ -358,6 +362,18 @@ export async function emailPayloadDigest( .join(''); } +/** Existing ledger rows retain the pre-unfolding digest for their full retention period. */ +export async function emailPayloadDigests( + kind: string, + from: string, + recipients: Array, + raw: Uint8Array, +): Promise<{ digest: string; legacyDigest?: string }> { + const digest = await emailPayloadDigest(kind, from, recipients, raw); + const legacyDigest = await emailPayloadDigest(kind, from, recipients, raw, 'legacy'); + return { digest, ...(legacyDigest === digest ? {} : { legacyDigest }) }; +} + export function admitEmail( env: Pick, body: { @@ -365,6 +381,7 @@ export function admitEmail( kind: 'direct' | 'forward' | 'reply'; key: string; digest: string; + legacyDigest?: string; from: string; recipients: Array; transport: 'binding' | 'send_raw' | 'native_forward' | 'native_reply'; diff --git a/platform/packages/proxy/src/email-router.ts b/platform/packages/proxy/src/email-router.ts index 51a1c7b79..bac3480c6 100644 --- a/platform/packages/proxy/src/email-router.ts +++ b/platform/packages/proxy/src/email-router.ts @@ -880,6 +880,8 @@ async function enactSanitized( } const to = action.to.toLowerCase(); const from = action.type === 'reply' ? action.from : message.to; + // Inbound operation keys embed this digest. Preserve the original algorithm: + // changing it would create a second operation for a retained receipt/action. const digest = await emailPayloadDigest( action.type, from, @@ -887,6 +889,7 @@ async function enactSanitized( action.type === 'reply' ? action.raw : new TextEncoder().encode(JSON.stringify([...(action.headers?.entries() ?? [])].sort())), + 'legacy', ); let admission; try { diff --git a/platform/packages/proxy/src/email.ts b/platform/packages/proxy/src/email.ts index 4b09e00d1..d23651ce1 100644 --- a/platform/packages/proxy/src/email.ts +++ b/platform/packages/proxy/src/email.ts @@ -4,7 +4,7 @@ import type { Context } from 'hono'; import { asString } from './body.ts'; import { admitEmail, - emailPayloadDigest, + emailPayloadDigests, finishEmailAttempt, startEmailAttempt, type Admission, @@ -1499,7 +1499,7 @@ async function admitDirectSend( projectId, kind: 'direct', key: operationKey, - digest: await emailPayloadDigest('direct', from, recipients, raw), + ...(await emailPayloadDigests('direct', from, recipients, raw)), from, recipients, transport, diff --git a/platform/packages/proxy/test/fixtures/legacy-multipart.ts b/platform/packages/proxy/test/fixtures/legacy-multipart.ts new file mode 100644 index 000000000..346f58b25 --- /dev/null +++ b/platform/packages/proxy/test/fixtures/legacy-multipart.ts @@ -0,0 +1,29 @@ +// SDK-generated MIME and its digest from before multipart header unfolding. +// Keep the digest fixed: a current-code calculation would hide upgrade regressions. +export const legacyMultipartDigest = + '8409457699c7de52030141f0afbd3a8b9c30c7b25a9e0cc4c5392c207e40d0e5'; +export const legacyMultipartMime = new TextEncoder().encode( + [ + 'Date: Sat, 10 Oct 2026 00:10:05 +0000', + 'From: acme+news@mail.example.com', + 'To: one@example.com', + 'Subject: upgrade retry', + 'Message-ID: ', + 'MIME-Version: 1.0', + 'Content-Type: multipart/alternative;', + ' boundary="=_void_alternative_1573725Jyj8yLCojOVRltQ"', + '', + '--=_void_alternative_1573725Jyj8yLCojOVRltQ', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: 7bit', + '', + 'Plain text', + '--=_void_alternative_1573725Jyj8yLCojOVRltQ', + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: 7bit', + '', + '

HTML

', + '--=_void_alternative_1573725Jyj8yLCojOVRltQ--', + '', + ].join('\r\n'), +); diff --git a/platform/packages/proxy/test/unit/email-admission-send.test.ts b/platform/packages/proxy/test/unit/email-admission-send.test.ts index dfc4060b8..c95885472 100644 --- a/platform/packages/proxy/test/unit/email-admission-send.test.ts +++ b/platform/packages/proxy/test/unit/email-admission-send.test.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { describe, expect, it, vi } from 'vite-plus/test'; import { buildMime, normalizeEmail } from '../../../../../packages/void/src/runtime/email/mime.ts'; +import { legacyMultipartDigest, legacyMultipartMime } from '../fixtures/legacy-multipart.ts'; vi.mock('cloudflare:email', () => ({ EmailMessage: class { @@ -35,7 +36,7 @@ function fixture() { if (path.endsWith('/admit')) { const key = body.key as string; const previous = actions.get(key); - if (previous && previous.digest !== body.digest) { + if (previous && previous.digest !== body.digest && previous.digest !== body.legacyDigest) { return Response.json({ code: 'CONFLICT' }, { status: 409 }); } if (previous) { @@ -124,6 +125,37 @@ function fixture() { } describe('proxy email admission delivery', () => { + it.each(['provider_accepted', 'outcome_unknown', 'attempt_started'])( + 'replays pre-upgrade multipart MIME with its recorded %s outcome', + async (state) => { + const test = fixture(); + test.actions.set('pre-upgrade', { + operationId: 'act_pre_upgrade', + digest: legacyMultipartDigest, + recipients: [ + { + recipient: 'one@example.com', + state, + errorCode: null, + providerReference: 'provider-before-upgrade', + }, + ], + }); + const response = await test.send('pre-upgrade', undefined, legacyMultipartMime); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ operationId: 'act_pre_upgrade' }); + expect(test.sender).not.toHaveBeenCalled(); + expect(test.actions.size).toBe(1); + expect(test.actions.get('pre-upgrade')?.digest).toBe(legacyMultipartDigest); + + const changed = new TextEncoder().encode( + new TextDecoder().decode(legacyMultipartMime).replace('Plain text', 'Changed text'), + ); + expect((await test.send('pre-upgrade', undefined, changed)).status).toBe(409); + expect(test.sender).not.toHaveBeenCalled(); + }, + ); + it('replays rebuilt SDK multipart MIME without another provider submission', async () => { const test = fixture(); const options = { diff --git a/platform/packages/proxy/test/unit/email-admission.test.ts b/platform/packages/proxy/test/unit/email-admission.test.ts index d4efc3f78..827001722 100644 --- a/platform/packages/proxy/test/unit/email-admission.test.ts +++ b/platform/packages/proxy/test/unit/email-admission.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vite-plus/test'; -import { admitEmail, emailPayloadDigest, finishEmailAttempt } from '../../src/email-admission.ts'; +import { + admitEmail, + emailPayloadDigest, + emailPayloadDigests, + finishEmailAttempt, +} from '../../src/email-admission.ts'; +import { legacyMultipartDigest, legacyMultipartMime } from '../fixtures/legacy-multipart.ts'; const encoder = new TextEncoder(); function message(date: string, id: string, boundary: string, body: string) { @@ -59,6 +65,32 @@ function nestedMessage(generation: string, bodyLine: string) { } describe('email admission payload digest', () => { + it('retains the exact historical digest for folded SDK multipart MIME', async () => { + const args = [ + 'direct', + 'acme+news@mail.example.com', + ['one@example.com'] as Array, + legacyMultipartMime, + ] as const; + expect(await emailPayloadDigest(...args, 'legacy')).toBe(legacyMultipartDigest); + expect(await emailPayloadDigests(...args)).toEqual({ + digest: await emailPayloadDigest(...args), + legacyDigest: legacyMultipartDigest, + }); + }); + + it('omits a redundant legacy digest for single-part MIME', async () => { + const args = [ + 'direct', + 'acme@mail.example.com', + ['one@example.com'] as Array, + encoder.encode('From: acme@mail.example.com\r\n\r\nbody'), + ] as const; + expect(await emailPayloadDigests(...args)).toEqual({ + digest: await emailPayloadDigest(...args), + }); + }); + it('deduplicates retries when generated date, message ID and multipart boundary change', async () => { const a = message( 'Fri, 18 Sep 2026 01:00:00 +0000', diff --git a/platform/packages/proxy/test/unit/email-router.test.ts b/platform/packages/proxy/test/unit/email-router.test.ts index c99285272..ed283ef60 100644 --- a/platform/packages/proxy/test/unit/email-router.test.ts +++ b/platform/packages/proxy/test/unit/email-router.test.ts @@ -17,6 +17,7 @@ vi.mock('../../../email-gateway/src/tenant-dispatch.ts', () => ({ return { actions, receiptId: input.receiptId }; }, })); +import { emailPayloadDigest } from '../../src/email-admission.ts'; import { MAX_INBOUND_BYTES, MAX_INBOUND_HEADERS, @@ -24,6 +25,7 @@ import { routeRelayed, } from '../../src/email-router.ts'; import type { Env } from '../../src/types.ts'; +import { legacyMultipartMime } from '../fixtures/legacy-multipart.ts'; const ROUTE_FENCE = { routeId: 'route-1', @@ -1172,6 +1174,24 @@ describe('routeRelayed — reply attempt outcomes', () => { return { outcome, apiFetch, providerFetch, env }; } + it('preserves pre-upgrade digest-derived reply keys', async () => { + const raw = new TextEncoder().encode( + new TextDecoder() + .decode(legacyMultipartMime) + .replaceAll('acme+news@mail.example.com', from) + .replaceAll('one@example.com', to), + ); + const digest = await emailPayloadDigest('reply', from, [to], raw, 'legacy'); + expect(await emailPayloadDigest('reply', from, [to], raw)).not.toBe(digest); + const { apiFetch } = await runReply(Response.json({ result: { message_id: 'reply-id' } }), { + raw, + }); + expect(apiBodies(apiFetch, '/admit')[0]).toMatchObject({ + key: `receipt_1:0:${digest.slice(0, 16)}`, + digest, + }); + }); + it('sends a relayed shared-sender reply through the shared binding', async () => { const { outcome, apiFetch, providerFetch, env } = await runReply(new Response(null), { from: `myslug+support@${PROD_DOMAIN}`, From 0fb0852021e4ab97ef06f992a936196373d7c9fa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 10 Oct 2026 16:39:45 +0800 Subject: [PATCH 4/7] fix(platform): accept provider CA URLs for Hyperdrive and tidy follow-ups - Stop rejecting sslrootcert file paths when Void creates Hyperdrive. Main accepted these URLs, providers such as RDS and Supabase document them, and the only accepted alternative made the same require-mode Hyperdrive. The old advice (sslrootcert=system) also broke Void's own pg clients with ENOENT. - Document that Void-created Hyperdrive uses Cloudflare's default require mode. - List Containers and Cloudchamber Edit in the core runtime token table; every packaged runtime enables Sandbox. - Unfold MIME boundary headers on SP/HTAB only. - Correct the reason given for the inbound digest algorithm. - Cover legacyDigest through the signed admission route. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/database/postgresql.md | 2 + .../platform/installation/credentials.md | 4 +- .../api/src/provisioning/cloudflare-api.ts | 13 +---- .../api/test/integration/api-projects.test.ts | 2 +- .../test/integration/email-admission.test.ts | 39 ++++++++++++- .../api/test/unit/cloudflare-api.test.ts | 56 ++++++++++++------- .../packages/proxy/src/email-admission.ts | 3 +- platform/packages/proxy/src/email-router.ts | 4 +- .../proxy/test/unit/email-admission.test.ts | 28 ++++++++++ 9 files changed, 114 insertions(+), 37 deletions(-) diff --git a/docs/guide/database/postgresql.md b/docs/guide/database/postgresql.md index 88f064768..136c0a8e0 100644 --- a/docs/guide/database/postgresql.md +++ b/docs/guide/database/postgresql.md @@ -40,6 +40,8 @@ For a Void platform, deploy prompts for the connection string if needed. You can Void provisions Hyperdrive without saving the connection string in project config. Both `postgres://` and `postgresql://` URLs are supported, including provider query strings such as `?sslmode=require`. +The Hyperdrive that Void creates connects with TLS in Cloudflare's default `require` mode. Hyperdrive does not use the `sslmode=verify-ca`, `sslmode=verify-full`, or `sslrootcert` settings from your connection string. To have Hyperdrive verify your database against a specific certificate authority on a direct Cloudflare deploy, [create the Hyperdrive in Cloudflare with that certificate](https://developers.cloudflare.com/hyperdrive/configuration/tls-ssl-certificates-for-hyperdrive/) and add its ID to [`cloudflare.hyperdrive`](../../integrations/cloudflare.md#databases-and-secrets). + ## Schema Definition Import schema helpers from `void/schema-pg`: diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index c6af9c617..45f34cf7a 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -39,6 +39,8 @@ Use the following permissions for the core platform. Cloudflare may label write | Account | Hyperdrive | Write | Write | | Account | Account Analytics | Read | Read | | Account | AI Gateway | Edit | Edit when installing with browser login | +| Account | Containers | — | Edit | +| Account | Cloudchamber | — | Edit | | Zone | Zone | Read | — | | Zone | DNS | Edit | — | | Zone | Workers Routes | Edit | — | @@ -48,8 +50,6 @@ When Void configures Access in the installation account, the initial management- The management token also needs **Zone Edit** with authority to create zones if you ask Void to create the zone. If it already exists, use the selected zone with Zone Read and DNS Edit. Nested application domains additionally need **SSL and Certificates: Read** on the management token. A custom runtime that enables custom project domains needs **SSL and Certificates: Edit** on the runtime token; the core runtime does not enable that feature. -If your platform enables Sandbox, its runtime token also needs **Account → Workers Containers → Edit** and **Account → Cloudchamber → Edit**. - Void checks access before provisioning. If it reports a missing permission, update the token's permissions for the selected account or zone and retry. ::: diff --git a/platform/packages/api/src/provisioning/cloudflare-api.ts b/platform/packages/api/src/provisioning/cloudflare-api.ts index 5912a293e..b0f49b0f8 100644 --- a/platform/packages/api/src/provisioning/cloudflare-api.ts +++ b/platform/packages/api/src/provisioning/cloudflare-api.ts @@ -636,13 +636,6 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper ); } - const sslrootcert = url.searchParams.getAll('sslrootcert').at(-1); - if (sslrootcert && sslrootcert !== 'system') { - throw new InvalidHyperdriveConnectionStringError( - 'Void-created Hyperdrive uses public certificate authorities and cannot honor sslrootcert. For a database signed by a public certificate authority, use sslrootcert=system.', - ); - } - const sslmode = url.searchParams.get('sslmode'); let mtls: HyperdriveConfigPayload['mtls']; if (sslmode) { @@ -651,9 +644,9 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper 'Unsupported sslmode. Cloudflare Hyperdrive supports "require", "verify-ca", or "verify-full".', ); } - // Hyperdrive's verify modes require an uploaded CA certificate ID, which a URL - // cannot supply. `require` validates server certificates using public WebPKI; - // direct database clients retain the connection URL's original verification mode. + // Hyperdrive's verify modes need a CA certificate uploaded to Cloudflare, which a URL + // cannot carry, so Void creates Hyperdrive in Cloudflare's default `require` mode. + // `sslrootcert` names a CA on the client machine and is not sent to Hyperdrive. mtls = { sslmode: 'require' }; } diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index b31012666..9e44f0bae 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -1347,7 +1347,7 @@ describe('PUT /projects/:id/database-url', () => { }, body: JSON.stringify({ connectionString: - 'postgresql://void_user:p%40ss@db.example.com:25060/appdb?sslmode=verify-full&sslrootcert=system', + 'postgresql://void_user:p%40ss@db.example.com:25060/appdb?sslmode=verify-full&sslrootcert=/home/dev/prod-ca-2021.crt', }), }), env, diff --git a/platform/packages/api/test/integration/email-admission.test.ts b/platform/packages/api/test/integration/email-admission.test.ts index 73692458a..7e6e01ae8 100644 --- a/platform/packages/api/test/integration/email-admission.test.ts +++ b/platform/packages/api/test/integration/email-admission.test.ts @@ -1,6 +1,6 @@ import { eq, sql } from 'drizzle-orm'; import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; -import { emailPayloadDigests } from '../../../proxy/src/email-admission.ts'; +import { admitEmail, emailPayloadDigests } from '../../../proxy/src/email-admission.ts'; import { legacyMultipartDigest, legacyMultipartMime, @@ -35,6 +35,7 @@ import { emailZoneConnections, } from '../../src/email/connection-schema.ts'; import { setEmailRecipientPolicy } from '../../src/email/settings.ts'; +import { app } from '../../src/index.ts'; import { emailDestinations, projects, users } from '../../src/schema.ts'; import { captureQueries, @@ -982,6 +983,42 @@ describe('authoritative email admission', () => { }, ); + it('replays a pre-upgrade multipart operation through the signed admission route', async () => { + const project = await fixture(); + const { sharedSenderDomain, ...original } = { + ...input(project.id, 'signed-upgrade'), + digest: legacyMultipartDigest, + }; + const first = await admitEmailAction(env.db, { ...original, sharedSenderDomain }); + const proxyEnv = { + API: { + fetch: (url: string, init?: RequestInit) => + app.fetch(new Request(url, init), { ...env, EMAIL_SENDER_DOMAIN: sharedSenderDomain }), + } as never, + EMAIL_SIGNING_SECRET: env.EMAIL_SIGNING_SECRET, + }; + const digests = await emailPayloadDigests( + 'direct', + original.from, + original.recipients, + legacyMultipartMime, + ); + expect(digests.digest).not.toBe(legacyMultipartDigest); + expect(digests.legacyDigest).toBe(legacyMultipartDigest); + + await expect(admitEmail(proxyEnv, { ...original, digest: digests.digest })).rejects.toThrow( + 'IDEMPOTENCY_CONFLICT', + ); + expect(await admitEmail(proxyEnv, { ...original, ...digests })).toMatchObject({ + operationId: first.operationId, + replay: true, + }); + expect((await env.db.select().from(emailActions))[0]?.payload_digest).toBe( + legacyMultipartDigest, + ); + expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); + }); + it('stores the current digest for new multipart sends and accepts rebuilt boundaries', async () => { const project = await fixture(); const original = input(project.id, 'new-multipart'); diff --git a/platform/packages/api/test/unit/cloudflare-api.test.ts b/platform/packages/api/test/unit/cloudflare-api.test.ts index 1fee20c32..c554f329a 100644 --- a/platform/packages/api/test/unit/cloudflare-api.test.ts +++ b/platform/packages/api/test/unit/cloudflare-api.test.ts @@ -305,28 +305,44 @@ describe('Hyperdrive connection strings', () => { }); }); - it.each(['verify-ca', 'verify-full', 'verify-full&sslrootcert=system'])( - 'uses Hyperdrive public-CA TLS verification for a PostgreSQL URL with sslmode=%s', - (sslmode) => { - expect( - parseHyperdriveConnectionString( - `postgresql://user:pass@db.example.com/app_db?sslmode=${sslmode}`, - ).mtls, - ).toEqual({ sslmode: 'require' }); - }, - ); + it.each([ + 'verify-ca', + 'verify-full', + 'verify-full&sslrootcert=system', + 'verify-full&sslrootcert=/home/dev/prod-ca-2021.crt', + 'require&sslrootcert=./rds-ca.pem', + ])('maps PostgreSQL sslmode=%s to Hyperdrive require mode', (sslmode) => { + expect( + parseHyperdriveConnectionString( + `postgresql://user:pass@db.example.com/app_db?sslmode=${sslmode}`, + ).mtls, + ).toEqual({ sslmode: 'require' }); + }); - it('rejects an explicit private CA before provisioning Hyperdrive', async () => { + it('creates Hyperdrive in require mode from a URL whose sslrootcert names a local CA file', async () => { fetchMock.mockResolvedValueOnce(cfSuccess({ id: 'hd_1', name: 'proj-hyperdrive' })); - await expect( - createHyperdrive( - ACCOUNT, - TOKEN, - 'proj-hyperdrive', - 'postgresql://user:pass@db.example.com/app?sslmode=verify-full&sslrootcert=/private/company-ca.pem', - ), - ).rejects.toThrow('cannot honor sslrootcert'); - expect(fetchMock).not.toHaveBeenCalled(); + + await createHyperdrive( + ACCOUNT, + TOKEN, + 'proj-hyperdrive', + 'postgresql://user:pass@db.example.com/app?sslmode=verify-full&sslrootcert=/home/dev/prod-ca-2021.crt', + ); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + name: 'proj-hyperdrive', + caching: { disabled: true }, + origin: { + database: 'app', + host: 'db.example.com', + password: 'pass', + port: 5432, + scheme: 'postgresql', + user: 'user', + }, + mtls: { sslmode: 'require' }, + }); }); it('parses MySQL connection strings and defaults to port 3306', () => { diff --git a/platform/packages/proxy/src/email-admission.ts b/platform/packages/proxy/src/email-admission.ts index 99c6aae8c..5eb8ac605 100644 --- a/platform/packages/proxy/src/email-admission.ts +++ b/platform/packages/proxy/src/email-admission.ts @@ -203,9 +203,10 @@ export function canonicalizeVoidMime( let afterHeader = nextLine; let line = decoder.decode(raw.subarray(lineStart, headerEnd)); // The SDK folds longer multipart boundary parameters onto continuation lines. + // Folding whitespace is SP or HTAB only; other leading characters stay significant. while (version === 'current' && (raw[afterHeader] === 32 || raw[afterHeader] === 9)) { headerEnd = findCrlf(raw, afterHeader); - line += ` ${decoder.decode(raw.subarray(afterHeader, headerEnd)).trimStart()}`; + line += ` ${decoder.decode(raw.subarray(afterHeader, headerEnd)).replace(/^[ \t]+/, '')}`; afterHeader = headerEnd === raw.byteLength ? raw.byteLength : headerEnd + 2; } const normalized = line.replace( diff --git a/platform/packages/proxy/src/email-router.ts b/platform/packages/proxy/src/email-router.ts index bac3480c6..5029de2db 100644 --- a/platform/packages/proxy/src/email-router.ts +++ b/platform/packages/proxy/src/email-router.ts @@ -880,8 +880,8 @@ async function enactSanitized( } const to = action.to.toLowerCase(); const from = action.type === 'reply' ? action.from : message.to; - // Inbound operation keys embed this digest. Preserve the original algorithm: - // changing it would create a second operation for a retained receipt/action. + // A receipt never reruns its handler, so no retry rebuilds this key and the digest + // version does not affect deduplication. Inbound digests keep the original algorithm. const digest = await emailPayloadDigest( action.type, from, diff --git a/platform/packages/proxy/test/unit/email-admission.test.ts b/platform/packages/proxy/test/unit/email-admission.test.ts index 827001722..1d9ec06c6 100644 --- a/platform/packages/proxy/test/unit/email-admission.test.ts +++ b/platform/packages/proxy/test/unit/email-admission.test.ts @@ -208,6 +208,34 @@ describe('email admission payload digest', () => { ); }); + it('unfolds boundary headers on SP or HTAB only and keeps other leading whitespace', async () => { + const folded = (fold: string, boundary: string) => + encoder.encode( + [ + 'From: acme@mail.example.com', + 'MIME-Version: 1.0', + 'Content-Type: multipart/alternative;', + `${fold}boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + 'hello', + `--${boundary}--`, + '', + ].join('\r\n'), + ); + const digest = (raw: Uint8Array) => + emailPayloadDigest('direct', 'acme@mail.example.com', ['recipient@example.com'], raw); + + const ideographicSpace = String.fromCodePoint(0x3000); + + const space = await digest(folded(' ', '=_void_alternative_AAAA')); + expect(await digest(folded('\t', '=_void_alternative_BBBB'))).toBe(space); + expect(await digest(folded(' \t ', '=_void_alternative_CCCC'))).toBe(space); + expect(await digest(folded(` ${ideographicSpace}`, '=_void_alternative_DDDD'))).not.toBe(space); + }); + it('normalizes deeply nested generated boundaries without normalizing dense body lines', async () => { const a = nestedMessage('1', 'same body'); const b = nestedMessage('2', 'same body'); From 0d9c8a1af322271fc9108c3c5380175dc0d2b932 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 10 Oct 2026 20:30:09 +0800 Subject: [PATCH 5/7] refactor(platform): use one email payload digest Remove the versioned digest and the second digest the proxy sent for direct sends. The API compares one digest again, and inbound actions use the same unfolding algorithm. The SDK rebuilds the MIME, with a new random boundary, on every send. So a retry of a text+HTML send recorded before the upgrade conflicts with or without a second digest; only a hand-sent, byte-identical resend would differ. A conflict and a replay both return before anything is sent or counted, and no upgrade step reads digests. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/email/admission.ts | 14 +- .../test/integration/email-admission.test.ts | 176 ++++-------------- .../packages/proxy/src/email-admission.ts | 23 +-- platform/packages/proxy/src/email-router.ts | 3 - platform/packages/proxy/src/email.ts | 4 +- .../proxy/test/fixtures/legacy-multipart.ts | 29 --- .../test/unit/email-admission-send.test.ts | 34 +--- .../proxy/test/unit/email-admission.test.ts | 34 +--- .../proxy/test/unit/email-router.test.ts | 20 -- 9 files changed, 41 insertions(+), 296 deletions(-) delete mode 100644 platform/packages/proxy/test/fixtures/legacy-multipart.ts diff --git a/platform/packages/api/src/email/admission.ts b/platform/packages/api/src/email/admission.ts index 1d766c3db..8b2e24c39 100644 --- a/platform/packages/api/src/email/admission.ts +++ b/platform/packages/api/src/email/admission.ts @@ -50,8 +50,6 @@ export type EmailAdmissionInput = { kind: EmailActionKind; key: string; digest: string; - /** Computed by the signed proxy from the same MIME, never accepted from a tenant. */ - legacyDigest?: string; from: string; recipients: Array; transport: 'binding' | 'send_raw' | 'native_forward' | 'native_reply'; @@ -310,10 +308,6 @@ export async function admitEmailAction( !input.key || input.key.length > (input.kind === 'direct' ? 128 : 256) || !/^[a-f0-9]{64}$/.test(input.digest) || - (input.legacyDigest !== undefined && - (input.kind !== 'direct' || - typeof input.legacyDigest !== 'string' || - !/^[a-f0-9]{64}$/.test(input.legacyDigest))) || input.recipients.length > (input.kind === 'direct' ? 50 : 100) || !validAddress(input.from) ) { @@ -347,18 +341,12 @@ export async function admitEmailAction( ) .limit(1); if (existing) { - if ( - (existing.payload_digest !== input.digest && - existing.payload_digest !== input.legacyDigest) || - existing.kind !== input.kind - ) { + if (existing.payload_digest !== input.digest || existing.kind !== input.kind) { throw new EmailAdmissionError( 'CONFLICT', 'idempotency key was used for a different action', ); } - // Do not rewrite the stored digest or recipient outcomes on a legacy replay. - // Older proxy executions still need to recognize the original operation. return { operationId: existing.id, replay: true, diff --git a/platform/packages/api/test/integration/email-admission.test.ts b/platform/packages/api/test/integration/email-admission.test.ts index 7e6e01ae8..7de6150db 100644 --- a/platform/packages/api/test/integration/email-admission.test.ts +++ b/platform/packages/api/test/integration/email-admission.test.ts @@ -1,14 +1,9 @@ import { eq, sql } from 'drizzle-orm'; import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; -import { admitEmail, emailPayloadDigests } from '../../../proxy/src/email-admission.ts'; -import { - legacyMultipartDigest, - legacyMultipartMime, -} from '../../../proxy/test/fixtures/legacy-multipart.ts'; +import { emailPayloadDigest } from '../../../proxy/src/email-admission.ts'; import { deleteEmailDestination } from '../../src/db.ts'; import { emailActionRecipients, - emailActions, emailMonthlyUsage, emailReceipts, emailUnresolvedAttempts, @@ -35,7 +30,6 @@ import { emailZoneConnections, } from '../../src/email/connection-schema.ts'; import { setEmailRecipientPolicy } from '../../src/email/settings.ts'; -import { app } from '../../src/index.ts'; import { emailDestinations, projects, users } from '../../src/schema.ts'; import { captureQueries, @@ -922,149 +916,45 @@ describe('authoritative email admission', () => { ).rejects.toMatchObject({ code: 'CONFLICT' } satisfies Partial); }); - it.each(['provider_accepted', 'outcome_unknown', 'attempt_started'] as const)( - 'replays a pre-upgrade multipart operation in state %s without changing its ledger', - async (state) => { - const project = await fixture(); - const original = { ...input(project.id, 'upgrade'), digest: legacyMultipartDigest }; - const first = await admitEmailAction(env.db, original); - await transitionEmailRecipients( - env.db, - first.operationId, - original.recipients, - 'attempt_started', - ); - if (state !== 'attempt_started') { - await transitionEmailRecipients(env.db, first.operationId, original.recipients, state, { - providerReference: 'original-provider-id', - }); - } - const ledger = await env.db.select().from(emailActions); - const recipients = await env.db.select().from(emailActionRecipients); - const digests = await emailPayloadDigests( - 'direct', - original.from, - original.recipients, - legacyMultipartMime, - ); - expect(digests.digest).not.toBe(legacyMultipartDigest); - expect(digests.legacyDigest).toBe(legacyMultipartDigest); - const replay = await admitEmailAction(env.db, { ...original, ...digests }); - expect(replay).toMatchObject({ - operationId: first.operationId, - replay: true, - recipients: [{ state }], - }); - expect(await env.db.select().from(emailActions)).toEqual(ledger); - expect(await env.db.select().from(emailActionRecipients)).toEqual(recipients); - expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); - expect((await admitEmailAction(env.db, original)).operationId).toBe(first.operationId); - - for (const [from, to, raw] of [ - [ - original.from, - original.recipients, - new TextEncoder().encode( - new TextDecoder().decode(legacyMultipartMime).replace('Plain text', 'Changed text'), - ), - ], - [original.from, ['two@example.com'], legacyMultipartMime], - ['acme+different@mail.example.com', original.recipients, legacyMultipartMime], - ] as const) { - await expect( - admitEmailAction(env.db, { - ...original, - from, - recipients: [...to], - ...(await emailPayloadDigests('direct', from, [...to], raw)), - }), - ).rejects.toMatchObject({ code: 'CONFLICT' }); - } - }, - ); - - it('replays a pre-upgrade multipart operation through the signed admission route', async () => { + it('replays a multipart send whose SDK boundary was rebuilt without a second quota debit', async () => { const project = await fixture(); - const { sharedSenderDomain, ...original } = { - ...input(project.id, 'signed-upgrade'), - digest: legacyMultipartDigest, - }; - const first = await admitEmailAction(env.db, { ...original, sharedSenderDomain }); - const proxyEnv = { - API: { - fetch: (url: string, init?: RequestInit) => - app.fetch(new Request(url, init), { ...env, EMAIL_SENDER_DOMAIN: sharedSenderDomain }), - } as never, - EMAIL_SIGNING_SECRET: env.EMAIL_SIGNING_SECRET, - }; - const digests = await emailPayloadDigests( - 'direct', - original.from, - original.recipients, - legacyMultipartMime, - ); - expect(digests.digest).not.toBe(legacyMultipartDigest); - expect(digests.legacyDigest).toBe(legacyMultipartDigest); - - await expect(admitEmail(proxyEnv, { ...original, digest: digests.digest })).rejects.toThrow( - 'IDEMPOTENCY_CONFLICT', - ); - expect(await admitEmail(proxyEnv, { ...original, ...digests })).toMatchObject({ - operationId: first.operationId, - replay: true, - }); - expect((await env.db.select().from(emailActions))[0]?.payload_digest).toBe( - legacyMultipartDigest, - ); - expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); - }); - - it('stores the current digest for new multipart sends and accepts rebuilt boundaries', async () => { - const project = await fixture(); - const original = input(project.id, 'new-multipart'); + const original = input(project.id, 'rebuilt-multipart'); + const mime = (boundary: string) => + new TextEncoder().encode( + [ + 'From: acme+news@mail.example.com', + 'To: one@example.com', + 'MIME-Version: 1.0', + 'Content-Type: multipart/alternative;', + ` boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Plain text', + `--${boundary}`, + 'Content-Type: text/html; charset=UTF-8', + '', + '

HTML

', + `--${boundary}--`, + '', + ].join('\r\n'), + ); + const digest = (raw: Uint8Array) => + emailPayloadDigest('direct', original.from, original.recipients, raw); const first = await admitEmailAction(env.db, { ...original, - ...(await emailPayloadDigests( - 'direct', - original.from, - original.recipients, - legacyMultipartMime, - )), - }); - const rebuilt = new TextEncoder().encode( - new TextDecoder() - .decode(legacyMultipartMime) - .replaceAll('=_void_alternative_1573725Jyj8yLCojOVRltQ', '=_void_alternative_rebuilt'), - ); - const digests = await emailPayloadDigests( - 'direct', - original.from, - original.recipients, - rebuilt, - ); - expect(digests.legacyDigest).not.toBe(legacyMultipartDigest); - expect((await env.db.select().from(emailActions))[0]?.payload_digest).toBe(digests.digest); - expect(await admitEmailAction(env.db, { ...original, ...digests })).toMatchObject({ - operationId: first.operationId, - replay: true, + digest: await digest(mime('=_void_alternative_1573725Jyj8yLCojOVRltQ')), }); + expect( + await admitEmailAction(env.db, { + ...original, + digest: await digest(mime('=_void_alternative_rebuilt')), + }), + ).toMatchObject({ operationId: first.operationId, replay: true }); expect((await getProjectEmailUsage(env.db, project.id)).attempts).toBe(1); }); - it.each(['invalid', 123, null, ['a'.repeat(64)]])( - 'rejects an invalid legacy digest %j', - async (legacyDigest) => { - const project = await fixture(); - await expect( - admitEmailAction(env.db, { - ...input(project.id, 'invalid-legacy'), - legacyDigest: legacyDigest as string, - }), - ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); - expect(await env.db.select().from(emailActions)).toHaveLength(0); - }, - ); - it('admits a partial batch under atomic recipient quotas and keeps charges after uncertain outcomes', async () => { const project = await fixture(); await setProjectEmailLimits(env.db, project.id, { burst: 1, monthly: 1 }); diff --git a/platform/packages/proxy/src/email-admission.ts b/platform/packages/proxy/src/email-admission.ts index 5eb8ac605..6d68093f6 100644 --- a/platform/packages/proxy/src/email-admission.ts +++ b/platform/packages/proxy/src/email-admission.ts @@ -153,10 +153,7 @@ export function completeRelayedReceipt( } /** Only generated, transport-irrelevant MIME fields are normalized. Body text stays byte-sensitive. */ -export function canonicalizeVoidMime( - raw: Uint8Array, - version: 'legacy' | 'current' = 'current', -): Uint8Array { +export function canonicalizeVoidMime(raw: Uint8Array): Uint8Array { if (findCrlfCrlf(raw) === -1) { return raw; } @@ -204,7 +201,7 @@ export function canonicalizeVoidMime( let line = decoder.decode(raw.subarray(lineStart, headerEnd)); // The SDK folds longer multipart boundary parameters onto continuation lines. // Folding whitespace is SP or HTAB only; other leading characters stay significant. - while (version === 'current' && (raw[afterHeader] === 32 || raw[afterHeader] === 9)) { + while (raw[afterHeader] === 32 || raw[afterHeader] === 9) { headerEnd = findCrlf(raw, afterHeader); line += ` ${decoder.decode(raw.subarray(afterHeader, headerEnd)).replace(/^[ \t]+/, '')}`; afterHeader = headerEnd === raw.byteLength ? raw.byteLength : headerEnd + 2; @@ -344,9 +341,8 @@ export async function emailPayloadDigest( from: string, recipients: Array, raw: Uint8Array, - version: 'legacy' | 'current' = 'current', ): Promise { - const canonical = canonicalizeVoidMime(raw, version); + const canonical = canonicalizeVoidMime(raw); const prefix = encoder.encode( JSON.stringify({ kind, @@ -363,18 +359,6 @@ export async function emailPayloadDigest( .join(''); } -/** Existing ledger rows retain the pre-unfolding digest for their full retention period. */ -export async function emailPayloadDigests( - kind: string, - from: string, - recipients: Array, - raw: Uint8Array, -): Promise<{ digest: string; legacyDigest?: string }> { - const digest = await emailPayloadDigest(kind, from, recipients, raw); - const legacyDigest = await emailPayloadDigest(kind, from, recipients, raw, 'legacy'); - return { digest, ...(legacyDigest === digest ? {} : { legacyDigest }) }; -} - export function admitEmail( env: Pick, body: { @@ -382,7 +366,6 @@ export function admitEmail( kind: 'direct' | 'forward' | 'reply'; key: string; digest: string; - legacyDigest?: string; from: string; recipients: Array; transport: 'binding' | 'send_raw' | 'native_forward' | 'native_reply'; diff --git a/platform/packages/proxy/src/email-router.ts b/platform/packages/proxy/src/email-router.ts index 5029de2db..51a1c7b79 100644 --- a/platform/packages/proxy/src/email-router.ts +++ b/platform/packages/proxy/src/email-router.ts @@ -880,8 +880,6 @@ async function enactSanitized( } const to = action.to.toLowerCase(); const from = action.type === 'reply' ? action.from : message.to; - // A receipt never reruns its handler, so no retry rebuilds this key and the digest - // version does not affect deduplication. Inbound digests keep the original algorithm. const digest = await emailPayloadDigest( action.type, from, @@ -889,7 +887,6 @@ async function enactSanitized( action.type === 'reply' ? action.raw : new TextEncoder().encode(JSON.stringify([...(action.headers?.entries() ?? [])].sort())), - 'legacy', ); let admission; try { diff --git a/platform/packages/proxy/src/email.ts b/platform/packages/proxy/src/email.ts index d23651ce1..4b09e00d1 100644 --- a/platform/packages/proxy/src/email.ts +++ b/platform/packages/proxy/src/email.ts @@ -4,7 +4,7 @@ import type { Context } from 'hono'; import { asString } from './body.ts'; import { admitEmail, - emailPayloadDigests, + emailPayloadDigest, finishEmailAttempt, startEmailAttempt, type Admission, @@ -1499,7 +1499,7 @@ async function admitDirectSend( projectId, kind: 'direct', key: operationKey, - ...(await emailPayloadDigests('direct', from, recipients, raw)), + digest: await emailPayloadDigest('direct', from, recipients, raw), from, recipients, transport, diff --git a/platform/packages/proxy/test/fixtures/legacy-multipart.ts b/platform/packages/proxy/test/fixtures/legacy-multipart.ts deleted file mode 100644 index 346f58b25..000000000 --- a/platform/packages/proxy/test/fixtures/legacy-multipart.ts +++ /dev/null @@ -1,29 +0,0 @@ -// SDK-generated MIME and its digest from before multipart header unfolding. -// Keep the digest fixed: a current-code calculation would hide upgrade regressions. -export const legacyMultipartDigest = - '8409457699c7de52030141f0afbd3a8b9c30c7b25a9e0cc4c5392c207e40d0e5'; -export const legacyMultipartMime = new TextEncoder().encode( - [ - 'Date: Sat, 10 Oct 2026 00:10:05 +0000', - 'From: acme+news@mail.example.com', - 'To: one@example.com', - 'Subject: upgrade retry', - 'Message-ID: ', - 'MIME-Version: 1.0', - 'Content-Type: multipart/alternative;', - ' boundary="=_void_alternative_1573725Jyj8yLCojOVRltQ"', - '', - '--=_void_alternative_1573725Jyj8yLCojOVRltQ', - 'Content-Type: text/plain; charset=UTF-8', - 'Content-Transfer-Encoding: 7bit', - '', - 'Plain text', - '--=_void_alternative_1573725Jyj8yLCojOVRltQ', - 'Content-Type: text/html; charset=UTF-8', - 'Content-Transfer-Encoding: 7bit', - '', - '

HTML

', - '--=_void_alternative_1573725Jyj8yLCojOVRltQ--', - '', - ].join('\r\n'), -); diff --git a/platform/packages/proxy/test/unit/email-admission-send.test.ts b/platform/packages/proxy/test/unit/email-admission-send.test.ts index c95885472..dfc4060b8 100644 --- a/platform/packages/proxy/test/unit/email-admission-send.test.ts +++ b/platform/packages/proxy/test/unit/email-admission-send.test.ts @@ -1,7 +1,6 @@ import { Hono } from 'hono'; import { describe, expect, it, vi } from 'vite-plus/test'; import { buildMime, normalizeEmail } from '../../../../../packages/void/src/runtime/email/mime.ts'; -import { legacyMultipartDigest, legacyMultipartMime } from '../fixtures/legacy-multipart.ts'; vi.mock('cloudflare:email', () => ({ EmailMessage: class { @@ -36,7 +35,7 @@ function fixture() { if (path.endsWith('/admit')) { const key = body.key as string; const previous = actions.get(key); - if (previous && previous.digest !== body.digest && previous.digest !== body.legacyDigest) { + if (previous && previous.digest !== body.digest) { return Response.json({ code: 'CONFLICT' }, { status: 409 }); } if (previous) { @@ -125,37 +124,6 @@ function fixture() { } describe('proxy email admission delivery', () => { - it.each(['provider_accepted', 'outcome_unknown', 'attempt_started'])( - 'replays pre-upgrade multipart MIME with its recorded %s outcome', - async (state) => { - const test = fixture(); - test.actions.set('pre-upgrade', { - operationId: 'act_pre_upgrade', - digest: legacyMultipartDigest, - recipients: [ - { - recipient: 'one@example.com', - state, - errorCode: null, - providerReference: 'provider-before-upgrade', - }, - ], - }); - const response = await test.send('pre-upgrade', undefined, legacyMultipartMime); - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ operationId: 'act_pre_upgrade' }); - expect(test.sender).not.toHaveBeenCalled(); - expect(test.actions.size).toBe(1); - expect(test.actions.get('pre-upgrade')?.digest).toBe(legacyMultipartDigest); - - const changed = new TextEncoder().encode( - new TextDecoder().decode(legacyMultipartMime).replace('Plain text', 'Changed text'), - ); - expect((await test.send('pre-upgrade', undefined, changed)).status).toBe(409); - expect(test.sender).not.toHaveBeenCalled(); - }, - ); - it('replays rebuilt SDK multipart MIME without another provider submission', async () => { const test = fixture(); const options = { diff --git a/platform/packages/proxy/test/unit/email-admission.test.ts b/platform/packages/proxy/test/unit/email-admission.test.ts index 1d9ec06c6..0839efc29 100644 --- a/platform/packages/proxy/test/unit/email-admission.test.ts +++ b/platform/packages/proxy/test/unit/email-admission.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it, vi } from 'vite-plus/test'; -import { - admitEmail, - emailPayloadDigest, - emailPayloadDigests, - finishEmailAttempt, -} from '../../src/email-admission.ts'; -import { legacyMultipartDigest, legacyMultipartMime } from '../fixtures/legacy-multipart.ts'; +import { admitEmail, emailPayloadDigest, finishEmailAttempt } from '../../src/email-admission.ts'; const encoder = new TextEncoder(); function message(date: string, id: string, boundary: string, body: string) { @@ -65,32 +59,6 @@ function nestedMessage(generation: string, bodyLine: string) { } describe('email admission payload digest', () => { - it('retains the exact historical digest for folded SDK multipart MIME', async () => { - const args = [ - 'direct', - 'acme+news@mail.example.com', - ['one@example.com'] as Array, - legacyMultipartMime, - ] as const; - expect(await emailPayloadDigest(...args, 'legacy')).toBe(legacyMultipartDigest); - expect(await emailPayloadDigests(...args)).toEqual({ - digest: await emailPayloadDigest(...args), - legacyDigest: legacyMultipartDigest, - }); - }); - - it('omits a redundant legacy digest for single-part MIME', async () => { - const args = [ - 'direct', - 'acme@mail.example.com', - ['one@example.com'] as Array, - encoder.encode('From: acme@mail.example.com\r\n\r\nbody'), - ] as const; - expect(await emailPayloadDigests(...args)).toEqual({ - digest: await emailPayloadDigest(...args), - }); - }); - it('deduplicates retries when generated date, message ID and multipart boundary change', async () => { const a = message( 'Fri, 18 Sep 2026 01:00:00 +0000', diff --git a/platform/packages/proxy/test/unit/email-router.test.ts b/platform/packages/proxy/test/unit/email-router.test.ts index ed283ef60..c99285272 100644 --- a/platform/packages/proxy/test/unit/email-router.test.ts +++ b/platform/packages/proxy/test/unit/email-router.test.ts @@ -17,7 +17,6 @@ vi.mock('../../../email-gateway/src/tenant-dispatch.ts', () => ({ return { actions, receiptId: input.receiptId }; }, })); -import { emailPayloadDigest } from '../../src/email-admission.ts'; import { MAX_INBOUND_BYTES, MAX_INBOUND_HEADERS, @@ -25,7 +24,6 @@ import { routeRelayed, } from '../../src/email-router.ts'; import type { Env } from '../../src/types.ts'; -import { legacyMultipartMime } from '../fixtures/legacy-multipart.ts'; const ROUTE_FENCE = { routeId: 'route-1', @@ -1174,24 +1172,6 @@ describe('routeRelayed — reply attempt outcomes', () => { return { outcome, apiFetch, providerFetch, env }; } - it('preserves pre-upgrade digest-derived reply keys', async () => { - const raw = new TextEncoder().encode( - new TextDecoder() - .decode(legacyMultipartMime) - .replaceAll('acme+news@mail.example.com', from) - .replaceAll('one@example.com', to), - ); - const digest = await emailPayloadDigest('reply', from, [to], raw, 'legacy'); - expect(await emailPayloadDigest('reply', from, [to], raw)).not.toBe(digest); - const { apiFetch } = await runReply(Response.json({ result: { message_id: 'reply-id' } }), { - raw, - }); - expect(apiBodies(apiFetch, '/admit')[0]).toMatchObject({ - key: `receipt_1:0:${digest.slice(0, 16)}`, - digest, - }); - }); - it('sends a relayed shared-sender reply through the shared binding', async () => { const { outcome, apiFetch, providerFetch, env } = await runReply(new Response(null), { from: `myslug+support@${PROD_DOMAIN}`, From 486c12fd784561317be3a764890e9313f996d521 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 12 Oct 2026 13:38:11 +0800 Subject: [PATCH 6/7] fix(platform): keep Hyperdrive TLS settings when Void sets the database URL Bug: When a database URL had an sslmode, the platform API sent Hyperdrive TLS settings set to require. On `void db set-url`, or when it adopted an existing Hyperdrive by name, this replaced TLS settings that an operator set by hand, such as verify-full with a CA. Cloudflare does not allow require with a CA, so the update could also fail with a 502. Cause: parseHyperdriveConnectionString mapped each accepted sslmode to an mtls block. Both create (POST) and update (PUT) sent that block. Fix: Send no mtls block. A new Hyperdrive gets Cloudflare's default require mode, which is the same Hyperdrive as before. A PUT keeps the TLS settings that are already on the Hyperdrive. The API still returns 400 for disable, prefer and no-verify, before it calls Cloudflare. Remove the mtls type field, variable and spread. Test: Unit tests check that no mtls is sent for each accepted sslmode, on create and on update. New rows check that disable, prefer and no-verify fail before fetch. Remove a create test that repeated the main create test, and move its sslrootcert URL into that test. The route tests for create and update compare the exact request body, now with a verify-full URL in both. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- .../api/src/provisioning/cloudflare-api.ts | 20 +++---- .../api/test/integration/api-projects.test.ts | 3 +- .../api/test/unit/cloudflare-api.test.ts | 58 +++++++------------ 3 files changed, 30 insertions(+), 51 deletions(-) diff --git a/platform/packages/api/src/provisioning/cloudflare-api.ts b/platform/packages/api/src/provisioning/cloudflare-api.ts index b0f49b0f8..3b4c392f0 100644 --- a/platform/packages/api/src/provisioning/cloudflare-api.ts +++ b/platform/packages/api/src/provisioning/cloudflare-api.ts @@ -579,7 +579,6 @@ type HyperdriveOrigin = Readonly<{ type HyperdriveConfigPayload = Readonly<{ name?: string; origin: HyperdriveOrigin; - mtls?: Readonly<{ sslmode: 'require' }>; }>; function decodeUrlPart(value: string): string { @@ -637,18 +636,14 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper } const sslmode = url.searchParams.get('sslmode'); - let mtls: HyperdriveConfigPayload['mtls']; - if (sslmode) { - if (sslmode !== 'require' && sslmode !== 'verify-ca' && sslmode !== 'verify-full') { - throw new InvalidHyperdriveConnectionStringError( - 'Unsupported sslmode. Cloudflare Hyperdrive supports "require", "verify-ca", or "verify-full".', - ); - } - // Hyperdrive's verify modes need a CA certificate uploaded to Cloudflare, which a URL - // cannot carry, so Void creates Hyperdrive in Cloudflare's default `require` mode. - // `sslrootcert` names a CA on the client machine and is not sent to Hyperdrive. - mtls = { sslmode: 'require' }; + if (sslmode && sslmode !== 'require' && sslmode !== 'verify-ca' && sslmode !== 'verify-full') { + throw new InvalidHyperdriveConnectionStringError( + 'Unsupported sslmode. Cloudflare Hyperdrive supports "require", "verify-ca", or "verify-full".', + ); } + // No `mtls` is sent. A new Hyperdrive gets Cloudflare's default `require` mode, and a PUT + // keeps the TLS settings already on the Hyperdrive. The verify modes need a CA certificate + // uploaded to Cloudflare, which a URL cannot carry; `sslrootcert` names a file on the client. return { origin: { @@ -659,7 +654,6 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper scheme, user: decodeUrlPart(url.username), }, - ...(mtls === undefined ? {} : { mtls }), }; } diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 9e44f0bae..b7fdc1ed5 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -1332,7 +1332,6 @@ describe('PUT /projects/:id/database-url', () => { scheme: 'postgresql', user: 'void_user', }, - mtls: { sslmode: 'require' }, }); return cfSuccess({ id: 'hd-created', name: hyperdriveName }); }, @@ -1469,7 +1468,7 @@ describe('PUT /projects/:id/database-url', () => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - connectionString: 'postgres://void_user:pass@db.example.com/appdb', + connectionString: 'postgres://void_user:pass@db.example.com/appdb?sslmode=verify-full', }), }), env, diff --git a/platform/packages/api/test/unit/cloudflare-api.test.ts b/platform/packages/api/test/unit/cloudflare-api.test.ts index c554f329a..38e52e976 100644 --- a/platform/packages/api/test/unit/cloudflare-api.test.ts +++ b/platform/packages/api/test/unit/cloudflare-api.test.ts @@ -283,7 +283,6 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgresql', user: 'user', }, - mtls: { sslmode: 'require' }, }); }); @@ -301,49 +300,38 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgresql', user: 'user', }, - mtls: { sslmode: 'require' }, }); }); it.each([ + 'require', 'verify-ca', 'verify-full', 'verify-full&sslrootcert=system', 'verify-full&sslrootcert=/home/dev/prod-ca-2021.crt', 'require&sslrootcert=./rds-ca.pem', - ])('maps PostgreSQL sslmode=%s to Hyperdrive require mode', (sslmode) => { + ])('sends no Hyperdrive TLS settings for PostgreSQL sslmode=%s', (sslmode) => { expect( parseHyperdriveConnectionString( `postgresql://user:pass@db.example.com/app_db?sslmode=${sslmode}`, - ).mtls, - ).toEqual({ sslmode: 'require' }); - }); - - it('creates Hyperdrive in require mode from a URL whose sslrootcert names a local CA file', async () => { - fetchMock.mockResolvedValueOnce(cfSuccess({ id: 'hd_1', name: 'proj-hyperdrive' })); - - await createHyperdrive( - ACCOUNT, - TOKEN, - 'proj-hyperdrive', - 'postgresql://user:pass@db.example.com/app?sslmode=verify-full&sslrootcert=/home/dev/prod-ca-2021.crt', - ); - - const [, init] = fetchMock.mock.calls[0]; - expect(JSON.parse((init as RequestInit).body as string)).toEqual({ - name: 'proj-hyperdrive', - caching: { disabled: true }, - origin: { - database: 'app', - host: 'db.example.com', - password: 'pass', - port: 5432, - scheme: 'postgresql', - user: 'user', - }, - mtls: { sslmode: 'require' }, - }); - }); + ), + ).not.toHaveProperty('mtls'); + }); + + it.each(['disable', 'prefer', 'no-verify'])( + 'rejects PostgreSQL sslmode=%s before calling Cloudflare', + async (sslmode) => { + await expect( + createHyperdrive( + ACCOUNT, + TOKEN, + 'proj-hyperdrive', + `postgresql://user:pass@db.example.com/app?sslmode=${sslmode}`, + ), + ).rejects.toThrow(/Unsupported sslmode/); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); it('parses MySQL connection strings and defaults to port 3306', () => { expect(parseHyperdriveConnectionString('mysql://user:p%40ss@mysql.example.com/app_db')).toEqual( @@ -367,7 +355,7 @@ describe('Hyperdrive connection strings', () => { ACCOUNT, TOKEN, 'proj-hyperdrive', - 'postgres://user:pass@db.example.com:5432/app?sslmode=verify-full', + 'postgres://user:pass@db.example.com:5432/app?sslmode=verify-full&sslrootcert=/home/dev/prod-ca-2021.crt', ); const [, init] = fetchMock.mock.calls[0]; @@ -382,11 +370,10 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgres', user: 'user', }, - mtls: { sslmode: 'require' }, }); }); - it('sends parsed origin fields when updating Hyperdrive configs', async () => { + it('sends no TLS settings when updating Hyperdrive configs, so Cloudflare keeps the existing ones', async () => { fetchMock.mockResolvedValueOnce(cfSuccess({ id: 'hd_1', name: 'proj-hyperdrive' })); await updateHyperdrive( @@ -409,7 +396,6 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgresql', user: 'user', }, - mtls: { sslmode: 'require' }, }); }); From edc53b5c34e3190c8213209072fc34f7952d8883 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 12 Oct 2026 13:51:12 +0800 Subject: [PATCH 7/7] fix(platform): turn off Hyperdrive query caching for existing project databases on deploy Bug: A PostgreSQL or MySQL project whose Hyperdrive was created before caching was turned off still cached reads. Hyperdrive does not drop cached reads when the app writes, so a read right after a write could return old rows for up to about 75 seconds. Only `void db set-url` fixed it, and that asks for the full database URL again. Cause: The platform sent `caching: { disabled: true }` only on create and on update. A deploy reused the project's Hyperdrive id as it was and never looked at its caching setting. Fix: Add ensureHyperdriveCachingDisabled. It reads the Hyperdrive config and sends a PATCH with only `caching: { disabled: true }` when caching is still on. The PATCH carries no origin, so no password is needed. Each deploy of a project with an existing Hyperdrive calls it, and the binding resolves only after it succeeds, so a Cloudflare error fails the deploy at the provisioning step. Create and update still turn caching off. The database guide now says an existing connection is updated on the project's next deploy. Test: Unit tests check that a config with caching off gets one GET and no write, and that a config with caching on gets a PATCH whose body is only `{ caching: { disabled: true } }`. provisionBindings tests check that an existing Hyperdrive row runs the check before it returns the binding, and that a failed check fails the deploy. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH --- docs/guide/database.md | 2 +- .../api/src/provisioning/cloudflare-api.ts | 24 +++++++++ .../api/src/provisioning/provision.ts | 13 +++-- .../api/test/unit/cloudflare-api.test.ts | 37 +++++++++++++ .../packages/api/test/unit/provision.test.ts | 53 +++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/docs/guide/database.md b/docs/guide/database.md index 58cb90844..2e94f11f0 100644 --- a/docs/guide/database.md +++ b/docs/guide/database.md @@ -47,7 +47,7 @@ Choose a database during `void init`, or set `database` in `void.config.ts`: | Managed by | Void | Bring your own database | Bring your own database | | Connection | Automatic D1 binding | Hyperdrive | Hyperdrive | -A Void platform disables Hyperdrive query caching when it creates or updates your database connection, so reads reflect completed writes. +A Void platform keeps Hyperdrive query caching off for your database connection, so reads reflect completed writes. An existing connection is updated on the project's next deploy. ## Schema Definition diff --git a/platform/packages/api/src/provisioning/cloudflare-api.ts b/platform/packages/api/src/provisioning/cloudflare-api.ts index 3b4c392f0..dfb9a4cdd 100644 --- a/platform/packages/api/src/provisioning/cloudflare-api.ts +++ b/platform/packages/api/src/provisioning/cloudflare-api.ts @@ -696,6 +696,30 @@ export async function updateHyperdrive( ); } +/** + * Hyperdrive never invalidates cached reads when the app writes, so project + * databases run uncached. A config may predate this setting, so deploys + * converge it. PATCH changes caching only and needs no origin password. + */ +export async function ensureHyperdriveCachingDisabled( + accountId: string, + apiToken: string, + hyperdriveId: string, +): Promise { + const url = `${CF_API_BASE}/${accountId}/hyperdrive/configs/${hyperdriveId}`; + const config = await cfFetch }>>( + url, + apiToken, + ); + if (config.caching?.disabled === true) { + return; + } + await cfFetch(url, apiToken, { + method: 'PATCH', + body: JSON.stringify({ caching: { disabled: true } }), + }); +} + export async function deleteHyperdrive( accountId: string, apiToken: string, diff --git a/platform/packages/api/src/provisioning/provision.ts b/platform/packages/api/src/provisioning/provision.ts index a81a68d06..8568083ad 100644 --- a/platform/packages/api/src/provisioning/provision.ts +++ b/platform/packages/api/src/provisioning/provision.ts @@ -12,6 +12,7 @@ import { deleteQueue, deleteR2Bucket, enableD1ReadReplication, + ensureHyperdriveCachingDisabled, getD1DatabaseByName, getKVNamespaceByTitle, getQueueByName, @@ -149,11 +150,13 @@ export async function provisionBindings( ); } tasks.push( - Promise.resolve({ - type: 'hyperdrive' as const, - name: resource.bindingName, - id: found.cf_id, - }), + ensureHyperdriveCachingDisabled(accountId, apiToken, found.cf_id).then( + (): WorkerBinding => ({ + type: 'hyperdrive', + name: resource.bindingName, + id: found.cf_id, + }), + ), ); break; } diff --git a/platform/packages/api/test/unit/cloudflare-api.test.ts b/platform/packages/api/test/unit/cloudflare-api.test.ts index 38e52e976..2734209eb 100644 --- a/platform/packages/api/test/unit/cloudflare-api.test.ts +++ b/platform/packages/api/test/unit/cloudflare-api.test.ts @@ -5,6 +5,7 @@ import { createD1Database, enableD1ReadReplication, createHyperdrive, + ensureHyperdriveCachingDisabled, getDispatchWorkerProxyToken, parseHyperdriveConnectionString, removeQueueConsumer, @@ -399,6 +400,42 @@ describe('Hyperdrive connection strings', () => { }); }); + it('leaves a Hyperdrive config with caching already off unchanged', async () => { + fetchMock.mockResolvedValueOnce(cfSuccess({ id: 'hd_1', caching: { disabled: true } })); + + await ensureHyperdriveCachingDisabled(ACCOUNT, TOKEN, 'hd_1'); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + 'https://api.cloudflare.com/client/v4/accounts/acc_test/hyperdrive/configs/hd_1', + ); + expect([undefined, 'GET']).toContain((init as RequestInit).method); + }); + + it('turns caching off with a PATCH that sends no origin', async () => { + fetchMock + .mockResolvedValueOnce( + cfSuccess({ + id: 'hd_1', + caching: { disabled: false, max_age: 60, stale_while_revalidate: 15 }, + }), + ) + .mockResolvedValueOnce(cfSuccess({ id: 'hd_1' })); + + await ensureHyperdriveCachingDisabled(ACCOUNT, TOKEN, 'hd_1'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[1]; + expect(url).toBe( + 'https://api.cloudflare.com/client/v4/accounts/acc_test/hyperdrive/configs/hd_1', + ); + expect((init as RequestInit).method).toBe('PATCH'); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + caching: { disabled: true }, + }); + }); + it('rejects malformed database URLs before calling Cloudflare', async () => { await expect( createHyperdrive(ACCOUNT, TOKEN, 'proj-hyperdrive', 'postgresql://user@db.example.com/app'), diff --git a/platform/packages/api/test/unit/provision.test.ts b/platform/packages/api/test/unit/provision.test.ts index 11f336716..89df6d6dc 100644 --- a/platform/packages/api/test/unit/provision.test.ts +++ b/platform/packages/api/test/unit/provision.test.ts @@ -6,6 +6,7 @@ import type { DeployManifest, Resource } from '../../src/types.ts'; vi.mock('../../src/provisioning/cloudflare-api', () => ({ createD1Database: vi.fn().mockResolvedValue({ uuid: 'd1-uuid-123', name: 'test-DB' }), enableD1ReadReplication: vi.fn().mockResolvedValue(undefined), + ensureHyperdriveCachingDisabled: vi.fn().mockResolvedValue(undefined), createKVNamespace: vi.fn().mockResolvedValue({ id: 'kv-id-456', title: 'test-KV' }), createR2Bucket: vi.fn().mockResolvedValue({ name: 'test-STORAGE' }), createQueue: vi.fn().mockResolvedValue({ queue_id: 'queue-id-789', queue_name: 'test-queue' }), @@ -39,6 +40,7 @@ import { getProjectResources, createResource } from '../../src/db.ts'; import { createD1Database, enableD1ReadReplication, + ensureHyperdriveCachingDisabled, createKVNamespace, createR2Bucket, createQueue, @@ -233,6 +235,57 @@ describe('provisionBindings', () => { expect(bindings).toHaveLength(5); }); + describe('existing project database', () => { + const manifest: DeployManifest = { version: 1, bindings: { hyperdrive: ['HYPERDRIVE'] } }; + + beforeEach(() => { + vi.mocked(getProjectResources).mockResolvedValue([ + { + id: 'res_hd', + project_id: PROJECT_ID, + type: 'hyperdrive', + cf_id: 'existing-hd-id', + binding_name: 'HYPERDRIVE', + created_at: new Date().toISOString(), + metadata: {}, + }, + ]); + }); + + it('checks Hyperdrive caching for an existing project database', async () => { + const bindings = await provisionBindings( + mockDb, + ACCOUNT_ID, + API_TOKEN, + PROJECT_ID, + manifest, + 'void-proxy', + { projectSlug: PROJECT_SLUG, proxyToken: PROXY_TOKEN }, + ); + + expect(ensureHyperdriveCachingDisabled).toHaveBeenCalledWith( + ACCOUNT_ID, + API_TOKEN, + 'existing-hd-id', + ); + expect(bindings[0]).toEqual({ type: 'hyperdrive', name: 'HYPERDRIVE', id: 'existing-hd-id' }); + expect(bindings).toHaveLength(5); + }); + + it('fails the deploy when the Hyperdrive caching check fails', async () => { + vi.mocked(ensureHyperdriveCachingDisabled).mockRejectedValueOnce( + new Error('Cloudflare API error: not found'), + ); + + await expect( + provisionBindings(mockDb, ACCOUNT_ID, API_TOKEN, PROJECT_ID, manifest, 'void-proxy', { + projectSlug: PROJECT_SLUG, + proxyToken: PROXY_TOKEN, + }), + ).rejects.toThrow('not found'); + }); + }); + it.each(['a'.repeat(32), 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'])( 'reuses queue ID %s without a name lookup or database repair', async (id) => {