Skip to content

Implement specs 17, 18 and 19: pluggable parent auth, self-hosted PostgreSQL, question prefetch - #33

Merged
abhisek merged 19 commits into
mainfrom
claude/zen-goodall-6ktx0w
Oct 17, 2026
Merged

abhisek merged 19 commits into
mainfrom
claude/zen-goodall-6ktx0w

Conversation

@abhisek

@abhisek abhisek commented Oct 17, 2026

Copy link
Copy Markdown
Owner

Implements the three specs from #32, with the owner decisions recorded in the rewritten specs.

Spec 17: pluggable parent auth

  • One generic auth.JWTVerifier checks every provider's tokens. A Preset holds the only vendor knowledge. Presets: supabase (the one real provider) and test (HS256, explicit opt-in, for unit tests, the dev stack and browser E2E). No Clerk code. A new JWKS/OIDC provider is one preset constructor plus one SPA adapter file.
  • Accounts are keyed on (auth_provider, auth_subject), never on email. supabase_user_id, the MATHIZ_SUPABASE_* variables and the old boot-config fields are removed (no back-compat: production starts on a fresh database). A set MATHIZ_SUPABASE_* variable stops the server and names its replacement.
  • New config: MATHIZ_AUTH_PROVIDER, MATHIZ_AUTH_URL (compulsory for Supabase, must be https), MATHIZ_AUTH_PUBLIC_KEY, MATHIZ_AUTH_HS256_SECRET, MATHIZ_AUTH_AUDIENCE, MATHIZ_AUTH_ALLOW_TEST.
  • An unverified email is refused with a 401, because invite acceptance matches on email. Supabase sessions count as verified, and the runbook now requires "Confirm email" ON. An explicit email_verified=false or a missing email is refused.
  • The server refuses to start on a database from before this change (the check runs before auto-migrate) and when accounts belong to another provider.
  • mathiz auth remap --from X --to Y --map map.csv [--dry-run] switches providers. It is all-or-nothing (no --allow-unmapped), checks every row before writing, and a rerun changes nothing.
  • SPA: web/src/auth/ holds one adapter per provider, and each adapter is its own chunk. Parent API calls take a TokenGetter. A 401 is retried once with a new token, then the parent is signed out with a "session ended" notice. A provider key outage is a 503, not a 401, so a provider blip does not sign parents out. The test adapter lets the parent dashboard run in a browser for the first time without an auth provider.
  • JWKS fixes found on the way. A failed first fetch now returns its error, so the 30 s retry runs; before, keyfunc built an empty key set and refetched only every 5 minutes. Each failed attempt cancels its refresh goroutine (before, each retry leaked one). Fetches time out after 5 s. Tokens on the JWKS path need a kid.

Spec 18: self-hosted PostgreSQL

  • PostgreSQL pool limits (20 open, 10 idle; env overrides). store.Open logs the server version and the effective sslmode, and refuses servers older than 16.
  • docker-compose.yml gains a prod profile for one VM: prod-db (postgres:18-alpine, no published port) and prod-app (loopback port for a TLS proxy, MATHIZ_AUTH_ALLOW_TEST blanked). The dev database and CI move to PostgreSQL 18, with the volume layout of the PG 18 image.
  • docs/saas.md covers the VM deploy, the 12-hour RPO and RTO, a host cron backup every 12 hours, restore steps and a monthly restore drill. There is no data migration: production starts fresh.

Spec 19: question latency

  • Telemetry: q_wait_ms, q_source and q_attempts on the /question canonical line. PostHog gets question_shown {index, waitMs} and question_generation_failed {index}, both anonymous.
  • A question a validator rejects as retryable is asked for again, up to 2 more times.
  • LLM questions generate without the expedition lock held, so "Sail home", hints and a replacing Start no longer wait on the provider. Quest expeditions stay on the locked path.
  • problemgen.Prefetcher (fingerprinted, one held question, generations on its own context) warms Q1 during "Setting sail…" and the next question while the kid reads feedback. The game and the terminal session both use it. MATHIZ_QUESTION_PREFETCH=0 is the off switch.
  • On a failed question the SPA keeps the expedition and shows "The map is foggy… 🌫️" with Try again, so no second credit is charged. After 6 s of loading the copy changes to "Still digging… the treasure is deep today ⛏️". The circuit breaker ends the expedition with the normal summary. Game errors carry stable codes.

