diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..b55ed239a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# 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 +# 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 diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 00a32702d..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/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` 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 11a8f2bbe..f34edf0c8 100644 --- a/packages/void/src/plugin-restart.ts +++ b/packages/void/src/plugin-restart.ts @@ -1,9 +1,13 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +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 +25,16 @@ 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="', 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('/', 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); + }); +}); + +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('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) => { + 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, token } = createFakeViteServer(); + const res = dispatch('POST', '/__void/restart', { ...headers, 'x-void-restart': token }); + 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 the token and %s', (_label, headers) => { + vi.useFakeTimers(); + 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(); + expect(restart).toHaveBeenCalledOnce(); + }); + + it('matches the Origin scheme against an HTTPS dev server', () => { + vi.useFakeTimers(); + const { dispatch, token } = createFakeViteServer(); + expect( + dispatch( + 'POST', + '/__void/restart', + { origin: 'https://localhost:5173', 'x-void-restart': token }, + 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); + }); +}); 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/scripts/seed.sql b/platform/packages/api/scripts/seed.sql index fd6ea8317..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; @@ -9,42 +18,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; 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/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-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/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/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"', + ); + }); +}); 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(); });