From f4d2bfee5cd823954f520fd75833331a7d750e90 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 01:09:30 +0800 Subject: [PATCH 1/8] Restrict the dev restart page to same-origin navigation and callers The /__void/restarting page navigated to the raw `return` query value, so any site could send the developer to a `javascript:` URL on the dev origin. The server now keeps `return` only when it is a same-origin path and embeds it into the page script with safeJsonStringify; anything else falls back to /. A request url that does not parse also falls back to / instead of failing with a 500. The page POSTs to /__void/restart as soon as it loads, so a link or hidden iframe from another site could restart the dev server. The page is now served only when Sec-Fetch-Site is same-origin, none, or absent, answers 403 otherwise, and is marked no-store. POST /__void/restart answers 403 to cross-site browser requests, judged by Sec-Fetch-Site, or by Origin when the browser omits it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/AGENTS.md | 2 +- packages/void/src/plugin-restart.ts | 100 +++++++- .../void/test/unit/plugin-restart.test.ts | 223 ++++++++++++++++++ 3 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 packages/void/test/unit/plugin-restart.test.ts diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 00a32702d..b5b14ae7a 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -197,7 +197,7 @@ production functions and real generated entries rather than copied code. - **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent. - **Workers AI has no local emulator.** For a saved direct Cloudflare destination, `shouldBindWorkersAi()` injects the owner's remote AI binding during development and preview, using the same Cloudflare credentials as the CLI. Production builds use the normal AI binding. The root `ai.binding` takes precedence over `inference.bindings.ai`, with `AI` as the default name; `__VOID_AI_BINDING` records a custom runtime name. Managed and unsaved projects keep the Void proxy path during development. Apps without inferred AI usage must perform no AI authentication or entitlement probe. - **`mergeBindings` may remove a generated Cloudflare entry only under the name Void itself writes, gated on the matching `needs*` flag.** The Cloudflare plugin `defu`-merges the returned patch over `resolved` (arrays concatenate, `undefined` is skipped), so the only way to drop a key is to mutate `resolved` — and a mutation there is a change to the user's own config, which the CF-plugin-API rule above says takes precedence. The `send_email` serve-strip once deleted the whole field, so an app that drove Cloudflare's email API through its own `send_email: [{ name: "MAILER" }]` without ever importing `void/email` started `void dev` with `env.MAILER` undefined. It now filters out only the reserved `SEND_EMAIL` entry (`SEND_EMAIL_BINDING` from `cli/email-cloudflare.ts`, the name `void deploy --platform cloudflare` writes and the runtime reads), only when `bindings.needsEmail` — the same signal that arms the dev inbox — and drops the key only when nothing is left. `test/unit/plugin-email-config.test.ts` runs the real closure through a mocked `cloudflare()`. -- **Every `/__void/inbox` route requires the dev-trigger token; the worker's capture POST carries it via a var.** Vite's host validation (`server.allowedHosts`, runs before plugin middlewares) only rejects DNS-rebinding hostnames — any IP-literal `Host` passes — and its default CORS reflects every `localhost` origin, so an unauthenticated inbox middleware hands every peer under `--host` (and every other local dev server) the raw MIME with Bcc and attachments, a DELETE, and a capture endpoint to plant messages. `plugin-email-dev-inbox.ts` therefore checks the same per-project token the cron/queue dev routes and the pages dev-CSS loopback use (`getOrCreateDevTriggerToken`, `.void/dev-trigger-token`), accepted as the `x-void-dev-trigger` header (worker, curl) or `?token=` (browser — the plugin prints the tokenized URL on `listening` and renders its own links with it; responses already send `Referrer-Policy: no-referrer`). Missing or wrong → `401` with a one-line body naming both forms and no ack header. The compiled router embeds the token as a module-local const that `runtime/email.ts` cannot import, so `mergeBindings` injects `__VOID_EMAIL_DEV_INBOX_TOKEN` next to `__VOID_EMAIL_DEV_INBOX` under the one `shouldInjectEmailDevInboxVar` gate (dev only — never in a build's `wrangler.json`); `sendEmail` sends it and, on a non-2xx, echoes the first line of the dev server's body so a missing token is diagnosable. Nothing on the worker side ever skips the inbox for a real transport because the token is absent. Because that file is now the inbox's whole credential and loopback needs no `--host`, `getOrCreateDevTriggerToken` creates it `0600` (`openSync(.., 'wx', 0o600)`) and best-effort `chmod`s a pre-existing file whose group/other bits are set, never rotating it (skipped on Windows, which has no such bits) — a default-umask `0644` file would hand every other account on a shared host the raw MIME, the DELETE, and the capture endpoint. `.void/dev-triggers.ts` (`plugin-framework-triggers.ts`) embeds the same token for Class A frameworks and is written `0600` the same way. `/__void/restart`, `/__void/ping` and `/__void/restarting` remain open by design: they expose no data. +- **Every `/__void/inbox` route requires the dev-trigger token; the worker's capture POST carries it via a var.** Vite's host validation (`server.allowedHosts`, runs before plugin middlewares) only rejects DNS-rebinding hostnames — any IP-literal `Host` passes — and its default CORS reflects every `localhost` origin, so an unauthenticated inbox middleware hands every peer under `--host` (and every other local dev server) the raw MIME with Bcc and attachments, a DELETE, and a capture endpoint to plant messages. `plugin-email-dev-inbox.ts` therefore checks the same per-project token the cron/queue dev routes and the pages dev-CSS loopback use (`getOrCreateDevTriggerToken`, `.void/dev-trigger-token`), accepted as the `x-void-dev-trigger` header (worker, curl) or `?token=` (browser — the plugin prints the tokenized URL on `listening` and renders its own links with it; responses already send `Referrer-Policy: no-referrer`). Missing or wrong → `401` with a one-line body naming both forms and no ack header. The compiled router embeds the token as a module-local const that `runtime/email.ts` cannot import, so `mergeBindings` injects `__VOID_EMAIL_DEV_INBOX_TOKEN` next to `__VOID_EMAIL_DEV_INBOX` under the one `shouldInjectEmailDevInboxVar` gate (dev only — never in a build's `wrangler.json`); `sendEmail` sends it and, on a non-2xx, echoes the first line of the dev server's body so a missing token is diagnosable. Nothing on the worker side ever skips the inbox for a real transport because the token is absent. Because that file is now the inbox's whole credential and loopback needs no `--host`, `getOrCreateDevTriggerToken` creates it `0600` (`openSync(.., 'wx', 0o600)`) and best-effort `chmod`s a pre-existing file whose group/other bits are set, never rotating it (skipped on Windows, which has no such bits) — a default-umask `0644` file would hand every other account on a shared host the raw MIME, the DELETE, and the capture endpoint. `.void/dev-triggers.ts` (`plugin-framework-triggers.ts`) embeds the same token for Class A frameworks and is written `0600` the same way. `/__void/ping` stays open: it exposes no data. `/__void/restart` refuses a cross-site POST (`Sec-Fetch-Site` other than `same-origin`, or a mismatched `Origin`) with `403`. `/__void/restarting` POSTs to it as soon as it loads, so it refuses a navigation whose `Sec-Fetch-Site` is not `same-origin` or `none` with `403`, and only navigates to a same-origin path from `?return=`, falling back to `/`. - **Sandbox SDK 1.0**: Keep the packaged Dockerfile helper version and the managed controller’s verified helper-layer digest in sync with `@cloudflare/sandbox`. Native execution uses Durable Object scheduling with named images; managed controller protocol 2 preserves admission, metering, and cleanup, including retained protocol 1 deployments. Cancellation crosses Workers RPC as a stream, not an AbortSignal. Run the Sandbox RPC and lifecycle tests when changing this boundary. diff --git a/packages/void/src/plugin-restart.ts b/packages/void/src/plugin-restart.ts index 11a8f2bbe..b1d9bd95f 100644 --- a/packages/void/src/plugin-restart.ts +++ b/packages/void/src/plugin-restart.ts @@ -1,9 +1,12 @@ +import type { IncomingMessage } from 'node:http'; +import type { TLSSocket } from 'node:tls'; import type { Plugin } from 'vite'; +import { safeJsonStringify } from './pages/protocol.ts'; const BINDING_HINT_VIRTUAL = 'virtual:void/binding-hint'; const BINDING_HINT_RESOLVED = '\0' + BINDING_HINT_VIRTUAL; -const RESTARTING_HTML = ` +const RESTARTING_HTML_HEAD = ` @@ -21,10 +24,13 @@ const RESTARTING_HTML = `
[void] Restarting dev server...
', + '//evil.com', + '//evil.com/path', + '/\\evil.com', + 'https://evil.com', + 'http://localhost:5173/', + 'evil.com', + '/\t/evil.com', + '/\n/evil.com', + '/foo\u0000', + '', + ])('rejects %j', (value) => { + expect(sanitizeReturnPath(value)).toBe('/'); + }); + + it('falls back to / when the value is missing', () => { + expect(sanitizeReturnPath(null)).toBe('/'); + expect(sanitizeReturnPath(undefined)).toBe('/'); + }); + + it.each(['/', '/foo?x=1', '/a/b#c', '/search?q=%2F%2Fevil.com'])('accepts %j', (value) => { + expect(sanitizeReturnPath(value)).toBe(value); + }); +}); + +describe('renderRestartingPage', () => { + it('embeds the return path so it cannot close the script or break the string', () => { + const html = renderRestartingPage('/x?a="'); + expect(html).not.toContain(''); + }); + + it('no longer reads the return value from location in the client', () => { + expect(renderRestartingPage('/')).not.toContain('location.search'); + }); +}); + +describe('restartPlugin middlewares', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it.each(['javascript:alert(document.domain)', '//evil.com', 'https://evil.com'])( + 'GET /__void/restarting?return=%s navigates to /', + (value) => { + const { dispatch } = createFakeViteServer(); + const res = dispatch('GET', `/__void/restarting?return=${encodeURIComponent(value)}`); + expect(res.status).toBe(200); + expect(targetOf(res.body)).toBe('/'); + }, + ); + + it('GET /__void/restarting keeps a same-origin return path', () => { + const { dispatch } = createFakeViteServer(); + const res = dispatch('GET', `/__void/restarting?return=${encodeURIComponent('/foo?x=1')}`); + expect(targetOf(res.body)).toBe('/foo?x=1'); + }); + + it.each(['cross-site', 'same-site'])( + 'refuses GET /__void/restarting with Sec-Fetch-Site: %s', + (site) => { + const { dispatch } = createFakeViteServer(); + const res = dispatch('GET', '/__void/restarting?return=%2Ffoo', { 'sec-fetch-site': site }); + expect(res.status).toBe(403); + expect(res.headers['Content-Type']).toBe('text/plain'); + expect(res.body).not.toContain('var target='); + expect(res.body).not.toContain('fetch("/__void/restart"'); + }, + ); + + it.each([ + ['Sec-Fetch-Site: same-origin', { 'sec-fetch-site': 'same-origin' }], + ['Sec-Fetch-Site: none', { 'sec-fetch-site': 'none' }], + ['no Sec-Fetch-Site', {}], + ])('serves GET /__void/restarting with %s', (_label, headers) => { + const { dispatch } = createFakeViteServer(); + const res = dispatch('GET', '/__void/restarting?return=%2Ffoo', headers); + expect(res.status).toBe(200); + expect(res.headers['Cache-Control']).toBe('no-store'); + expect(targetOf(res.body)).toBe('/foo'); + }); + + it('serves GET /__void/restarting with a request url that does not parse', () => { + const { dispatch } = createFakeViteServer(); + const res = dispatch('GET', '/__void/restarting', {}, false, '//['); + expect(res.status).toBe(200); + expect(targetOf(res.body)).toBe('/'); + }); + + it.each([ + ['cross-site Sec-Fetch-Site', { 'sec-fetch-site': 'cross-site', origin: 'https://evil.com' }], + [ + 'same-site Sec-Fetch-Site', + { 'sec-fetch-site': 'same-site', origin: 'http://localhost:3000' }, + ], + ['a foreign Origin', { origin: 'https://evil.com' }], + ['a different port', { origin: 'http://localhost:3000' }], + ['a different scheme', { origin: 'https://localhost:5173' }], + ['a null Origin', { origin: 'null' }], + ])('rejects a POST /__void/restart with %s', (_label, headers) => { + vi.useFakeTimers(); + const { dispatch, restart } = createFakeViteServer(); + const res = dispatch('POST', '/__void/restart', headers); + expect(res.status).toBe(403); + vi.runAllTimers(); + expect(restart).not.toHaveBeenCalled(); + expect(dispatch('GET', '/__void/ping').status).toBe(200); + }); + + it.each([ + [ + 'same-origin Sec-Fetch-Site', + { 'sec-fetch-site': 'same-origin', origin: 'http://localhost:5173' }, + ], + ['a matching Origin', { origin: 'http://localhost:5173' }], + ['no browser origin headers', {}], + ])('accepts a POST /__void/restart with %s', (_label, headers) => { + vi.useFakeTimers(); + const { dispatch, restart } = createFakeViteServer(); + const res = dispatch('POST', '/__void/restart', headers); + expect(res.status).toBe(200); + expect(dispatch('GET', '/__void/ping').status).toBe(503); + vi.runAllTimers(); + expect(restart).toHaveBeenCalledOnce(); + }); + + it('matches the Origin scheme against an HTTPS dev server', () => { + vi.useFakeTimers(); + const { dispatch } = createFakeViteServer(); + expect( + dispatch('POST', '/__void/restart', { origin: 'https://localhost:5173' }, true).status, + ).toBe(200); + }); + + it('still answers 405 to a non-POST /__void/restart', () => { + const { dispatch } = createFakeViteServer(); + expect(dispatch('GET', '/__void/restart').status).toBe(405); + }); +}); From 3b2c227bed362119418f0e1aa4b062225b98a0d7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 01:09:37 +0800 Subject: [PATCH 2/8] Validate Worker module names before the Cloudflare upload Module names from workerFiles become multipart part names next to the platform's own "metadata" part. The deploy API now rejects names that are empty, absolute, contain backslashes, control characters, or ".", ".." or empty segments, equal "metadata", or lack a module extension the CLI uploads. It also requires the main module to be one of the uploaded modules. The check runs before the deploy is admitted, on both direct and staged deployment inputs. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/deploy/execute.ts | 11 +++ platform/packages/api/src/deploy/shared.ts | 62 ++++++++++++++++ .../packages/api/src/deploy/upload-worker.ts | 9 ++- .../api/test/integration/api-deploy.test.ts | 38 ++++++++++ .../api/test/unit/worker-module-name.test.ts | 72 +++++++++++++++++++ 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 platform/packages/api/test/unit/worker-module-name.test.ts diff --git a/platform/packages/api/src/deploy/execute.ts b/platform/packages/api/src/deploy/execute.ts index fc5ec7e2b..afeb43b23 100644 --- a/platform/packages/api/src/deploy/execute.ts +++ b/platform/packages/api/src/deploy/execute.ts @@ -80,6 +80,7 @@ import { decodeBase64ToBytes, MAX_WORKER_FILE_BYTES, moduleContentType, + workerFilesError, } from './shared.ts'; /** @@ -549,6 +550,16 @@ export async function handleManifestOnlyDeploy(context: Context): Promis ); } + // Module names become multipart part names in the Cloudflare upload. This + // also covers modules restored from a staged deployment input. + const workerFilesProblem = workerFilesError( + workerFiles.map(({ name }) => name), + incomingManifest.workerMain, + ); + if (workerFilesProblem) { + return context.json({ event: 'error', message: workerFilesProblem }, 400); + } + // Pre-validation done — now run the atomic precheck-and-claim // transaction. FOR UPDATE on the project row, 10-minute stale-deploy // reaper, placeholder insert. Once the placeholder row exists, every diff --git a/platform/packages/api/src/deploy/shared.ts b/platform/packages/api/src/deploy/shared.ts index f873cc0d7..cfbcf9ca7 100644 --- a/platform/packages/api/src/deploy/shared.ts +++ b/platform/packages/api/src/deploy/shared.ts @@ -114,6 +114,68 @@ export type UploadUrlsRequestAsset = Readonly<{ contentType: string; }>; +// Main module Cloudflare runs when the manifest does not name `workerMain`. +export const DEFAULT_WORKER_MAIN_MODULE = 'index.js'; + +// Extensions the CLI collects from the worker build output, plus the JSON +// modules `moduleContentType` understands. +const WORKER_MODULE_EXTENSION_REGEX = /\.(?:js|mjs|cjs|wasm|map|txt|json)$/; +// C0 controls, DEL, and C1 controls. +// oxlint-disable-next-line no-control-regex +const CONTROL_CHARACTER_REGEX = /[\u0000-\u001f\u007f-\u009f]/; + +/** + * Explain why `name` cannot be a Worker module, or return null when it can. + * Module names become multipart part names next to the platform's own + * `metadata` part in the Cloudflare upload, so they must be plain relative + * module paths. + */ +export function workerModuleNameError(name: string): string | null { + if (name.length === 0) { + return 'must not be empty'; + } + if (CONTROL_CHARACTER_REGEX.test(name)) { + return 'must not contain control characters'; + } + if (name.includes('\\')) { + return 'must use forward slashes'; + } + if (name.startsWith('/')) { + return 'must be a relative path'; + } + if (name.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) { + return 'must not contain empty, "." or ".." path segments'; + } + if (name.toLowerCase() === 'metadata') { + return 'is reserved'; + } + if (!WORKER_MODULE_EXTENSION_REGEX.test(name)) { + return 'must end in .js, .mjs, .cjs, .wasm, .map, .txt or .json'; + } + return null; +} + +/** + * Explain why an uploaded module set cannot be deployed, or return null when + * every name is a valid module and the main module is among them. + */ +export function workerFilesError( + names: ReadonlyArray, + workerMain: string | undefined, +): string | null { + for (const name of names) { + const nameError = workerModuleNameError(name); + if (nameError) { + return `workerFiles entry ${JSON.stringify(name)} ${nameError}`; + } + } + const mainModule = workerMain ?? DEFAULT_WORKER_MAIN_MODULE; + if (names.length > 0 && !names.includes(mainModule)) { + return `workerFiles must include the main module ${JSON.stringify(mainModule)}`; + } + return null; +} + export function moduleContentType(filename: string): string { if (filename.endsWith('.wasm')) { return 'application/wasm'; diff --git a/platform/packages/api/src/deploy/upload-worker.ts b/platform/packages/api/src/deploy/upload-worker.ts index 09301b236..a62bd3a26 100644 --- a/platform/packages/api/src/deploy/upload-worker.ts +++ b/platform/packages/api/src/deploy/upload-worker.ts @@ -36,7 +36,12 @@ import { import { assertDeploymentCanProceed } from './guards.ts'; import { isCfAssetPlainText } from './migrations.ts'; import { StaticAssetDisappearedError, attachServingStoreMetadata } from './serving-assets.ts'; -import { R2_HEAD_VERIFY_CONCURRENCY, DEFAULT_TAIL_SERVICE_NAME, CF_RETRY } from './shared.ts'; +import { + R2_HEAD_VERIFY_CONCURRENCY, + DEFAULT_TAIL_SERVICE_NAME, + CF_RETRY, + DEFAULT_WORKER_MAIN_MODULE, +} from './shared.ts'; export async function uploadDeploymentWorker(input: { currentStep: string; @@ -323,7 +328,7 @@ export async function uploadDeploymentWorker(input: { ); const tailServiceName = context.env.TAIL_SERVICE_NAME ?? DEFAULT_TAIL_SERVICE_NAME; const metadata: Record = { - main_module: manifest.workerMain ?? 'index.js', + main_module: manifest.workerMain ?? DEFAULT_WORKER_MAIN_MODULE, compatibility_date: manifest.compatibilityDate ?? '2026-02-12', compatibility_flags: manifest.compatibilityFlags ?? ['nodejs_compat'], bindings: allBindings, diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 5828ed928..112fb0cab 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -1634,6 +1634,44 @@ describe('POST /projects/:id/deploy', () => { expect(rows).toEqual([]); }); + it.each([ + [ + { 'index.js': 'export default {};', metadata: '{}' }, + {}, + 'workerFiles entry "metadata" is reserved', + ], + [ + { 'index.js': 'export default {};', '../x.js': 'x' }, + {}, + 'workerFiles entry "../x.js" must not contain empty, "." or ".." path segments', + ], + [ + { 'index.js': 'export default {};' }, + { workerMain: 'entry.mjs' }, + 'workerFiles must include the main module "entry.mjs"', + ], + ])( + 'rejects invalid worker module sets before admitting the deploy (%#)', + async (workers, manifest, message) => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'bad-module-name'); + const res = await app.fetch( + await buildDeployRequest(env, project.id, user.jwt, { + manifest: { version: 3, bindings: {}, type: 'void', ...manifest }, + workers, + }), + env, + ); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ event: 'error', message }); + const rows = await env.db + .select({ id: deployments.id }) + .from(deployments) + .where(eq(deployments.project_id, project.id)); + expect(rows).toEqual([]); + }, + ); + it('accepts a manifest limits.cpuMs under the plan ceiling and routes with it', async () => { const user = await createTestUser(env.db, 'prolimituser2', 'github', undefined, 'pro'); const project = await createTestProject(env.db, user.id, 'under-ceiling-app'); diff --git a/platform/packages/api/test/unit/worker-module-name.test.ts b/platform/packages/api/test/unit/worker-module-name.test.ts new file mode 100644 index 000000000..08f48acc2 --- /dev/null +++ b/platform/packages/api/test/unit/worker-module-name.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { workerFilesError, workerModuleNameError } from '../../src/deploy/shared.ts'; + +describe('workerModuleNameError', () => { + it.each([ + 'index.js', + 'index.mjs', + 'chunks/abc.js', + 'server/chunks/_/nitro.mjs', + 'x.wasm', + 'index.js.map', + 'legal.txt', + 'data.json', + 'commonjs.cjs', + ])('accepts %j', (name) => { + expect(workerModuleNameError(name)).toBeNull(); + }); + + it.each([ + ['', 'must not be empty'], + ['metadata', 'is reserved'], + ['METADATA', 'is reserved'], + ['Metadata', 'is reserved'], + ['../x.js', 'must not contain empty, "." or ".." path segments'], + ['chunks/../../x.js', 'must not contain empty, "." or ".." path segments'], + ['./x.js', 'must not contain empty, "." or ".." path segments'], + ['a//b.js', 'must not contain empty, "." or ".." path segments'], + ['chunks/', 'must not contain empty, "." or ".." path segments'], + ['/x.js', 'must be a relative path'], + ['\\x.js', 'must use forward slashes'], + ['a\\b.js', 'must use forward slashes'], + ['a\nb.js', 'must not contain control characters'], + ['a\u0000.js', 'must not contain control characters'], + ['x.exe', 'must end in .js, .mjs, .cjs, .wasm, .map, .txt or .json'], + ['index', 'must end in .js, .mjs, .cjs, .wasm, .map, .txt or .json'], + ['index.JS', 'must end in .js, .mjs, .cjs, .wasm, .map, .txt or .json'], + ])('rejects %j', (name, reason) => { + expect(workerModuleNameError(name)).toBe(reason); + }); +}); + +describe('workerFilesError', () => { + it('accepts a module set that contains the default main module', () => { + expect(workerFilesError(['index.js', 'chunks/abc.js'], undefined)).toBeNull(); + }); + + it('accepts a module set that contains the declared main module', () => { + expect(workerFilesError(['entry.mjs', 'chunks/abc.mjs'], 'entry.mjs')).toBeNull(); + }); + + it('accepts an empty module set', () => { + expect(workerFilesError([], 'entry.mjs')).toBeNull(); + }); + + it('names the first invalid entry', () => { + expect(workerFilesError(['index.js', 'metadata'], undefined)).toBe( + 'workerFiles entry "metadata" is reserved', + ); + }); + + it('rejects a module set without the declared main module', () => { + expect(workerFilesError(['index.js'], 'entry.mjs')).toBe( + 'workerFiles must include the main module "entry.mjs"', + ); + }); + + it('rejects a module set without the default main module', () => { + expect(workerFilesError(['worker.js'], undefined)).toBe( + 'workerFiles must include the main module "index.js"', + ); + }); +}); From 8ce8a26c1aee565e01f3ecf38f96f0404591b30a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 01:09:43 +0800 Subject: [PATCH 3/8] Require a generated project id before verifying project tokens A project token is an HMAC-SHA256 over the project id under the key that also signs login JWTs and OAuth state. A login JWT split into projectId = "header.body" and token = signature therefore verified as a project token at the proxy and at the API's internal project routes. Add isProjectId() next to generateId() (proj_ plus 12 characters of [0-9a-z]) and require it before every project-token HMAC check: the proxy's service-binding auth and receipt verification, and the internal revalidate, resolve-resource, resolve-worker and verify-prerender-build routes. Each rejects a malformed id with its existing invalid-token response. The proxy README documents the check. Test fixtures now use ids in the generated shape. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/package.json | 1 + platform/packages/api/src/id.ts | 12 + .../src/routes/internal-resolve-resource.ts | 6 +- .../api/src/routes/internal-resolve-worker.ts | 6 +- .../api/src/routes/internal-revalidate.ts | 6 +- .../routes/internal-verify-prerender-build.ts | 6 +- .../api-internal-project-token.test.ts | 164 ++++++++++ .../packages/api/test/integration/helpers.ts | 3 +- platform/packages/api/test/unit/id.test.ts | 34 ++ platform/packages/proxy/README.md | 10 +- platform/packages/proxy/src/index.ts | 13 +- platform/packages/proxy/test/unit/d1.test.ts | 38 +-- .../packages/proxy/test/unit/dispatch.test.ts | 58 ++-- .../test/unit/email-admission-send.test.ts | 2 +- .../test/unit/email-custom-domain.test.ts | 8 +- .../packages/proxy/test/unit/email.test.ts | 39 +-- platform/packages/proxy/test/unit/kv.test.ts | 54 ++-- .../packages/proxy/test/unit/proxy.test.ts | 293 +++++++++++------- platform/packages/proxy/test/unit/r2.test.ts | 62 ++-- .../packages/proxy/test/unit/resolve.test.ts | 39 ++- 20 files changed, 586 insertions(+), 268 deletions(-) create mode 100644 platform/packages/api/test/integration/api-internal-project-token.test.ts create mode 100644 platform/packages/api/test/unit/id.test.ts diff --git a/platform/packages/api/package.json b/platform/packages/api/package.json index fe4f56924..aa76e332e 100644 --- a/platform/packages/api/package.json +++ b/platform/packages/api/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { "./tiers": "./src/tiers.ts", + "./id": "./src/id.ts", "./api": "./src/api.ts", "./reserved-slugs": "./src/reserved-slugs.ts", "./routing": "./src/routing.ts", diff --git a/platform/packages/api/src/id.ts b/platform/packages/api/src/id.ts index 1cbe4d42b..107bea262 100644 --- a/platform/packages/api/src/id.ts +++ b/platform/packages/api/src/id.ts @@ -14,3 +14,15 @@ export function generateId(prefix: string): string { } return `${prefix}_${id}`; } + +const PROJECT_ID = new RegExp(`^proj_[${ALPHABET}]{${ID_LENGTH}}$`); + +/** + * Whether `value` has the shape `generateId('proj')` produces. The project + * token is an HMAC over the project id under the same key that signs login + * JWTs and OAuth state, so callers check this before verifying the token: + * any other string (such as a JWT's `header.body`) must not authenticate. + */ +export function isProjectId(value: unknown): value is string { + return typeof value === 'string' && PROJECT_ID.test(value); +} diff --git a/platform/packages/api/src/routes/internal-resolve-resource.ts b/platform/packages/api/src/routes/internal-resolve-resource.ts index 36f180d02..8bc09d910 100644 --- a/platform/packages/api/src/routes/internal-resolve-resource.ts +++ b/platform/packages/api/src/routes/internal-resolve-resource.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { hmacVerifyState } from '../auth/hmac.ts'; import { getResourceByBinding } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { isProjectId } from '../id.ts'; const app = new Hono(); @@ -20,7 +21,10 @@ app.post('/:projectId', async (context) => { if (!token) { return context.json({ error: 'missing internal auth' }, 401); } - if (!(await hmacVerifyState(projectId, token, context.env.JWT_SECRET))) { + if ( + !isProjectId(projectId) || + !(await hmacVerifyState(projectId, token, context.env.JWT_SECRET)) + ) { return context.json({ error: 'invalid internal auth' }, 403); } diff --git a/platform/packages/api/src/routes/internal-resolve-worker.ts b/platform/packages/api/src/routes/internal-resolve-worker.ts index c0f0c7263..fca338a80 100644 --- a/platform/packages/api/src/routes/internal-resolve-worker.ts +++ b/platform/packages/api/src/routes/internal-resolve-worker.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { hmacVerifyState } from '../auth/hmac.ts'; import { getDeployment, getProject } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { isProjectId } from '../id.ts'; import { getProjectDeploymentWorkerName } from '../naming.ts'; const app = new Hono(); @@ -22,7 +23,10 @@ app.post('/:projectId', async (context) => { if (!token) { return context.json({ error: 'missing internal auth' }, 401); } - if (!(await hmacVerifyState(projectId, token, context.env.JWT_SECRET))) { + if ( + !isProjectId(projectId) || + !(await hmacVerifyState(projectId, token, context.env.JWT_SECRET)) + ) { return context.json({ error: 'invalid internal auth' }, 403); } diff --git a/platform/packages/api/src/routes/internal-revalidate.ts b/platform/packages/api/src/routes/internal-revalidate.ts index 708fe8717..c36de1a78 100644 --- a/platform/packages/api/src/routes/internal-revalidate.ts +++ b/platform/packages/api/src/routes/internal-revalidate.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { hmacVerifyState } from '../auth/hmac.ts'; import { getProject } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { isProjectId } from '../id.ts'; import { runRevalidate } from '../revalidate-core.ts'; const app = new Hono(); @@ -17,7 +18,10 @@ app.post('/:id', async (context) => { if (!token) { return context.json({ error: 'missing internal auth' }, 401); } - if (!(await hmacVerifyState(projectId, token, context.env.JWT_SECRET))) { + if ( + !isProjectId(projectId) || + !(await hmacVerifyState(projectId, token, context.env.JWT_SECRET)) + ) { return context.json({ error: 'invalid internal auth' }, 403); } diff --git a/platform/packages/api/src/routes/internal-verify-prerender-build.ts b/platform/packages/api/src/routes/internal-verify-prerender-build.ts index 50c2ac3b2..c4aac34a2 100644 --- a/platform/packages/api/src/routes/internal-verify-prerender-build.ts +++ b/platform/packages/api/src/routes/internal-verify-prerender-build.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { hmacVerifyState } from '../auth/hmac.ts'; import { ACTIVE_BUILD_STATUSES, getBuild, getUser } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { isProjectId } from '../id.ts'; import { ProjectAccessDeniedError, ProjectNotFoundError, @@ -23,7 +24,10 @@ app.post('/:projectId', async (context) => { if (!token) { return context.json({ error: 'missing internal auth' }, 401); } - if (!(await hmacVerifyState(projectId, token, context.env.JWT_SECRET))) { + if ( + !isProjectId(projectId) || + !(await hmacVerifyState(projectId, token, context.env.JWT_SECRET)) + ) { return context.json({ error: 'invalid internal auth' }, 403); } diff --git a/platform/packages/api/test/integration/api-internal-project-token.test.ts b/platform/packages/api/test/integration/api-internal-project-token.test.ts new file mode 100644 index 000000000..4ff2e96dd --- /dev/null +++ b/platform/packages/api/test/integration/api-internal-project-token.test.ts @@ -0,0 +1,164 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; +import { signJwt } from '../../src/auth/jwt.ts'; +import { createResource } from '../../src/db.ts'; +import app from '../../src/index.ts'; +import { + clearTables, + createTestDeployment, + createTestProject, + createTestUser, + JWT_SECRET, + mockCfApi, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** + * A login JWT signs `header.body` with the same key and algorithm as the + * project token, so its parts look like a project id and its token. + */ +async function loginJwtAsProjectToken(): Promise<{ projectId: string; token: string }> { + const jwt = await signJwt({ sub: 'user_attacker', login: 'attacker', aud: 'api' }, JWT_SECRET); + const [header, body, signature] = jwt.split('.'); + const projectId = `${header}.${body}`; + expect(await hmacSignState(projectId, JWT_SECRET)).toBe(signature); + return { projectId, token: signature }; +} + +async function internalRequest( + route: string, + projectId: string, + token: string, + body: unknown = {}, +): Promise { + return app.fetch( + new Request(`http://localhost/internal/${route}/${projectId}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-void-internal': token }, + body: JSON.stringify(body), + }), + env, + ); +} + +async function proxyRequest(projectId: string, aiToken: string) { + const proxyModuleUrl = new URL('../../../proxy/src/index.ts', import.meta.url).href; + const proxyWorker = ( + (await import(/* @vite-ignore */ proxyModuleUrl)) as { + default: { fetch(request: Request, env: unknown): Promise }; + } + ).default; + const run = vi.fn(async () => ({ response: 'ok' })); + const response = await proxyWorker.fetch( + new Request('https://proxy.example.com/ai/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId, aiToken, model: 'test-model', inputs: {} }), + }), + { + ...env, + AI: { + run, + models: vi.fn(async () => []), + toMarkdown: vi.fn(async () => ({ markdown: '' })), + gateway: vi.fn(), + }, + } as never, + ); + return { response, run }; +} + +describe('per-project token checks', () => { + it('rejects a login JWT presented as a project id and token at the proxy', async () => { + const forged = await loginJwtAsProjectToken(); + const { response, run } = await proxyRequest(forged.projectId, forged.token); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'invalid project token' }); + expect(run).not.toHaveBeenCalled(); + }); + + it('accepts a generated project id with its token at the proxy', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + const { response, run } = await proxyRequest( + project.id, + await hmacSignState(project.id, JWT_SECRET), + ); + expect(response.status).toBe(200); + expect(run).toHaveBeenCalledOnce(); + }); + + it.each(['revalidate', 'resolve-resource', 'resolve-worker', 'verify-prerender-build'])( + 'rejects a login JWT presented as a project id and token at /internal/%s', + async (route) => { + const forged = await loginJwtAsProjectToken(); + const response = await internalRequest(route, forged.projectId, forged.token); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'invalid internal auth' }); + }, + ); + + it('accepts a generated project id with its token at every internal route', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id); + const deployment = await createTestDeployment(env.db, project.id, 'live'); + await createResource(env.db, project.id, 'kv', 'cf-kv-id', 'CACHE'); + await env.ROUTING.put(project.slug, JSON.stringify({ version: deployment.id })); + mockCfApi(); + const token = await hmacSignState(project.id, JWT_SECRET); + + const revalidated = await internalRequest('revalidate', project.id, token, { + paths: ['/about'], + }); + expect(revalidated.status).toBe(200); + + const resource = await internalRequest('resolve-resource', project.id, token, { + type: 'kv', + binding: 'CACHE', + }); + expect(resource.status).toBe(200); + expect(await resource.json()).toEqual({ cf_id: 'cf-kv-id' }); + + const worker = await internalRequest('resolve-worker', project.id, token); + expect(worker.status).toBe(200); + expect(await worker.json()).toEqual({ + workerName: `user-${project.slug}-${deployment.id}`, + deploymentId: deployment.id, + }); + + const prerender = await internalRequest('verify-prerender-build', project.id, token, { + userId: user.id, + }); + expect(prerender.status).toBe(200); + expect(await prerender.json()).toEqual({ ok: true }); + }); +}); diff --git a/platform/packages/api/test/integration/helpers.ts b/platform/packages/api/test/integration/helpers.ts index 86c747aef..bef0ab931 100644 --- a/platform/packages/api/test/integration/helpers.ts +++ b/platform/packages/api/test/integration/helpers.ts @@ -650,7 +650,8 @@ export async function createTestProject( ): Promise<{ id: string; slug: string }> { const n = ++projectCounter; const s = slug ?? `test-project-${n}`; - const id = `proj_test${n}`; + // Same shape as generateId('proj'): internal routes reject any other id. + const id = `proj_test${String(n).padStart(8, '0')}`; await db.insert(projects).values({ id, user_id: userId, slug: s }); return { id, slug: s }; } diff --git a/platform/packages/api/test/unit/id.test.ts b/platform/packages/api/test/unit/id.test.ts new file mode 100644 index 000000000..f1dd4022f --- /dev/null +++ b/platform/packages/api/test/unit/id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { signJwt } from '../../src/auth/jwt.ts'; +import { generateId, isProjectId } from '../../src/id.ts'; + +describe('isProjectId', () => { + it('accepts generated project ids', () => { + for (let i = 0; i < 100; i++) { + expect(isProjectId(generateId('proj'))).toBe(true); + } + }); + + it('rejects the signed part of a login JWT', async () => { + const jwt = await signJwt({ sub: 'user_1', login: 'octocat', aud: 'api' }, 'secret'); + const [header, body] = jwt.split('.'); + expect(isProjectId(`${header}.${body}`)).toBe(false); + }); + + it('rejects other prefixes, lengths, and characters', () => { + for (const value of [ + generateId('res'), + 'proj_1', + 'proj_a1b2c3d4e5f6a', + 'proj_A1B2C3D4E5F6', + 'proj_a1b2c3d4e5f.', + 'proj_a1b2c3d4e5f6\n', + ' proj_a1b2c3d4e5f6', + '', + undefined, + 42, + ]) { + expect(isProjectId(value)).toBe(false); + } + }); +}); diff --git a/platform/packages/proxy/README.md b/platform/packages/proxy/README.md index fd9695d3e..42568d81d 100644 --- a/platform/packages/proxy/README.md +++ b/platform/packages/proxy/README.md @@ -24,11 +24,11 @@ Deployed user worker Proxy Downstream Project requests use these authentication paths: -| Path | When | Auth | -| --------------- | ------------------ | ----------------------------------------- | -| Service binding | Deployed Void apps | HMAC-signed `projectId` token (`aiToken`) | -| HTTPS | Local development | `Authorization: Bearer ` | -| HTTPS | Managed prerender | Five-minute, project-scoped prerender JWT | +| Path | When | Auth | +| --------------- | ------------------ | ------------------------------------------------------------------------------------- | +| Service binding | Deployed Void apps | HMAC-signed `projectId` token (`aiToken`); `projectId` must be a generated project id | +| HTTPS | Local development | `Authorization: Bearer ` | +| HTTPS | Managed prerender | Five-minute, project-scoped prerender JWT | Project-scoped POST routes require `projectId` in the JSON body. The public `GET /health` route has no project body, and `/email/verify-receipt` checks a diff --git a/platform/packages/proxy/src/index.ts b/platform/packages/proxy/src/index.ts index 1ff6282fd..0b713dbf6 100644 --- a/platform/packages/proxy/src/index.ts +++ b/platform/packages/proxy/src/index.ts @@ -1,6 +1,7 @@ import { isRecord } from '@void/deploy-core'; import { usageLimitResponse } from '@void/edge'; import { verifyEmailReceipt } from '@void/platform/email-protocol'; +import { isProjectId } from '@voidcloud/api/id'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { ai } from './ai.ts'; @@ -97,8 +98,11 @@ app.post('/email/verify-receipt', async (context) => { ) { return context.json({ valid: false }, 400); } - const projectToken = await hmacSign(input.projectId, context.env.JWT_SECRET); - if (context.req.header('x-void-internal') !== projectToken) { + if ( + !isProjectId(input.projectId) || + context.req.header('x-void-internal') !== + (await hmacSign(input.projectId, context.env.JWT_SECRET)) + ) { return context.json({ valid: false }, 403); } if (!context.env.EMAIL_SIGNING_SECRET) { @@ -231,7 +235,10 @@ app.post('/*', async (context, next) => { if (!projectId || !aiToken) { return context.json({ error: 'projectId and aiToken required' }, 400); } - if (!(await verifyHmac(projectId, aiToken, context.env.JWT_SECRET))) { + if ( + !isProjectId(projectId) || + !(await verifyHmac(projectId, aiToken, context.env.JWT_SECRET)) + ) { return context.json({ error: 'invalid project token' }, 403); } context.set('projectId', projectId); diff --git a/platform/packages/proxy/test/unit/d1.test.ts b/platform/packages/proxy/test/unit/d1.test.ts index 658a8d7e5..70915d747 100644 --- a/platform/packages/proxy/test/unit/d1.test.ts +++ b/platform/packages/proxy/test/unit/d1.test.ts @@ -97,7 +97,7 @@ describe('D1 proxy endpoints', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { - token = await signAiToken('proj_1', JWT_SECRET); + token = await signAiToken('proj_000000000001', JWT_SECRET); }); afterEach(() => { @@ -113,14 +113,14 @@ describe('D1 proxy endpoints', () => { ]; globalThis.fetch = asFetchMock(vi.fn(async () => cfD1Response(rows))); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/query', { binding: 'DB', sql: 'SELECT * FROM users', params: [], method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -144,14 +144,14 @@ describe('D1 proxy endpoints', () => { const rows = [{ id: 1, name: 'Alice' }]; globalThis.fetch = asFetchMock(vi.fn(async () => cfD1Response(rows))); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/query', { binding: 'DB', sql: 'SELECT * FROM users WHERE id = ?', params: [1], method: 'first', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -164,14 +164,14 @@ describe('D1 proxy endpoints', () => { it('returns null for method=first with no results', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => cfD1Response([]))); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/query', { binding: 'DB', sql: 'SELECT * FROM users WHERE id = ?', params: [999], method: 'first', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -188,13 +188,13 @@ describe('D1 proxy endpoints', () => { ]; globalThis.fetch = asFetchMock(vi.fn(async () => cfD1Response(rows))); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/query', { binding: 'DB', sql: 'SELECT * FROM users', method: 'raw', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -213,14 +213,14 @@ describe('D1 proxy endpoints', () => { const meta = { duration: 0.002, changes: 1 }; globalThis.fetch = asFetchMock(vi.fn(async () => cfD1Response([], meta))); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/query', { binding: 'DB', sql: 'UPDATE users SET name = ? WHERE id = ?', params: ['Charlie', 1], method: 'run', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -239,7 +239,7 @@ describe('D1 proxy endpoints', () => { binding: 'MISSING', sql: 'SELECT 1', method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -252,7 +252,7 @@ describe('D1 proxy endpoints', () => { it('returns 400 when binding or sql is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/d1/query', { method: 'all', projectId: 'proj_1', aiToken: token }), + post('/d1/query', { method: 'all', projectId: 'proj_000000000001', aiToken: token }), env, ); @@ -265,13 +265,13 @@ describe('D1 proxy endpoints', () => { vi.fn(async () => new Response('Internal Server Error', { status: 500 })), ); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/query', { binding: 'DB', sql: 'SELECT 1', method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -285,7 +285,7 @@ describe('D1 proxy endpoints', () => { it('fails before writing when the active deployment has no native batch handler', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => cfD1Response([{ count: 1 }]))); - const env = createEnv({ 'proj_1:d1:DB': 'd1-uuid-abc' }); + const env = createEnv({ 'proj_000000000001:d1:DB': 'd1-uuid-abc' }); const res = await worker.fetch( post('/d1/batch', { binding: 'DB', @@ -293,7 +293,7 @@ describe('D1 proxy endpoints', () => { { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Alice'] }, { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Bob'] }, ], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -309,7 +309,7 @@ describe('D1 proxy endpoints', () => { it('returns 400 when statements is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/d1/batch', { binding: 'DB', projectId: 'proj_1', aiToken: token }), + post('/d1/batch', { binding: 'DB', projectId: 'proj_000000000001', aiToken: token }), env, ); @@ -323,7 +323,7 @@ describe('D1 proxy endpoints', () => { post('/d1/batch', { binding: 'MISSING', statements: [{ sql: 'SELECT 1' }], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, diff --git a/platform/packages/proxy/test/unit/dispatch.test.ts b/platform/packages/proxy/test/unit/dispatch.test.ts index cbb53992c..ee8e08073 100644 --- a/platform/packages/proxy/test/unit/dispatch.test.ts +++ b/platform/packages/proxy/test/unit/dispatch.test.ts @@ -156,7 +156,7 @@ describe('WfP dispatch path', () => { let token: string; beforeAll(async () => { - token = await signAiToken('proj_1', JWT_SECRET); + token = await signAiToken('proj_000000000001', JWT_SECRET); }); afterEach(() => { @@ -165,7 +165,7 @@ describe('WfP dispatch path', () => { describe('D1 via dispatch', () => { it('dispatches D1 query through user worker and returns response', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => { + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => { return Response.json({ results: [{ id: 1, name: 'Alice' }], success: true, @@ -179,7 +179,7 @@ describe('WfP dispatch path', () => { sql: 'SELECT * FROM users', params: [], method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -198,7 +198,7 @@ describe('WfP dispatch path', () => { }); it('dispatches D1 batch through user worker', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => { + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => { return Response.json([ { results: [{ count: 1 }], success: true, meta: {} }, { results: [{ count: 2 }], success: true, meta: {} }, @@ -212,7 +212,7 @@ describe('WfP dispatch path', () => { { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Alice'] }, { sql: 'INSERT INTO users (name) VALUES (?)', params: ['Bob'] }, ], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -229,7 +229,7 @@ describe('WfP dispatch path', () => { it('keeps a native batch atomic when the second statement violates a constraint', async () => { const native = createAtomicD1(); - const env = createEnv({ proj_1: 'user-my-app-dpl_active' }); + const env = createEnv({ proj_000000000001: 'user-my-app-dpl_active' }); attachNativeBindingHandler(env, token, native.db); vi.spyOn(console, 'error').mockImplementation(() => {}); const restFetch = vi.spyOn(globalThis, 'fetch'); @@ -241,7 +241,7 @@ describe('WfP dispatch path', () => { { sql: 'INSERT INTO users (id, name) VALUES (?, ?)', params: [1, 'Alice'] }, { sql: 'INSERT INTO users (id, name) VALUES (?, ?)', params: [1, 'Bob'] }, ], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -255,7 +255,7 @@ describe('WfP dispatch path', () => { it('commits a successful native batch exactly once', async () => { const native = createAtomicD1(); - const env = createEnv({ proj_1: 'user-my-app-dpl_active' }); + const env = createEnv({ proj_000000000001: 'user-my-app-dpl_active' }); attachNativeBindingHandler(env, token, native.db); const restFetch = vi.spyOn(globalThis, 'fetch'); @@ -266,7 +266,7 @@ describe('WfP dispatch path', () => { { sql: 'INSERT INTO users (id, name) VALUES (?, ?)', params: [1, 'Alice'] }, { sql: 'INSERT INTO users (id, name) VALUES (?, ?)', params: [2, 'Bob'] }, ], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -284,7 +284,7 @@ describe('WfP dispatch path', () => { it('does not replay a native batch when transport fails after the commit', async () => { const native = createAtomicD1(); - const env = createEnv({ proj_1: 'user-my-app-dpl_active' }); + const env = createEnv({ proj_000000000001: 'user-my-app-dpl_active' }); attachNativeBindingHandler(env, token, native.db, (response) => { if (response.ok) { throw new Error('response lost after native commit'); @@ -299,7 +299,7 @@ describe('WfP dispatch path', () => { { sql: 'INSERT INTO users (id, name) VALUES (?, ?)', params: [1, 'Alice'] }, { sql: 'INSERT INTO users (id, name) VALUES (?, ?)', params: [2, 'Bob'] }, ], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -320,14 +320,16 @@ describe('WfP dispatch path', () => { describe('KV via dispatch', () => { it('dispatches KV get through user worker', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => Response.json('hello world')); + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => + Response.json('hello world'), + ); const res = await worker.fetch( post('/kv/get', { binding: 'CACHE', key: 'greeting', type: 'text', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -338,7 +340,7 @@ describe('WfP dispatch path', () => { }); it('dispatches KV put through user worker', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => Response.json({ success: true }), ); @@ -347,7 +349,7 @@ describe('WfP dispatch path', () => { binding: 'CACHE', key: 'greeting', value: 'hello', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -358,7 +360,7 @@ describe('WfP dispatch path', () => { }); it('preserves the REST fallback for a read-only dispatch transport failure', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => { + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => { throw new Error('dispatch transport unavailable'); }); env.API.fetch = vi.fn(async (url: string) => { @@ -374,7 +376,7 @@ describe('WfP dispatch path', () => { binding: 'CACHE', key: 'greeting', type: 'text', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -386,7 +388,7 @@ describe('WfP dispatch path', () => { }); it('does not replay a write after a dispatch transport failure', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => { + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => { throw new Error('response lost after KV write'); }); const restFetch = vi.spyOn(globalThis, 'fetch'); @@ -396,7 +398,7 @@ describe('WfP dispatch path', () => { binding: 'CACHE', key: 'greeting', value: 'hello', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -412,7 +414,7 @@ describe('WfP dispatch path', () => { describe('R2 via dispatch', () => { it('dispatches R2 list through user worker', async () => { - const env = createEnv({ proj_1: 'user-my-app' }, async () => + const env = createEnv({ proj_000000000001: 'user-my-app' }, async () => Response.json({ objects: [{ key: 'file.txt', size: 100, lastModified: '' }], truncated: false, @@ -423,7 +425,7 @@ describe('WfP dispatch path', () => { const res = await worker.fetch( post('/r2/list', { binding: 'STORAGE', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -475,7 +477,7 @@ describe('WfP dispatch path', () => { sql: 'SELECT * FROM users', params: [], method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -500,7 +502,7 @@ describe('WfP dispatch path', () => { it('falls back when dispatch returns 404 (old deploy)', async () => { // Worker resolved but returns 404 for /__void const env = createEnv( - { proj_1: 'user-my-app' }, + { proj_000000000001: 'user-my-app' }, async () => new Response('Not Found', { status: 404 }), ); @@ -531,7 +533,7 @@ describe('WfP dispatch path', () => { binding: 'DB', sql: 'SELECT 1', method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -564,7 +566,7 @@ describe('WfP dispatch path', () => { binding: 'DB', sql: 'SELECT 1', method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -576,7 +578,7 @@ describe('WfP dispatch path', () => { binding: 'DB', sql: 'SELECT 2', method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -591,7 +593,7 @@ describe('WfP dispatch path', () => { describe('auth forwarding', () => { it('sends x-void-internal header to dispatched worker', async () => { let capturedHeaders: Record = {}; - const env = createEnv({ proj_1: 'user-my-app' }, async (_url, init) => { + const env = createEnv({ proj_000000000001: 'user-my-app' }, async (_url, init) => { capturedHeaders = Object.fromEntries(new Headers(init.headers as HeadersInit).entries()); return Response.json({ results: [], success: true, meta: {} }); }); @@ -601,7 +603,7 @@ describe('WfP dispatch path', () => { binding: 'DB', sql: 'SELECT 1', method: 'all', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, 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 95bdbc520..fb50411b5 100644 --- a/platform/packages/proxy/test/unit/email-admission-send.test.ts +++ b/platform/packages/proxy/test/unit/email-admission-send.test.ts @@ -95,7 +95,7 @@ function fixture() { }; const app = new Hono(); app.use('*', async (context, next) => { - context.set('projectId', 'proj_1'); + context.set('projectId', 'proj_000000000001'); context.set('body', await context.req.json()); await next(); }); diff --git a/platform/packages/proxy/test/unit/email-custom-domain.test.ts b/platform/packages/proxy/test/unit/email-custom-domain.test.ts index 8d091b088..024bb10c5 100644 --- a/platform/packages/proxy/test/unit/email-custom-domain.test.ts +++ b/platform/packages/proxy/test/unit/email-custom-domain.test.ts @@ -164,8 +164,8 @@ async function postSend( async function sendBody(overrides: Record = {}) { return { - projectId: 'proj_1', - aiToken: await signHmac('proj_1', JWT_SECRET), + projectId: 'proj_000000000001', + aiToken: await signHmac('proj_000000000001', JWT_SECRET), from: CUSTOM_FROM, recipients: ['dev@example.com'], mimeBase64: mimeBase64(), @@ -270,11 +270,11 @@ describe('POST /email/send — custom sender domain', () => { expect(leaseInit.method).toBe('POST'); expect(JSON.parse(leaseInit.body as string)).toEqual({ domain: CUSTOM_DOMAIN, - projectId: 'proj_1', + projectId: 'proj_000000000001', }); const leaseHeaders = new Headers(leaseInit.headers); expect(leaseHeaders.get('x-void-internal')).toBe( - await signHmac(`email-lease-v1\nproj_1\n${CUSTOM_DOMAIN}`, EMAIL_SIGNING_SECRET), + await signHmac(`email-lease-v1\nproj_000000000001\n${CUSTOM_DOMAIN}`, EMAIL_SIGNING_SECRET), ); // The delivery: one POST to the CUSTOMER account's send_raw, leased diff --git a/platform/packages/proxy/test/unit/email.test.ts b/platform/packages/proxy/test/unit/email.test.ts index db48d1069..772338052 100644 --- a/platform/packages/proxy/test/unit/email.test.ts +++ b/platform/packages/proxy/test/unit/email.test.ts @@ -184,15 +184,15 @@ async function postSend( } /** - * A `/email/send` body that passes every gate for `proj_1` / slug `acme`. + * A `/email/send` body that passes every gate for `proj_000000000001` / slug `acme`. * The MIME `From:` follows the envelope under test, exactly as the framework * renders both from one `from.email`; pass `mimeBase64` to diverge them. */ async function sendBody(overrides: Record = {}) { const from = typeof overrides.from === 'string' ? overrides.from : DEFAULT_FROM; return { - projectId: 'proj_1', - aiToken: await signHmac('proj_1', JWT_SECRET), + projectId: 'proj_000000000001', + aiToken: await signHmac('proj_000000000001', JWT_SECRET), from, recipients: ['dev@example.com'], mimeBase64: mimeBase64(mimeFor(from)), @@ -201,11 +201,11 @@ async function sendBody(overrides: Record = {}) { }; } -/** KV contents that let `proj_1` send to `dev@example.com`. */ +/** KV contents that let `proj_000000000001` send to `dev@example.com`. */ function happyPathKv(extra: Record = {}) { return createMockKv({ - 'email-slug:proj_1': 'acme', - 'email-allowlist:proj_1': JSON.stringify(['dev@example.com']), + 'email-slug:proj_000000000001': 'acme', + 'email-allowlist:proj_000000000001': JSON.stringify(['dev@example.com']), ...extra, }); } @@ -618,7 +618,7 @@ describe('POST /email/send — request validation', () => { ); expect((await readSend(res)).ok).toBe(true); expect(sender.recipients()).toEqual(['dev@example.com']); - expect(kv.store.get(monthKeyFor('proj_1'))).toBeUndefined(); + expect(kv.store.get(monthKeyFor('proj_000000000001'))).toBeUndefined(); }); it('shares one request deadline across admission and binding fan-out', async () => { @@ -626,7 +626,7 @@ describe('POST /email/send — request validation', () => { try { const recipients = ['first@example.com', 'second@example.com', 'third@example.com']; const kv = happyPathKv({ - 'email-allowlist:proj_1': JSON.stringify(recipients), + 'email-allowlist:proj_000000000001': JSON.stringify(recipients), }); let markFirst!: () => void; let markSecond!: () => void; @@ -745,12 +745,12 @@ describe('POST /email/send — request validation', () => { const MAX_RECIPIENTS = 50; const recipients = Array.from({ length: MAX_RECIPIENTS }, (_, i) => `dev${i}@example.com`); const kv = createMockKv({ - 'email-slug:proj_1': 'acme', - 'email-allowlist:proj_1': JSON.stringify(recipients), + 'email-slug:proj_000000000001': 'acme', + 'email-allowlist:proj_000000000001': JSON.stringify(recipients), // Raised so the burst + monthly gates cannot answer first and hide // whether the cap itself let the list through. - 'email-burst-limit:proj_1': '1000', - 'email-limit:proj_1': '1000', + 'email-burst-limit:proj_000000000001': '1000', + 'email-limit:proj_000000000001': '1000', }); const sender = createSender(); const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); @@ -758,7 +758,7 @@ describe('POST /email/send — request validation', () => { expect((await readSend(res)).ok).toBe(true); expect(sender.recipients()).toEqual(recipients); - expect(kv.store.get(monthKeyFor('proj_1'))).toBeUndefined(); + expect(kv.store.get(monthKeyFor('proj_000000000001'))).toBeUndefined(); }); // Bound each address before normalization copies it. @@ -1180,7 +1180,7 @@ describe('POST /email/send — per-recipient fan-out', () => { // already-drained (empty) stream. Assert the delivered BYTES. const mime = mimeFor(DEFAULT_FROM, 'Subject: fan-out\r\n\r\nbody bytes\r\n'); const recipients = ['a@example.com', 'b@example.com', 'c@example.com']; - const kv = happyPathKv({ 'email-allowlist:proj_1': JSON.stringify(recipients) }); + const kv = happyPathKv({ 'email-allowlist:proj_000000000001': JSON.stringify(recipients) }); const sender = createSender(); const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); const res = await postSend(env, await sendBody({ recipients, mimeBase64: mimeBase64(mime) })); @@ -1211,7 +1211,7 @@ describe('POST /email/send — per-recipient fan-out', () => { it('records per-recipient outcomes when one send fails mid-batch', async () => { const recipients = ['alice@example.com', 'bob@example.com', 'carol@example.com']; - const kv = happyPathKv({ 'email-allowlist:proj_1': JSON.stringify(recipients) }); + const kv = happyPathKv({ 'email-allowlist:proj_000000000001': JSON.stringify(recipients) }); const sender = createSender({ fail: (to) => (to === 'bob@example.com' ? new Error('upstream blew up') : undefined), }); @@ -1294,7 +1294,7 @@ describe('POST /email/send — the envelope handed to CF', () => { ['a carriage return', `acme\r\nX-Bcc: attacker@evil.com@${PROD_DOMAIN}`, 'acme'], ['a null byte', `acme\x00@${PROD_DOMAIN}`, 'acme'], ])('never hands a control character to the send binding (%s)', async (_label, from, slug) => { - const kv = happyPathKv({ 'email-slug:proj_1': slug }); + const kv = happyPathKv({ 'email-slug:proj_000000000001': slug }); const sender = createSender(); const env = createMockEnv(kv, { SEND_EMAIL: sender.binding }); await postSend(env, await sendBody({ from })); @@ -1340,10 +1340,11 @@ describe('POST /email/send — requestId is bounded and sanitized at intake', () expect(lines).toHaveLength(1); // The id is sliced to 128 code units and each LF becomes one space, // so the whole line is the fixed fields plus at most 128 chars of id. - const fixed = `[email-log] proj_1 dev@example.com sent cf-msg-1 @${Date.now()}`.length; + const fixed = `[email-log] proj_000000000001 dev@example.com sent cf-msg-1 @${Date.now()}` + .length; expect(lines[0]!.length).toBeLessThanOrEqual(fixed + 128); expect(lines[0]).not.toMatch(CONTROL); - expect(lines[0]!.split(' ')[1]).toBe('proj_1'); + expect(lines[0]!.split(' ')[1]).toBe('proj_000000000001'); // Generous: the slice-first path is ~0 ms; the JSON body is ~5 MB. expect(elapsed).toBeLessThan(500); @@ -1357,7 +1358,7 @@ describe('POST /email/send — requestId is bounded and sanitized at intake', () expect(lines2).toHaveLength(1); expect(lines2[0]).not.toMatch(CONTROL); expect(lines2[0]).not.toContain('\n[email-log]'); - expect(lines2[0]!.split(' ')[1]).toBe('proj_1'); + expect(lines2[0]!.split(' ')[1]).toBe('proj_000000000001'); } finally { logSpy.mockRestore(); } diff --git a/platform/packages/proxy/test/unit/kv.test.ts b/platform/packages/proxy/test/unit/kv.test.ts index 90e9c12fa..b9be01f60 100644 --- a/platform/packages/proxy/test/unit/kv.test.ts +++ b/platform/packages/proxy/test/unit/kv.test.ts @@ -86,7 +86,7 @@ describe('KV proxy endpoints', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { - token = await signAiToken('proj_1', JWT_SECRET); + token = await signAiToken('proj_000000000001', JWT_SECRET); }); afterEach(() => { @@ -98,13 +98,13 @@ describe('KV proxy endpoints', () => { it('returns text value', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => new Response('hello world'))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/get', { binding: 'CACHE', key: 'greeting', type: 'text', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -124,13 +124,13 @@ describe('KV proxy endpoints', () => { it('returns JSON value', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ count: 42 }))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/get', { binding: 'CACHE', key: 'data', type: 'json', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -143,13 +143,13 @@ describe('KV proxy endpoints', () => { it('returns null for missing key (404)', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => new Response('Not Found', { status: 404 }))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/get', { binding: 'CACHE', key: 'missing', type: 'text', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -162,13 +162,13 @@ describe('KV proxy endpoints', () => { it('URL-encodes the key', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => new Response('val'))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); await worker.fetch( post('/kv/get', { binding: 'CACHE', key: 'user:1:profile', type: 'text', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -186,7 +186,7 @@ describe('KV proxy endpoints', () => { post('/kv/get', { binding: 'MISSING', key: 'k', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -198,7 +198,7 @@ describe('KV proxy endpoints', () => { it('returns 400 when key is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/kv/get', { binding: 'CACHE', projectId: 'proj_1', aiToken: token }), + post('/kv/get', { binding: 'CACHE', projectId: 'proj_000000000001', aiToken: token }), env, ); @@ -210,13 +210,13 @@ describe('KV proxy endpoints', () => { it('writes value to CF KV API', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/put', { binding: 'CACHE', key: 'greeting', value: 'hello', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -234,14 +234,14 @@ describe('KV proxy endpoints', () => { it('includes expiration query params', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); await worker.fetch( post('/kv/put', { binding: 'CACHE', key: 'temp', value: 'x', expiration_ttl: 300, - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -256,13 +256,13 @@ describe('KV proxy endpoints', () => { it('serializes non-string values as JSON', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); await worker.fetch( post('/kv/put', { binding: 'CACHE', key: 'data', value: { count: 1 }, - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -281,7 +281,7 @@ describe('KV proxy endpoints', () => { binding: 'MISSING', key: 'k', value: 'v', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -295,12 +295,12 @@ describe('KV proxy endpoints', () => { it('deletes key via CF KV API', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/delete', { binding: 'CACHE', key: 'greeting', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -318,7 +318,7 @@ describe('KV proxy endpoints', () => { it('returns 400 when key is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/kv/delete', { binding: 'CACHE', projectId: 'proj_1', aiToken: token }), + post('/kv/delete', { binding: 'CACHE', projectId: 'proj_000000000001', aiToken: token }), env, ); @@ -338,11 +338,11 @@ describe('KV proxy endpoints', () => { ), ); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/list', { binding: 'CACHE', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -361,14 +361,14 @@ describe('KV proxy endpoints', () => { ), ); - const env = createEnv({ 'proj_1:kv:CACHE': 'kv-ns-123' }); + const env = createEnv({ 'proj_000000000001:kv:CACHE': 'kv-ns-123' }); const res = await worker.fetch( post('/kv/list', { binding: 'CACHE', prefix: 'user:', limit: 10, cursor: 'prev-cursor', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -388,7 +388,7 @@ describe('KV proxy endpoints', () => { it('returns 400 when binding is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/kv/list', { projectId: 'proj_1', aiToken: token }), + post('/kv/list', { projectId: 'proj_000000000001', aiToken: token }), env, ); @@ -400,7 +400,7 @@ describe('KV proxy endpoints', () => { const res = await worker.fetch( post('/kv/list', { binding: 'MISSING', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, diff --git a/platform/packages/proxy/test/unit/proxy.test.ts b/platform/packages/proxy/test/unit/proxy.test.ts index 703f4ce25..59cf0cc60 100644 --- a/platform/packages/proxy/test/unit/proxy.test.ts +++ b/platform/packages/proxy/test/unit/proxy.test.ts @@ -91,7 +91,7 @@ function createMockKv(data: Record = {}) { * its legacy verify-owner URL while the API now resolves roles dynamically. */ const DEFAULT_OWNERSHIP: Record> = { - user_1: ['proj_1', 'proj_42', 'proj_99', 'p'], + user_1: ['proj_000000000001', 'proj_000000000042', 'proj_000000000099', 'proj_000000000000'], }; type ApiFetchHandler = (input: string | Request, init?: RequestInit) => Promise; @@ -184,7 +184,12 @@ describe('proxy worker', () => { beforeAll(async () => { validToken = await signJwt({ sub: 'user_1', login: 'testuser' }, JWT_SECRET); - for (const pid of ['proj_1', 'proj_42', 'proj_99', 'p']) { + for (const pid of [ + 'proj_000000000001', + 'proj_000000000042', + 'proj_000000000099', + 'proj_000000000000', + ]) { aiTokens[pid] = await signAiToken(pid, JWT_SECRET); } }); @@ -358,7 +363,11 @@ describe('proxy worker', () => { it('allows a larger remote write body through to authentication', async () => { const response = await worker.fetch( - post('/r2/put', { projectId: 'proj_1', aiToken: 'invalid', pad: 'x'.repeat(1024 * 1024) }), + post('/r2/put', { + projectId: 'proj_000000000001', + aiToken: 'invalid', + pad: 'x'.repeat(1024 * 1024), + }), createEnv(), ); expect(response.status).toBe(403); @@ -446,7 +455,7 @@ describe('proxy worker', () => { new Request('https://proxy.void.cloud/email/send', { method: 'POST', body: JSON.stringify({ - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: 'invalid-project-token', mimeBase64: 'AAAA'.repeat(Math.ceil((5 * 1024 * 1024) / 3)), }), @@ -500,7 +509,7 @@ describe('proxy worker', () => { const claims = { version: 1 as const, receiptId: 'a'.repeat(64), - projectId: 'proj_1', + projectId: 'proj_000000000001', deploymentId: 'deploy_1', recipient: 'support@example.com', expiresAt: Date.now() + 30_000, @@ -528,13 +537,36 @@ describe('proxy worker', () => { expect((await call(JWT_SECRET, projectToken, missing)).status).toBe(503); }); + it('rejects a receipt check for a non-project id even when its HMAC matches', async () => { + for (const projectId of ['eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEifQ', 'proj_1']) { + const claims = { + version: 1 as const, + receiptId: 'a'.repeat(64), + projectId, + deploymentId: 'deploy_1', + recipient: 'support@example.com', + expiresAt: Date.now() + 30_000, + }; + const res = await worker.fetch( + post( + '/email/verify-receipt', + { ...claims, receipt: await signEmailReceipt(claims, EMAIL_SIGNING_SECRET) }, + { 'x-void-internal': await signAiToken(projectId, JWT_SECRET) }, + ), + createEnv(), + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ valid: false }); + } + }); + describe('JWT authentication (HTTPS path)', () => { it('accepts valid JWT for the project owner', async () => { const env = createEnv(); const res = await worker.fetch( post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${validToken}` }, ), env, @@ -553,7 +585,7 @@ describe('proxy worker', () => { const res = await worker.fetch( post( '/ai/run', - { model: 'm', inputs: {}, projectId: 'p' }, + { model: 'm', inputs: {}, projectId: 'proj_000000000000' }, { authorization: `Bearer ${expiredToken}` }, ), createEnv(), @@ -572,7 +604,7 @@ describe('proxy worker', () => { const res = await worker.fetch( post( '/ai/run', - { model: 'm', inputs: {}, projectId: 'p' }, + { model: 'm', inputs: {}, projectId: 'proj_000000000000' }, { authorization: `Bearer ${badToken}` }, ), createEnv(), @@ -585,7 +617,7 @@ describe('proxy worker', () => { const res = await worker.fetch( post( '/ai/run', - { model: 'm', inputs: {}, projectId: 'p' }, + { model: 'm', inputs: {}, projectId: 'proj_000000000000' }, { authorization: 'Bearer not.a.valid.jwt.at.all' }, ), createEnv(), @@ -608,7 +640,7 @@ describe('proxy worker', () => { describe('JWT project ownership verification', () => { it('accepts a prerender token only for its scoped project', async () => { const token = await signJwt( - { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_1' }, + { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_000000000001' }, JWT_SECRET, ); const api = createMockApi(); @@ -618,7 +650,7 @@ describe('proxy worker', () => { const allowed = await worker.fetch( post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${token}` }, ), env, @@ -626,13 +658,13 @@ describe('proxy worker', () => { expect(allowed.status).toBe(200); expect(apiFetch).toHaveBeenCalledTimes(1); expect(String(apiFetch.mock.calls[0]?.[0])).toContain( - '/internal/verify-prerender-build/proj_1', + '/internal/verify-prerender-build/proj_000000000001', ); const denied = await worker.fetch( post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_42' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000042' }, { authorization: `Bearer ${token}` }, ), env, @@ -648,12 +680,12 @@ describe('proxy worker', () => { it('revokes an already-minted manual prerender token when project access changes', async () => { const token = await signJwt( - { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_1' }, + { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_000000000001' }, JWT_SECRET, ); let active = true; const api = createMockApi(async (input) => { - expect(String(input)).toContain('/internal/verify-prerender-build/proj_1'); + expect(String(input)).toContain('/internal/verify-prerender-build/proj_000000000001'); return active ? Response.json({ ok: true }) : Response.json({ error: 'forbidden' }, { status: 403 }); @@ -662,7 +694,7 @@ describe('proxy worker', () => { const request = () => post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${token}` }, ); @@ -679,11 +711,11 @@ describe('proxy worker', () => { it('accepts a manual prerender token while the previous API revision is active', async () => { const token = await signJwt( - { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_1' }, + { sub: 'user_1', login: 'testuser', aud: 'prerender', project: 'proj_000000000001' }, JWT_SECRET, ); const api = createMockApi(async (input) => { - expect(String(input)).toContain('/internal/verify-prerender-build/proj_1'); + expect(String(input)).toContain('/internal/verify-prerender-build/proj_000000000001'); return Response.json( { error: 'userId and credential provenance required' }, { status: 400 }, @@ -693,7 +725,7 @@ describe('proxy worker', () => { const response = await worker.fetch( post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${token}` }, ), createEnv(undefined, undefined, api), @@ -708,7 +740,7 @@ describe('proxy worker', () => { sub: 'user_1', login: 'testuser', aud: 'prerender', - project: 'proj_1', + project: 'proj_000000000001', build: 'bld_1', }, JWT_SECRET, @@ -719,7 +751,7 @@ describe('proxy worker', () => { if (url.includes('/internal/verify-owner/')) { return Response.json({ ok: true }); } - expect(url).toContain('/internal/verify-prerender-build/proj_1'); + expect(url).toContain('/internal/verify-prerender-build/proj_000000000001'); return active ? Response.json({ ok: true }) : Response.json({ error: 'build is no longer active' }, { status: 403 }); @@ -728,7 +760,7 @@ describe('proxy worker', () => { const request = () => post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${token}` }, ); @@ -745,13 +777,13 @@ describe('proxy worker', () => { it('rejects deploy tokens at the binding proxy', async () => { const token = await signJwt( - { sub: 'user_1', login: 'testuser', aud: 'deploy', project: 'proj_1' }, + { sub: 'user_1', login: 'testuser', aud: 'deploy', project: 'proj_000000000001' }, JWT_SECRET, ); const res = await worker.fetch( post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${token}` }, ), createEnv(), @@ -765,14 +797,14 @@ describe('proxy worker', () => { sub: 'user_1', login: 'testuser', aud: 'prerender', - project: 'proj_1', + project: 'proj_000000000001', credential: 'cred_1', }, JWT_SECRET, ); let active = true; const api = createMockApi(async (input, init) => { - expect(String(input)).toContain('/internal/verify-prerender-build/proj_1'); + expect(String(input)).toContain('/internal/verify-prerender-build/proj_000000000001'); expect(JSON.parse(String(init?.body))).toEqual({ userId: 'user_1', credentialId: 'cred_1', @@ -785,7 +817,7 @@ describe('proxy worker', () => { const request = () => post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${token}` }, ); expect((await worker.fetch(request(), env)).status).toBe(200); @@ -904,7 +936,7 @@ describe('proxy worker', () => { const res = await worker.fetch( post( '/ai/run', - { model: 'test-model', inputs: {}, projectId: 'proj_1' }, + { model: 'test-model', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${validToken}` }, ), env, @@ -957,7 +989,7 @@ describe('proxy worker', () => { const response = await worker.fetch( post( '/r2/get', - { binding: 'FILES', key: 'large.bin', projectId: 'proj_1' }, + { binding: 'FILES', key: 'large.bin', projectId: 'proj_000000000001' }, { authorization: `Bearer ${validToken}` }, ), createEnv(undefined, undefined, api), @@ -979,8 +1011,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'test-model', inputs: {}, - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -997,8 +1029,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'test-model', inputs: {}, - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1016,7 +1048,7 @@ describe('proxy worker', () => { it('rejects request with missing aiToken', async () => { const res = await worker.fetch( - post('/ai/run', { model: 'm', inputs: {}, projectId: 'proj_1' }), + post('/ai/run', { model: 'm', inputs: {}, projectId: 'proj_000000000001' }), createEnv(), ); @@ -1029,7 +1061,7 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: 'invalid-token', }), createEnv(), @@ -1044,8 +1076,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'proj_1', - aiToken: aiTokens['proj_42'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000042'], }), createEnv(), ); @@ -1053,17 +1085,54 @@ describe('proxy worker', () => { expect(res.status).toBe(403); expect(await res.json()).toEqual({ error: 'invalid project token' }); }); + + it('rejects a login JWT split into projectId and aiToken', async () => { + const [header, body, signature] = validToken.split('.'); + // The JWT signature is the same HMAC a project token would carry for this string. + expect(await signAiToken(`${header}.${body}`, JWT_SECRET)).toBe(signature); + const env = createEnv(); + const res = await worker.fetch( + post('/ai/run', { + model: 'test-model', + inputs: {}, + projectId: `${header}.${body}`, + aiToken: signature, + }), + env, + ); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'invalid project token' }); + expect(env.AI.run).not.toHaveBeenCalled(); + }); + + it('rejects a project id outside the generated format even with a matching token', async () => { + const env = createEnv(); + const res = await worker.fetch( + post('/ai/run', { + model: 'test-model', + inputs: {}, + projectId: 'proj_1', + aiToken: await signAiToken('proj_1', JWT_SECRET), + }), + env, + ); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'invalid project token' }); + expect(env.AI.run).not.toHaveBeenCalled(); + }); }); describe('AI usage limit (KV block)', () => { it('returns 429 when ai-block key exists (service binding path)', async () => { - const env = createEnv({}, { 'ai-block:proj_1': '1' }); + const env = createEnv({}, { 'ai-block:proj_000000000001': '1' }); const res = await worker.fetch( post('/ai/run', { model: 'm', inputs: {}, - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1079,11 +1148,11 @@ describe('proxy worker', () => { }); it('returns 429 when ai-block key exists (JWT path)', async () => { - const env = createEnv({}, { 'ai-block:proj_1': '1' }); + const env = createEnv({}, { 'ai-block:proj_000000000001': '1' }); const res = await worker.fetch( post( '/ai/run', - { model: 'm', inputs: {}, projectId: 'proj_1' }, + { model: 'm', inputs: {}, projectId: 'proj_000000000001' }, { authorization: `Bearer ${validToken}` }, ), env, @@ -1104,8 +1173,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1122,8 +1191,8 @@ describe('proxy worker', () => { model: '@cf/meta/llama-3-8b-instruct', inputs: { prompt: 'hi' }, options: { max_tokens: 100 }, - projectId: 'proj_42', - aiToken: aiTokens['proj_42'], + projectId: 'proj_000000000042', + aiToken: aiTokens['proj_000000000042'], }), env, ); @@ -1134,7 +1203,7 @@ describe('proxy worker', () => { { prompt: 'hi' }, { max_tokens: 100, - gateway: { id: 'void', metadata: { projectId: 'proj_42' } }, + gateway: { id: 'void', metadata: { projectId: 'proj_000000000042' } }, }, ); }); @@ -1145,8 +1214,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'proj_42', - aiToken: aiTokens['proj_42'], + projectId: 'proj_000000000042', + aiToken: aiTokens['proj_000000000042'], }), env, ); @@ -1155,7 +1224,7 @@ describe('proxy worker', () => { 'm', {}, { - gateway: { id: 'void-team', metadata: { projectId: 'proj_42' } }, + gateway: { id: 'void-team', metadata: { projectId: 'proj_000000000042' } }, }, ); }); @@ -1174,8 +1243,8 @@ describe('proxy worker', () => { metadata: { user: 'alice', projectId: 'wrong' }, }, }, - projectId: 'proj_42', - aiToken: aiTokens['proj_42'], + projectId: 'proj_000000000042', + aiToken: aiTokens['proj_000000000042'], }), env, ); @@ -1188,7 +1257,7 @@ describe('proxy worker', () => { id: 'void', skipCache: true, cacheTtl: 60, - metadata: { user: 'alice', projectId: 'proj_42' }, + metadata: { user: 'alice', projectId: 'proj_000000000042' }, }, }, ); @@ -1196,7 +1265,11 @@ describe('proxy worker', () => { it('returns 400 when model is missing', async () => { const res = await worker.fetch( - post('/ai/run', { inputs: {}, projectId: 'p', aiToken: aiTokens['p'] }), + post('/ai/run', { + inputs: {}, + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], + }), createEnv(), ); @@ -1217,8 +1290,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: { prompt: 'hi', stream: true }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1238,8 +1311,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: { prompt: 'hi' }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1257,8 +1330,8 @@ describe('proxy worker', () => { post('/ai/run', { model: '@cf/black-forest-labs/flux-1-schnell', inputs: { prompt: 'a red cube' }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1279,8 +1352,8 @@ describe('proxy worker', () => { post('/ai/run', { model: '@cf/image-model', inputs: { prompt: 'a blue cube' }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1296,8 +1369,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'proj_99', - aiToken: aiTokens['proj_99'], + projectId: 'proj_000000000099', + aiToken: aiTokens['proj_000000000099'], }), env, ); @@ -1306,7 +1379,7 @@ describe('proxy worker', () => { 'm', {}, { - gateway: { id: 'void', metadata: { projectId: 'proj_99' } }, + gateway: { id: 'void', metadata: { projectId: 'proj_000000000099' } }, }, ); }); @@ -1320,8 +1393,8 @@ describe('proxy worker', () => { const res = await worker.fetch( post('/ai/models', { inputs: { search: 'llama' }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1341,8 +1414,8 @@ describe('proxy worker', () => { post('/ai/toMarkdown', { inputs: { content: 'hello' }, options: { format: 'md' }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1391,8 +1464,8 @@ describe('proxy worker', () => { }, }, providerKey: 'sk-test-key', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1411,7 +1484,7 @@ describe('proxy worker', () => { expect(init.method).toBe('POST'); const headers = new Headers(init.headers); expect(headers.get('authorization')).toBe('Bearer sk-test-key'); - expect(headers.get('cf-aig-metadata-projectId')).toBe('proj_1'); + expect(headers.get('cf-aig-metadata-projectId')).toBe('proj_000000000001'); expect(headers.get('content-type')).toBe('application/json'); expect(headers.get('openai-beta')).toBe('assistants=v2'); expect(JSON.parse(init.body as string)).toEqual({ @@ -1430,8 +1503,8 @@ describe('proxy worker', () => { provider: 'openai', path: '/chat/completions', providerKey: 'key', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1456,8 +1529,8 @@ describe('proxy worker', () => { value: { contents: [{ parts: [{ text: 'Hello' }] }] }, }, providerKey: 'google-key', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1470,7 +1543,7 @@ describe('proxy worker', () => { const headers = new Headers(fetchMock.mock.calls[0][1]!.headers); expect(headers.get('x-goog-api-key')).toBe('google-key'); expect(headers.get('authorization')).toBeNull(); - expect(headers.get('cf-aig-metadata-projectId')).toBe('proj_1'); + expect(headers.get('cf-aig-metadata-projectId')).toBe('proj_000000000001'); }); it('reconstructs multipart form data bodies', async () => { @@ -1501,8 +1574,8 @@ describe('proxy worker', () => { ], }, providerKey: 'sk-test', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1535,8 +1608,8 @@ describe('proxy worker', () => { path: '/v1/images', body: { type: 'json', value: { prompt: 'a small robot' } }, providerKey: 'fal-key', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1554,8 +1627,8 @@ describe('proxy worker', () => { post('/ai/provider', { path: '/chat/completions', providerKey: 'sk-test', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1566,8 +1639,8 @@ describe('proxy worker', () => { post('/ai/provider', { provider: 'openai', providerKey: 'sk-test', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1578,8 +1651,8 @@ describe('proxy worker', () => { post('/ai/provider', { provider: 'openai', path: '/chat/completions', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1602,8 +1675,8 @@ describe('proxy worker', () => { provider: 'openai', path, providerKey: 'sk-test', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1627,8 +1700,8 @@ describe('proxy worker', () => { path: '/chat/completions', body: { type: 'json', value: { model: 'gpt-4o' } }, providerKey: 'sk-bad-key', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1639,14 +1712,14 @@ describe('proxy worker', () => { }); it('respects usage limits for provider-native requests', async () => { - const env = createEnv({}, { 'ai-block:proj_1': '1' }); + const env = createEnv({}, { 'ai-block:proj_000000000001': '1' }); const res = await worker.fetch( post('/ai/provider', { provider: 'openai', path: '/chat/completions', providerKey: 'sk-test', - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1674,8 +1747,8 @@ describe('proxy worker', () => { const res = await worker.fetch( post('/revalidate', { paths: ['/', '/blog'], - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1686,9 +1759,9 @@ describe('proxy worker', () => { expect(apiFetch).toHaveBeenCalledOnce(); // Fetcher.fetch() receives (url: string, init: RequestInit) const [url, init] = apiFetch.mock.calls[0]; - expect(url).toBe('https://api/internal/revalidate/proj_1'); + expect(url).toBe('https://api/internal/revalidate/proj_000000000001'); expect(init.method).toBe('POST'); - expect(new Headers(init.headers).get('x-void-internal')).toBe(aiTokens['proj_1']); + expect(new Headers(init.headers).get('x-void-internal')).toBe(aiTokens['proj_000000000001']); expect(JSON.parse(init.body)).toEqual({ paths: ['/', '/blog'] }); }); @@ -1704,8 +1777,8 @@ describe('proxy worker', () => { const res = await worker.fetch( post('/revalidate', { all: true, - projectId: 'proj_1', - aiToken: aiTokens['proj_1'], + projectId: 'proj_000000000001', + aiToken: aiTokens['proj_000000000001'], }), env, ); @@ -1736,8 +1809,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1757,8 +1830,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1786,8 +1859,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'google/gemini-2.5-flash', inputs: { contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1810,8 +1883,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'google/gemini-2.5-flash', inputs: { messages: [{ role: 'user', content: 'Hello' }] }, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); @@ -1831,8 +1904,8 @@ describe('proxy worker', () => { post('/ai/run', { model: 'm', inputs: {}, - projectId: 'p', - aiToken: aiTokens['p'], + projectId: 'proj_000000000000', + aiToken: aiTokens['proj_000000000000'], }), env, ); diff --git a/platform/packages/proxy/test/unit/r2.test.ts b/platform/packages/proxy/test/unit/r2.test.ts index 3fff66a5d..004314067 100644 --- a/platform/packages/proxy/test/unit/r2.test.ts +++ b/platform/packages/proxy/test/unit/r2.test.ts @@ -86,7 +86,7 @@ describe('R2 proxy endpoints', () => { const originalFetch = globalThis.fetch; beforeAll(async () => { - token = await signAiToken('proj_1', JWT_SECRET); + token = await signAiToken('proj_000000000001', JWT_SECRET); }); afterEach(() => { @@ -109,12 +109,12 @@ describe('R2 proxy endpoints', () => { ), ); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/get', { binding: 'STORAGE', key: 'avatar.png', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -136,12 +136,12 @@ describe('R2 proxy endpoints', () => { it('returns null for missing object (404)', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => new Response('Not Found', { status: 404 }))); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/get', { binding: 'STORAGE', key: 'missing.txt', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -157,7 +157,7 @@ describe('R2 proxy endpoints', () => { post('/r2/get', { binding: 'MISSING', key: 'k', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -169,7 +169,7 @@ describe('R2 proxy endpoints', () => { it('returns 400 when key is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/r2/get', { binding: 'STORAGE', projectId: 'proj_1', aiToken: token }), + post('/r2/get', { binding: 'STORAGE', projectId: 'proj_000000000001', aiToken: token }), env, ); @@ -189,7 +189,7 @@ describe('R2 proxy endpoints', () => { globalThis.fetch = asFetchMock(fetchMock); const response = await worker.fetch( post('/r2/put-conditional', { - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, binding: 'STORAGE', key: 'key', @@ -209,10 +209,10 @@ describe('R2 proxy endpoints', () => { it('rejects conditional writes without a compatible deployed worker before REST fallback', async () => { const fetchMock = vi.fn(async () => Response.json({ success: true })); globalThis.fetch = asFetchMock(fetchMock); - const env = createEnv({ 'proj_1:r2:STORAGE': 'bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'bucket' }); const res = await worker.fetch( post('/r2/put-conditional', { - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, binding: 'STORAGE', key: 'key', @@ -229,7 +229,7 @@ describe('R2 proxy endpoints', () => { it('writes base64-encoded data via CF R2 API', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const base64 = btoa('hello world'); const res = await worker.fetch( post('/r2/put', { @@ -237,7 +237,7 @@ describe('R2 proxy endpoints', () => { key: 'test.txt', base64, contentType: 'text/plain', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -255,13 +255,13 @@ describe('R2 proxy endpoints', () => { it('writes text value when no base64', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/put', { binding: 'STORAGE', key: 'hello.txt', value: 'hello', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -278,7 +278,7 @@ describe('R2 proxy endpoints', () => { binding: 'MISSING', key: 'k', value: 'v', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -292,12 +292,12 @@ describe('R2 proxy endpoints', () => { it('deletes single key', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/delete', { binding: 'STORAGE', key: 'old.txt', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -315,12 +315,12 @@ describe('R2 proxy endpoints', () => { it('deletes multiple keys', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => Response.json({ success: true }))); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/delete', { binding: 'STORAGE', keys: ['a.txt', 'b.txt'], - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -332,11 +332,11 @@ describe('R2 proxy endpoints', () => { }); it('returns 400 when no key or keys provided', async () => { - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/delete', { binding: 'STORAGE', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -361,11 +361,11 @@ describe('R2 proxy endpoints', () => { ), ); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/list', { binding: 'STORAGE', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -392,13 +392,13 @@ describe('R2 proxy endpoints', () => { ), ); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/list', { binding: 'STORAGE', prefix: 'uploads/', limit: 1, - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -420,7 +420,7 @@ describe('R2 proxy endpoints', () => { const res = await worker.fetch( post('/r2/list', { binding: 'MISSING', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -447,12 +447,12 @@ describe('R2 proxy endpoints', () => { ), ); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/head', { binding: 'STORAGE', key: 'avatar.png', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -474,12 +474,12 @@ describe('R2 proxy endpoints', () => { it('returns null for missing object', async () => { globalThis.fetch = asFetchMock(vi.fn(async () => new Response(null, { status: 404 }))); - const env = createEnv({ 'proj_1:r2:STORAGE': 'my-bucket' }); + const env = createEnv({ 'proj_000000000001:r2:STORAGE': 'my-bucket' }); const res = await worker.fetch( post('/r2/head', { binding: 'STORAGE', key: 'missing.txt', - projectId: 'proj_1', + projectId: 'proj_000000000001', aiToken: token, }), env, @@ -492,7 +492,7 @@ describe('R2 proxy endpoints', () => { it('returns 400 when key is missing', async () => { const env = createEnv({}); const res = await worker.fetch( - post('/r2/head', { binding: 'STORAGE', projectId: 'proj_1', aiToken: token }), + post('/r2/head', { binding: 'STORAGE', projectId: 'proj_000000000001', aiToken: token }), env, ); diff --git a/platform/packages/proxy/test/unit/resolve.test.ts b/platform/packages/proxy/test/unit/resolve.test.ts index ff27ea181..96f8541e8 100644 --- a/platform/packages/proxy/test/unit/resolve.test.ts +++ b/platform/packages/proxy/test/unit/resolve.test.ts @@ -28,24 +28,24 @@ describe('resolveResource', () => { }); it('returns cf_id for a known resource', async () => { - const api = createMockApi({ 'proj_1:d1:DB': 'd1-uuid-123' }); - const result = await resolveResource(api, JWT_SECRET, 'proj_1', 'd1', 'DB'); + const api = createMockApi({ 'proj_000000000001:d1:DB': 'd1-uuid-123' }); + const result = await resolveResource(api, JWT_SECRET, 'proj_000000000001', 'd1', 'DB'); expect(result).toBe('d1-uuid-123'); }); it('throws for an unknown resource', async () => { const api = createMockApi({}); - await expect(resolveResource(api, JWT_SECRET, 'proj_1', 'kv', 'MISSING')).rejects.toThrow( - 'Resource resolution failed (404)', - ); + await expect( + resolveResource(api, JWT_SECRET, 'proj_000000000001', 'kv', 'MISSING'), + ).rejects.toThrow('Resource resolution failed (404)'); }); it('calls API with correct URL and body', async () => { - const api = createMockApi({ 'proj_42:r2:STORAGE': 'r2-uuid-456' }); - await resolveResource(api, JWT_SECRET, 'proj_42', 'r2', 'STORAGE'); + const api = createMockApi({ 'proj_000000000042:r2:STORAGE': 'r2-uuid-456' }); + await resolveResource(api, JWT_SECRET, 'proj_000000000042', 'r2', 'STORAGE'); expect(api.fetch).toHaveBeenCalledWith( - 'https://api/internal/resolve-resource/proj_42', + 'https://api/internal/resolve-resource/proj_000000000042', expect.objectContaining({ method: 'POST', body: JSON.stringify({ type: 'r2', binding: 'STORAGE' }), @@ -54,17 +54,17 @@ describe('resolveResource', () => { }); it('caches resolved resource IDs', async () => { - const api = createMockApi({ 'proj_1:d1:DB': 'd1-uuid-123' }); - await resolveResource(api, JWT_SECRET, 'proj_1', 'd1', 'DB'); - await resolveResource(api, JWT_SECRET, 'proj_1', 'd1', 'DB'); - await resolveResource(api, JWT_SECRET, 'proj_1', 'd1', 'DB'); + const api = createMockApi({ 'proj_000000000001:d1:DB': 'd1-uuid-123' }); + await resolveResource(api, JWT_SECRET, 'proj_000000000001', 'd1', 'DB'); + await resolveResource(api, JWT_SECRET, 'proj_000000000001', 'd1', 'DB'); + await resolveResource(api, JWT_SECRET, 'proj_000000000001', 'd1', 'DB'); expect((api.fetch as ReturnType).mock.calls).toHaveLength(1); }); it('includes HMAC token in x-void-internal header', async () => { - const api = createMockApi({ 'proj_1:d1:DB': 'd1-uuid-123' }); - await resolveResource(api, JWT_SECRET, 'proj_1', 'd1', 'DB'); + const api = createMockApi({ 'proj_000000000001:d1:DB': 'd1-uuid-123' }); + await resolveResource(api, JWT_SECRET, 'proj_000000000001', 'd1', 'DB'); const call = (api.fetch as ReturnType).mock.calls[0]; const init = call[1] as RequestInit; @@ -82,7 +82,7 @@ describe('verifyProjectDeployAccess', () => { verifyProjectDeployAccess( api, JWT_SECRET, - 'proj_1', + 'proj_000000000001', 'user_1', 'jwt', 'access', @@ -100,7 +100,14 @@ describe('verifyProjectDeployAccess', () => { const missing = { fetch: vi.fn() } as unknown as Fetcher; await expect( - verifyProjectDeployAccess(missing, JWT_SECRET, 'proj_1', 'user_1', 'jwt', 'access'), + verifyProjectDeployAccess( + missing, + JWT_SECRET, + 'proj_000000000001', + 'user_1', + 'jwt', + 'access', + ), ).resolves.toBe(false); expect(missing.fetch).not.toHaveBeenCalled(); }); From 656ee95770d70c69c2128fa0c789abda3c9d1361 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 6 Oct 2026 21:54:13 +0800 Subject: [PATCH 4/8] Add CODEOWNERS for CI, scripts, and dependency manifests Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/CODEOWNERS | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..d51896504 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Changes to CI, release scripts, and dependency manifests need a review from +# the Void team. Later rules take precedence over earlier ones. + +/.github/ @voidzero-dev/void +/scripts/ @voidzero-dev/void +/platform/scripts/ @voidzero-dev/void +/pnpm-workspace.yaml @voidzero-dev/void +/pnpm-lock.yaml @voidzero-dev/void +package.json @voidzero-dev/void From 2175cce240243fb123af7e2f1c63e4f40230feb4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 17:32:59 +0800 Subject: [PATCH 5/8] Use generated-shape ids for seeded local projects Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/scripts/seed.sql | 58 +++++++++++++------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/platform/packages/api/scripts/seed.sql b/platform/packages/api/scripts/seed.sql index fd6ea8317..1d68ea970 100644 --- a/platform/packages/api/scripts/seed.sql +++ b/platform/packages/api/scripts/seed.sql @@ -9,42 +9,42 @@ INSERT INTO users (id, provider, provider_id, login, email, plan, billing_cycle_ INSERT INTO users (id, provider, provider_id, login, email, plan, billing_cycle_start, suspended_reason, role, created_at) VALUES ('user_seed5', 'google', '100005', 'eve', 'eve@example.com', 'free', '2026-01-21T00:00:00.000Z', 'usage_limit_exceeded', 'user', '2026-01-21T00:00:00.000Z') ON CONFLICT (id) DO NOTHING; -- Projects -INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed1', 'user_seed1', 'my-app', 'void', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed2', 'user_seed1', 'docs-site', 'static', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed3', 'user_seed2', 'api-service', 'void', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed4', 'user_seed2', 'landing-page', 'spa', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed5', 'user_seed3', 'dashboard', 'framework', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed6', 'user_seed4', 'blog', 'static', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed00000001', 'user_seed1', 'my-app', 'void', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed00000002', 'user_seed1', 'docs-site', 'static', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed00000003', 'user_seed2', 'api-service', 'void', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed00000004', 'user_seed2', 'landing-page', 'spa', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed00000005', 'user_seed3', 'dashboard', 'framework', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO projects (id, user_id, slug, mode, created_at) VALUES ('proj_seed00000006', 'user_seed4', 'blog', 'static', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -- Deployments (live) -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed1', 'proj_seed1', 'live', '{"bindings":{"d1":["DB"],"kv":["CACHE"]},"migrations":[{"name":"001_create_users.sql","destructive":false}]}', NULL, now() - interval '30 days', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed2', 'proj_seed1', 'live', NULL, NULL, now() - interval '7 days', now() - interval '7 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed3', 'proj_seed2', 'live', NULL, NULL, now() - interval '60 days', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed4', 'proj_seed3', 'live', '{"bindings":{"d1":["DB"],"r2":["UPLOADS"]},"migrations":[{"name":"001_create_orders.sql","destructive":false},{"name":"002_add_status.sql","destructive":false}]}', NULL, now() - interval '30 days', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed5', 'proj_seed3', 'live', NULL, NULL, now() - interval '7 days', now() - interval '7 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed6', 'proj_seed4', 'live', NULL, NULL, now() - interval '60 days', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed7', 'proj_seed5', 'live', '{"bindings":{"d1":["DB"],"kv":["SESSIONS"],"r2":["ASSETS"]},"schedules":["0 */6 * * *"]}', NULL, now() - interval '30 days', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed8', 'proj_seed6', 'live', '{"bindings":{"kv":["CACHE"]}}', NULL, now() - interval '7 days', now() - interval '7 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed1', 'proj_seed00000001', 'live', '{"bindings":{"d1":["DB"],"kv":["CACHE"]},"migrations":[{"name":"001_create_users.sql","destructive":false}]}', NULL, now() - interval '30 days', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed2', 'proj_seed00000001', 'live', NULL, NULL, now() - interval '7 days', now() - interval '7 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed3', 'proj_seed00000002', 'live', NULL, NULL, now() - interval '60 days', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed4', 'proj_seed00000003', 'live', '{"bindings":{"d1":["DB"],"r2":["UPLOADS"]},"migrations":[{"name":"001_create_orders.sql","destructive":false},{"name":"002_add_status.sql","destructive":false}]}', NULL, now() - interval '30 days', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed5', 'proj_seed00000003', 'live', NULL, NULL, now() - interval '7 days', now() - interval '7 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed6', 'proj_seed00000004', 'live', NULL, NULL, now() - interval '60 days', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed7', 'proj_seed00000005', 'live', '{"bindings":{"d1":["DB"],"kv":["SESSIONS"],"r2":["ASSETS"]},"schedules":["0 */6 * * *"]}', NULL, now() - interval '30 days', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_seed8', 'proj_seed00000006', 'live', '{"bindings":{"kv":["CACHE"]}}', NULL, now() - interval '7 days', now() - interval '7 days') ON CONFLICT (id) DO NOTHING; -- Deployments (failed) -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_fail1', 'proj_seed1', 'failed', NULL, 'destructive migration rejected: 003_drop_users.sql', now() - interval '7 days', NULL) ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_fail2', 'proj_seed3', 'failed', NULL, 'migration failed: no such table: orders', now() - interval '30 days', NULL) ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_fail3', 'proj_seed5', 'failed', NULL, 'worker upload failed: 500 Internal Server Error', now() - interval '60 days', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_fail1', 'proj_seed00000001', 'failed', NULL, 'destructive migration rejected: 003_drop_users.sql', now() - interval '7 days', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_fail2', 'proj_seed00000003', 'failed', NULL, 'migration failed: no such table: orders', now() - interval '30 days', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_fail3', 'proj_seed00000005', 'failed', NULL, 'worker upload failed: 500 Internal Server Error', now() - interval '60 days', NULL) ON CONFLICT (id) DO NOTHING; -- Today's deployments (deploy count demo) -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_today1', 'proj_seed1', 'live', NULL, NULL, now(), now()) ON CONFLICT (id) DO NOTHING; -INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_today2', 'proj_seed1', 'live', NULL, NULL, now(), now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_today1', 'proj_seed00000001', 'live', NULL, NULL, now(), now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO deployments (id, project_id, status, manifest, error_message, created_at, deployed_at) VALUES ('dpl_today2', 'proj_seed00000001', 'live', NULL, NULL, now(), now()) ON CONFLICT (id) DO NOTHING; -- Custom domains -INSERT INTO domains (id, project_id, hostname, cf_hostname_id, status, created_at) VALUES ('dom_seed1', 'proj_seed1', 'myapp.example.com', 'cfhost_fake_001', 'active', now() - interval '14 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO domains (id, project_id, hostname, cf_hostname_id, status, created_at) VALUES ('dom_seed2', 'proj_seed3', 'api.bobs-service.com', 'cfhost_fake_002', 'active', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO domains (id, project_id, hostname, cf_hostname_id, status, created_at) VALUES ('dom_seed3', 'proj_seed5', 'app.carol.dev', 'cfhost_fake_003', 'pending', now() - interval '3 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO domains (id, project_id, hostname, cf_hostname_id, status, created_at) VALUES ('dom_seed1', 'proj_seed00000001', 'myapp.example.com', 'cfhost_fake_001', 'active', now() - interval '14 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO domains (id, project_id, hostname, cf_hostname_id, status, created_at) VALUES ('dom_seed2', 'proj_seed00000003', 'api.bobs-service.com', 'cfhost_fake_002', 'active', now() - interval '30 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO domains (id, project_id, hostname, cf_hostname_id, status, created_at) VALUES ('dom_seed3', 'proj_seed00000005', 'app.carol.dev', 'cfhost_fake_003', 'pending', now() - interval '3 days') ON CONFLICT (id) DO NOTHING; -- Resources -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed1', 'proj_seed1', 'd1', 'a1b2c3d4-0001-4000-8000-000000000001', 'DB', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed2', 'proj_seed1', 'kv', 'a1b2c3d4-0002-4000-8000-000000000002', 'CACHE', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed3', 'proj_seed3', 'd1', 'a1b2c3d4-0003-4000-8000-000000000003', 'DB', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed4', 'proj_seed3', 'r2', 'a1b2c3d4-0004-4000-8000-000000000004', 'UPLOADS', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed5', 'proj_seed5', 'd1', 'a1b2c3d4-0005-4000-8000-000000000005', 'DB', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed6', 'proj_seed5', 'kv', 'a1b2c3d4-0006-4000-8000-000000000006', 'SESSIONS', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; -INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed7', 'proj_seed5', 'r2', 'a1b2c3d4-0007-4000-8000-000000000007', 'ASSETS', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed1', 'proj_seed00000001', 'd1', 'a1b2c3d4-0001-4000-8000-000000000001', 'DB', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed2', 'proj_seed00000001', 'kv', 'a1b2c3d4-0002-4000-8000-000000000002', 'CACHE', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed3', 'proj_seed00000003', 'd1', 'a1b2c3d4-0003-4000-8000-000000000003', 'DB', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed4', 'proj_seed00000003', 'r2', 'a1b2c3d4-0004-4000-8000-000000000004', 'UPLOADS', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed5', 'proj_seed00000005', 'd1', 'a1b2c3d4-0005-4000-8000-000000000005', 'DB', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed6', 'proj_seed00000005', 'kv', 'a1b2c3d4-0006-4000-8000-000000000006', 'SESSIONS', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; +INSERT INTO resources (id, project_id, type, cf_id, binding_name, created_at) VALUES ('res_seed7', 'proj_seed00000005', 'r2', 'a1b2c3d4-0007-4000-8000-000000000007', 'ASSETS', now() - interval '60 days') ON CONFLICT (id) DO NOTHING; From ff04643e85c835b3b066e1a8d3a2163e8b07665f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 19:46:52 +0800 Subject: [PATCH 6/8] Require a per-server token to restart the dev server Browsers send no Fetch Metadata to plain-HTTP LAN origins, so with `void dev --host` any page could open /__void/restarting and its auto-POST carried the dev server's own Origin. The binding-hint toast now navigates with a random per-server token that the restarting page sends back, and POST /__void/restart refuses a missing or wrong token. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/AGENTS.md | 2 +- packages/void/src/plugin-restart.ts | 83 +++++++-- .../void/test/unit/plugin-restart.test.ts | 157 ++++++++++++++++-- 3 files changed, 216 insertions(+), 26 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index b5b14ae7a..bef5ae964 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -197,7 +197,7 @@ production functions and real generated entries rather than copied code. - **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent. - **Workers AI has no local emulator.** For a saved direct Cloudflare destination, `shouldBindWorkersAi()` injects the owner's remote AI binding during development and preview, using the same Cloudflare credentials as the CLI. Production builds use the normal AI binding. The root `ai.binding` takes precedence over `inference.bindings.ai`, with `AI` as the default name; `__VOID_AI_BINDING` records a custom runtime name. Managed and unsaved projects keep the Void proxy path during development. Apps without inferred AI usage must perform no AI authentication or entitlement probe. - **`mergeBindings` may remove a generated Cloudflare entry only under the name Void itself writes, gated on the matching `needs*` flag.** The Cloudflare plugin `defu`-merges the returned patch over `resolved` (arrays concatenate, `undefined` is skipped), so the only way to drop a key is to mutate `resolved` — and a mutation there is a change to the user's own config, which the CF-plugin-API rule above says takes precedence. The `send_email` serve-strip once deleted the whole field, so an app that drove Cloudflare's email API through its own `send_email: [{ name: "MAILER" }]` without ever importing `void/email` started `void dev` with `env.MAILER` undefined. It now filters out only the reserved `SEND_EMAIL` entry (`SEND_EMAIL_BINDING` from `cli/email-cloudflare.ts`, the name `void deploy --platform cloudflare` writes and the runtime reads), only when `bindings.needsEmail` — the same signal that arms the dev inbox — and drops the key only when nothing is left. `test/unit/plugin-email-config.test.ts` runs the real closure through a mocked `cloudflare()`. -- **Every `/__void/inbox` route requires the dev-trigger token; the worker's capture POST carries it via a var.** Vite's host validation (`server.allowedHosts`, runs before plugin middlewares) only rejects DNS-rebinding hostnames — any IP-literal `Host` passes — and its default CORS reflects every `localhost` origin, so an unauthenticated inbox middleware hands every peer under `--host` (and every other local dev server) the raw MIME with Bcc and attachments, a DELETE, and a capture endpoint to plant messages. `plugin-email-dev-inbox.ts` therefore checks the same per-project token the cron/queue dev routes and the pages dev-CSS loopback use (`getOrCreateDevTriggerToken`, `.void/dev-trigger-token`), accepted as the `x-void-dev-trigger` header (worker, curl) or `?token=` (browser — the plugin prints the tokenized URL on `listening` and renders its own links with it; responses already send `Referrer-Policy: no-referrer`). Missing or wrong → `401` with a one-line body naming both forms and no ack header. The compiled router embeds the token as a module-local const that `runtime/email.ts` cannot import, so `mergeBindings` injects `__VOID_EMAIL_DEV_INBOX_TOKEN` next to `__VOID_EMAIL_DEV_INBOX` under the one `shouldInjectEmailDevInboxVar` gate (dev only — never in a build's `wrangler.json`); `sendEmail` sends it and, on a non-2xx, echoes the first line of the dev server's body so a missing token is diagnosable. Nothing on the worker side ever skips the inbox for a real transport because the token is absent. Because that file is now the inbox's whole credential and loopback needs no `--host`, `getOrCreateDevTriggerToken` creates it `0600` (`openSync(.., 'wx', 0o600)`) and best-effort `chmod`s a pre-existing file whose group/other bits are set, never rotating it (skipped on Windows, which has no such bits) — a default-umask `0644` file would hand every other account on a shared host the raw MIME, the DELETE, and the capture endpoint. `.void/dev-triggers.ts` (`plugin-framework-triggers.ts`) embeds the same token for Class A frameworks and is written `0600` the same way. `/__void/ping` stays open: it exposes no data. `/__void/restart` refuses a cross-site POST (`Sec-Fetch-Site` other than `same-origin`, or a mismatched `Origin`) with `403`. `/__void/restarting` POSTs to it as soon as it loads, so it refuses a navigation whose `Sec-Fetch-Site` is not `same-origin` or `none` with `403`, and only navigates to a same-origin path from `?return=`, falling back to `/`. +- **Every `/__void/inbox` route requires the dev-trigger token; the worker's capture POST carries it via a var.** Vite's host validation (`server.allowedHosts`, runs before plugin middlewares) only rejects DNS-rebinding hostnames — any IP-literal `Host` passes — and its default CORS reflects every `localhost` origin, so an unauthenticated inbox middleware hands every peer under `--host` (and every other local dev server) the raw MIME with Bcc and attachments, a DELETE, and a capture endpoint to plant messages. `plugin-email-dev-inbox.ts` therefore checks the same per-project token the cron/queue dev routes and the pages dev-CSS loopback use (`getOrCreateDevTriggerToken`, `.void/dev-trigger-token`), accepted as the `x-void-dev-trigger` header (worker, curl) or `?token=` (browser — the plugin prints the tokenized URL on `listening` and renders its own links with it; responses already send `Referrer-Policy: no-referrer`). Missing or wrong → `401` with a one-line body naming both forms and no ack header. The compiled router embeds the token as a module-local const that `runtime/email.ts` cannot import, so `mergeBindings` injects `__VOID_EMAIL_DEV_INBOX_TOKEN` next to `__VOID_EMAIL_DEV_INBOX` under the one `shouldInjectEmailDevInboxVar` gate (dev only — never in a build's `wrangler.json`); `sendEmail` sends it and, on a non-2xx, echoes the first line of the dev server's body so a missing token is diagnosable. Nothing on the worker side ever skips the inbox for a real transport because the token is absent. Because that file is now the inbox's whole credential and loopback needs no `--host`, `getOrCreateDevTriggerToken` creates it `0600` (`openSync(.., 'wx', 0o600)`) and best-effort `chmod`s a pre-existing file whose group/other bits are set, never rotating it (skipped on Windows, which has no such bits) — a default-umask `0644` file would hand every other account on a shared host the raw MIME, the DELETE, and the capture endpoint. `.void/dev-triggers.ts` (`plugin-framework-triggers.ts`) embeds the same token for Class A frameworks and is written `0600` the same way. `/__void/ping` stays open: it exposes no data. `/__void/restart` answers `403` unless the POST is same-origin (`Sec-Fetch-Site`, else `Origin`) and carries this server instance's random token in `x-void-restart` — browsers send no Fetch Metadata to plain-HTTP LAN origins (`--host`), so the token, embedded only in the `virtual:void/binding-hint` toast (which navigates with `&t=`; Vite's default CORS exposes it to localhost origins only, like the HMR token), is what stops a foreign page from opening `/__void/restarting` and riding its auto-POST. `/__void/restarting` refuses a navigation whose `Sec-Fetch-Site` is not `same-origin` or `none`, sends `Referrer-Policy: no-referrer`, and only navigates to a same-origin path from `?return=`, falling back to `/`. - **Sandbox SDK 1.0**: Keep the packaged Dockerfile helper version and the managed controller’s verified helper-layer digest in sync with `@cloudflare/sandbox`. Native execution uses Durable Object scheduling with named images; managed controller protocol 2 preserves admission, metering, and cleanup, including retained protocol 1 deployments. Cancellation crosses Workers RPC as a stream, not an AbortSignal. Run the Sandbox RPC and lifecycle tests when changing this boundary. diff --git a/packages/void/src/plugin-restart.ts b/packages/void/src/plugin-restart.ts index b1d9bd95f..f34edf0c8 100644 --- a/packages/void/src/plugin-restart.ts +++ b/packages/void/src/plugin-restart.ts @@ -1,3 +1,4 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; import type { IncomingMessage } from 'node:http'; import type { TLSSocket } from 'node:tls'; import type { Plugin } from 'vite'; @@ -26,8 +27,11 @@ const RESTARTING_HTML_HEAD = ` '); + const html = renderRestartingPage('/x?a="', null); expect(html).not.toContain(''); }); + it('embeds the token so it cannot close the script or break the string', () => { + const html = renderRestartingPage('/', '"'); + expect(html).not.toContain(''); + expect(html).toContain('"x-void-restart":token'); + }); + it('no longer reads the return value from location in the client', () => { - expect(renderRestartingPage('/')).not.toContain('location.search'); + expect(renderRestartingPage('/', null)).not.toContain('location.search'); + }); +}); + +describe('isRestartTokenValid', () => { + it('accepts only the exact token', () => { + expect(isRestartTokenValid('abc', 'abc')).toBe(true); + expect(isRestartTokenValid('abd', 'abc')).toBe(false); + expect(isRestartTokenValid('ab', 'abc')).toBe(false); + expect(isRestartTokenValid(undefined, 'abc')).toBe(false); + expect(isRestartTokenValid(['abc'], 'abc')).toBe(false); + }); + + it('never matches before a token exists', () => { + expect(isRestartTokenValid('', '')).toBe(false); }); }); @@ -140,6 +187,87 @@ describe('restartPlugin middlewares', () => { expect(targetOf(res.body)).toBe('/foo?x=1'); }); + it('GET /__void/restarting keeps the token out of the return path and the referrer', () => { + const { dispatch, token } = createFakeViteServer(); + const res = dispatch('GET', `/__void/restarting?return=%2Ffoo&t=${token}`); + expect(targetOf(res.body)).toBe('/foo'); + expect(pageTokenOf(res.body)).toBe(token); + expect(res.headers['Referrer-Policy']).toBe('no-referrer'); + }); + + it('the toast navigates to /__void/restarting with the server token', () => { + const { plugin, token } = createFakeViteServer(); + expect(token).toMatch(/^[\w-]{22}$/); + expect(loadClientModule(plugin)).toContain( + '"/__void/restarting?return=" + encodeURIComponent(location.pathname + location.search) + "&t=" + encodeURIComponent(token)', + ); + }); + + // Plain-HTTP LAN origins (`void dev --host`) get no Fetch Metadata, so a + // foreign page can navigate here and the page's POST carries our Origin. + it('does not restart from a foreign navigation without Fetch Metadata', () => { + vi.useFakeTimers(); + const { dispatch, restart } = createFakeViteServer(); + const page = dispatch('GET', '/__void/restarting?return=%2F'); + expect(page.status).toBe(200); + const res = dispatch('POST', '/__void/restart', { + origin: 'http://localhost:5173', + 'x-void-restart': pageTokenOf(page.body), + }); + expect(res.status).toBe(403); + vi.runAllTimers(); + expect(restart).not.toHaveBeenCalled(); + expect(dispatch('GET', '/__void/ping').status).toBe(200); + }); + + it('restarts from the toast navigation without Fetch Metadata', () => { + vi.useFakeTimers(); + const { dispatch, restart, token } = createFakeViteServer(); + const page = dispatch('GET', `/__void/restarting?return=%2F&t=${encodeURIComponent(token)}`); + const res = dispatch('POST', '/__void/restart', { + origin: 'http://localhost:5173', + 'x-void-restart': pageTokenOf(page.body), + }); + expect(res.status).toBe(200); + vi.runAllTimers(); + expect(restart).toHaveBeenCalledOnce(); + }); + + it.each([ + ['no token', {}], + ['a wrong token', { 'x-void-restart': 'wrong' }], + ])('rejects a same-origin POST /__void/restart with %s', (_label, headers) => { + vi.useFakeTimers(); + const { dispatch, restart } = createFakeViteServer(); + const res = dispatch('POST', '/__void/restart', { + 'sec-fetch-site': 'same-origin', + origin: 'http://localhost:5173', + ...headers, + }); + expect(res.status).toBe(403); + vi.runAllTimers(); + expect(restart).not.toHaveBeenCalled(); + }); + + it('gives each server instance a new token', () => { + vi.useFakeTimers(); + const first = createFakeViteServer(); + // `server.restart()` runs configureServer again for the new instance. + const second = createFakeViteServer(first.plugin); + expect(second.token).not.toBe(first.token); + expect(tokenOf(loadClientModule(first.plugin))).toBe(second.token); + expect( + second.dispatch('POST', '/__void/restart', { 'x-void-restart': first.token }).status, + ).toBe(403); + expect( + second.dispatch('POST', '/__void/restart', { 'x-void-restart': second.token }).status, + ).toBe(200); + // A restart that failed leaves the old instance checking its own token. + expect( + first.dispatch('POST', '/__void/restart', { 'x-void-restart': first.token }).status, + ).toBe(200); + }); + it.each(['cross-site', 'same-site'])( 'refuses GET /__void/restarting with Sec-Fetch-Site: %s', (site) => { @@ -183,8 +311,8 @@ describe('restartPlugin middlewares', () => { ['a null Origin', { origin: 'null' }], ])('rejects a POST /__void/restart with %s', (_label, headers) => { vi.useFakeTimers(); - const { dispatch, restart } = createFakeViteServer(); - const res = dispatch('POST', '/__void/restart', headers); + const { dispatch, restart, token } = createFakeViteServer(); + const res = dispatch('POST', '/__void/restart', { ...headers, 'x-void-restart': token }); expect(res.status).toBe(403); vi.runAllTimers(); expect(restart).not.toHaveBeenCalled(); @@ -198,10 +326,10 @@ describe('restartPlugin middlewares', () => { ], ['a matching Origin', { origin: 'http://localhost:5173' }], ['no browser origin headers', {}], - ])('accepts a POST /__void/restart with %s', (_label, headers) => { + ])('accepts a POST /__void/restart with the token and %s', (_label, headers) => { vi.useFakeTimers(); - const { dispatch, restart } = createFakeViteServer(); - const res = dispatch('POST', '/__void/restart', headers); + const { dispatch, restart, token } = createFakeViteServer(); + const res = dispatch('POST', '/__void/restart', { ...headers, 'x-void-restart': token }); expect(res.status).toBe(200); expect(dispatch('GET', '/__void/ping').status).toBe(503); vi.runAllTimers(); @@ -210,9 +338,14 @@ describe('restartPlugin middlewares', () => { it('matches the Origin scheme against an HTTPS dev server', () => { vi.useFakeTimers(); - const { dispatch } = createFakeViteServer(); + const { dispatch, token } = createFakeViteServer(); expect( - dispatch('POST', '/__void/restart', { origin: 'https://localhost:5173' }, true).status, + dispatch( + 'POST', + '/__void/restart', + { origin: 'https://localhost:5173', 'x-void-restart': token }, + true, + ).status, ).toBe(200); }); From fd5a6bfc2afa32432adf181965522bb0fb8c5953 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 19:47:57 +0800 Subject: [PATCH 7/8] Remove old demo projects before reseeding local data Local databases seeded before the generated-shape ids still hold proj_seed1..6 with the same slugs, so setup --force failed on the unique slug and kept the old rows. The seed now deletes those projects and their deployments, builds, domains and resources first. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/scripts/seed.sql | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/platform/packages/api/scripts/seed.sql b/platform/packages/api/scripts/seed.sql index 1d68ea970..d1fb99eea 100644 --- a/platform/packages/api/scripts/seed.sql +++ b/platform/packages/api/scripts/seed.sql @@ -1,6 +1,15 @@ -- Shared seed data for local dev -- Local: psql -d void_api_dev -f scripts/seed.sql +-- Remove the demo projects older seeds created as proj_seed1..6: they hold +-- the same slugs, so the projects below would not insert. Deployments, +-- builds, domains and resources do not cascade from projects. +DELETE FROM builds WHERE project_id IN ('proj_seed1', 'proj_seed2', 'proj_seed3', 'proj_seed4', 'proj_seed5', 'proj_seed6'); +DELETE FROM deployments WHERE project_id IN ('proj_seed1', 'proj_seed2', 'proj_seed3', 'proj_seed4', 'proj_seed5', 'proj_seed6'); +DELETE FROM domains WHERE project_id IN ('proj_seed1', 'proj_seed2', 'proj_seed3', 'proj_seed4', 'proj_seed5', 'proj_seed6'); +DELETE FROM resources WHERE project_id IN ('proj_seed1', 'proj_seed2', 'proj_seed3', 'proj_seed4', 'proj_seed5', 'proj_seed6'); +DELETE FROM projects WHERE id IN ('proj_seed1', 'proj_seed2', 'proj_seed3', 'proj_seed4', 'proj_seed5', 'proj_seed6'); + -- Users INSERT INTO users (id, provider, provider_id, login, email, plan, billing_cycle_start, suspended_reason, role, created_at) VALUES ('user_seed1', 'github', '100001', 'alice', 'alice@example.com', 'free', '2026-01-21T00:00:00.000Z', NULL, 'user', '2025-11-22T00:00:00.000Z') ON CONFLICT (id) DO NOTHING; INSERT INTO users (id, provider, provider_id, login, email, plan, billing_cycle_start, suspended_reason, role, created_at) VALUES ('user_seed2', 'github', '100002', 'bob', 'bob@example.com', 'solo', '2026-02-13T00:00:00.000Z', NULL, 'user', '2025-12-22T00:00:00.000Z') ON CONFLICT (id) DO NOTHING; From db153ea236a610cff63688dc27c48ee59bb050e3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 11:06:31 +0800 Subject: [PATCH 8/8] Require a Void team review for script folders at any depth Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/CODEOWNERS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d51896504..b55ed239a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,8 +2,9 @@ # the Void team. Later rules take precedence over earlier ones. /.github/ @voidzero-dev/void -/scripts/ @voidzero-dev/void -/platform/scripts/ @voidzero-dev/void +# Script folders at any depth: the publish and deploy jobs run package build +# scripts such as packages/void/scripts/ and packages/platform/scripts/. +scripts/ @voidzero-dev/void /pnpm-workspace.yaml @voidzero-dev/void /pnpm-lock.yaml @voidzero-dev/void package.json @voidzero-dev/void