Verification

  • CGO_ENABLED=0 go build ./..., go vet ./... and go test ./... all pass.
  • go test -race -count=2 passes on internal/saas, problemgen, screens, llm and cmd.
  • The store suite passes on PostgreSQL 16 and 18.
  • web: tsc, oxlint and the build all pass.
  • Browser E2E (Playwright) on the full stack:
    • Login with a pasted test token, then sign-out, then a forced sign-out with the notice.
    • The Supabase adapter renders its forms and loads only its own chunk.
    • Kid surfaces load no adapter.
    • Q2 appears 74 ms after "Next clue" (a prefetch hit).
    • "Still digging" appears at 6.4 s.
    • Fog, then Try again, recovers with one debit.
    • The breaker ends with the normal summary, with one debit.
  • The compose prod profile was booted once: PG 18 data directory, a dump, a restore, and the empty-password refusal.
  • Fable reviews ran on the plan, on each complex task and on the whole branch. Every finding is fixed on the branch.

Ops steps after merge

  • Production: create .env from .env.example with MATHIZ_AUTH_PROVIDER=supabase and a URL-safe MATHIZ_DB_PASSWORD. Turn on "Confirm email" in Supabase. Then run docker compose --profile prod up -d --build.
  • Drop the old mathiz database on the Supabase cluster.
  • A local dev database from before this change: recreate it, or drop accounts.supabase_user_id if accounts is empty.

Generated by Claude Code

claude added 19 commits October 17, 2026 09:32
A self-run PostgreSQL has no Supabase pooler in front of it. Without a
cap, a burst of queries opens one connection each and exhausts the
server's max_connections. The pool now defaults to 20 open and 10 idle
connections, with MATHIZ_DB_MAX_OPEN_CONNS and MATHIZ_DB_MAX_IDLE_CONNS
as overrides. A malformed override fails at open.

