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();
});