From f3f7f857f7e8385ec564b29e037aa8801d187d11 Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Tue, 28 Jul 2026 14:09:49 -0400 Subject: [PATCH 1/3] enable managed auth in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips VITE_MANAGED_AUTH to "true". Users sign in with Clerk and the server provisions a per-user OpenRouter key, so nobody pastes their own. Rollback is the same line back to "false" — one deploy, no data migration, since projects live in IndexedDB and are untouched either way. Also records the Phase 2 COEP spike result, open since June. Serving a real managed build behind the same COEP: require-corp / COOP: same-origin headers render.yaml sets and driving it in Chrome: crossOriginIsolated is true, Clerk's chunks load and initialize, ZERO external origins are requested, and nothing is blocked. The no-rhc + bundled @clerk/ui approach does what it was meant to. Two things must be true in the dashboards or this deploy is not what it looks like, and both are noted in render.yaml: VITE_CLERK_PUBLISHABLE_KEY must be set (unset silently falls back to BYOK, because isManagedAuthEnabled() requires BOTH), and Google must be enabled as a Clerk social connection — which on a production instance needs your own Google OAuth credentials. Still unvalidated: the OAuth redirect round-trip, and the managed agent loop itself. /api/agent/step and the three web tools have only ever seen fixtures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019gS8yjMedGgujEad6zRD7i --- docs/managed-openrouter-migration-plan.md | 14 ++++++++++++++ render.yaml | 19 ++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/managed-openrouter-migration-plan.md b/docs/managed-openrouter-migration-plan.md index 1d86840..280a24b 100644 --- a/docs/managed-openrouter-migration-plan.md +++ b/docs/managed-openrouter-migration-plan.md @@ -91,6 +91,20 @@ create table users ( 3. `gleam-externals/agent.mjs`: fetch a fresh token via `clerk.session.getToken()` per request (Clerk tokens are short-lived) and send `Authorization: Bearer ` to `/api/agent`. > **Status (2026-06-10):** Code side implemented behind `VITE_MANAGED_AUTH` (off by default; old behavior unchanged): bundled `@clerk/clerk-js` (code-split chunk, no CDN script), boot gate in `src/main-gleam.ts` (`src/managed-auth.ts`), fresh `getToken()` per request to `/api/agent` (`src/managed-agent-client.ts`, unit-tested incl. 402 code mapping). Spike **validation** still requires a Render preview deploy with a Clerk dev instance — runbook in `docs/phase2-coep-spike.md`. +> +> **Status (2026-07-28): the COEP half of the spike PASSES.** Measured by serving +> a real `VITE_MANAGED_AUTH=true` production build behind the same +> `COEP: require-corp` + `COOP: same-origin` headers `render.yaml` sets, then +> driving it in Chrome: `crossOriginIsolated === true` (so WebContainers still +> work), Clerk's chunks load and initialize, **zero external origins are +> requested**, and nothing is blocked by COEP. The `no-rhc` + bundled +> `@clerk/ui` approach does what it was supposed to. Managed auth also builds +> cleanly — ~240 code-split chunks, 4.4 MB total, none of it in the entry chunk. +> +> Still unvalidated, and needing real credentials: the **OAuth redirect +> round-trip** (it leaves the origin and comes back, which a placeholder key +> cannot exercise) and the **managed agent loop** — `/api/agent/step`, +> `web_search`, `web_fetch`, `web_post` have only ever seen fixtures. **Success criteria (spike — gate for the rest of the phase):** diff --git a/render.yaml b/render.yaml index 35591ce..698ab09 100644 --- a/render.yaml +++ b/render.yaml @@ -5,12 +5,21 @@ services: envVars: - key: GLEAM_VERSION value: 1.16.0 - # Phase 5 rollout switch: off in production until the COEP spike passes - # and dogfooding is done; PR previews get the managed flow automatically. - # The production flip is: change this value to "true" (and the rollback - # is flipping it back — one deploy each way). + # Phase 5 rollout switch, flipped on 2026-07-28. Managed mode: users sign + # in with Clerk and the server provisions a per-user OpenRouter key, so + # nobody pastes their own. + # + # ROLLBACK is this value back to "false" — one deploy, no data migration + # (projects live in IndexedDB and are untouched either way). + # + # Two things must be true in the dashboards or this is not what it looks + # like: VITE_CLERK_PUBLISHABLE_KEY must be set below — unset silently + # falls back to BYOK, because isManagedAuthEnabled() requires BOTH — and + # Google must be enabled as a Clerk social connection, which on a + # production instance needs your own Google OAuth credentials rather than + # Clerk's shared development ones. - key: VITE_MANAGED_AUTH - value: "false" + value: "true" previewValue: "true" - key: VITE_CLERK_PUBLISHABLE_KEY sync: false From e27c753c03a58e6459129f5e38e04ae2793945a4 Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Thu, 30 Jul 2026 16:50:43 -0400 Subject: [PATCH 2/3] managed auth: sign-out never revoked the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local smoke test found it: clicking "Sign out" closed the account panel and reloaded the page, but the session stayed alive. Clerk's own API still reported it `active`, with an expire_at a week out. A user signing out on a shared machine stayed signed in. Three correct-looking pieces raced. `managedSignOut()` fires `void signOut()` unawaited; Clerk clears `user` locally and synchronously *before* the revoke request lands; the boot gate's listener saw `!user` and reloaded immediately, and the navigation killed the request in flight. On reload Clerk restored the session from the still-valid cookie. Nothing looked wrong from the UI, which is why unit tests missed it. Only checking against Clerk's API rather than the screen could catch it. signOut() now guards with `signingOut`, awaits the revoke to completion, and owns the reload itself. The gate's listener still reloads for a session that ends elsewhere (expiry, another tab) — that path is unchanged. The new test fails against the pre-fix code, so it locks the bug out rather than merely passing beside the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CwNJQ4sAbsG9ZgoioFaSLr --- docs/managed-auth-local-smoke-prd.html | 215 +++++++++++++ docs/managed-auth-local-smoke-progress.html | 320 ++++++++++++++++++++ src/managed-auth.test.ts | 109 +++++++ src/managed-auth.ts | 25 +- 4 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 docs/managed-auth-local-smoke-prd.html create mode 100644 docs/managed-auth-local-smoke-progress.html create mode 100644 src/managed-auth.test.ts diff --git a/docs/managed-auth-local-smoke-prd.html b/docs/managed-auth-local-smoke-prd.html new file mode 100644 index 0000000..cae0cd0 --- /dev/null +++ b/docs/managed-auth-local-smoke-prd.html @@ -0,0 +1,215 @@ + + + + + +PRD — Managed auth local smoke test + + + +
+ +

Managed auth — local smoke test

+

Task slug managed-auth-local-smoke · branch enable-managed-auth · 2026-07-30

+ +

Production was flipped to managed auth in f3f7f85, but the paths that flip +turns on have never run against real credentials. This is a bounded, evidence-gathering +run: stand up both processes locally with a real Clerk dev instance and a real OpenRouter +provisioning key, then drive the browser through the whole funnel and record what actually +happens.

+ +

Definition: what "smoke test" means here

+

In this project, a managed-auth smoke test is a single human-path traversal — +signed-out browser → signed-in → provisioned key → generated app rendering in the preview — +performed against real Clerk and real OpenRouter, with evidence captured at each boundary +the flag controls.

+

It explicitly is not:

+
    +
  • a load, security, or penetration test;
  • +
  • a substitute for the 796 unit tests (already green) — those cover logic in isolation, this covers the seams between processes and third parties;
  • +
  • a production verification — localhost with a Clerk dev instance differs from the deployed pairing (see Risks);
  • +
  • a code-change task. Code changes are only in scope as repairs for defects this run surfaces.
  • +
+ +

Why this run exists

+

docs/managed-openrouter-migration-plan.md marks these as unvalidated, needing real +credentials. This run targets exactly them:

+
    +
  • Phase 2 spike: the OAuth redirect round-trip — it leaves the origin and comes back, which a placeholder key cannot exercise.
  • +
  • Phase 2 spike: the managed agent loop/api/agent/step, web_search, web_fetch, web_post have only ever seen fixtures.
  • +
  • Phase 1: lazy provisioning, 402 budget_exhausted, /api/me budget accuracy, or_key_enc not stored in plaintext.
  • +
  • Phase 3: the friction test — landing → signed up → first app rendering, never seeing a key field, provider dropdown, or model name.
  • +
+ +

Constraints

+ + + + + + + + +
ConstraintConsequence for this run
Both flag halves requiredisManagedAuthEnabled() (src/managed-auth.ts:16) needs VITE_MANAGED_AUTH=true and a non-empty publishable key. A blank key silently falls back to BYOK — the run would pass while testing the wrong path. Step 1 must prove managed mode is actually on before anything else counts.
COOP same-originSevers window.opener, so popup OAuth can never report back. The gate forces oauthFlow: 'redirect' (src/managed-auth.ts:98). Any popup-based sign-in attempt is a defect, not a workaround.
COEP require-corpRequired for WebContainers. Vite dev sets the same headers (vite.config.ts), so local is a faithful stand-in. Zero external-origin requests is the bar the bundled no-rhc approach claims to meet.
Real moneyThe provisioning key mints real budgeted keys and agent calls bill real spend. Keep prompts small; record spend observed.
No webhook reachabilityClerk cannot deliver a webhook to localhost. CLERK_WEBHOOK_SIGNING_SECRET is deliberately unset, so the run exercises the lazy-provisioning fallback instead. Webhook-delivered provisioning stays unvalidated by this run — a stated gap, not a pass.
SecretsCredentials live only in .env.local and server/.env (both gitignored — verified via git check-ignore). No key, token, or JWT is copied into these artifacts, commits, or memory. Evidence records shape and prefix only (e.g. "starts with sk-or-v1-", "does not start with sk-or-").
+ +

