feat(dx): local dev server with real gateway access - #294
Draft
pavlo-flamingo wants to merge 1 commit into
Draft
pavlo-flamingo wants to merge 1 commit into
pavlo-flamingo wants to merge 1 commit into
Conversation
`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
force-pushed
the
dx/local-dev-gateway-proxy
branch
from
August 27, 2026 13:04
eacbe8a to
30925b9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
npm run devgives the UI but no API: the browser calls the gateway on a different origin, and the gateway does not allow-listlocalhost.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
__ENVcarries noNEXT_PUBLIC_TENANT_HOST_URL, all three URL builders fall back to relative paths when the host is empty (api-client.tsbuildUrl,relay/environment.tsgetGraphqlUrl,auth-api-client.tsbuildAuthUrl), and a reverse proxy in front fans the paths out.next.config.mjsalready encoded a one-path version of exactly this for/content/*. Local dev was simply missing the proxy.What's in it
scripts/dev-login.mjs.dev-chrome/) and CDP, waits for a human to sign in, reads the cookies viaStorage.getCookies(HttpOnly included — a page cannot), verifies them against/api/me, writes.dev-session.json.scripts/dev-proxy.mjsSet-Cookiecoming down, persists rotations. Routes/oauth+/sasto the shared host and the rest to the tenant host, mirroring the deployment's own split.scripts/dev.mjs.env.localcannot silently bypass the rewrites.Two properties this buys, and they are the point:
401 → /oauth/refreshpath 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 devstampsx-forwarded-host: localhost:3000on rewritten requests, and the authz server builds the token'sissclaim from it — a refresh through the proxy mintediss: 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=0is the delete instruction (RFC 6265 §5.2.2), not "expires about now". Deciding it by comparing a computed expiry againstDate.now()is a sub-millisecond race, and this gateway makes it a live one: it clears session cookies withMax-Age=0; Expires=Thu, 01 Jan 1970, so a lost race stored an empty cookie under the name of a real one./chat(tickets/mingo) was missing; the call fell through tonext 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/meand/api/graphqlauthenticated through the full chain (browser → rewrite → proxy → gateway)./oauth/*correctly routed to the shared host,/api/*to the tenant host.204, both tokens rotate, absorbed into the jar, session survives; correctissafter the fix.Set-Cookiereaching the browser: 0 headers.npm run devunchanged.Safety
.dev-session.jsonis a live credential: written0600, gitignored, and the proxy refuses any upstream whose hostname carries noqa/dev/test/stage/localmarker unlessOPENFRAME_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.originandrewrites()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 devis 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.