From 2a3bb8527d095471c45915b1a0291a60d51c2298 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:15:42 -0700 Subject: [PATCH 1/7] feat(frontend): npm run dev:live serves the branch against a real backend (TASK-686) Vite dev server proxies /api and /socket.io to staging by default, or to prod with DEV_LIVE_TARGET=prod. Prod is read-only for the browser: HTTP writes are refused at the proxy and the socket is not proxied. The proxy drops the browser Origin header, since prod refuses http://localhost:3000. Prod CORS is unchanged. Plain npm start has no proxy. Verified: proxied /api/health returns the backend JSON with no Origin, and an Origin-bearing request reaches the backend without it (staging 403s a localhost Origin when called direct). The branch renders at localhost. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- frontend/TESTING.md | 21 ++++++++++++++++++ frontend/package.json | 1 + frontend/vite.config.ts | 47 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/frontend/TESTING.md b/frontend/TESTING.md index 22f07844a8..e00300843d 100644 --- a/frontend/TESTING.md +++ b/frontend/TESTING.md @@ -20,6 +20,27 @@ npm test -- --testPathPattern=WhatsHappening.test.js npm test -- --watchAll=false ``` +## Live UI check against a real backend (`npm run dev:live`) + +For a quick UI check of this branch against real data, run the dev server with a +proxy to a backend instead of building against localhost:5000. + +```bash +cd frontend && npm run dev:live # localhost:3000, /api and /socket.io proxied to staging +``` + +- **Target.** Defaults to `https://api-staging.commonly.me`, where writes are safe. + The proxy drops the browser `Origin` header, because prod refuses + `http://localhost:3000`. Do not change prod CORS for this. +- **Prod, read-only.** `DEV_LIVE_TARGET=prod npm run dev:live` points at + `https://api.commonly.me`. HTTP writes are refused at the proxy (the response is + a Vite 404), and `/socket.io` is not proxied, so the page renders from GET + requests only. Use a test account only. Sign-in is a POST, so for prod, inject + a token into `localStorage` instead (see the MCP Playwright auth injection in + `CLAUDE.md`). +- **Config.** `DEV_LIVE=1` turns the proxy on in `vite.config.ts`. Plain + `npm start` is unchanged and has no proxy. + ## Test Setup and Configuration ### Jest Configuration (`package.json`) diff --git a/frontend/package.json b/frontend/package.json index 0ff85f1548..90b65817bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,7 @@ }, "scripts": { "start": "vite", + "dev:live": "REACT_APP_API_URL=http://localhost:3000 DEV_LIVE=1 vite", "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview", "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/signup-source.test.mjs scripts/ui-card-catalogue.test.mjs scripts/og-card.test.mjs", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 87debaadc7..2faf76a374 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,57 @@ -import { defineConfig } from 'vite'; +import { defineConfig, type ProxyOptions } from 'vite'; import react from '@vitejs/plugin-react'; +// `npm run dev:live` (TASK-686): serve the branch on localhost:3000 and proxy +// /api and /socket.io to a real backend. The browser's Origin header is +// dropped on the way out, because prod refuses http://localhost:3000 and the +// backend accepts no-Origin requests. Target defaults to staging, where writes +// are safe; DEV_LIVE_TARGET=prod points at prod, read-only, see TESTING.md. +const DEV_LIVE = process.env.DEV_LIVE === '1'; +const LIVE_TARGETS: Record = { + staging: 'https://api-staging.commonly.me', + prod: 'https://api.commonly.me', +}; +const liveName = process.env.DEV_LIVE_TARGET || 'staging'; +const liveTarget = LIVE_TARGETS[liveName]; +if (DEV_LIVE && !liveTarget) { + throw new Error(`DEV_LIVE_TARGET must be one of: ${Object.keys(LIVE_TARGETS).join(', ')}`); +} +const dropOrigin: NonNullable = (proxy) => { + proxy.on('proxyReq', (req) => req.removeHeader('origin')); + proxy.on('proxyReqWs', (req) => req.removeHeader('origin')); +}; +// Prod is read-only for the browser: HTTP writes are refused at the proxy and +// the socket is not proxied, so the page renders from REST GETs only. +const readOnlyProd = liveName === 'prod'; + export default defineConfig({ plugins: [react()], server: { port: 3000, host: true, open: false, + proxy: DEV_LIVE + ? { + '/api': { + target: liveTarget, + changeOrigin: true, + secure: true, + configure: dropOrigin, + bypass: (req) => (readOnlyProd && req.method !== 'GET' && req.method !== 'HEAD' ? '/__dev-live-read-only' : undefined), + }, + ...(readOnlyProd + ? {} + : { + '/socket.io': { + target: liveTarget, + changeOrigin: true, + secure: true, + ws: true, + configure: dropOrigin, + }, + }), + } + : undefined, }, build: { outDir: 'build', // match CRA output dir for deployment compatibility From 2ee498b4e227452666324241d6b63dbf51cd0cba Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:24:43 -0700 Subject: [PATCH 2/7] fix(frontend): dev:live fails fast when port 3000 is taken (TASK-686) Without --strictPort, Vite moves to another port while the page's API client still sends bearer tokens to localhost:3000. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 90b65817bb..5b869f70b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,7 +41,7 @@ }, "scripts": { "start": "vite", - "dev:live": "REACT_APP_API_URL=http://localhost:3000 DEV_LIVE=1 vite", + "dev:live": "REACT_APP_API_URL=http://localhost:3000 DEV_LIVE=1 vite --strictPort", "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview", "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/signup-source.test.mjs scripts/ui-card-catalogue.test.mjs scripts/og-card.test.mjs", From fad5d48b0b2d38a7fb7ae263385d1cf9d531f46a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:39:22 -0700 Subject: [PATCH 3/7] test(frontend): dev:live refuses prod writes at the proxy (TASK-686) Runs the real vite.config.ts in prod read-only mode and asserts POST/PUT/ PATCH/DELETE to /api are refused locally, /socket.io never reaches prod, and an unknown DEV_LIVE_TARGET fails the config load. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- frontend/package.json | 2 +- frontend/scripts/dev-live-proxy.test.mjs | 67 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 frontend/scripts/dev-live-proxy.test.mjs diff --git a/frontend/package.json b/frontend/package.json index 5b869f70b5..d03ed50594 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,7 +44,7 @@ "dev:live": "REACT_APP_API_URL=http://localhost:3000 DEV_LIVE=1 vite --strictPort", "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview", - "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/signup-source.test.mjs scripts/ui-card-catalogue.test.mjs scripts/og-card.test.mjs", + "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/signup-source.test.mjs scripts/ui-card-catalogue.test.mjs scripts/og-card.test.mjs scripts/dev-live-proxy.test.mjs", "test:coverage": "jest --coverage --watchAll=false --forceExit", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .js,.jsx,.ts,.tsx", diff --git a/frontend/scripts/dev-live-proxy.test.mjs b/frontend/scripts/dev-live-proxy.test.mjs new file mode 100644 index 0000000000..41e48a4963 --- /dev/null +++ b/frontend/scripts/dev-live-proxy.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +// `npm run dev:live` (TASK-686) proxies /api to a real backend. In prod +// read-only mode the proxy must refuse every HTTP write before it leaves the +// machine, and must not proxy the socket at all. These run the real +// vite.config.ts against prod's hostname, so a refusal that failed open would +// send a request to api.commonly.me; every assertion below is answered locally. + +const frontendDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const configFile = resolve(frontendDir, 'vite.config.ts'); + +async function withDevLive(env, port, fn) { + const saved = { DEV_LIVE: process.env.DEV_LIVE, DEV_LIVE_TARGET: process.env.DEV_LIVE_TARGET }; + Object.assign(process.env, env); + let server; + try { + server = await createServer({ + configFile, + logLevel: 'silent', + server: { port, strictPort: true, host: '127.0.0.1' }, + }); + await server.listen(); + return await fn(`http://127.0.0.1:${port}`); + } finally { + await server?.close(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +test('prod read-only refuses every HTTP write at the proxy', async () => { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod' }, 3199, async (base) => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + const res = await fetch(`${base}/api/auth/login`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'DELETE' ? undefined : '{}', + }); + assert.equal(res.status, 404, `${method} /api/* must be refused locally`); + assert.notEqual(res.headers.get('content-type')?.includes('application/json'), true, `${method} must not carry a backend JSON body`); + } + }); +}); + +test('prod read-only does not proxy the socket path', async () => { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod' }, 3199, async (base) => { + // Vite answers with its SPA fallback (HTML), not an engine.io handshake, + // so the socket never reaches prod. A proxied handshake would carry "sid". + const res = await fetch(`${base}/socket.io/?EIO=4&transport=polling`); + const body = await res.text(); + assert.doesNotMatch(body, /"sid"/, '/socket.io must not reach prod in read-only mode'); + assert.match(res.headers.get('content-type') ?? '', /text\/html/); + }); +}); + +test('an unknown DEV_LIVE_TARGET fails the config load instead of falling through', async () => { + await assert.rejects( + withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'nowhere' }, 3199, async () => {}), + /DEV_LIVE_TARGET must be one of: staging, prod/, + ); +}); From d3dccd37581d583c0177a5d088ea7002a60fc924 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:22:36 -0700 Subject: [PATCH 4/7] docs(frontend): name the seeded staging pod for dev:live walks (TASK-686) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- frontend/TESTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/TESTING.md b/frontend/TESTING.md index e00300843d..e9e7026ece 100644 --- a/frontend/TESTING.md +++ b/frontend/TESTING.md @@ -38,6 +38,12 @@ cd frontend && npm run dev:live # localhost:3000, /api and /socket.io proxi requests only. Use a test account only. Sign-in is a POST, so for prod, inject a token into `localStorage` instead (see the MCP Playwright auth injection in `CLAUDE.md`). +- **Seeded staging pod.** Screenshots for design matching use the "D215 sample" + pod on staging (id `1c2f4b4e67bfeba38ebeabfa`, invite-only: 3 humans, 4 agents, + 13 board rows, 13 messages, one card). Seed it with the TASK-697 staging seed + script (#3081). The seat test login lives in the host operator token store, + outside this repo. Never commit it, and never paste it into a PR, pod message + or task row. - **Config.** `DEV_LIVE=1` turns the proxy on in `vite.config.ts`. Plain `npm start` is unchanged and has no proxy. From e3c93cd7bb1b42e1aa8d393659ab8529b2cfd3d2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:52:53 -0700 Subject: [PATCH 5/7] docs: dev:live step in ui-validation.md (TASK-686) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- docs/development/ui-validation.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/development/ui-validation.md b/docs/development/ui-validation.md index 953089f5af..14d5278f29 100644 --- a/docs/development/ui-validation.md +++ b/docs/development/ui-validation.md @@ -93,6 +93,24 @@ the seat's address `d215-seat@seed.staging.commonly.me` must be allowed, or the ## Then: check the screens +### Run the branch against staging (`npm run dev:live`) + +```bash +cd frontend && npm run dev:live # localhost:3000, /api and /socket.io proxied to staging +``` + +- The default target is staging, where writes are safe. The proxy drops the browser + `Origin` header, because prod refuses `http://localhost:3000`. +- Sign in with the seat account, not your own: POST `/api/auth/login` with the + `email` and `password` from the credentials file, then put the returned token in + `localStorage` under `token` and reload. Login goes through the proxy as a POST, + which is allowed on staging. +- Keep the port at 3000. The npm script sets `REACT_APP_API_URL=http://localhost:3000`, + so a different `--port` also needs that variable changed, or the app calls the wrong + origin and every pod list shows "Network Error". +- Prod is read-only through the same command with `DEV_LIVE_TARGET=prod`. Use it only + with a test account, and sign in by injecting the token, never by POST. + - Layout, overflow and scroll need a real browser (CLAUDE.md design rule 9). Sign in as `d215-seat`, open the D215 sample pod, and check at 1440 and 390 wide, in light and in dark. From 3669368fdd2074e43c03de0e59f36b0899ddefff Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:55:55 -0700 Subject: [PATCH 6/7] docs(dev-live): clear the shared token before switching targets (TASK-686) Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- docs/development/ui-validation.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/development/ui-validation.md b/docs/development/ui-validation.md index 14d5278f29..92d4118548 100644 --- a/docs/development/ui-validation.md +++ b/docs/development/ui-validation.md @@ -110,6 +110,9 @@ cd frontend && npm run dev:live # localhost:3000, /api and /socket.io proxied origin and every pod list shows "Network Error". - Prod is read-only through the same command with `DEV_LIVE_TARGET=prod`. Use it only with a test account, and sign in by injecting the token, never by POST. +- Both targets share one origin, so one `localStorage` `token` serves staging and prod. + Before switching `DEV_LIVE_TARGET`, run `localStorage.removeItem('token')` in the page + and sign in again. A prod token left behind would make a staging run hit prod. - Layout, overflow and scroll need a real browser (CLAUDE.md design rule 9). Sign in as `d215-seat`, open the D215 sample pod, and check at 1440 and 390 wide, From 7c9d4c8175b7753e4068070511d2f25b84fc6261 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:05:20 -0700 Subject: [PATCH 7/7] fix(dev-live): one port per target, stub-upstream proxy tests (TASK-686) Staging and prod now serve on separate origins (3102 / 3101), so a prod token saved on one cannot reach a staging run. The config sets the API base to the same origin. Proxy tests run the real config against a stub upstream (DEV_LIVE_UPSTREAM) and assert refused prod writes never reach it, GET reads and staging writes are proxied with Origin dropped, the socket path is not proxied in prod, and each target binds its own port. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- docs/development/ui-validation.md | 15 ++- frontend/TESTING.md | 10 +- frontend/package.json | 2 +- frontend/scripts/dev-live-proxy.test.mjs | 139 +++++++++++++++++------ frontend/vite.config.ts | 28 +++-- 5 files changed, 136 insertions(+), 58 deletions(-) diff --git a/docs/development/ui-validation.md b/docs/development/ui-validation.md index 92d4118548..c7f1595b34 100644 --- a/docs/development/ui-validation.md +++ b/docs/development/ui-validation.md @@ -96,23 +96,22 @@ the seat's address `d215-seat@seed.staging.commonly.me` must be allowed, or the ### Run the branch against staging (`npm run dev:live`) ```bash -cd frontend && npm run dev:live # localhost:3000, /api and /socket.io proxied to staging +cd frontend && npm run dev:live # localhost:3102, /api and /socket.io proxied to staging ``` - The default target is staging, where writes are safe. The proxy drops the browser - `Origin` header, because prod refuses `http://localhost:3000`. + `Origin` header, because prod refuses `http://localhost:3102`. - Sign in with the seat account, not your own: POST `/api/auth/login` with the `email` and `password` from the credentials file, then put the returned token in `localStorage` under `token` and reload. Login goes through the proxy as a POST, which is allowed on staging. -- Keep the port at 3000. The npm script sets `REACT_APP_API_URL=http://localhost:3000`, - so a different `--port` also needs that variable changed, or the app calls the wrong - origin and every pod list shows "Network Error". +- Each target has its own port: staging 3102, prod 3101. The config sets the API base to + the same origin, so the app and the proxy always agree. A `--port` override is not + needed and would leave the app calling the wrong origin. - Prod is read-only through the same command with `DEV_LIVE_TARGET=prod`. Use it only with a test account, and sign in by injecting the token, never by POST. -- Both targets share one origin, so one `localStorage` `token` serves staging and prod. - Before switching `DEV_LIVE_TARGET`, run `localStorage.removeItem('token')` in the page - and sign in again. A prod token left behind would make a staging run hit prod. +- Staging and prod run on different origins, so a token saved on one never reaches the + other. Still, do not reuse a prod token on staging: sign in again per target. - Layout, overflow and scroll need a real browser (CLAUDE.md design rule 9). Sign in as `d215-seat`, open the D215 sample pod, and check at 1440 and 390 wide, diff --git a/frontend/TESTING.md b/frontend/TESTING.md index e9e7026ece..7551aef680 100644 --- a/frontend/TESTING.md +++ b/frontend/TESTING.md @@ -26,14 +26,16 @@ For a quick UI check of this branch against real data, run the dev server with a proxy to a backend instead of building against localhost:5000. ```bash -cd frontend && npm run dev:live # localhost:3000, /api and /socket.io proxied to staging +cd frontend && npm run dev:live # localhost:3102 (staging), /api and /socket.io proxied ``` - **Target.** Defaults to `https://api-staging.commonly.me`, where writes are safe. The proxy drops the browser `Origin` header, because prod refuses - `http://localhost:3000`. Do not change prod CORS for this. -- **Prod, read-only.** `DEV_LIVE_TARGET=prod npm run dev:live` points at - `https://api.commonly.me`. HTTP writes are refused at the proxy (the response is + `http://localhost:3102`. Do not change prod CORS for this. +- **Ports.** Each target has its own port: staging 3102, prod 3101. Two origins never + share `localStorage`, so a prod token cannot reach a staging run. +- **Prod, read-only.** `DEV_LIVE_TARGET=prod npm run dev:live` serves on localhost:3101 + and points at `https://api.commonly.me`. HTTP writes are refused at the proxy (the response is a Vite 404), and `/socket.io` is not proxied, so the page renders from GET requests only. Use a test account only. Sign-in is a POST, so for prod, inject a token into `localStorage` instead (see the MCP Playwright auth injection in diff --git a/frontend/package.json b/frontend/package.json index d03ed50594..c5383fd867 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,7 +41,7 @@ }, "scripts": { "start": "vite", - "dev:live": "REACT_APP_API_URL=http://localhost:3000 DEV_LIVE=1 vite --strictPort", + "dev:live": "DEV_LIVE=1 vite --strictPort", "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview", "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/signup-source.test.mjs scripts/ui-card-catalogue.test.mjs scripts/og-card.test.mjs scripts/dev-live-proxy.test.mjs", diff --git a/frontend/scripts/dev-live-proxy.test.mjs b/frontend/scripts/dev-live-proxy.test.mjs index 41e48a4963..9fd7cd7350 100644 --- a/frontend/scripts/dev-live-proxy.test.mjs +++ b/frontend/scripts/dev-live-proxy.test.mjs @@ -1,67 +1,134 @@ import assert from 'node:assert/strict'; +import { createServer as createHttpServer } from 'node:http'; import test from 'node:test'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createServer } from 'vite'; -// `npm run dev:live` (TASK-686) proxies /api to a real backend. In prod -// read-only mode the proxy must refuse every HTTP write before it leaves the -// machine, and must not proxy the socket at all. These run the real -// vite.config.ts against prod's hostname, so a refusal that failed open would -// send a request to api.commonly.me; every assertion below is answered locally. +// `npm run dev:live` (TASK-686) proxies /api to a real backend. These run the +// real vite.config.ts against a local stub backend (DEV_LIVE_UPSTREAM), so the +// assertions see exactly what would have reached the upstream. A refusal that +// failed open would show up as a recorded request on the stub. const frontendDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const configFile = resolve(frontendDir, 'vite.config.ts'); -async function withDevLive(env, port, fn) { - const saved = { DEV_LIVE: process.env.DEV_LIVE, DEV_LIVE_TARGET: process.env.DEV_LIVE_TARGET }; +async function startStub() { + const seen = []; + const stub = createHttpServer((req, res) => { + seen.push({ method: req.method, url: req.url, origin: req.headers.origin }); + res.setHeader('content-type', 'application/json'); + res.end('{"stub":true}'); + }); + await new Promise((ok) => stub.listen(0, '127.0.0.1', ok)); + return { seen, url: `http://127.0.0.1:${stub.address().port}`, close: () => new Promise((ok) => stub.close(ok)) }; +} + +async function withDevLive(env, fn) { + const keys = ['DEV_LIVE', 'DEV_LIVE_TARGET', 'DEV_LIVE_UPSTREAM']; + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])); Object.assign(process.env, env); let server; try { - server = await createServer({ - configFile, - logLevel: 'silent', - server: { port, strictPort: true, host: '127.0.0.1' }, - }); + server = await createServer({ configFile, logLevel: 'silent', server: { host: '127.0.0.1' } }); await server.listen(); - return await fn(`http://127.0.0.1:${port}`); + const port = server.httpServer.address().port; + return await fn(`http://127.0.0.1:${port}`, port); } finally { await server?.close(); - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; + for (const k of keys) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; } } } -test('prod read-only refuses every HTTP write at the proxy', async () => { - await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod' }, 3199, async (base) => { - for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { +test('prod read-only refuses every HTTP write locally, without an upstream call', async () => { + const stub = await startStub(); + try { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod', DEV_LIVE_UPSTREAM: stub.url }, async (base) => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + const res = await fetch(`${base}/api/auth/login`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'DELETE' ? undefined : '{}', + }); + assert.equal(res.status, 404, `${method} /api/* must be refused locally`); + assert.notEqual(res.headers.get('content-type')?.includes('application/json'), true, `${method} must not carry a backend JSON body`); + } + }); + assert.deepEqual(stub.seen, [], 'a refused write must never reach the upstream'); + } finally { + await stub.close(); + } +}); + +test('prod read-only still proxies GET reads', async () => { + const stub = await startStub(); + try { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod', DEV_LIVE_UPSTREAM: stub.url }, async (base) => { + const res = await fetch(`${base}/api/health`); + assert.equal(res.status, 200); + assert.equal(await res.text(), '{"stub":true}'); + }); + assert.deepEqual(stub.seen.map((r) => r.method), ['GET']); + } finally { + await stub.close(); + } +}); + +test('prod read-only does not proxy the socket path', async () => { + const stub = await startStub(); + try { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod', DEV_LIVE_UPSTREAM: stub.url }, async (base) => { + // Vite answers with its SPA fallback (HTML), not an engine.io handshake. + const res = await fetch(`${base}/socket.io/?EIO=4&transport=polling`); + const body = await res.text(); + assert.doesNotMatch(body, /"sid"/, '/socket.io must not reach prod in read-only mode'); + assert.match(res.headers.get('content-type') ?? '', /text\/html/); + }); + assert.deepEqual(stub.seen, [], 'the socket path must not reach the upstream'); + } finally { + await stub.close(); + } +}); + +test('staging proxies writes to the upstream with the browser Origin dropped', async () => { + const stub = await startStub(); + try { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'staging', DEV_LIVE_UPSTREAM: stub.url }, async (base) => { const res = await fetch(`${base}/api/auth/login`, { - method, - headers: { 'Content-Type': 'application/json' }, - body: method === 'DELETE' ? undefined : '{}', + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'http://localhost:3102' }, + body: '{}', }); - assert.equal(res.status, 404, `${method} /api/* must be refused locally`); - assert.notEqual(res.headers.get('content-type')?.includes('application/json'), true, `${method} must not carry a backend JSON body`); - } - }); + assert.equal(res.status, 200); + }); + assert.equal(stub.seen.length, 1); + assert.equal(stub.seen[0].method, 'POST'); + assert.equal(stub.seen[0].origin, undefined, 'the proxy must drop the browser Origin'); + } finally { + await stub.close(); + } }); -test('prod read-only does not proxy the socket path', async () => { - await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod' }, 3199, async (base) => { - // Vite answers with its SPA fallback (HTML), not an engine.io handshake, - // so the socket never reaches prod. A proxied handshake would carry "sid". - const res = await fetch(`${base}/socket.io/?EIO=4&transport=polling`); - const body = await res.text(); - assert.doesNotMatch(body, /"sid"/, '/socket.io must not reach prod in read-only mode'); - assert.match(res.headers.get('content-type') ?? '', /text\/html/); - }); +test('each target listens on its own port, so localStorage cannot cross targets', async () => { + const stub = await startStub(); + try { + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'prod', DEV_LIVE_UPSTREAM: stub.url }, async (_base, port) => { + assert.equal(port, 3101); + }); + await withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'staging', DEV_LIVE_UPSTREAM: stub.url }, async (_base, port) => { + assert.equal(port, 3102); + }); + } finally { + await stub.close(); + } }); test('an unknown DEV_LIVE_TARGET fails the config load instead of falling through', async () => { await assert.rejects( - withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'nowhere' }, 3199, async () => {}), + withDevLive({ DEV_LIVE: '1', DEV_LIVE_TARGET: 'nowhere' }, async () => {}), /DEV_LIVE_TARGET must be one of: staging, prod/, ); }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2faf76a374..32904c4f96 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,21 +1,27 @@ import { defineConfig, type ProxyOptions } from 'vite'; import react from '@vitejs/plugin-react'; -// `npm run dev:live` (TASK-686): serve the branch on localhost:3000 and proxy +// `npm run dev:live` (TASK-686): serve the branch on localhost and proxy // /api and /socket.io to a real backend. The browser's Origin header is -// dropped on the way out, because prod refuses http://localhost:3000 and the +// dropped on the way out, because prod refuses http://localhost: and the // backend accepts no-Origin requests. Target defaults to staging, where writes // are safe; DEV_LIVE_TARGET=prod points at prod, read-only, see TESTING.md. +// Each target gets its own port, so the two never share localStorage: a prod +// token saved on one origin cannot reach a staging run. +// DEV_LIVE_UPSTREAM replaces the target URL; the proxy tests use it to stand +// in a local stub backend, so no test request leaves the machine. const DEV_LIVE = process.env.DEV_LIVE === '1'; -const LIVE_TARGETS: Record = { - staging: 'https://api-staging.commonly.me', - prod: 'https://api.commonly.me', +const LIVE_TARGETS: Record = { + staging: { url: 'https://api-staging.commonly.me', port: 3102 }, + prod: { url: 'https://api.commonly.me', port: 3101 }, }; const liveName = process.env.DEV_LIVE_TARGET || 'staging'; -const liveTarget = LIVE_TARGETS[liveName]; -if (DEV_LIVE && !liveTarget) { +const liveEntry = LIVE_TARGETS[liveName]; +if (DEV_LIVE && !liveEntry) { throw new Error(`DEV_LIVE_TARGET must be one of: ${Object.keys(LIVE_TARGETS).join(', ')}`); } +const livePort = DEV_LIVE ? liveEntry.port : 3000; +const liveTarget = DEV_LIVE ? process.env.DEV_LIVE_UPSTREAM || liveEntry.url : undefined; const dropOrigin: NonNullable = (proxy) => { proxy.on('proxyReq', (req) => req.removeHeader('origin')); proxy.on('proxyReqWs', (req) => req.removeHeader('origin')); @@ -27,7 +33,7 @@ const readOnlyProd = liveName === 'prod'; export default defineConfig({ plugins: [react()], server: { - port: 3000, + port: livePort, host: true, open: false, proxy: DEV_LIVE @@ -68,7 +74,11 @@ export default defineConfig({ // pass it through explicitly (a longer define key wins over `process.env`) // so the app actually reads it. Empty string when unset keeps self-hosted // same-origin builds on their existing fallback path. - 'process.env.REACT_APP_API_URL': JSON.stringify(process.env.REACT_APP_API_URL || ''), + // dev:live serves on its own port, so the API base is that same origin: + // the proxy answers /api there and forwards it to the target. + 'process.env.REACT_APP_API_URL': JSON.stringify( + DEV_LIVE ? `http://localhost:${livePort}` : process.env.REACT_APP_API_URL || '', + ), 'process.env.REACT_APP_TELEGRAM_BOT_HANDLE': JSON.stringify(process.env.REACT_APP_TELEGRAM_BOT_HANDLE || ''), // Same `process.env: {}` clobber trap as REACT_APP_API_URL above: without an // explicit define, the showcase pod id fell back to the literal