store.Open also logs the server version and the DSN's sslmode, and fails
on a server older than PostgreSQL 16 (specs/18 §4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Production moves off the Supabase cluster to a PostgreSQL we run
(specs/18). The deployment is one VM with docker compose, so the compose
file gains a prod profile: prod-db (postgres:18-alpine, no published
port) and prod-app (loopback port for a TLS proxy on the host). The prod
profile has no dev defaults. An empty MATHIZ_DB_PASSWORD stops prod-db,
and prod-app blanks MATHIZ_AUTH_ALLOW_TEST so a .env copied from dev
cannot enable test tokens.

The dev database and CI move to PostgreSQL 18. PG 18 images keep data in
/var/lib/postgresql/18/docker, so the dev volume mounts one level up and
carries a new name. An old 16 volume is never mounted into 18.

docs/saas.md documents the VM deploy, the 12-hour RPO and RTO, the host
cron backup, the restore steps and the monthly restore drill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Parent auth was Supabase-shaped from the verifier to the account table
(specs/17). Now one JWTVerifier checks every provider's tokens, and a
Preset holds the only vendor knowledge: issuer, keys, audience, claims.
Supabase is the one real preset. The test preset (HS256, explicit
opt-in) serves unit tests, the dev stack and browser E2E.

- Accounts are keyed on (auth_provider, auth_subject), never on email.
  supabase_user_id is gone. This deployment restarts on a fresh
  database. On an older database the server refuses to start and names
  the one-line fix, because auto-migrate keeps that NOT NULL column.
- The server refuses to start when accounts belong to another provider,
  so a config typo cannot lock every family out.
- withParent refuses an unverified email. Invite acceptance matches on
  Account.Email, so an unverified address could claim an invite.
  Supabase sessions count as verified (the runbook requires "Confirm
  email"), except a token that says email_verified=false.
- Config is MATHIZ_AUTH_*. The provider URL is compulsory for Supabase.
  A set MATHIZ_SUPABASE_* variable fails at boot. The test preset is
  refused on an https public origin.
- /api/v1/config serves {auth: {provider, url, publicKey}}.
- JWKS: a failed first fetch now returns its error, so the 30 s retry
  runs. Before, keyfunc built an empty set and refetched only every 5
  minutes. Each failed attempt cancels its refresh goroutine. Fetches
  time out after 5 s. Tokens need a kid. A key outage is a 503, not a
  401, so the SPA does not sign parents out during a provider blip.
- The provider URL must be an https origin (http only on loopback),
  because the JWKS comes from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
A deployment serves one auth provider, and serve refuses to start when
accounts belong to another. remap is the explicit way to switch: it
moves every account of --from to --to with a CSV of old and new
subjects, in one transaction (specs/17 §6).

The map must cover every --from account, or nothing is written: a
parent left behind would lose their family (no --allow-unmapped). Each
row is checked before the first write, and an error names the row. A
rerun with the same map is a no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Review fixes for the pluggable-auth change:

- The legacy supabase_user_id check ran after auto-migrate. On a table
  with rows, adding the NOT NULL identity columns fails first, and the
  operator saw a migrate error instead of the fix. store.Open now takes
  BeforeMigrate checks, and serve runs the legacy check there.
- serve builds the verifier before it opens the database, so a bad
  provider URL or a short secret fails without touching the database.
- ColumnExists resolves the table through search_path (to_regclass), not
  current_schema() alone.
- The test preset refuses email_verified=false in either claim shape.
- The provider-pin hint names the --map flag of remap.
- New tests: a provider outage is a 503, and a pre-17 table with a row
  is refused before migrate and left unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
The SPA named Supabase in App, Login, the dashboard and api.ts. Now
web/src/auth/ holds one adapter per provider behind one interface
(getToken, user, onChange, signOut, SignIn), chosen from the boot
config's auth.provider. A static import() per provider keeps each SDK
in its own chunk. A Supabase deployment never downloads the test
adapter, and kid and public pages load no adapter at all.

- supabase.ts + SupabaseSignIn.tsx: the earlier client and the earlier
  forms (email code, magic link, password fallback), unchanged in
  behaviour. They are the only Supabase imports in web/src.
- test.ts + TestSignIn.tsx: paste a token minted for the test preset.
  The parent dashboard can now be driven in a browser without any auth
  provider.
- Parent API calls take a TokenGetter instead of a token string.
  request() gets a token per call. On a 401 it retries once with a
  fresh token. A second 401 signs out, and the router returns to /login.
  A 503 (provider keys unreachable) does not sign out.
- The privacy page names "our hosted authentication provider".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
The docs still told operators and agents to set MATHIZ_SUPABASE_*, which
now stops the server at boot. They now describe MATHIZ_AUTH_* and the
supabase and test presets. They add the required Supabase step "Confirm
email" ON: Mathiz treats Supabase sessions as verified, and co-parent
invites match on email. They describe browser parent login with a
pasted test token, the remap runbook, and the startup refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
specs/19 sets latency goals (p50 300 ms, p95 3 s), and nothing measured
the wait. The /question canonical line now carries q_wait_ms, q_source
(fresh, quest, current; prefetch_hit and prefetch_join come with the
prefetcher) and q_attempts. q_attempts counts provider attempts with
retries through a counter in the context, incremented in
TimeoutProvider, which every attempt passes with or without event
logging.

Game errors also carry a stable code (no_expedition, expedition_over,
generation_failed, …) next to the kid copy. The SPA can then branch on
the code and not on prose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
specs/05 says callers retry a rejected question, but nobody did. One
bad answer format cost the kid a fog screen, and on the third time it
cost the expedition. LLMGenerator.Generate now asks up to two more
times on a retryable ValidationError, while at least 5 s of the
deadline remain. The rejected text joins a copy of the prior questions,
so the model does not repeat it. A non-retryable rejection, a provider
error or a parse error still returns at once. llm.RetryProvider already
retries provider errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
- One signOut() for the button and for a dead session. It resets
  analytics both ways, and concurrent 401s share a single provider
  sign-out.
- request() retries a 401 only with a token that is actually new. A
  dead session costs one request, not two, before sign-out.
- After a forced sign-out, /login says the session ended.
- The dashboard is keyed on the provider user ID, so a different parent
  who signs in from another tab gets a fresh dashboard and not the
  previous parent's cached family. Token refreshes that change no user
  no longer re-render it.
- A failed first /me (for example a 503 while the provider is down)
  shows a retry panel instead of "create your Family Space".
- The test adapter decodes claims as UTF-8 and no longer reports a
  pasted token as a completed sign-in before the server sees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Every question was one blocking LLM call that started only when the kid
tapped "Next clue". The time the kid spends on feedback was idle
(specs/19 §5). Prefetcher.Warm starts that call early, and Get hands
out the result.

A warmed question is used only for an input whose fingerprint (every
GenerateInput field, length-prefixed) is identical. If anything changed,
the warm is discarded, and no caller has to reason about staleness. It
holds one question at most. Every generation runs on the prefetcher's
own context plus a budget, so a caller that gives up cannot cancel a
call that another caller or the next tap waits on. Concurrent identical
Gets share one call. A failed warm is retried fresh once. The mutex is
never held while waiting, and Close only cancels, so callers can use it
under their own locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
- A fresh entry cancelled under its waiter (replaced by a Warm for
  another input, or closed) now retries once. The caller gets a question
  or ErrPrefetcherClosed, never a bare context.Canceled that the game
  would count as a kid-visible failure.
- A zero Budget means no deadline. Before, every generation failed at
  once.
- An entry is marked claimed when a Get takes it, so a replacement
  before the Get returns is not also logged as an unused warm.
- A cancel is recognised from the generator's own error.
- TestPrefetchJoin no longer depends on a sleep for ordering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Question held exp.mu across the LLM call (up to 30 s), so "Sail home",
hints, answers and a replacing Start all waited on the provider. The
next question also started only when the kid tapped "Next clue"
(specs/19 §3.2, §5.2).

- LLM questions: lock, build the input (slices copied), unlock,
  Prefetcher.Get, lock, re-check. If the expedition finished meanwhile,
  the kid gets "over". If a double tap installed a question first, the
  kid gets that question. Both taps share one generator call.
- Start warms Q1 during "Setting sail…". Answer warms the next question
  while the kid reads feedback. finishLocked closes the prefetcher
  before the tools close and the play slot frees.
- Quest expeditions keep generating under the lock with no prefetcher:
  questGenerator advances a cursor, so a discarded warm would skip a
  parent's question.
- A request whose kid left (context done) no longer counts toward the
  circuit breaker. A tripped breaker answers "expedition over" and keeps
  the finished expedition reachable, so End still returns the summary
  instead of a 404.
- MATHIZ_QUESTION_PREFETCH=0 turns warming off for the first release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
Review fixes for the unlocked question path:

- A tripped breaker keeps the finished expedition reachable, so End can
  return its summary. Hint, Lesson and AnswerLesson did not check
  finished: a hint after the session's end event, or a lesson consumed
  after the tools closed. All five handlers now share activeLocked. It
  refuses a finished expedition and does not refresh its activity, so
  polling cannot keep it from the idle reaper.
- When a double tap's question lands first and the kid answers it before
  the second tap re-locks, the second tap now starts Question over. It
  no longer returns the answered question as current.
- A request whose kid left now returns ErrClientGone, logged as 499
  client_gone, and not as a 503 generation failure. The latency data
  stays clean, and the breaker still does not tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
The SPA dropped the expedition on the first 503 from /question. Tapping
the spot again then ended it and charged a new credit (specs/19 §3.3).

- A generation_failed error keeps the expedition and shows "The map is
  foggy… 🌫️" with Try again. Try again asks for the same question at no
  cost. expedition_over (a tripped breaker) shows the normal summary
  from End. The client branches on the error code, never on the text.
- After 6 s of loading, the copy changes to "Still digging… the
  treasure is deep today ⛏️".
- PostHog: question_shown {index, waitMs} (tap to render, network
  included) and question_generation_failed {index}. Both are anonymous,
  per the analytics skill.
- llmstub gains delay and failure knobs (env and POST /__stub) for the
  E2E flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
The terminal session had the same blocking pattern as the game. It also
read the snapshot again for the learner profile before every question
(specs/19 §3.4, §5.3).

- The learner profile is read once at session start and cached on the
  screen.
- The screen owns one Prefetcher. It warms the next question after an
  answer while feedback is on screen, when that question is
  predictable: same slot, skill not just mastered, time left. The
  prefetcher closes at session end and on every exit path.
- The generation input is built on the update loop. The background
  command no longer reads session state, and RecentErrors is copied
  under its mutex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
The three specs described the first draft: a staged migration,
back-compat names, Clerk, a Supabase data move, and an open budget
question. They now record the owner's decisions and what was built:

- specs/17: no back-compat, the URL is compulsory, Supabase sessions
  count as verified with an explicit false refused, no Clerk code,
  remap without --allow-unmapped, the legacy-column refusal before
  migrate, the 503 on a key outage.
- specs/18: one VM with the compose prod profile, PostgreSQL 18, 12-hour
  RPO and RTO as docs, no data migration.
- specs/19: budgets, quests under the lock, ErrClientGone, the
  breaker that stays reachable for End, error codes, the prefetcher API
  as built, and the cost of a retried warm.

specs/05, 12 and 13 now point at 17, 18 and 19. The glossary drops the
"proposed" markers. It retires the Supabase verifier entry, adds
Prefetcher, Question source and Foggy map, and adds a homonym line for
fog. personas.md gains the kid's wait and retry flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
- Two taps waiting on one shared generation counted its failure twice,
  so the breaker tripped after two outages, not three. Prefetcher
  results now carry a generation number, and the game counts a shared
  failure once.
- The test provider refusal on an https public origin compared the
  scheme case-sensitively. It now parses the URL.
- mathiz auth remap runs the same pre-migrate legacy-accounts check as
  serve, so it names the fix and does not fail on a NOT NULL error.
- A comment on Verify says why a kid missing from a fetched key set
  stays a 401 even if the refetch fails: a forged kid must not make
  sign-in look down.
- Docs: MATHIZ_DB_PASSWORD goes into a URL, so it must be URL-safe. The
  MATHIZ_QUESTION_PREFETCH off switch is in .env.example, docs/saas.md,
  personas, specs/12 and serve --help. Stale Supabase lines in specs/12
  are fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPjWqPL2EbjU4Tg22ZBo2
@safedep

safedep Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

No dependency changes detected. Nothing to scan.

View complete scan results →

This report is generated by SafeDep GitHub App

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@abhisek
abhisek merged commit 1aeeebf into main Oct 17, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants