diff --git a/.gitignore b/.gitignore index 6179ac42..204c336e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ src/generated/ # Claude **/.claude/ + +# Local dev session captured by `npm run dev:login` — live credentials, never commit. +.dev-session.json +.dev-chrome/ diff --git a/CLAUDE.md b/CLAUDE.md index 92ac82cb..447f7f39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,9 @@ Access: http://localhost:3000 ### All Commands | Command | Purpose | |---------|----------| -| `npm run dev` | Dev server (port 3000, `PORT` env to override) | +| `npm run dev` | Dev server (port 3000, `PORT` env to override) — UI only, no gateway access | +| `npm run dev:login` | Capture a dev session once (dedicated Chrome + CDP) → `.dev-session.json` | +| `npm run dev:proxy` | Dev server **with** working gateway access — same-origin proxy + server-side cookie jar | | `npm run build` | Production build (`generate-enums` + `relay-compiler` + `next build`; standalone output in `dist/`) | | `npm run build:export` | Static-export build (`OPENFRAME_BUILD_TARGET=export`) — SPA bundle for Capacitor/Tauri native shells | | `npm run build:local` | Production build with webpack | @@ -68,6 +70,15 @@ NEXT_PUBLIC_GTM_CONTAINER_ID=GTM-XXXXXXX # Google Tag Manager NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER=true # Dev ticket auth mode (Bearer tokens instead of cookies) ``` +**Working against a real backend locally:** `npm run dev` has no API — the browser +would call the gateway cross-origin and it does not allow-list `localhost`. A +deployed OpenFrame is same-origin (relative URLs + a reverse proxy in front), so +the fix is to supply that proxy locally, not to add CORS: `npm run dev:login` +once, then `npm run dev:proxy`. The session cookie is held server-side by +`scripts/dev-proxy.mjs`, so even a fresh browser profile is signed in. Full +rationale and caveats (NATS WS is not proxied) in +`docs/development/setup/local-development.md`. + Feature flags are **not** env vars — they are server-loaded via GraphQL (see Feature Flags below). Native-shell env split is documented in `.env.export.example`. ### Payment UI Visibility (native app builds) diff --git a/biome.jsonc b/biome.jsonc index 2ab3668a..897dbc8b 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -14,7 +14,12 @@ "!**/next-env.d.ts", "!**/__generated__/**", "!**/src/generated/**", - "!**/schema.graphql" + "!**/schema.graphql", + // Local dev-session artefacts (see scripts/dev-login.mjs). `.dev-chrome/` + // is a whole Chrome profile — thousands of vendor JSON files Biome would + // otherwise check and report on. + "!**/.dev-chrome/**", + "!**/.dev-session.json" ] }, "assist": { "actions": { "source": { "organizeImports": "on" } } }, diff --git a/docs/development/setup/local-development.md b/docs/development/setup/local-development.md index d633da74..78647606 100644 --- a/docs/development/setup/local-development.md +++ b/docs/development/setup/local-development.md @@ -121,6 +121,79 @@ The `ApiClient` automatically: --- +## Working Against a Real Backend (`npm run dev:proxy`) + +`npm run dev` gives you the UI but no API: the browser calls the gateway on a +different origin, and the gateway does not allow-list `localhost` for CORS. The +usual reflex — ask the backend for CORS headers — is the wrong fix here, because +**a deployed OpenFrame is a same-origin app**. Its `__ENV` carries no +`NEXT_PUBLIC_TENANT_HOST_URL`, all three URL builders fall back to relative paths +when the host is empty (`api-client.ts` `buildUrl`, `relay/environment.ts` +`getGraphqlUrl`, `auth-api-client.ts` `buildAuthUrl`), and a reverse proxy in +front fans the paths out. Local dev was simply missing that proxy. + +`npm run dev:proxy` supplies it. + +```bash +# once — opens a dedicated Chrome, you log in as usual, the session is captured +npm run dev:login -- --tenant-host https://test-env.qa.openframe.build + +# from then on +npm run dev:proxy +``` + +``` +browser (localhost:3000) ← one origin, so CORS never enters the picture + ├── /api /oauth /sas /tools /content ──► dev-proxy :7787 ──► gateway + └── everything else ─────────────────► next dev +``` + +### What each piece does + +| Piece | Role | +|---|---| +| `scripts/dev-login.mjs` | Launches Chrome with a dedicated profile (`.dev-chrome/`) and CDP enabled, waits for you to sign in, reads the cookies via `Storage.getCookies` (HttpOnly included — a page cannot), verifies them against `/api/me`, writes `.dev-session.json`. | +| `scripts/dev-proxy.mjs` | Holds that cookie jar **server-side**: attaches cookies going up, absorbs `Set-Cookie` coming down, persists rotations. Routes `/oauth` + `/sas` to the shared host and the rest to the tenant host, mirroring the deployment's own split. | +| `scripts/dev.mjs` | Runs both as one process with a shared fate, and clears `NEXT_PUBLIC_TENANT_HOST_URL` / `NEXT_PUBLIC_SHARED_HOST_URL` — a set host would build absolute URLs and bypass the rewrites entirely. | + +### Why the cookie stays out of the browser + +The session cookie belongs to the gateway's domain and can never be set on +`localhost`. Holding it in the proxy instead of relaying it gives two properties +that are the whole reason for this setup: + +- **A cookie-less browser profile is signed in.** Open `localhost:3000` in a + fresh profile — including one driven by browser-automation tooling — and it is + authenticated. No storage seeding, no OAuth dance per run. +- **Expiry stops being a session-length limit.** Nothing refreshes on a timer. + The app's own 401 → `/oauth/refresh` path runs exactly as it does in + production, and the rotated cookies land back in the jar. + +### Caveats + +- `.dev-session.json` is a **live credential**. Written `0600`, gitignored, and + the proxy refuses any upstream whose hostname carries no `qa`/`dev`/`test`/ + `stage`/`local` marker unless `OPENFRAME_DEV_PROXY_ALLOW_PROD=1`. +- **NATS live updates do not work** through this. The browser opens that socket + against `window.location.origin`, and `rewrites()` do not proxy WebSocket + upgrades — so nothing reaches the proxy. Notifications and chat streaming fall + back to whatever their non-live path is; everything over HTTP is unaffected. +- Cookie mode is forced (`NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER=false`), which + is what a deployment runs. The dev-ticket bearer flow is a separate mechanism + and the two must not be mixed. +- `npm run dev` is unchanged. This is opt-in. +- **A missing route prefix poisons the browser cache, and fixing the proxy is not + enough.** If a gateway path is not in the route table, it falls through to + `next dev` and comes back as ~32 KB of 404 HTML where JSON was expected. The + app retries it inside a layout-level boundary, so *every* page sits in its + skeleton with a perfectly healthy session behind it — and because some of those + responses stick in the browser cache, adding the route and restarting still + leaves the tab broken. **Empty Cache and Hard Reload** (or a reload with cache + disabled) is what clears it. Suspect this first when pages hang while + `curl localhost:3000/api/me` is happily returning 200. + +--- + ## Debugging in VS Code Create a `.vscode/launch.json` configuration to attach the VS Code debugger to the Next.js server: diff --git a/next.config.mjs b/next.config.mjs index 8b80b2ac..d4e8191f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -55,10 +55,35 @@ const nextConfig = { // `rewrites()` is unsupported under `output: 'export'` (no server to run // them), so it is omitted in export mode; the embedded-chat proxy is replaced // there by absolute gateway URLs + Bearer + CORS (migration item 7). + // + // `OPENFRAME_DEV_PROXY` generalises the same idea to the WHOLE gateway surface + // for local dev: with it set, every gateway path is rewritten to the local + // credential-injecting proxy (`scripts/dev-proxy.mjs`) instead of the browser + // reaching the gateway itself. That makes `next dev` the same shape as a + // deployment — one origin in the browser, a reverse proxy behind it — so no + // CORS is involved at all, and the session cookie (which belongs to the gateway + // domain and can never be set on `localhost`) is attached server-side. + // + // It is set only by `npm run dev` via the dev-proxy script. Absent, everything + // below behaves exactly as before, and it can never reach a production build: + // the deployed image builds with no `.env*` and no such variable. ...(isStaticExport ? {} : { async rewrites() { + const devProxy = (process.env.OPENFRAME_DEV_PROXY || '').replace(/\/+$/, ''); + if (devProxy) { + return { + // `beforeFiles`, so these win over the App Router's own matching. + // `/api` is safe to claim wholesale: this app defines no route + // handlers (there is no `src/app/api`), every `/api/*` call is a + // gateway call. + beforeFiles: ['/api', '/oauth', '/sas', '/chat', '/tools', '/content'].map(prefix => ({ + source: `${prefix}/:path*`, + destination: `${devProxy}${prefix}/:path*`, + })), + }; + } const tenantHost = (process.env.NEXT_PUBLIC_TENANT_HOST_URL || '').replace(/\/+$/, ''); if (!tenantHost) return []; return { diff --git a/package.json b/package.json index 6621fe3c..e656b138 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "next dev -p ${PORT:-3000}", + "dev:proxy": "node scripts/dev.mjs", + "dev:login": "node scripts/dev-login.mjs", "build": "npm run generate-enums && relay-compiler && next build", "generate-enums": "node scripts/generate-schema-enums.mjs", "build:export": "OPENFRAME_BUILD_TARGET=export npm run build", diff --git a/scripts/dev-login.mjs b/scripts/dev-login.mjs new file mode 100644 index 00000000..9ac21ce6 --- /dev/null +++ b/scripts/dev-login.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +/** + * Captures a dev session for `scripts/dev-proxy.mjs`. + * + * ## The problem it solves + * + * A deployed OpenFrame authenticates by HttpOnly cookie on the gateway's domain + * (confirmed by a real deployment's `__ENV`: no `NEXT_PUBLIC_TENANT_HOST_URL`, no + * dev-ticket flag). HttpOnly is the point of HttpOnly — `document.cookie` cannot + * read it, so there is no in-page way to lift a session, and the OAuth flow + * itself ends at an SSO provider no script should be driving. + * + * The Chrome DevTools Protocol can read it, because it is the browser talking + * about itself rather than a page reaching into another origin. So: open a real + * Chrome, let a human log in exactly as they would anyway, then ask the browser + * for the cookies it now holds. + * + * ## Why not a local callback listener + * + * The obvious design — spin up `http://localhost:PORT/callback`, pass it as + * `redirectTo`, catch the `devTicket` the gateway appends, exchange it at + * `/oauth/dev-exchange` for a bearer pair — does not work, and fails in a way + * worth recording so it is not re-attempted: + * + * GET /oauth/login?tenantId=…&authMobile=true&redirectTo=http%3A%2F%2Flocalhost%3A7788%2Fcallback + * → HTTP 502 + * + * The same request without `redirectTo` 302s to the authz server normally. This + * matches what `src/lib/native-login.ts` documents from the other side: the + * app's custom scheme is "the only redirect target the gateway honours verbatim + * in every environment" — an https redirect is rewritten to the tenant root, and + * a localhost one is not survivable at all. Capturing the browser's own cookies + * needs nothing from the gateway, which is why it is the approach here. + * + * ## Why a dedicated profile + * + * Chrome ignores `--remote-debugging-port` on a profile that is already running, + * and attaching to someone's everyday browser would expose every cookie in it to + * this script. `.dev-chrome/` is a separate profile that holds one login to one + * QA tenant and nothing else. It persists, so this is a rare command, not a daily + * one — and it doubles as the profile browser-automation tooling can drive. + * + * ## What it does NOT do + * + * It never types a password and never touches the SSO provider. The human logs + * in; this waits for `/api/me` to answer `authenticated: true` through the + * cookies the browser ended up with, and only then writes the file. + * + * npm run dev:login + * npm run dev:login -- --tenant-host https://test-env.qa.openframe.build + */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SESSION_FILE = resolve(projectRoot, '.dev-session.json'); +const PROFILE_DIR = resolve(projectRoot, '.dev-chrome'); + +const args = process.argv.slice(2); +const flag = name => { + const i = args.indexOf(`--${name}`); + return i !== -1 && args[i + 1] ? args[i + 1] : undefined; +}; + +const tenantHost = (flag('tenant-host') || process.env.OPENFRAME_DEV_TENANT_HOST || '').replace(/\/+$/, ''); +const cdpPort = Number(flag('cdp-port') || 9222); +const timeoutMs = Number(flag('timeout') || 300_000); + +if (!tenantHost) { + console.error( + 'Usage: npm run dev:login -- --tenant-host https://.qa.openframe.build\n' + + ' or: OPENFRAME_DEV_TENANT_HOST=https://... npm run dev:login', + ); + process.exit(1); +} + +/** + * The shared auth host, defaulted from the tenant host by dropping one label: + * `test-env.qa.openframe.build` → `qa.openframe.build`, which is the pairing a + * real deployment's `__ENV` shows (`NEXT_PUBLIC_SHARED_HOST_URL`). Overridable, + * because nothing guarantees that shape forever. + */ +function defaultSharedHost(tenant) { + const url = new URL(tenant); + const labels = url.hostname.split('.'); + return labels.length > 3 ? `${url.protocol}//${labels.slice(1).join('.')}` : tenant; +} +const sharedHost = (flag('shared-host') || defaultSharedHost(tenantHost)).replace(/\/+$/, ''); + +const CHROME_CANDIDATES = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', +]; + +function findChrome() { + const explicit = flag('chrome') || process.env.CHROME_PATH; + if (explicit) return explicit; + const found = CHROME_CANDIDATES.find(p => existsSync(p)); + if (!found) { + console.error('Could not find Chrome. Pass --chrome /path/to/chrome or set CHROME_PATH.'); + process.exit(1); + } + return found; +} + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function waitForCdp(port, deadline) { + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + if (res.ok) return (await res.json()).webSocketDebuggerUrl; + } catch { + // Chrome is still starting — the connection refusal IS the "not yet". + } + await sleep(250); + } + throw new Error('Chrome did not expose its debugging port in time'); +} + +/** + * One CDP command over the browser-level socket. + * + * `Storage.getCookies` with no `browserContextId` returns the whole jar, + * HttpOnly included. That is the single capability this script exists for, and + * it is only available to the browser endpoint — a page-level `document.cookie` + * would silently return the non-HttpOnly subset, i.e. everything except the + * session. + */ +function cdpSend(ws, method, params = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const id = (cdpSend.nextId = (cdpSend.nextId || 0) + 1); + const onMessage = event => { + let msg; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + if (msg.id !== id) return; + ws.removeEventListener('message', onMessage); + if (msg.error) rejectPromise(new Error(`${method}: ${msg.error.message}`)); + else resolvePromise(msg.result); + }; + ws.addEventListener('message', onMessage); + ws.send(JSON.stringify({ id, method, params })); + }); +} + +function openSocket(url) { + return new Promise((resolvePromise, rejectPromise) => { + const ws = new WebSocket(url); + ws.addEventListener('open', () => resolvePromise(ws), { once: true }); + ws.addEventListener('error', () => rejectPromise(new Error('CDP socket failed')), { once: true }); + }); +} + +function relevantCookies(all) { + const hosts = [new URL(tenantHost).hostname, new URL(sharedHost).hostname]; + return all + .filter(c => hosts.some(h => h === c.domain.replace(/^\./, '') || h.endsWith(`.${c.domain.replace(/^\./, '')}`))) + .map(({ name, value, domain, path, expires, secure, httpOnly }) => ({ + name, + value, + domain: domain.replace(/^\./, ''), + path: path || '/', + expires: expires ?? -1, + secure, + httpOnly, + })); +} + +/** + * The only definition of "logged in" this script trusts: the gateway saying so. + * + * A cookie count would be the tempting check and the wrong one — the login page + * itself sets cookies, so a jar can look convincingly full while the session + * behind it does not exist. `/api/me` is also exactly the call the app makes + * first, so a pass here means the app will get past its auth gate. + */ +async function verify(cookies) { + const host = new URL(tenantHost).hostname; + const header = cookies + .filter(c => host === c.domain || host.endsWith(`.${c.domain}`)) + .map(c => `${c.name}=${c.value}`) + .join('; '); + if (!header) return false; + try { + const res = await fetch(`${tenantHost}/api/me`, { + headers: { cookie: header, accept: 'application/json' }, + }); + if (!res.ok) return false; + const body = await res.json().catch(() => null); + return Boolean(body?.authenticated); + } catch { + return false; + } +} + +async function main() { + mkdirSync(PROFILE_DIR, { recursive: true }); + const chrome = findChrome(); + + console.log(`\n Opening ${tenantHost} in a dedicated Chrome profile.`); + console.log(' Log in as you normally would — this waits, then captures the session.\n'); + + const child = spawn( + chrome, + [ + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${PROFILE_DIR}`, + '--no-first-run', + '--no-default-browser-check', + tenantHost, + ], + { stdio: 'ignore', detached: false }, + ); + child.on('error', err => { + console.error(`Failed to launch Chrome: ${err.message}`); + process.exit(1); + }); + + const deadline = Date.now() + timeoutMs; + const wsUrl = await waitForCdp(cdpPort, deadline); + const ws = await openSocket(wsUrl); + + let captured = null; + while (Date.now() < deadline) { + const { cookies } = await cdpSend(ws, 'Storage.getCookies'); + const relevant = relevantCookies(cookies); + if (relevant.length && (await verify(relevant))) { + captured = relevant; + break; + } + await sleep(2000); + } + + ws.close(); + + if (!captured) { + console.error('\n Timed out waiting for a signed-in session. Nothing written.'); + child.kill(); + process.exit(1); + } + + writeFileSync( + SESSION_FILE, + `${JSON.stringify({ tenantHost, sharedHost, capturedAt: new Date().toISOString(), cookies: captured }, null, 2)}\n`, + // The file is a live credential. 0600 is not security theatre here: the + // repo directory is readable by anything else the machine runs. + { mode: 0o600 }, + ); + + console.log(` Captured ${captured.length} cookie(s) → .dev-session.json`); + console.log(' You can close that Chrome window. Run "npm run dev:proxy".\n'); + child.kill(); +} + +main().catch(err => { + console.error(`\n ${err.message}\n`); + process.exit(1); +}); diff --git a/scripts/dev-proxy.mjs b/scripts/dev-proxy.mjs new file mode 100644 index 00000000..e5157210 --- /dev/null +++ b/scripts/dev-proxy.mjs @@ -0,0 +1,400 @@ +#!/usr/bin/env node + +/** + * Local credential-injecting reverse proxy for `next dev`. + * + * ## Why this exists + * + * The deployed app is a SAME-ORIGIN app. `__ENV` on a real deployment carries no + * `NEXT_PUBLIC_TENANT_HOST_URL`, and all three URL builders fall back to a + * relative path when the host is empty (`api-client.ts` `buildUrl`, + * `relay/environment.ts` `getGraphqlUrl`, `auth-api-client.ts` `buildAuthUrl`): + * the browser only ever talks to its own origin and a reverse proxy in front + * fans the paths out to the gateway. `next.config.mjs` already encodes a + * one-path version of exactly this for `/content/*`. + * + * Local dev was the only configuration that pointed the browser at a DIFFERENT + * origin — and then needed the backend to grow CORS for a shape production never + * has. This process is the missing reverse proxy, so dev matches the deployment + * instead of asking the backend to accommodate it. + * + * ## What it adds beyond path routing + * + * The session cookie belongs to the upstream domain and can never be set on + * `localhost`, so proxying alone would leave every request signed out. This + * process holds the cookie jar SERVER-SIDE: it attaches the stored cookies going + * up, swallows `Set-Cookie` coming down, and persists rotations back to + * `.dev-session.json`. + * + * Two consequences worth naming, because they are the whole point: + * + * - **The browser never holds a credential.** A fresh, cookie-less profile — + * which is what any browser-automation tool drives — is fully signed in the + * moment it opens `localhost:3000`. No storage seeding, no OAuth dance per run. + * - **Expiry stops being a session-length limit.** Nothing here refreshes + * anything on a timer; the APP's own 401 → `/oauth/refresh` path does, exactly + * as it does in production, and the rotated cookies land in the jar on the way + * back. The proxy only has to not lose them. + * + * ## Usage + * + * npm run dev:login # once — capture a session (scripts/dev-login.mjs) + * npm run dev:proxy # starts this alongside `next dev` (scripts/dev.mjs) + * + * `npm run dev` is untouched and still runs a bare dev server with no gateway + * access — this is opt-in, not a new default. + * + * Flags: `--port`, `--session`, `--quiet`. Env: `OPENFRAME_DEV_PROXY_PORT`. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { createServer, request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const args = process.argv.slice(2); +const flag = name => { + const i = args.indexOf(`--${name}`); + return i !== -1 && args[i + 1] ? args[i + 1] : undefined; +}; + +export const SESSION_FILE = resolve(projectRoot, '.dev-session.json'); +const sessionPath = flag('session') ? resolve(flag('session')) : SESSION_FILE; +const PORT = Number(flag('port') || process.env.OPENFRAME_DEV_PROXY_PORT || 7787); +const quiet = args.includes('--quiet'); + +/** + * Path → upstream. Two upstreams, not one, and the split is not arbitrary: the + * deployed app already makes this exact distinction. `/api`, `/tools`, `/content` + * are same-origin on the tenant host; `/oauth` and `/sas` go to + * `NEXT_PUBLIC_SHARED_HOST_URL` — a DIFFERENT host even in QA (`qa.openframe.build` + * vs `test-env.qa.openframe.build`). + * + * Locally both are proxied, which is why `.env.local` must leave BOTH host vars + * empty: a set `SHARED_HOST_URL` would send auth calls straight to the shared + * host from `localhost`, and that is the one origin its CORS policy does not + * list. + */ +const ROUTES = [ + { prefix: '/oauth/', upstream: 'shared' }, + { prefix: '/sas/', upstream: 'shared' }, + { prefix: '/api/', upstream: 'tenant' }, + // saas-ai-agent (tickets + Mingo). Easy to forget, because nothing on a + // scripts page mentions chat — but the APP LAYOUT mounts it in saas-tenant + // mode, so leaving it out does not merely disable chat: the call falls through + // to `next dev`, comes back as 32 KB of 404 HTML where JSON was expected, and + // retries forever inside a layout-level boundary. Every page then sits in its + // skeleton with a perfectly healthy session behind it. + { prefix: '/chat/', upstream: 'tenant' }, + { prefix: '/tools/', upstream: 'tenant' }, + { prefix: '/content/', upstream: 'tenant' }, +]; + +/** Hop-by-hop headers: meaningful to ONE connection, never to be relayed (RFC 9110 §7.6.1). */ +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +const log = (...parts) => { + if (!quiet) console.log('[dev-proxy]', ...parts); +}; + +// --------------------------------------------------------------------------- +// Session file +// --------------------------------------------------------------------------- + +function loadSession() { + let raw; + try { + raw = readFileSync(sessionPath, 'utf-8'); + } catch { + console.error( + `[dev-proxy] No session at ${sessionPath}.\n` + `[dev-proxy] Run "npm run dev:login" first — it captures one.`, + ); + process.exit(1); + } + const session = JSON.parse(raw); + if (!session.tenantHost) { + console.error('[dev-proxy] Session file has no tenantHost. Re-run "npm run dev:login".'); + process.exit(1); + } + session.sharedHost ||= session.tenantHost; + session.cookies ||= []; + return session; +} + +const session = loadSession(); + +/** + * Refuse to carry a live production session by default. + * + * This process turns "whatever is in a file on disk" into an authenticated + * request against a real backend, and it is driven by tooling that clicks + * things. Pointing it at production is a mistake worth making loud rather than + * discoverable; a marker in the hostname is a crude test, but it fails CLOSED, + * which is the direction that matters. + */ +function assertNonProdUpstream(url) { + if (process.env.OPENFRAME_DEV_PROXY_ALLOW_PROD === '1') return; + const { hostname } = new URL(url); + const looksSafe = + hostname === 'localhost' || + hostname.endsWith('.localhost') || + /(^|[.-])(qa|dev|test|stage|staging|local)([.-]|$)/.test(hostname); + if (!looksSafe) { + console.error( + `[dev-proxy] Refusing to proxy to "${hostname}" — it does not look like a dev/QA host.\n` + + `[dev-proxy] Set OPENFRAME_DEV_PROXY_ALLOW_PROD=1 if this really is intended.`, + ); + process.exit(1); + } +} + +assertNonProdUpstream(session.tenantHost); +assertNonProdUpstream(session.sharedHost); + +const UPSTREAM = { + tenant: new URL(session.tenantHost), + shared: new URL(session.sharedHost), +}; + +// --------------------------------------------------------------------------- +// Cookie jar +// --------------------------------------------------------------------------- + +/** + * Keyed the way RFC 6265 identifies a cookie: name + domain + path, not name + * alone. The separator is NUL because none of the three parts can contain one, + * so no two distinct cookies can collide on a joined key — and it is written as + * an escape rather than a literal byte, which would make git treat this whole + * file as binary and hand reviewers `Bin 0 -> 16286 bytes` instead of a diff. + */ +const jarKey = c => `${c.name}\u0000${c.domain}\u0000${c.path || '/'}`; +const jar = new Map(session.cookies.map(c => [jarKey(c), c])); + +let persistTimer = null; +/** + * Rotations are written back so the session survives a restart of this process — + * a refresh-token rotation that only lived in memory would leave the file holding + * a credential the gateway has already invalidated, i.e. a session that dies at + * the next `npm run dev` for no visible reason. + * + * Debounced because a single page load can rotate once and then fan out twenty + * requests behind it. + */ +function persistJar() { + clearTimeout(persistTimer); + persistTimer = setTimeout(() => { + const next = { ...session, cookies: [...jar.values()], updatedAt: new Date().toISOString() }; + writeFileSync(sessionPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); + }, 250); + persistTimer.unref?.(); +} + +function domainMatches(cookieDomain, host) { + const d = cookieDomain.replace(/^\./, ''); + return host === d || host.endsWith(`.${d}`); +} + +function cookieHeaderFor(host, path) { + const now = Date.now() / 1000; + const matched = [...jar.values()].filter(c => { + if (!domainMatches(c.domain, host)) return false; + if (c.path && c.path !== '/' && !path.startsWith(c.path)) return false; + // `expires: -1` is CDP's encoding for a session cookie — no expiry, keep it. + if (typeof c.expires === 'number' && c.expires > 0 && c.expires < now) return false; + return true; + }); + // Longest path first (RFC 6265 §5.4). This gateway sets some cookies twice — + // once with `Domain=.openframe.build` and once host-only — so one name can + // legitimately appear twice in the header, exactly as a browser would send it. + // The order is what lets the server pick the more specific one. + matched.sort((a, b) => (b.path || '/').length - (a.path || '/').length); + return matched.map(c => `${c.name}=${c.value}`).join('; '); +} + +/** + * Absorbs a `Set-Cookie` from an upstream response instead of relaying it. + * + * Relaying would put the credential in the browser under the WRONG domain + * (`localhost`), where it would be sent back to us on every request and give the + * jar a second, diverging source of truth — and would undo the one property this + * whole design is for: that a browser profile driven by tooling holds nothing. + */ +function absorbSetCookie(rawList, defaultDomain) { + for (const raw of rawList) { + const [pair, ...attrs] = raw.split(';'); + const eq = pair.indexOf('='); + if (eq < 1) continue; + const cookie = { + name: pair.slice(0, eq).trim(), + value: pair.slice(eq + 1).trim(), + domain: defaultDomain, + path: '/', + expires: -1, + }; + let maxAge; + for (const attr of attrs) { + const idx = attr.indexOf('='); + const key = (idx === -1 ? attr : attr.slice(0, idx)).trim().toLowerCase(); + const val = idx === -1 ? '' : attr.slice(idx + 1).trim(); + if (key === 'domain' && val) cookie.domain = val.replace(/^\./, ''); + else if (key === 'path' && val) cookie.path = val; + else if (key === 'max-age') maxAge = Number(val); + else if (key === 'expires') cookie.expires = Date.parse(val) / 1000; + } + // Max-Age wins over Expires when both are present (RFC 6265 §5.3), and + // `Max-Age<=0` is not "expires about now" — it is the DELETE instruction, + // stated unconditionally (§5.2.2). Deciding it by comparing a computed + // expiry against `Date.now()` a few statements later is a sub-millisecond + // race, and this gateway makes it a live one: it clears its session cookies + // with `Max-Age=0; Expires=Thu, 01 Jan 1970`, so a lost race stores an + // EMPTY cookie under the name of a real one — which then rides along on + // every request and can shadow the session it was supposed to remove. + if (maxAge !== undefined && Number.isFinite(maxAge)) { + if (maxAge <= 0) { + jar.delete(jarKey(cookie)); + continue; + } + cookie.expires = Date.now() / 1000 + maxAge; + } + if (typeof cookie.expires === 'number' && cookie.expires > 0 && cookie.expires * 1000 <= Date.now()) { + jar.delete(jarKey(cookie)); + continue; + } + jar.set(jarKey(cookie), cookie); + } + persistJar(); +} + +// --------------------------------------------------------------------------- +// Proxy +// --------------------------------------------------------------------------- + +function pickUpstream(pathname) { + const route = ROUTES.find(r => pathname.startsWith(r.prefix)); + return route ? UPSTREAM[route.upstream] : null; +} + +const server = createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + const upstream = pickUpstream(url.pathname); + + if (!upstream) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end(`dev-proxy: no upstream for ${url.pathname}\n`); + return; + } + + const headers = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (HOP_BY_HOP.has(key) || key === 'cookie' || key === 'host' || key === 'content-length') continue; + // Drop the dev chain's forwarding trail. `next dev` stamps + // `x-forwarded-host: localhost:3000` on everything it rewrites, and the authz + // server BUILDS THE TOKEN'S `iss` CLAIM from it: a refresh performed through + // this proxy came back minting `iss: https://localhost:3000/sas/` + // instead of the real issuer. The gateway then 500s on every request made + // with that token — a session that dies at the first rotation, ~15 minutes + // in, for no reason visible from the browser. + // + // Nothing downstream wants these: the upstream is being addressed directly, + // so it should derive host and scheme from the request line and its own + // config, exactly as it does for a request off the public internet. + if (key.startsWith('x-forwarded-') || key === 'forwarded' || key === 'x-real-ip') continue; + headers[key] = value; + } + + // The gateway sees a first-party request, because that is what it is once the + // browser's own origin is an implementation detail of the dev setup. Leaving + // `localhost:3000` here invites an Origin/CSRF rejection that would look like + // a broken endpoint rather than a proxy misconfiguration. + headers.host = upstream.host; + headers.origin = upstream.origin; + if (req.headers.referer) { + headers.referer = req.headers.referer.replace(/^https?:\/\/[^/]+/, upstream.origin); + } + + const cookie = cookieHeaderFor(upstream.hostname, url.pathname); + if (cookie) headers.cookie = cookie; + + const doRequest = upstream.protocol === 'https:' ? httpsRequest : httpRequest; + const proxied = doRequest( + { + protocol: upstream.protocol, + hostname: upstream.hostname, + port: upstream.port || (upstream.protocol === 'https:' ? 443 : 80), + method: req.method, + path: req.url, + headers, + }, + upstreamRes => { + const setCookie = upstreamRes.headers['set-cookie']; + if (setCookie?.length) absorbSetCookie(setCookie, upstream.hostname); + + const out = {}; + for (const [key, value] of Object.entries(upstreamRes.headers)) { + if (HOP_BY_HOP.has(key) || key === 'set-cookie' || key === 'content-length') continue; + out[key] = value; + } + if (upstreamRes.statusCode >= 400) { + log(`${upstreamRes.statusCode} ${req.method} ${url.pathname}`); + } + res.writeHead(upstreamRes.statusCode, out); + // Piped, never buffered: `/content` chat responses stream, and buffering + // would turn a token-by-token reply into one silent wait. + upstreamRes.pipe(res); + }, + ); + + proxied.on('error', err => { + console.error(`[dev-proxy] upstream error on ${url.pathname}:`, err.message); + if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' }); + res.end(`dev-proxy: upstream error: ${err.message}\n`); + }); + + req.pipe(proxied); +}); + +/** + * WebSocket upgrades are NOT handled. They would have to be, to make NATS live + * updates work locally — but the browser opens that socket against + * `window.location.origin`, i.e. the Next dev server, and `rewrites()` do not + * proxy upgrades, so nothing would reach this process anyway. Answering the + * upgrade with a clean refusal beats leaving the socket hanging until it times + * out: the client retries on close, and a retry loop is at least legible. + */ +server.on('upgrade', (_req, socket) => { + socket.end('HTTP/1.1 501 Not Implemented\r\n\r\n'); +}); + +// A port left occupied by a previous run is the most likely way to meet this +// script, and an unhandled 'error' event would greet it with a raw stack trace +// about `net:2016`. Name the actual problem instead. +server.on('error', err => { + if (err.code === 'EADDRINUSE') { + console.error( + `[dev-proxy] Port ${PORT} is already in use — most likely a dev-proxy from an earlier run.\n` + + `[dev-proxy] Stop it (lsof -ti:${PORT} | xargs kill) or pick another with OPENFRAME_DEV_PROXY_PORT.`, + ); + } else { + console.error(`[dev-proxy] ${err.message}`); + } + process.exit(1); +}); + +server.listen(PORT, '127.0.0.1', () => { + log(`listening on http://127.0.0.1:${PORT}`); + log(`tenant → ${UPSTREAM.tenant.origin}`); + log(`shared → ${UPSTREAM.shared.origin}`); + log(`jar → ${jar.size} cookie(s) from ${sessionPath}`); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 00000000..c8902c30 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** + * `npm run dev:proxy` — the dev server WITH working gateway access. + * + * Runs two processes as one: the credential-injecting proxy + * (`scripts/dev-proxy.mjs`) and `next dev` with `OPENFRAME_DEV_PROXY` pointed at + * it, which is what switches on the whole-gateway rewrites in `next.config.mjs`. + * + * One process rather than two terminals, and specifically so they SHARE A FATE: + * a proxy still holding a live session after its dev server is gone is a + * credential left listening on a local port with nothing to explain it. Either + * child exiting takes the other down. + */ + +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const sessionFile = resolve(projectRoot, '.dev-session.json'); + +if (!existsSync(sessionFile)) { + console.error( + '\n No .dev-session.json — run "npm run dev:login" first.\n' + + ' (Plain "npm run dev" still works; it just has no gateway access.)\n', + ); + process.exit(1); +} + +const proxyPort = Number(process.env.OPENFRAME_DEV_PROXY_PORT || 7787); +const children = []; +let shuttingDown = false; + +function shutdown(code) { + if (shuttingDown) return; + shuttingDown = true; + for (const child of children) child.kill('SIGTERM'); + process.exit(code ?? 0); +} + +function start(name, command, args, env) { + const child = spawn(command, args, { + cwd: projectRoot, + stdio: 'inherit', + env: { ...process.env, ...env }, + }); + child.on('exit', code => { + if (!shuttingDown) console.error(`\n ${name} exited (${code}) — stopping the other half.\n`); + shutdown(code ?? 0); + }); + children.push(child); + return child; +} + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); + +start('dev-proxy', process.execPath, [resolve(projectRoot, 'scripts/dev-proxy.mjs'), '--port', String(proxyPort)]); +start('next dev', 'npx', ['next', 'dev', '-p', process.env.PORT || '3000'], { + OPENFRAME_DEV_PROXY: `http://127.0.0.1:${proxyPort}`, + // Both host vars MUST be empty for this to work: they are what make the + // client build absolute gateway URLs, and an absolute URL bypasses the + // rewrites entirely — straight back to the cross-origin request this setup + // exists to remove. Cleared here rather than trusted to `.env.local`, so one + // stale line in an untracked file cannot silently disable the proxy. + NEXT_PUBLIC_TENANT_HOST_URL: '', + NEXT_PUBLIC_SHARED_HOST_URL: '', + // Cookie mode, matching the deployment. The dev-ticket observer would flip + // `isBearerAuthMode()` on, and the app would then look for a bearer token in + // localStorage that this setup deliberately never puts there. + NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER: 'false', +});