From 30925b91d97417b441c251fbd9b13c34e27b7ef0 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Thu, 27 Aug 2026 12:15:04 +0100 Subject: [PATCH] feat(dx): local dev server with real gateway access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run dev` gives the UI but no API: the browser calls the gateway on a different origin and the gateway does not allow-list localhost. The reflex fix — ask the backend for CORS — is wrong, because a deployed OpenFrame is a SAME-ORIGIN app. A real deployment's `__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. `next.config.mjs` already encoded a one-path version of this for `/content/*`. Local dev was simply missing the proxy. npm run dev:login # once — capture a session npm run dev:proxy # dev server + injector browser (localhost:3000) one origin, so CORS never arises |- /api /oauth /sas /chat /tools /content -> dev-proxy :7787 -> gateway `- everything else ------------------------> next dev - scripts/dev-login.mjs: launches Chrome with a dedicated profile (.dev-chrome/) and CDP, waits for a human 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 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 split. - scripts/dev.mjs: runs both with a shared fate and clears the host env vars, so a stale line in .env.local cannot silently bypass the rewrites. Two properties this buys, and they are the point: - A cookie-less browser profile is signed in. Fresh profiles — including ones driven by browser-automation tooling — need no seeding and no OAuth dance. - Expiry stops being a session-length limit. Nothing refreshes on a timer; the app's own 401 -> /oauth/refresh path runs as it does in production and the rotated cookies land back in the jar. Three things found by running it against QA, each of which would otherwise have surfaced as a mystery: - `x-forwarded-*` must be stripped. `next dev` stamps `x-forwarded-host: localhost:3000` on rewritten requests, and the authz server builds the token's `iss` claim from it — a refresh through the proxy minted `iss: https://localhost:3000/sas/` and the gateway then 500s on every request. The session died at the first rotation, ~15 minutes in. - `Max-Age=0` is the delete instruction (RFC 6265 §5.2.2), not "expires about now". Deciding it by comparing a computed expiry against Date.now() is a sub-millisecond race, and this gateway makes it live: it clears cookies with `Max-Age=0; Expires=Thu, 01 Jan 1970`, so a lost race stored an EMPTY cookie under the name of a real one. - A missing route prefix (`/chat`, for tickets/mingo) does not merely disable that feature: the call falls through to `next dev`, returns 32 KB of 404 HTML where JSON was expected, and retries forever inside a layout-level boundary — every page sits in its skeleton with a healthy session behind it. Some of those responses also pin in the browser cache, so fixing the route needs a hard reload too. Documented. .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. Not covered: WebSocket upgrades, so NATS live updates do not work locally — the browser opens that socket against window.location.origin and `rewrites()` do not proxy upgrades. Everything over HTTP is unaffected. `npm run dev` is unchanged; this is opt-in. --- .gitignore | 4 + CLAUDE.md | 13 +- biome.jsonc | 7 +- docs/development/setup/local-development.md | 73 ++++ next.config.mjs | 25 ++ package.json | 2 + scripts/dev-login.mjs | 270 +++++++++++++ scripts/dev-proxy.mjs | 400 ++++++++++++++++++++ scripts/dev.mjs | 74 ++++ 9 files changed, 866 insertions(+), 2 deletions(-) create mode 100644 scripts/dev-login.mjs create mode 100644 scripts/dev-proxy.mjs create mode 100644 scripts/dev.mjs 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', +});