Skip to content

feat(dx): local dev server with real gateway access - #294

Draft
pavlo-flamingo wants to merge 1 commit into
mainfrom
dx/local-dev-gateway-proxy
Draft

pavlo-flamingo wants to merge 1 commit into
mainfrom
dx/local-dev-gateway-proxy

Conversation

@pavlo-flamingo

Copy link
Copy Markdown
Contributor

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 the wrong one, 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 exactly 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

What's in it

File Role
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 own 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 storage seeding and 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 in production, and the rotated cookies land back in the jar.

Three things found by running it against QA

Each would otherwise have surfaced as a mystery rather than a bug:

  • 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/<tenant>, and the gateway then 500s on every request made with it. The session died at the first rotation, ~15 minutes in, with nothing visible from the browser.
  • 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 a live one: it clears session 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 poisons more than its own feature. /chat (tickets/mingo) was missing; the call fell through to next dev, came back as 32 KB of 404 HTML where JSON was expected, and retried forever inside a layout-level boundary — every page sat in its skeleton with a perfectly healthy session behind it. Some of those responses also pin in the browser cache, so adding the route and restarting is not enough without a hard reload. Both documented.

Verified against QA

  • /api/me and /api/graphql authenticated through the full chain (browser → rewrite → proxy → gateway).
  • /oauth/* correctly routed to the shared host, /api/* to the tenant host.
  • Refresh rotation: 204, both tokens rotate, absorbed into the jar, session survives; correct iss after the fix.
  • Set-Cookie reaching the browser: 0 headers.
  • Real pages render real QA data end to end.
  • Plain npm run dev unchanged.

Safety

.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.

Known gap

WebSocket upgrades are not proxied, so NATS live updates do not work locally — the browser opens that socket against window.location.origin and rewrites() do not proxy upgrades, so nothing reaches this process. Everything over HTTP is unaffected. The proxy answers such an upgrade with a clean 501 rather than leaving the socket hanging.

npm run dev is unchanged; this is entirely opt-in.

Draft because

The design is proven against QA but has only been exercised by one person on macOS. Worth a second pair of eyes on the route table (/api /oauth /sas /chat /tools /content — anything missing behaves as described above) and on whether the Chrome-profile approach fits everyone's setup.

`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/<tenant>` 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.
@pavlo-flamingo
pavlo-flamingo force-pushed the dx/local-dev-gateway-proxy branch from eacbe8a to 30925b9 Compare August 27, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant