diff --git a/docs/guide/database.md b/docs/guide/database.md index 347e8683a..2e94f11f0 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 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 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/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 54f1c0e6c..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 | — | 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/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/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..dfb9a4cdd 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' | 'verify-ca' | 'verify-full' }>; }>; function decodeUrlPart(value: string): string { @@ -637,15 +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".', - ); - } - mtls = { sslmode }; + 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: { @@ -656,7 +654,6 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper scheme, user: decodeUrlPart(url.username), }, - ...(mtls === undefined ? {} : { mtls }), }; } @@ -666,7 +663,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 +683,7 @@ export async function updateHyperdrive( ): Promise { const payload = { ...(name === undefined ? {} : { name }), + caching: { disabled: true }, ...parseHyperdriveConnectionString(connectionString), }; return cfFetch( @@ -694,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/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/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', 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..b7fdc1ed5 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', @@ -1331,7 +1332,6 @@ describe('PUT /projects/:id/database-url', () => { scheme: 'postgresql', user: 'void_user', }, - mtls: { sslmode: 'require' }, }); return cfSuccess({ id: 'hd-created', name: hyperdriveName }); }, @@ -1346,7 +1346,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=/home/dev/prod-ca-2021.crt', }), }), env, @@ -1369,6 +1369,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 +1446,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', @@ -1466,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/integration/email-admission.test.ts b/platform/packages/api/test/integration/email-admission.test.ts index 662791b00..7de6150db 100644 --- a/platform/packages/api/test/integration/email-admission.test.ts +++ b/platform/packages/api/test/integration/email-admission.test.ts @@ -1,5 +1,6 @@ import { eq, sql } from 'drizzle-orm'; import { beforeAll, afterAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { emailPayloadDigest } from '../../../proxy/src/email-admission.ts'; import { deleteEmailDestination } from '../../src/db.ts'; import { emailActionRecipients, @@ -915,6 +916,45 @@ describe('authoritative email admission', () => { ).rejects.toMatchObject({ code: 'CONFLICT' } satisfies Partial); }); + it('replays a multipart send whose SDK boundary was rebuilt without a second quota debit', async () => { + const project = await fixture(); + 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, + 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('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/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..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, @@ -283,7 +284,6 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgresql', user: 'user', }, - mtls: { sslmode: 'require' }, }); }); @@ -301,10 +301,39 @@ 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', + ])('sends no Hyperdrive TLS settings for PostgreSQL sslmode=%s', (sslmode) => { + expect( + parseHyperdriveConnectionString( + `postgresql://user:pass@db.example.com/app_db?sslmode=${sslmode}`, + ), + ).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( { @@ -327,12 +356,13 @@ 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]; expect(JSON.parse((init as RequestInit).body as string)).toEqual({ name: 'proj-hyperdrive', + caching: { disabled: true }, origin: { database: 'app', host: 'db.example.com', @@ -341,24 +371,24 @@ describe('Hyperdrive connection strings', () => { scheme: 'postgres', user: 'user', }, - mtls: { sslmode: 'verify-full' }, }); }); - 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( 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', @@ -370,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/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/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) => { 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..6d68093f6 100644 --- a/platform/packages/proxy/src/email-admission.ts +++ b/platform/packages/proxy/src/email-admission.ts @@ -196,7 +196,16 @@ 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. + // Folding whitespace is SP or HTAB only; other leading characters stay significant. + 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; + } const normalized = line.replace( /boundary="(=_void_(?:mixed|alternative|related)_[A-Za-z0-9_-]+)"/g, (_, token: string) => { @@ -209,8 +218,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/packages/proxy/test/unit/email-admission.test.ts b/platform/packages/proxy/test/unit/email-admission.test.ts index d4efc3f78..0839efc29 100644 --- a/platform/packages/proxy/test/unit/email-admission.test.ts +++ b/platform/packages/proxy/test/unit/email-admission.test.ts @@ -176,6 +176,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'); 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 - -