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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } },
Expand Down
73 changes: 73 additions & 0 deletions docs/development/setup/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading