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

+ + +

Non-goals

+ + +

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

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

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

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

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

+ + +

Residual risks and follow-ups

+ + +

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/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:

+ + +

Why this run exists

+

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

+ + +

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

+ + +

Plan

+ +
+

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

+

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

+Acceptance criteria + +

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

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

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

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

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

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

+ + +

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

+ + +

What this run did NOT validate

+ + +

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/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 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/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() }) } 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