Non-goals

+
    +
  • Deleting the BYOK path (migration plan defers this to post-Phase-5 cleanup).
  • +
  • Clerk Billing / tier upgrades (Phase 4) — the run uses whatever tier a fresh user gets.
  • +
  • Validating the Render deploy or the /api/* rewrite in production.
  • +
  • Fixing pre-existing defects unrelated to the managed path.
  • +
+ +

Plan

+ +
+

Step 1 — Both processes up, managed mode provably on gate

+

Fill the two env files, start server (:3000) and vite (:5173).

+Acceptance criteria +
    +
  • AC1.1 GET localhost:3000/api/health200 {"ok":true}.
  • +
  • AC1.2 GET localhost:5173/api/health200 — proves the Vite proxy stands in for the Render rewrite.
  • +
  • AC1.3 Server boots without throwing on requireEnv; log shows the PGlite data dir.
  • +
  • AC1.4 Server logs the expected CLERK_WEBHOOK_SIGNING_SECRET not set warning (confirms lazy-provisioning mode, not a misconfiguration).
  • +
  • AC1.5 Managed mode is on: in the browser console, import.meta.env equivalents resolve such that the sign-in gate — not the app — renders. A rendered app at this point means BYOK fallback and is a hard fail.
  • +
+

Evidence: curl status lines; server stdout excerpt; screenshot of the first paint.

+
+ +
+

Step 2 — Boot gate and isolation spike

+

Load localhost:5173 in Chrome, signed out.

+Acceptance criteria +
    +
  • AC2.1 Clerk sign-in component renders inside the branded landing shell (not the Account Portal on another origin).
  • +
  • AC2.2 crossOriginIsolated === true in the console.
  • +
  • AC2.3 Zero CORP/COEP errors in the console during load and sign-in.
  • +
  • AC2.4 Network log shows no requests to external origins other than Clerk's own API — specifically none to a CDN for clerk-js/ui chunks.
  • +
  • AC2.5 The Gleam app, WebContainer boot, and IndexedDB access have not started (no WebContainer logs, no IndexedDB databases created).
  • +
+

Evidence: console snapshot, filtered network request list, screenshot.

+
+ +
+

Step 3 — Sign in with the managed Clerk account never validated

+

Complete sign-up/sign-in. If Google is enabled on the dev instance, prefer the OAuth path since the redirect round-trip is the specific unknown.

+Acceptance criteria +
    +
  • AC3.1 Sign-in completes and the gate's listener resolves ([managed-auth] gate event with a non-null user).
  • +
  • AC3.2 The redirect round-trip returns to localhost:5173 in the same tab (no popup, no orphaned window) and the session survives it.
  • +
  • AC3.3 window.Clerk.session.getToken() returns a JWT (recorded as "present, three-segment", never pasted).
  • +
  • AC3.4 The sign-in component unmounts, the shell is removed, and the Gleam app mounts.
  • +
+

Evidence: console debug lines, screenshots before/after, token shape assertion.

+
+ +
+

Step 4 — Lazy provisioning and budget display never validated

+

First authenticated call triggers inline provisioning (no webhook in dev).

+Acceptance criteria +
    +
  • AC4.1 GET /api/me → 200 with plan, model, limit, limitRemaining.
  • +
  • AC4.2 A users row exists in PGlite for the Clerk user id.
  • +
  • AC4.3 or_key_enc does not start with sk-or- (encrypted at rest).
  • +
  • AC4.4 The OpenRouter dashboard / management API shows a newly minted key with the tier limit and limit_reset: "monthly".
  • +
  • AC4.5 The account panel in the UI shows plan and remaining budget matching /api/me — and shows no key field, provider dropdown, or raw model id.
  • +
+

Evidence: response body with values redacted to shape; PGlite query output; management API listing; screenshot of the account panel.

+
+ +
+

Step 5 — Full agent round-trip never validated

+

Submit a small prompt and let the agent loop run to a rendered app.

+Acceptance criteria +
    +
  • AC5.1 /api/agent and/or /api/agent/step return 200 with the expected shape; the loop terminates rather than spinning.
  • +
  • AC5.2 A generated app renders in the preview iframe.
  • +
  • AC5.3 A WebContainer boots (install/serve output visible in the terminal pane) — i.e. COEP isolation survived sign-in.
  • +
  • AC5.4 No browser request goes to openrouter.ai — only /api/* and Clerk.
  • +
  • AC5.5 Spend is attributed to that user's provisioned key, not the provisioning key (compare usage before/after).
  • +
  • AC5.6 At no point in the funnel did a key field, provider dropdown, or model name appear (the friction test).
  • +
+

Evidence: network log, preview screenshot, terminal excerpt, OpenRouter usage delta.

+
+ +
+

Step 6 — Session lifecycle stretch

+

Only if Steps 1–5 pass; these are the migration plan's remaining Phase 2 criteria.

+Acceptance criteria +
    +
  • AC6.1 A request issued after the ~60s token lifetime still succeeds (fresh getToken() per request).
  • +
  • AC6.2 Sign-out returns to the sign-in screen (the gate's listener reloads the page).
  • +
  • AC6.3 A replayed post-sign-out token against /api/me401.
  • +
+

Evidence: timestamped request pair, screenshot, curl status.

+
+ +

Validation commands

+
+
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/health
+curl -s -o /dev/null -w '%{http_code}\n' localhost:5173/api/health
+npm test              # 197 gleam + 290 vitest   (baseline: green)
+cd server && npm test  # 309 server              (baseline: green)
+
+ +

Definition of done

+

Steps 1–5 each have every acceptance criterion marked pass with recorded evidence, +or a criterion is marked fail with a diagnosed root cause and either a repair +landed or an explicit escalation. Step 6 is best-effort. The final report states plainly which of +the migration plan's unvalidated criteria this run closed and which remain open — with webhook- +delivered provisioning and production-instance OAuth named as still-unvalidated regardless of +outcome.

+ +

Judge rubric

+ + + + + + + + +
AreaJudgment
CorrectnessDoes each criterion's evidence actually support the verdict, or was a pass inferred from an adjacent observation?
Path authenticityWas managed mode provably on throughout? Any BYOK-fallback contamination invalidates the run.
Security/privacyNo secret, JWT, or raw key in artifacts, commits, or memory. Evidence records shape only.
Honesty of reportingAre gaps (webhook, production instance, tier/billing) stated as gaps rather than quietly folded into "passed"?
Repair qualityIf defects were found and fixed, is each fix minimal, idiomatic, and covered by a test that would have caught it?
ValidationWere the unit suites re-run after any repair?
+

Taste/originality applies only to UI or copy changes made as repairs +(a broken sign-in surface, a bad error state). Pure verification work is not scored on taste. +Threshold if it applies: below 4 triggers a repair loop.

+ +

Risks

+ + + + + + + +
RiskHandling
Blank publishable key → silent BYOK fallback → false passAC1.5 makes this a gate before any other step counts.
Dev instance ≠ production instanceGoogle OAuth on dev uses Clerk's shared credentials; production needs your own. A dev pass does not clear production — stated in the report.
Real spend on a real keySmall prompts; spend delta recorded; the provisioned key's own limit caps blast radius by design.
Runaway agent loopAC5.1 requires termination; abort and diagnose rather than letting it spin.
Local PGlite state leaking between attemptsserver/.data is gitignored; note when it is cleared, since a stale row changes whether provisioning is lazy or a no-op.
+ +
+ + diff --git a/docs/managed-auth-local-smoke-progress.html b/docs/managed-auth-local-smoke-progress.html new file mode 100644 index 0000000..b756f86 --- /dev/null +++ b/docs/managed-auth-local-smoke-progress.html @@ -0,0 +1,320 @@ + + + + + +Progress — Managed auth local smoke test + + + +
+ +

Progress — managed auth local smoke test

+

Task slug managed-auth-local-smoke · branch enable-managed-auth · PRD: managed-auth-local-smoke-prd.html

+ +

Status: complete — all six steps pass, +end to end, against real Clerk and real OpenRouter. One security-relevant defect was found and +fixed (sign-out never revoked the session); one unrelated pre-existing defect is documented +and left for the user to schedule.

+ +

Headline results

+
    +
  1. The Phase 2 spike is fully closed. crossOriginIsolated === true with + Clerk loaded, zero COEP/CORP errors, and zero CDN requests (250 resources: 248 local, + 2 to Clerk's API). The OAuth redirect round-trip — explicitly listed as unvalidatable with a + placeholder key — completes in the same tab and the session survives it.
  2. +
  3. The managed agent loop works against a real model. One prompt → 9 tool steps → + 2 files written → the agent verified its own build → the app rendered in a live WebContainer. + Zero browser requests to openrouter.ai, and the $0.0122 of spend landed + on that user's provisioned key.
  4. +
  5. Sign-out was silently broken, and is now fixed. Found only because the run + checked Clerk's own API rather than trusting the UI. See F4.
  6. +
+ +

Blocker

+

Three real credentials are needed; the run cannot start without them, and starting without them +would produce a false pass (blank publishable key ⇒ silent BYOK fallback ⇒ the smoke test +exercises the pre-managed path while appearing to succeed).

+ + + + + + + + +
FileKeySourceState
.env.localVITE_MANAGED_AUTHset true
.env.localVITE_CLERK_PUBLISHABLE_KEYClerk dev instance → API keys → Publishable key (pk_test_…)empty
server/.envCLERK_SECRET_KEYClerk dev instance → API keys → Secret key (sk_test_…)empty
server/.envOPENROUTER_PROVISIONING_KEYOpenRouter → Settings → Provisioning keys (not a normal API key)empty
server/.envKEY_ENCRYPTION_SECRETgenerated locallyset
server/.envPGLITE_DATA_DIRset .data
+

Both files are gitignored — confirmed with git check-ignore -v: +.gitignore:7:.env → server/.env and .gitignore:9:.env*.local → .env.local.

+ +

Pre-flight findings

+ + + + + + + + + +
CheckResult
Toolchainpass gleam 1.16.0 (matches GLEAM_VERSION in render.yaml), node v22.22.0 (matches engines.node >=22). Ports 3000 and 5173 free.
Local ≈ production topologypass vite.config.ts proxies /api and /webhooks to localhost:3000 and sets the same COOP: same-origin + COEP: require-corp that render.yaml sets. Local is a faithful stand-in for the Render rewrite.
Existing OPENROUTER_API_KEY usable for provisioning?no GET openrouter.ai/api/v1/keys401 {"error":{"message":"Invalid management key"}}. It is a normal inference key; a separate provisioning key is required.
Clerk credentials present anywhere locallyno Not in env, not in shell profiles, no .env.local, no server/.env. Render has them as sync: false dashboard values.
Baseline: gleam testpass 197 passed, no failures
Baseline: vitest run (frontend)pass 290 passed / 19 files
Baseline: vitest run (server)pass 309 passed / 13 files
+

Baseline total: 796 tests green before any change. Any red after a repair is +attributable to this run.

+ +

Step checklist

+ + + + + + + + +
StepStatusEvidence
1 — Processes up, managed mode provably onpass5/5 criteria — see below
2 — Boot gate and isolationpass5/5 criteria — see below
3 — Sign in (OAuth redirect round-trip)pass4/4 — Google OAuth, user user_3ExWw…
4 — Lazy provisioning and budget displaypass5/5 — key minted, encrypted at rest, budget shown
5 — Full agent round-trippass6/6 — app rendered, spend correctly attributed
6 — Session lifecyclepass3/3 — after the F4 repair
+ +

Step 3 — evidence

+ + + + + + +
ACVerdictEvidence
3.1 gate resolvespassUser user_3ExWwDyvFfzrokOxgnth2cZk4W5 (tom@hyper.io), session sess_3HEdwx…, external account provider google
3.2 redirect round-trippassReturned to localhost:5173 in the same tab; session intact. This is the item the migration plan said "a placeholder key cannot exercise".
3.3 token issuedpassJWT present, 3 segments, 820 chars, 60s lifetime (exp-iat). Value never recorded.
3.4 app mountspassShell removed, Gleam app mounted, __buildManagedAuth registered, WebContainer preview live
+ +

Step 4 — evidence

+ + + + + + + +
ACVerdictEvidence
4.1 /api/mepass200 in 1220 ms — {plan:"free", model:"qwen/qwen3.6-35b-a3b", limit:5, usage:0, limitRemaining:5, toolCapable:true, maxToolSteps:8}. Lazy provisioning fired with no webhook, as designed.
4.2 users rowpass1 row: clerk_user_id=user_3ExWw…, tier=free, disabled=false
4.3 encrypted at restpassor_key_enc is a 101-byte Uint8Array; does not start with sk-or- and does not contain that substring anywhere
4.4 key at OpenRouterpassbuild-user-user_3ExWw… created 2026-07-30T18:51:06Z, limit: 5, limit_reset: "monthly"
4.5 account panelpass"Plan: free", "Budget: $4.99 of $5.00 remaining this month" — matches /api/me after spend. No provider dropdown, no model name. (See F5 on the ScoutOS publishing field.)
+ +

Step 5 — evidence

+ + + + + + + + +
ACVerdictEvidence
5.1 loop terminatespass"9 steps · 2 files · checked it builds" — the harness ran tools, self-verified, and stopped
5.2 app renderspassHeading "Smoke Test OK" on a pale-green background, exactly as prompted; src/style.css + src/main.tsx patched
5.3 WebContainer bootspassPreview served from …local-corp.webcontainer-api.io — COEP isolation survived sign-in
5.4 no direct provider trafficpassResource origins after a full session: localhost:5173 ×211, Clerk ×7, stackblitz.com ×1, webcontainer preview ×2. Requests matching openrouter.ai: 0.
5.5 spend attributionpassNew key usage 0 → 0.012206708. The user's older June key stayed at 0.121544874. Provisioning key itself not charged.
5.6 friction testpassSign-in → prompt → rendered app with no key field, provider dropdown, or model name anywhere in the path
+ +

Step 6 — evidence

+ + + + + +
ACVerdictEvidence
6.1 token refreshpassPaired control past expiry: fresh getToken()200; the token captured 60s earlier → 401 unauthorized. Proves both per-request refresh and real server-side expiry.
6.2 sign-out returns to gatefailpassInitially failed (F4). After the repair: gate rendered, and Clerk reports the session removed.
6.3 old token rejectedpass401 on replay. Caveat recorded honestly: the replayed token was already past its 60s expiry, so this demonstrates expiry enforcement, not instant revocation. Clerk verification is networkless, so a still-valid token remains accepted for up to 60s after sign-out by design.
+ +

Step 1 — evidence

+ + + + + + + +
ACVerdictEvidence
1.1 api health directpassGET :3000/api/health200 {"ok":true}
1.2 health via proxypassGET :5173/api/health200 {"ok":true} — the Render rewrite stand-in works
1.3 clean bootpass[build-api] pglite data dir: .data / listening on :3000; no requireEnv throw
1.4 lazy-provisioning modepassCLERK_WEBHOOK_SIGNING_SECRET not set — /webhooks/clerk disabled, relying on lazy provisioning — expected, not a misconfiguration
1.5 managed mode provably onpassFirst paint is the Clerk gate, not the app. gleamAppMounted: false. No BYOK contamination.
+

Bonus, unplanned: unauthenticated GET /api/me401 unauthorized, and a +garbage bearer token → 401 as well. Server-side JWT rejection is wired correctly through +the proxy.

+ +

Step 2 — evidence

+ + + + + + + +
ACVerdictEvidence
2.1 gate renders inlinepassClerk component renders inside the branded landing shell alongside the "What do you want to build?" panel — not bounced to the Account Portal. "Development mode" badge confirms the dev instance.
2.2 isolationpasscrossOriginIsolated === true with Clerk.status === "ready"
2.3 no COEP/CORP errorspassConsole contains only the two expected [managed-auth] debug lines. Zero errors.
2.4 no external originspass250 resources: 248 localhost:5173, 2 fond-moose-70.clerk.accounts.dev. Zero CDN requests — the whole point of the no-rhc bundling.
2.5 app not startedpasswebContainerBooted: false, managedAuthBridge: false, gleamAppMounted: false, Clerk.user === null. See the IndexedDB note below.
+ +

Findings

+ +

F1 — env-store.ts opens build-db at the wrong version pre-existing defect fixed 2026-07-30

+

Fixed in the follow-up task build-db-schema-owner: +projects.ts is now the sole schema owner at version 3 and creates the env-store +store; env-store.ts delegates to openBuildDb. Verified against the user's real +database — v2 → v3 with all 3 projects, their files, messages, and +current-project-id intact.

+

Correction to what this section originally claimed. It described the defect as +breaking the env-vars feature. It did not: env-store.ts has no consumers +anywhere in the repo — the string appears only in the module and its own test. This was a +latent defect in an unused module, which would have bitten whoever wired it up. The +VersionError reproduction below is real; the user-facing impact was overstated.

+

Not caused by managed auth — recorded because it was found in the course of +clearing AC2.5.

+

src/projects.ts:33 opens build-db at DB_VERSION = 2. Its own +comment (projects.ts:30) states the rule: "build-db must be opened at ONE +version, and two modules opening it at different versions throws on whichever is second." +src/env-store.ts:2 opens the same database at DB_VERSION = 1 — breaking +exactly that rule.

+

Measured in the live profile: build-db is currently at version 1 with +stores ['meta','projects'] — no workspace, no env-store. So the +ordering is currently benign only because projects.ts has not yet upgraded it this session. +Once projects.ts opens at v2, a subsequent env-store open at v1 fails with a +VersionError (requested version is less than existing). Both open lazily and memoize, so +which one loses depends purely on call order.

+

Also worth noting: both modules are memoized per module instance, and the Gleam build +mirrors src/ into build/dev/javascript/build/ — the same duplicate-module +hazard managed-auth.ts:65 already works around via globalThis.

+ +

F2 — IndexedDB pre-existence is not a gate leak resolved

+

indexedDB.databases() reports build-db while signed out, which naively +reads as an AC2.5 violation. It is not: both openBuildDb() (projects.ts:43) +and openDb() (env-store.ts:7) open lazily inside memoized functions, with no +module-level side effect. The database is a leftover from earlier dev sessions on this origin. +Deliberately not deleted to confirm this — it holds the user's projects.

+ +

Decisions taken

+
    +
  • Clerk dev instance, not production. Dev keys work on localhost and Google OAuth uses Clerk's shared dev credentials. Consequence: a pass here does not clear the production instance, which needs its own Google OAuth credentials and localhost as an allowed origin.
  • +
  • Webhook secret deliberately unset. Clerk cannot deliver to localhost, so /webhooks/clerk stays disabled and the run exercises the lazy-provisioning fallback instead. Webhook-delivered provisioning remains unvalidated by this run.
  • +
  • Full round-trip scope including real model spend, per user direction. The provisioned key's own limit caps blast radius.
  • +
  • Subagents not used. Session policy disallows spawning them; the LFG planner and judge roles are performed inline against the same rubric. Noted so a re-entering session does not look for agent output that never existed.
  • +
+ +

F3 — provisioning is not idempotent across a lost users table design risk, now demonstrated

+

Lazy provisioning minted a second OpenRouter key for a user who already had one. Both are +live right now:

+
+
build-user-user_3ExWwDyvFfzrokOxgnth2cZk4W5   created 2026-06-10   limit 1   usage 0.1215
+build-user-user_3ExWwDyvFfzrokOxgnth2cZk4W5   created 2026-07-30   limit 5   usage 0.0122   <- minted by this run
+
+

Cause: the server decides whether to provision from its own users table, and this +local PGlite was empty. It never asks OpenRouter whether a key of that name already exists. The old key +is now orphaned — still enabled, still carrying budget, and no longer referenced by any row.

+

render.yaml already names this hazard ("a lost users table orphans every provisioned +OpenRouter key") and mitigates it with the persistent disk. This run confirms the failure mode is real +rather than theoretical, and shows it also triggers whenever a second environment points at the +same provisioning key — which is exactly what a local dev setup does. There are 7 build-user-* +keys on this provisioning key today.

+

Not fixed here — the remedy is a product decision (reuse-by-name lookup on provision, +or a cleanup pass for unreferenced build-user-* keys), not a smoke-test repair.

+ +

F4 — sign-out never revoked the session security-relevant fixed

+

The most valuable thing this run found. Clicking "Sign out" in the account panel +appeared to work — the panel closed and the page reloaded — but the user stayed signed in, and Clerk's +own API still reported the session active, with an expire_at roughly a week out.

+

Root cause (a race between two correct-looking pieces of code):

+
    +
  1. managedSignOut() (src/gleam-externals/managed.mjs:16) fires + void __buildManagedAuth.signOut() — deliberately unawaited.
  2. +
  3. Clerk's signOut() clears user locally and synchronously, then + sends the revoke request.
  4. +
  5. The boot gate's listener (src/managed-auth.ts) saw !user and called + window.location.reload() immediately.
  6. +
  7. The navigation aborted the revoke request in flight. After reload, Clerk restored the session from + the still-valid cookie.
  8. +
+

Every individual piece looked right, which is why unit tests missed it and why only an out-of-band +check against Clerk's API could catch it. A user signing out on a shared machine stayed signed in.

+

Fix: signOut() now sets a signingOut guard, awaits +clerk.signOut() to completion, and only then reloads. The gate's listener ignores the +user → null event while that guard is set, so a session ending elsewhere (expiry, +another tab) still returns the visitor to the gate.

+

Verified end to end: re-running the exact unawaited call the account panel makes +now drives the session from active to removed at Clerk, and the gate renders.

+ +

F5 — one key field does remain in the account panel by design

+

The friction test passes for the build path, but the account panel does contain a +ScoutOS API key field (for publishing). This is not a provider/LLM credential and is +never required to build. Phase 3's own criterion scopes it out ("no hits outside the account panel"). +Recorded so nobody later reads "no key fields anywhere" into this result.

+ +

Repair log

+ + + + + + + + +
#StepDiagnosisActionResult
16 (AC6.2)F4 — reload raced the in-flight revoke request. Diagnosed by querying Clerk's session API directly rather than trusting the UI, which is what turned "looks fine" into "provably still active".Guarded + awaited signOut() in src/managed-auth.ts; added src/managed-auth.test.ts (3 tests).pass — session now removed; gate renders; suites green
+

The new test was checked against the pre-fix code and fails there +("does not reload until the revoke request has actually settled"), so it genuinely locks the bug out +rather than merely passing alongside the fix.

+ +

Validation after repair

+
+
gleam test         197 passed, no failures
+vitest run         293 passed / 20 files   (+3 new, was 290/19)
+server vitest      309 passed / 13 files
+                   ---
+                   799 green
+
+ +

Files changed

+
    +
  • src/managed-auth.ts — awaited, guarded sign-out (the F4 fix)
  • +
  • src/managed-auth.test.ts — new, 3 regression tests
  • +
  • docs/managed-auth-local-smoke-prd.html, docs/managed-auth-local-smoke-progress.html — these artifacts
  • +
  • .env.local, server/.env — local credentials, gitignored, not committed
  • +
+ +

What this run did NOT validate

+
    +
  • Webhook-delivered provisioning. Clerk cannot reach localhost; /webhooks/clerk was disabled throughout. Only the lazy fallback is proven. user.deleted cleanup is entirely unexercised.
  • +
  • The production Clerk instance. This was a dev instance; its Google connection uses Clerk's shared credentials. Production needs its own Google OAuth credentials — the caveat render.yaml already carries.
  • +
  • The Render deploy path — the /api/* rewrite was stood in for by the Vite proxy, which is equivalent in shape but not the same infrastructure.
  • +
  • Budget exhaustion (402). Not exercised — it would have required burning $5 or provisioning a throwaway low-limit key. budget_exhausted remains fixture-tested only.
  • +
  • Tiers/billing (Phase 4) — the run only ever saw the free tier.
  • +
  • Instant revocation. By design, a valid token is accepted for up to 60s after sign-out (networkless verification).
  • +
+ +

Handoff log

+
+
2026-07-30  begin        managed-auth-local-smoke  step 0  pre-flight complete, 796 tests green
+2026-07-30  blocked      managed-auth-local-smoke  step 1  awaiting VITE_CLERK_PUBLISHABLE_KEY,
+                                                            CLERK_SECRET_KEY, OPENROUTER_PROVISIONING_KEY
+2026-07-30  unblocked    managed-auth-local-smoke  step 1  Clerk keys supplied; pk+sk verified same
+                                                            dev instance fond-moose-70; secret key
+                                                            relocated .env.local -> server/.env
+2026-07-30  step-1-done  managed-auth-local-smoke  step 1  5/5 AC pass; both processes up
+2026-07-30  step-2-done  managed-auth-local-smoke  step 2  5/5 AC pass; crossOriginIsolated true,
+                                                            0 CDN requests, 0 COEP errors
+2026-07-30  needs-human  managed-auth-local-smoke  step 3  sign-in must be performed by the user
+                                                            (no password entry / account creation by
+                                                            the agent); provisioning key still needed
+                                                            for steps 4-5
+2026-07-30  step-3-done  managed-auth-local-smoke  step 3  Google OAuth redirect round-trip works
+2026-07-30  step-4-done  managed-auth-local-smoke  step 4  lazy provisioning + encryption at rest
+2026-07-30  step-5-done  managed-auth-local-smoke  step 5  agent loop rendered an app; 0 openrouter.ai
+                                                            hits; $0.0122 on the user's own key
+2026-07-30  repairing    managed-auth-local-smoke  step 6  F4: sign-out left session active at Clerk
+2026-07-30  step-6-done  managed-auth-local-smoke  step 6  F4 fixed + regression test; session now
+                                                            'removed'; 799 tests green
+2026-07-30  complete     managed-auth-local-smoke  --      all 6 steps pass; F1/F3/F5 documented,
+                                                            not fixed (out of scope, user's call)
+
+ +
+ + diff --git a/src/managed-auth.test.ts b/src/managed-auth.test.ts new file mode 100644 index 0000000..29102b8 --- /dev/null +++ b/src/managed-auth.test.ts @@ -0,0 +1,109 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Regression cover for the sign-out race found by the 2026-07-30 local smoke + * test: the account panel fired signOut() without awaiting it, and the boot + * gate's listener reloaded the page the instant Clerk cleared `user` locally. + * The navigation aborted the revoke request in flight, so Clerk never ended + * the session — it came back alive from the still-valid cookie, and the + * session stayed `active` server-side for days. + */ + +const reload = vi.fn() + +type Listener = (state: { user: unknown; session: unknown }) => void + +let listeners: Listener[] = [] +let signOutResolve: () => void +let signOutCalls = 0 +let clerkUser: unknown = { id: 'user_1' } + +const clerkStub = { + get user() { + return clerkUser + }, + session: { id: 'sess_1', getToken: async () => 'jwt' }, + status: 'ready', + load: vi.fn(async () => {}), + mountSignIn: vi.fn(), + unmountSignIn: vi.fn(), + addListener: (fn: Listener) => { + listeners.push(fn) + return () => { + listeners = listeners.filter(l => l !== fn) + } + }, + signOut: vi.fn(() => { + signOutCalls += 1 + // Clerk clears local state immediately; the network revoke settles later. + clerkUser = null + listeners.forEach(l => l({ user: null, session: null })) + return new Promise(res => { + signOutResolve = res + }) + }), +} + +// Must be constructible — managed-auth.ts calls `new Clerk(publishableKey)`. +vi.mock('@clerk/clerk-js/no-rhc', () => ({ + Clerk: function Clerk(this: unknown) { + return clerkStub + }, +})) +vi.mock('@clerk/ui/no-rhc', () => ({ ui: {} })) +vi.mock('./landing', () => ({ + createLandingShell: () => ({ + expandToLanding: () => ({ signInSlot: document.createElement('div') }), + remove: vi.fn(), + }), +})) + +async function loadModule() { + vi.resetModules() + vi.stubEnv('VITE_MANAGED_AUTH', 'true') + vi.stubEnv('VITE_CLERK_PUBLISHABLE_KEY', 'pk_test_x') + return import('./managed-auth') +} + +beforeEach(() => { + listeners = [] + signOutCalls = 0 + clerkUser = { id: 'user_1' } + reload.mockClear() + vi.stubGlobal('location', { reload, href: 'http://localhost:5173/' }) +}) + +describe('signOut', () => { + it('does not reload until the revoke request has actually settled', async () => { + const mod = await loadModule() + await mod.ensureSignedIn() + + const pending = mod.signOut() + // Clerk has already cleared `user` and fired the listener by now. If the + // gate reloads here, the revoke request dies with the page. + await Promise.resolve() + expect(signOutCalls).toBe(1) + expect(reload).not.toHaveBeenCalled() + + signOutResolve() + await pending + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('still returns a session that ended elsewhere to the gate', async () => { + const mod = await loadModule() + await mod.ensureSignedIn() + + // Not a deliberate sign-out — an expiry or a revoke from another tab. + listeners.forEach(l => l({ user: null, session: null })) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('is a no-op before Clerk has loaded', async () => { + const mod = await loadModule() + await mod.signOut() + expect(signOutCalls).toBe(0) + expect(reload).not.toHaveBeenCalled() + }) +}) diff --git a/src/managed-auth.ts b/src/managed-auth.ts index a54259e..b0b9dda 100644 --- a/src/managed-auth.ts +++ b/src/managed-auth.ts @@ -57,9 +57,27 @@ export async function getSessionToken(): Promise { return clerk.session ? clerk.session.getToken() : null } +/** + * True while a deliberate sign-out is in flight, so the gate's listener below + * does not reload the page out from under it. Clerk clears `user` locally the + * moment signOut() is called, long before the revoke request reaches Clerk — + * reloading on that event aborts the request in flight, and the session comes + * back alive from the still-valid cookie. + */ +let signingOut = false + export async function signOut(): Promise { if (!clerkInstance) return - await clerkInstance.signOut() + signingOut = true + try { + // Must be awaited: this is the call that actually revokes the session + // server-side. Reloading before it settles leaves the session active. + await clerkInstance.signOut() + } finally { + signingOut = false + } + // Re-run the boot gate now that the session is genuinely gone. + window.location.reload() } /** @@ -112,8 +130,11 @@ export async function ensureSignedIn(): Promise { } shell.remove() + // A session that ends elsewhere (expiry, revoked in another tab) returns the + // visitor to the gate. A deliberate sign-out is excluded: signOut() owns the + // reload, and only after the revoke request has actually settled. // @ts-ignore — listener callback type may vary by Clerk version clerk.addListener(({ user }) => { - if (!user) window.location.reload() + if (!user && !signingOut) window.location.reload() }) } From 9b3285b6faeb98d7f11b231b40446279eef7426e Mon Sep 17 00:00:00 2001 From: Tom Wilson Date: Thu, 30 Jul 2026 16:51:05 -0400 Subject: [PATCH 3/3] build-db: one schema owner, and the version bump that makes it true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projects.ts has always claimed the schema in a comment: "build-db must be opened at ONE version, and two modules opening it at different versions throws on whichever is second." workspace-store.ts obeys it. env-store.ts did not — it opened the same database at version 1 while projects.ts owned it at 2, so once anything upgraded the database, env-store threw VersionError. Latent rather than live: nothing imports env-store today. Bumping env-store to 2 would have been the wrong fix. A database already at 2 never fires onupgradeneeded again, so the store would silently never be created — a quiet NotFoundError instead of a loud VersionError. The version has to advance, so it goes to 3 and the owner creates the store. env-store.ts now delegates to openBuildDb, the same one-liner workspace-store.ts already used. There is exactly one indexedDB.open for build-db in src/. A version bump also needs onblocked and onversionchange, which were missing: a connection open at an older version blocks the upgrade silently and forever. The pre-fix test run demonstrated it — one case failed by timing out at 5001ms, not by assertion. The old env-store test carried ~50 lines of fake-DB scaffolding no test used, and its stubbed open() ignored the version argument, so the four storage functions had zero coverage while appearing tested. Rewritten on fake-indexeddb (as projects.test.ts already did): 3 tests to 14, covering both module orderings and every historical database shape. Five fail against the pre-fix code. Verified against a real v2 database: upgrades to v3 with all projects, their files and messages, and current-project-id intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CwNJQ4sAbsG9ZgoioFaSLr --- docs/build-db-schema-owner-prd.html | 211 ++++++++++++++++++++ docs/build-db-schema-owner-progress.html | 173 ++++++++++++++++ src/env-store.test.ts | 239 ++++++++++++++++++----- src/env-store.ts | 27 +-- src/projects.ts | 29 ++- 5 files changed, 606 insertions(+), 73 deletions(-) create mode 100644 docs/build-db-schema-owner-prd.html create mode 100644 docs/build-db-schema-owner-progress.html diff --git a/docs/build-db-schema-owner-prd.html b/docs/build-db-schema-owner-prd.html new file mode 100644 index 0000000..b22032e --- /dev/null +++ b/docs/build-db-schema-owner-prd.html @@ -0,0 +1,211 @@ + + + + + +PRD — One schema owner for build-db + + + +
+ +

One schema owner for build-db

+

Task slug build-db-schema-owner · branch enable-managed-auth · 2026-07-30 · follow-up to finding F1 of the managed-auth smoke test

+ +

Definition

+

In this project, "schema owner" means exactly one module declares +build-db's version number and creates every object store in a single +onupgradeneeded handler; every other module obtains its handle from that owner and +never calls indexedDB.open itself.

+

src/projects.ts already claims this role in its own comment +(projects.ts:30): "This module owns the schema: build-db must be opened +at ONE version, and two modules opening it at different versions throws on whichever is second." +src/workspace-store.ts:40 already complies, via export const openWorkspaceDb = openBuildDb. +This task makes the one non-complying module comply.

+ +

The defect

+ + + + + +
ModuleOpensAt versionCreates
src/projects.ts:33build-db2projects, meta, workspace
src/workspace-store.ts:40— (delegates)
src/env-store.ts:9build-db1env-store
+

Measured in a real browser during the smoke test: once projects.ts has upgraded the +database to 2, env-store's open at 1 fails with +VersionError: The requested version (1) is less than the existing version (2).

+ +
+

Scope correction, recorded honestly. The smoke-test writeup described this as +breaking the env-vars feature. It does not, today: env-store.ts has no +consumers — a repo-wide search finds the string env-store only in the module +itself and its own test. It is a latent defect in an unused module. That lowers the urgency but not +the correctness argument: the module ships, and it is wrong the moment anyone wires it up.

+
+ +

Why the obvious fix is wrong

+

Changing env-store.ts's DB_VERSION from 1 to 2 looks like a one-character +fix and is a trap. Databases already at version 2 would open without firing +onupgradeneeded at all, so the env-store object store would never be +created — trading a loud VersionError for a quieter +NotFoundError on first use. The store has to be created by the owner's upgrade handler, +which means the version must actually advance to 3.

+ +

Goals

+
    +
  • One module opens build-db; everyone else delegates.
  • +
  • Every existing database — v1 with {meta, projects}, v2 with + {meta, projects, workspace}, or an env-store-first v1 — converges on the same v3 shape + with no data loss.
  • +
  • A test that fails against today's code, in real IndexedDB semantics rather than a stub.
  • +
  • Make the version bump safe to deploy while a tab running the old code is still open.
  • +
+ +

Non-goals

+
    +
  • Deleting env-store.ts. Defensible — the migration plan's Phase 0 + deleted dead trees on exactly this reasoning — but the user asked to fix, not to remove. Flagged as + an open decision below rather than taken unilaterally.
  • +
  • Building any env-vars UI or wiring the module to a consumer.
  • +
  • Touching the F3 provisioning-idempotency finding, which needs a product decision.
  • +
  • Changing what any store holds, or migrating record shapes.
  • +
+ +

Constraints

+ + + + + + +
ConstraintConsequence
Real user databuild-db holds the user's actual projects. A migration that drops a store destroys work. Every createObjectStore stays guarded by a contains() check, and the upgrade is verified against the user's live database, not just a synthetic one.
Duplicate module instancesThe Gleam build mirrors src/ into build/dev/javascript/build/, and main-gleam.ts imports from both paths. Two copies of projects.ts can each hold a memoized connection. Same version in both, so they coexist — but it means an upgrade can find another connection already open.
Version bumps can blockAn open connection at v2 blocks a v3 upgrade indefinitely. Today openBuildDb has neither an onblocked nor an onversionchange handler, so a second tab on the old code would hang the new one silently — with no error, forever.
No subagentsSession policy disallows spawning them; planner and judge roles are performed inline against the same rubric.
+ +

Open decision (not a blocker)

+

Since env-store.ts is unused, the alternative to fixing it is deleting it (~78 lines +plus its test). Recommendation: fix now, decide deletion separately. The fix is small +and makes the schema rule uniformly true, which is worth having regardless; deletion is a product call +about whether per-project env vars are still wanted. Proceeding with the fix under that assumption.

+ +

Plan

+ +
+

Step 1 — projects.ts becomes the sole schema owner at v3

+

Bump DB_VERSION to 3, create the env-store object store in the same +guarded upgrade handler, and add the missing onblocked / onversionchange +handling that a version bump makes necessary.

+Acceptance criteria +
    +
  • AC1.1 DB_VERSION === 3 and the upgrade handler creates all four stores, each guarded by objectStoreNames.contains().
  • +
  • AC1.2 onblocked rejects with a message naming the cause, instead of hanging forever.
  • +
  • AC1.3 onversionchange closes the connection so another tab's upgrade is never blocked by this one.
  • +
  • AC1.4 The store-name constant is exported so env-store.ts cannot drift from the owner's spelling.
  • +
+

Evidence: diff; unit tests in step 3.

+
+ +
+

Step 2 — env-store.ts delegates

+

Delete its DB_NAME/DB_VERSION/openDb and import +openBuildDb, mirroring workspace-store.ts exactly — same shape, same +explanatory comment style.

+Acceptance criteria +
    +
  • AC2.1 env-store.ts contains no indexedDB.open call and declares no version.
  • +
  • AC2.2 A repo-wide search shows exactly one indexedDB.open for build-db in src/.
  • +
  • AC2.3 The module's public API is unchanged — getEnvVars, setEnvVars, upsertEnvVar, deleteEnvVar, envToDotEnv keep their signatures.
  • +
+

Evidence: diff; grep -rn "indexedDB.open" src/.

+
+ +
+

Step 3 — a test that would have caught it

+

The existing env-store.test.ts replaces indexedDB with a hand-rolled stub +whose open() ignores the version argument entirely — which is precisely why this bug +survived. Move it to real fake-indexeddb semantics, the pattern +projects.test.ts already uses.

+Acceptance criteria +
    +
  • AC3.1 A test opens the DB through projects.ts first, then reads/writes through env-store.ts, and passes.
  • +
  • AC3.2 The reverse order also passes (env-store first, then projects).
  • +
  • AC3.3 The new test fails against the pre-fix code — verified by stashing the fix, not assumed.
  • +
  • AC3.4 A test proves an existing v2 database upgrades to v3 with its records intact.
  • +
  • AC3.5 The existing env-store behavioral tests (envToDotEnv escaping etc.) still pass.
  • +
+

Evidence: npx vitest run src/env-store.test.ts src/projects.test.ts; a recorded pre-fix failure.

+
+ +
+

Step 4 — validate, including against the user's real database

+Acceptance criteria +
    +
  • AC4.1 npm test (gleam + vitest) and server vitest all green, with no test count regression (baseline 799).
  • +
  • AC4.2 In the live browser, build-db reports version: 3 with all four stores present.
  • +
  • AC4.3 The user's existing projects survive — project count and current-project id unchanged across the upgrade, compared before and after.
  • +
  • AC4.4 An env-store read/write round-trips in the real browser without a VersionError.
  • +
  • AC4.5 No new console errors on load.
  • +
+

Evidence: suite output; before/after DB snapshot from the live page.

+
+ +

Validation commands

+
+
npx vitest run src/env-store.test.ts src/projects.test.ts
+npm test              # 197 gleam + 293 vitest
+cd server && npm test  # 309
+grep -rn "indexedDB.open" src/    # expect exactly one hit
+
+ +

Definition of done

+

One indexedDB.open for build-db in the codebase; both module orders work +under real IndexedDB semantics; the new test demonstrably fails without the fix; the user's live +database is at v3 with all four stores and every project still present; all suites +green.

+ +

Judge rubric

+ + + + + + + + +
AreaJudgment
CorrectnessDo all three starting states (v1 two-store, v1 env-first, v2) converge on v3 without loss?
Data safetyIs every createObjectStore guarded? Was the real database verified, not just a synthetic one?
Test qualityDoes the test fail on the old code? Does it use real IDB semantics rather than a stub that ignores versions?
SimplicityDoes env-store.ts now read like workspace-store.ts, or has a second pattern been invented?
TasteDo the comments explain why the rule exists rather than restating the code? Is the delegation as quiet as workspace-store.ts's?
MaintainabilityWould the next contributor adding a store know where to put it?
+

Taste threshold: below 4 triggers a repair loop. Originality is not scored — the correct answer +here is deliberately to copy the pattern the repo already established, not to invent one.

+ +

Risks

+ + + + + + +
RiskHandling
Upgrade drops a store and destroys projectsGuarded creates only; no deletes. Verified against the live DB with a before/after project count (AC4.3).
Old tab blocks the upgrade foreverExplicitly addressed in Step 1 (AC1.2, AC1.3) rather than left latent.
Version bump ships to users mid-sessiononversionchange closes stale connections so the next load upgrades cleanly.
Fixing dead code is wasted effortAcknowledged in the open decision above; the fix is small and the schema rule becomes uniformly true.
+ +
+ + diff --git a/docs/build-db-schema-owner-progress.html b/docs/build-db-schema-owner-progress.html new file mode 100644 index 0000000..6cebc20 --- /dev/null +++ b/docs/build-db-schema-owner-progress.html @@ -0,0 +1,173 @@ + + + + + +Progress — One schema owner for build-db + + + +
+ +

Progress — one schema owner for build-db

+

Task slug build-db-schema-owner · branch enable-managed-auth · PRD: build-db-schema-owner-prd.html

+ +

Status: complete — all four steps +pass. build-db now has exactly one indexedDB.open; every historical database +shape converges on v3 with data intact, verified both in tests and against the user's real database.

+ +

Step checklist

+ + + + + + +
StepStatusCriteria
1 — projects.ts sole owner at v3pass4/4
2 — env-store.ts delegatespass3/3
3 — test that would have caught itpass5/5
4 — validate, incl. the real databasepass5/5
+ +

Step 1 — evidence

+ + + + + + +
ACVerdictEvidence
1.1 v3, four guarded createspassDB_VERSION = 3; all four createObjectStore calls guarded by contains(); no deletes anywhere in the handler
1.2 onblockedpassRejects with "build-db upgrade is blocked by another open tab — close it and reload" instead of hanging
1.3 onversionchangepassConnection closes itself when another tab upgrades
1.4 exported store namepassexport const ENV_STORE = 'env-store', consumed by env-store.ts
+

1.2 and 1.3 were not in the original brief — they became necessary because of the version +bump. Without them, a user with a second tab on the old code gets a page that silently never boots. +The pre-fix test run made this concrete rather than theoretical: the "env-store touches the database +first" case failed by timing out at 5001 ms, not by assertion — a real deadlock.

+ +

Step 2 — evidence

+ + + + + +
ACVerdictEvidence
2.1 no open, no versionpassenv-store.ts is now const openDb = openBuildDb with the same explanatory comment style as workspace-store.ts
2.2 exactly one ownerpassgrep -rn "indexedDB.open" src/ → a single hit, src/projects.ts:52
2.3 API unchangedpassAll five exports keep their signatures; tsc -b exits 0
+ +

Step 3 — evidence

+ + + + + + + +
ACVerdictEvidence
3.1 projects-first orderingpass"works when projects.ts opens the database first"
3.2 env-store-first orderingpass"works when env-store touches the database first"
3.3 fails pre-fixpass5 of 12 failed against stashed pre-fix sources; restored and re-verified afterwards
3.4 v2 upgrade keeps datapassSeeded v2 DB with a project + current-project-id; both survive to v3
3.5 existing behaviour intactpassThe three envToDotEnv tests carried over unchanged
+

The old test file is worth recording as its own finding: it carried ~50 lines of fake-DB scaffolding +(fakeDB, testStore, openTestDb) that no test used, +and its stubbed indexedDB.open() ignored the version argument entirely. The four storage +functions had zero coverage while appearing to be tested. That is why a version conflict could +ship. The replacement runs on fake-indexeddb — the pattern projects.test.ts +already used — so version semantics are real.

+

Coverage went from 3 tests (one pure function) to 14, adding storage round-trips, +per-project isolation, both module orderings, and three migration paths.

+ +

Step 4 — evidence

+ + + + + + + +
ACVerdictEvidence
4.1 suites greenpass197 gleam + 304 vitest + 309 server = 810 (baseline was 799; +11 net). tsc -b exits 0.
4.2 real DB at v3passLive browser: version: 3, stores ['env-store','meta','projects','workspace']
4.3 no data losspassSee the before/after table below
4.4 env-store works livepassRound-tripped a probe record on the upgraded database, then deleted it — env-store left with 0 rows, exactly as found
4.5 no console errorspass102 messages reviewed: Vite connects, Clerk's expected dev-keys warning, React DevTools notice, our own debug lines. No VersionError, no blocked, no errors.
+ +

The user's real database, before and after

+
+ + + + + + + + + +
Before (v2)After (v3)
version23
storesmeta, projects, workspaceenv-store, meta, projects, workspace
projects33 same ids
files per project6 / 13 / 6 intact
messages per project0 / 2 / 4 intact
current-project-id303e3d3e…303e3d3e… unchanged
workspace records11 unchanged
+
+

Verified by importing the app's own modules (/src/projects.ts, +/src/env-store.ts) into the live page through Vite's dev transform, so the upgrade ran +through production code paths against real data — not a re-implementation of the migration.

+ +

Migration paths covered

+ + + + + + +
Starting shapeWhere proven
v1 {meta, projects} — pre-workspace, the oldest shape in the wildtest: "upgrades a v1 {meta, projects} database straight to v3"
v1 {env-store} — what the old env-store-first path would have createdtest: "upgrades a v1 database that only has env-store"
v2 {meta, projects, workspace}test + the user's real database
no database at alltest: "opens every store at one version"
+ +

Inline judge review

+

No subagents this session (policy), so the LLM-as-judge pass was performed inline against the PRD rubric.

+ + + + + + + + + +
AreaVerdictNote
CorrectnesspassAll four starting shapes converge on v3. Initially only two were covered; the gap was found during this review and closed with two more tests rather than claimed.
Data safetypassGuarded creates only, no deletes. Verified against real data with files and messages counted, not just row counts.
Test qualitypassFails on the old code (5/12). Real IDB semantics. The deadlock surfaced as a timeout, which is what motivated onblocked.
Simplicitypassenv-store.ts lost 14 lines and gained a one-line delegation identical in shape to workspace-store.ts. No new pattern invented.
Security/privacypassNo credentials touched. The probe record written during live verification was removed.
Taste4/5The fix is deliberately unoriginal — it makes the third module obey a rule the codebase had already written down and enforced once. Comments explain why the version had to move to 3 rather than restating the code. Not a 5 only because the underlying module is still unused.
MaintainabilitypassOne upgrade handler, one version constant, exported store names. Where a fifth store goes is now unambiguous.
+

Originality deliberately not scored — per the PRD, copying the established pattern is the +right answer here.

+ +

Files changed

+
    +
  • src/projects.ts — v3, env-store creation, exported ENV_STORE, onblocked + onversionchange
  • +
  • src/env-store.ts — delegates to openBuildDb; own open/version removed
  • +
  • src/env-store.test.ts — rewritten on fake-indexeddb; 3 tests → 14
  • +
  • docs/build-db-schema-owner-{prd,progress}.html — these artifacts
  • +
+ +

Residual risks and follow-ups

+
    +
  • env-store.ts is still unused. It is now correct, but nothing imports it. Deleting it remains a live option, and is a product call about whether per-project env vars are still wanted.
  • +
  • The version bump reaches existing users on next deploy. Safe by construction (guarded creates) and now safe under a stale tab (onblocked/onversionchange), but it is a schema change and worth a deliberate deploy rather than a drive-by.
  • +
  • Only one real database was exercised — the user's, at v2. The v1 shapes are covered by tests, not by a real profile.
  • +
  • F3 (non-idempotent provisioning) is untouched and still needs a product decision. See the smoke-test progress doc.
  • +
+ +

Handoff log

+
+
2026-07-30  begin        build-db-schema-owner  step 0  F1 follow-up; baseline 799 tests green
+2026-07-30  scope-fix    build-db-schema-owner  step 0  env-store.ts has NO consumers — latent, not
+                                                        live; smoke-test writeup overstated impact
+2026-07-30  plan-approved build-db-schema-owner step 0  inline plan review; v3 (not v2) required, or
+                                                        the store is never created
+2026-07-30  step-1-done  build-db-schema-owner  step 1  v3 + guarded creates + blocked/versionchange
+2026-07-30  step-2-done  build-db-schema-owner  step 2  one indexedDB.open in src/
+2026-07-30  step-3-done  build-db-schema-owner  step 3  14 tests; 5 fail pre-fix (one by deadlock)
+2026-07-30  step-4-done  build-db-schema-owner  step 4  real DB v2->v3, 3 projects intact, 810 green
+2026-07-30  complete     build-db-schema-owner  --      all 4 steps pass; env-store deletion still open
+
+ +
+ + diff --git a/src/env-store.test.ts b/src/env-store.test.ts index b3edc69..8640825 100644 --- a/src/env-store.test.ts +++ b/src/env-store.test.ts @@ -1,60 +1,199 @@ +import { IDBFactory } from 'fake-indexeddb' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - getEnvVars, - setEnvVars, - upsertEnvVar, - deleteEnvVar, - envToDotEnv, -} from './env-store' - -// Override indexedDB with a fake implementation for testing -const fakeDB: Record> = {} - -vi.stubGlobal('indexedDB', { - open: (_name: string, version: number) => ({ - result: { - objectStoreNames: [], - transaction: () => ({ - objectStore: () => ({ - get: () => ({ result: undefined }), - put: () => {}, - }), - oncomplete: null as (() => void) | null, - onerror: null as (() => void) | null, - }), - onupgradeneeded: null as (() => void) | null, - }, - onupgradeneeded: (cb: () => void) => cb(), - onsuccess: (cb: () => void) => cb(), - onerror: (cb: () => void) => cb(), - }), -}) +import { envToDotEnv } from './env-store' + +/** + * These run against real IndexedDB semantics (fake-indexeddb), not a stub. + * The previous version of this file replaced `indexedDB` with a hand-rolled + * object whose `open()` ignored the version argument entirely — so it could + * not have caught `env-store` opening `build-db` at version 1 while + * `projects.ts` owned it at 2, which is precisely the bug that shipped. + */ -// Simple in-memory store for testing -const testStore: Record> = {} - -function openTestDb() { - return { - transaction: (store: string, mode: string) => ({ - objectStore: () => ({ - get: (key: string) => { - return { result: testStore[key] ?? null, onsuccess: null, onerror: null } - }, - put: (val: { projectId: string; vars: Record }) => { - testStore[val.projectId] = val.vars - }, - }), - oncomplete: null as (() => void) | null, - onerror: null as (() => void) | null, - }), - } as unknown as IDBDatabase +// Both modules memoize their connection, so each test needs a fresh registry. +async function freshModules() { + vi.resetModules() + const projects = await import('./projects') + const envStore = await import('./env-store') + return { ...projects, ...envStore } } -describe('env-store', () => { +describe('env-store persistence', () => { + beforeEach(() => { + vi.unstubAllGlobals() + vi.stubGlobal('indexedDB', new IDBFactory()) + vi.stubGlobal('crypto', { randomUUID: vi.fn(() => `project-${Math.random()}`) }) + }) + + it('round-trips env vars through the shared database', async () => { + const { setEnvVars, getEnvVars } = await freshModules() + await setEnvVars('proj-1', { API_KEY: 'abc', PORT: '3000' }) + expect(await getEnvVars('proj-1')).toEqual({ API_KEY: 'abc', PORT: '3000' }) + }) + + it('returns an empty map for a project with nothing stored', async () => { + const { getEnvVars } = await freshModules() + expect(await getEnvVars('never-seen')).toEqual({}) + }) + + it('upserts and deletes individual keys', async () => { + const { setEnvVars, upsertEnvVar, deleteEnvVar, getEnvVars } = await freshModules() + await setEnvVars('proj-1', { A: '1' }) + await upsertEnvVar('proj-1', 'B', '2') + expect(await getEnvVars('proj-1')).toEqual({ A: '1', B: '2' }) + await deleteEnvVar('proj-1', 'A') + expect(await getEnvVars('proj-1')).toEqual({ B: '2' }) + }) + + it('keeps env vars separated per project', async () => { + const { setEnvVars, getEnvVars } = await freshModules() + await setEnvVars('proj-1', { SHARED: 'one' }) + await setEnvVars('proj-2', { SHARED: 'two' }) + expect(await getEnvVars('proj-1')).toEqual({ SHARED: 'one' }) + expect(await getEnvVars('proj-2')).toEqual({ SHARED: 'two' }) + }) +}) + +describe('build-db has a single schema owner', () => { + beforeEach(() => { + vi.unstubAllGlobals() + vi.stubGlobal('indexedDB', new IDBFactory()) + vi.stubGlobal('crypto', { randomUUID: vi.fn(() => `project-${Math.random()}`) }) + }) + + // The regression: projects.ts upgrading first used to leave env-store's own + // open() requesting a lower version, which throws VersionError. + it('works when projects.ts opens the database first', async () => { + const { createProject, setEnvVars, getEnvVars } = await freshModules() + const project = await createProject() + await setEnvVars(project.id, { TOKEN: 'xyz' }) + expect(await getEnvVars(project.id)).toEqual({ TOKEN: 'xyz' }) + }) + + it('works when env-store touches the database first', async () => { + const { setEnvVars, getEnvVars, createProject, listProjects } = await freshModules() + await setEnvVars('proj-1', { TOKEN: 'xyz' }) + const project = await createProject() + expect(await getEnvVars('proj-1')).toEqual({ TOKEN: 'xyz' }) + expect((await listProjects()).map(p => p.id)).toContain(project.id) + }) + + it('opens every store at one version', async () => { + const { openBuildDb } = await freshModules() + const db = await openBuildDb() + expect(db.version).toBe(3) + expect([...db.objectStoreNames].sort()).toEqual(['env-store', 'meta', 'projects', 'workspace']) + }) +}) + +describe('upgrading an existing database', () => { beforeEach(() => { - Object.keys(testStore).forEach(key => delete testStore[key]) + vi.unstubAllGlobals() + vi.stubGlobal('indexedDB', new IDBFactory()) + vi.stubGlobal('crypto', { randomUUID: vi.fn(() => `project-${Math.random()}`) }) + }) + + /** Seed the shape a real v2 user has on disk today, with data in it. */ + async function seedV2WithData() { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('build-db', 2) + request.onupgradeneeded = () => { + const d = request.result + d.createObjectStore('projects', { keyPath: 'id' }) + d.createObjectStore('meta', { keyPath: 'key' }) + d.createObjectStore('workspace', { keyPath: 'id' }) + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + await new Promise((resolve, reject) => { + const tx = db.transaction(['projects', 'meta'], 'readwrite') + tx.objectStore('projects').put({ id: 'kept-1', name: 'Important Work', files: [], messages: [] }) + tx.objectStore('meta').put({ key: 'current-project-id', value: 'kept-1' }) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + // Leave no connection open, or the version bump would block. + db.close() + } + + it('upgrades v2 to v3 without losing existing projects', async () => { + await seedV2WithData() + const { openBuildDb, listProjects, getCurrentProjectId } = await freshModules() + + const db = await openBuildDb() + expect(db.version).toBe(3) + expect([...db.objectStoreNames]).toContain('env-store') + + const projects = await listProjects() + expect(projects.map(p => p.id)).toEqual(['kept-1']) + expect(projects[0].name).toBe('Important Work') + expect(await getCurrentProjectId()).toBe('kept-1') + }) + + it('makes the new env-store usable on an upgraded database', async () => { + await seedV2WithData() + const { setEnvVars, getEnvVars } = await freshModules() + await setEnvVars('kept-1', { AFTER_UPGRADE: 'yes' }) + expect(await getEnvVars('kept-1')).toEqual({ AFTER_UPGRADE: 'yes' }) + }) + + /** + * The oldest shape still in the wild: created before the `workspace` store + * existed. It has to gain two stores in one upgrade, not one. + */ + it('upgrades a v1 {meta, projects} database straight to v3', async () => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('build-db', 1) + request.onupgradeneeded = () => { + const d = request.result + d.createObjectStore('projects', { keyPath: 'id' }) + d.createObjectStore('meta', { keyPath: 'key' }) + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + await new Promise((resolve, reject) => { + const tx = db.transaction('projects', 'readwrite') + tx.objectStore('projects').put({ id: 'ancient-1', name: 'From v1', files: [], messages: [] }) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + db.close() + + const { openBuildDb, listProjects, setEnvVars, getEnvVars } = await freshModules() + const upgraded = await openBuildDb() + expect(upgraded.version).toBe(3) + expect([...upgraded.objectStoreNames].sort()).toEqual(['env-store', 'meta', 'projects', 'workspace']) + expect((await listProjects()).map(p => p.name)).toEqual(['From v1']) + + await setEnvVars('ancient-1', { OK: '1' }) + expect(await getEnvVars('ancient-1')).toEqual({ OK: '1' }) }) + /** An env-store-first v1 database: the store the old code would have made. */ + it('upgrades a v1 database that only has env-store', async () => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('build-db', 1) + request.onupgradeneeded = () => { + request.result.createObjectStore('env-store', { keyPath: 'projectId' }) + } + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + db.close() + + const { openBuildDb, createProject, listProjects } = await freshModules() + const upgraded = await openBuildDb() + expect(upgraded.version).toBe(3) + expect([...upgraded.objectStoreNames].sort()).toEqual(['env-store', 'meta', 'projects', 'workspace']) + + const project = await createProject() + expect((await listProjects()).map(p => p.id)).toContain(project.id) + }) +}) + +describe('envToDotEnv', () => { it('serializes env vars to .env format correctly', () => { expect(envToDotEnv({ API_KEY: 'secret123', PORT: '3000', EMPTY: '' })) .toEqual('API_KEY="secret123"\nPORT="3000"\nEMPTY=') diff --git a/src/env-store.ts b/src/env-store.ts index 3e2ca1b..43138d3 100644 --- a/src/env-store.ts +++ b/src/env-store.ts @@ -1,23 +1,12 @@ -const DB_NAME = 'build-db' -const DB_VERSION = 1 -const ENV_STORE = 'env-store' +import { ENV_STORE, openBuildDb } from './projects' -let dbPromise: Promise | undefined - -function openDb(): Promise { - dbPromise ??= new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) - request.onupgradeneeded = () => { - const db = request.result - if (!db.objectStoreNames.contains(ENV_STORE)) { - db.createObjectStore(ENV_STORE, { keyPath: 'projectId' }) - } - } - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(new Error('Failed to open env store')) - }) - return dbPromise -} +/** + * The store is created by `openBuildDb` in `src/projects.ts`, which owns the + * `build-db` schema and its version. Opening the same database at a second + * version from here would throw on whichever handle opened second — which is + * exactly what this module used to do. + */ +const openDb = openBuildDb function requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { diff --git a/src/projects.ts b/src/projects.ts index f8ee596..089fac0 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -24,16 +24,23 @@ export type SavedProject = { const DB_NAME = 'build-db' /** * Bumped to 2 for the `workspace` store (skills, agent personas — see - * `src/workspace-store.ts`). `onupgradeneeded` only creates what is missing, so - * an existing database upgrades in place with no migration and no data loss. + * `src/workspace-store.ts`), then to 3 for `env-store`, which + * `src/env-store.ts` used to create by opening this same database at its own + * version 1 — throwing `VersionError` once anything had upgraded it past that. + * Advancing the version is what actually creates the store: databases already + * at 2 never fire `onupgradeneeded` again unless the number moves. + * `onupgradeneeded` only creates what is missing, so an existing database + * upgrades in place with no migration and no data loss. * * This module owns the schema: `build-db` must be opened at ONE version, and * two modules opening it at different versions throws on whichever is second. */ -const DB_VERSION = 2 +const DB_VERSION = 3 const PROJECTS_STORE = 'projects' const META_STORE = 'meta' export const WORKSPACE_STORE = 'workspace' +/** Exported so `src/env-store.ts` cannot drift from the owner's spelling. */ +export const ENV_STORE = 'env-store' const CURRENT_PROJECT_ID_KEY = 'current-project-id' type MetaRecord = { key: string; value: string | null } @@ -44,14 +51,28 @@ export function openBuildDb(): Promise { dbPromise ??= new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION) + // Guarded creates only, never deletes: this database holds the user's + // projects, and an upgrade that dropped a store would destroy their work. request.onupgradeneeded = () => { const db = request.result if (!db.objectStoreNames.contains(PROJECTS_STORE)) db.createObjectStore(PROJECTS_STORE, { keyPath: 'id' }) if (!db.objectStoreNames.contains(META_STORE)) db.createObjectStore(META_STORE, { keyPath: 'key' }) if (!db.objectStoreNames.contains(WORKSPACE_STORE)) db.createObjectStore(WORKSPACE_STORE, { keyPath: 'id' }) + if (!db.objectStoreNames.contains(ENV_STORE)) db.createObjectStore(ENV_STORE, { keyPath: 'projectId' }) } - request.onsuccess = () => resolve(request.result) + // A connection still open at an older version blocks the upgrade — silently + // and forever, without this. Surfacing it beats an app that just never boots. + request.onblocked = () => + reject(new Error('build-db upgrade is blocked by another open tab — close it and reload')) + + request.onsuccess = () => { + const db = request.result + // The mirror image: when another tab upgrades, step aside instead of + // being the connection that blocks it. + db.onversionchange = () => db.close() + resolve(db) + } request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB')) }) return dbPromise