diff --git a/docs/development/ui-validation.md b/docs/development/ui-validation.md index 953089f5af..c7f1595b34 100644 --- a/docs/development/ui-validation.md +++ b/docs/development/ui-validation.md @@ -93,6 +93,26 @@ 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: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: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. +- 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. +- 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, in light and in dark. diff --git a/frontend/TESTING.md b/frontend/TESTING.md index 22f07844a8..7551aef680 100644 --- a/frontend/TESTING.md +++ b/frontend/TESTING.md @@ -20,6 +20,35 @@ 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: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: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 + `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. + ## Test Setup and Configuration ### Jest Configuration (`package.json`) diff --git a/frontend/package.json b/frontend/package.json index 0ff85f1548..c5383fd867 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,9 +41,10 @@ }, "scripts": { "start": "vite", + "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", + "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..9fd7cd7350 --- /dev/null +++ b/frontend/scripts/dev-live-proxy.test.mjs @@ -0,0 +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. 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 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: { host: '127.0.0.1' } }); + await server.listen(); + const port = server.httpServer.address().port; + return await fn(`http://127.0.0.1:${port}`, port); + } finally { + await server?.close(); + 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 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: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'http://localhost:3102' }, + 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('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' }, async () => {}), + /DEV_LIVE_TARGET must be one of: staging, prod/, + ); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 87debaadc7..32904c4f96 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,63 @@ -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 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: 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: { 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 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')); +}; +// 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, + port: livePort, 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 @@ -23,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