Repository navigation
Implement specs 17, 18 and 19: pluggable parent auth, self-hosted PostgreSQL, question prefetch - #33
Merged
Merged
Conversation
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 Report SummaryNo dependency changes detected. Nothing to scan. This report is generated by SafeDep GitHub App |
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Implements the three specs from #32, with the owner decisions recorded in the rewritten specs.
Spec 17: pluggable parent auth
auth.JWTVerifierchecks every provider's tokens. APresetholds the only vendor knowledge. Presets:supabase(the one real provider) andtest(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.(auth_provider, auth_subject), never on email.supabase_user_id, theMATHIZ_SUPABASE_*variables and the old boot-config fields are removed (no back-compat: production starts on a fresh database). A setMATHIZ_SUPABASE_*variable stops the server and names its replacement.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.email_verified=falseor a missing email is refused.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.web/src/auth/holds one adapter per provider, and each adapter is its own chunk. Parent API calls take aTokenGetter. 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.kid.Spec 18: self-hosted PostgreSQL
store.Openlogs the server version and the effectivesslmode, and refuses servers older than 16.docker-compose.ymlgains aprodprofile for one VM:prod-db(postgres:18-alpine, no published port) andprod-app(loopback port for a TLS proxy,MATHIZ_AUTH_ALLOW_TESTblanked). The dev database and CI move to PostgreSQL 18, with the volume layout of the PG 18 image.docs/saas.mdcovers 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
q_wait_ms,q_sourceandq_attemptson the/questioncanonical line. PostHog getsquestion_shown {index, waitMs}andquestion_generation_failed {index}, both anonymous.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=0is the off switch.Verification
CGO_ENABLED=0 go build ./...,go vet ./...andgo test ./...all pass.go test -race -count=2passes oninternal/saas,problemgen,screens,llmandcmd.web:tsc,oxlintand the build all pass.prodprofile was booted once: PG 18 data directory, a dump, a restore, and the empty-password refusal.Ops steps after merge
.envfrom.env.examplewithMATHIZ_AUTH_PROVIDER=supabaseand a URL-safeMATHIZ_DB_PASSWORD. Turn on "Confirm email" in Supabase. Then rundocker compose --profile prod up -d --build.mathizdatabase on the Supabase cluster.accounts.supabase_user_idifaccountsis empty.Generated by Claude Code