Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/development/ui-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions frontend/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
134 changes: 134 additions & 0 deletions frontend/scripts/dev-live-proxy.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
61 changes: 58 additions & 3 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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:<port> 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<string, { url: string; port: number }> = {
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<ProxyOptions['configure']> = (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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
...(readOnlyProd
? {}
: {
'/socket.io': {
target: liveTarget,
changeOrigin: true,
secure: true,
ws: true,
configure: dropOrigin,
},
}),
}
: undefined,
},
build: {
outDir: 'build', // match CRA output dir for deployment compatibility
Expand All @@ -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
Expand Down
Loading