The signed-in browser client for lunarlog: a thin client over the Supabase backend that keeps nothing at rest in the browser — no data copy, no offline store, not even the auth session. Every later slice of the web client epic builds on this scaffold.
The issue referenced
docs/repo-docs-standard.mdas the README standard; no such file exists in the repo (as of this scaffold's branch), so this file follows the live README conventions ofdocs/links/README.mdinstead.
The browser keeps nothing at rest — the rule is enforced from three directions, and CI fails if any of them regresses:
- ESLint bans the storage surfaces in app code (
eslint.config.js):localStorage,sessionStorage,indexedDB,caches,cookieStore,navigator.serviceWorker, anddocument.cookieare restricted insrc/**— as bare globals and through a qualified reference alike (window.localStorage,globalThis.sessionStorage,window.navigator.serviceWorker,window.document.cookie, …; issue #1275).test/lint-bans.test.tslints offending snippets through the real config and fails if the ban stops firing (the acceptance criterion: "a lint test proves the storage ban fires"). - supabase-js cannot write a session either: the client is created with
the
accessTokenoption (src/lib/supabase.ts, issue #1250) — per its own contract theauthnamespace is then unusable, so no session is ever persisted or read back; an inert storage adapter sits underneath as belt-and-braces. - A Playwright test asserts the browser is empty after a session
(
e2e/storage-empty.spec.ts): all six surfaces measured, all must be empty — for the signed-out surfaces and for a real signed-in session (the facade-backed home and account pages, issue #1721). Later issues extend this test as the client grows.
Two build-time generators feed the client from single sources outside
webapp/; both artifacts are committed, and freshness tests fail when they
drift — the same discipline CI applies to db.g.dart:
- Message catalogue (
scripts/generate.mjs, run by everynpm run dev/build):src/i18n/messages.en.json+ the typedMessageIdunion insrc/i18n/message-ids.ts, generated fromlib/l10n/app_en.arb— the same file the Flutter app translates from, so user-facing copy has exactly one home.t('id')(insrc/i18n/t.ts) is the only door to copy; the ESLint no-typed-copy ban enforces it. - Design tokens (
src/theme/tokens.generated.json+ derivedsrc/theme/tokens.css): the resolved M3 colour schemes, type ramp, spacing, radii, elevation, and motion — produced from the app theme byflutter test tool/export_webapp_tokens_generate_test.dart(re-run that after touchinglib/ui/theme/). Hand-written CSS may referencevar(--ll-*)only;test/tokens.test.tsscans for literal colours. - Tag taxonomy (
src/lib/taxonomy/taxonomy.generated.json,scripts/generate-taxonomy.mjs, issue #1254): the day editor's symptom vocabulary — codes, categories, display labels, the single-select and positive-assertion rules — extracted from the app's ownlib/domain/tags.dart(andtracking_preferences.dart's minor-visibility set,care_modes.dart's standard category headings), so the web picker speaks the app's taxonomy instead of re-typing it.test/taxonomy.test.tsre-runs the builder and fails on drift; when #1251 compileslib/domainto JavaScript, the compiled module replaces this artifact.
src/pages/DayPage.tsx (route /day/:profileId?date=YYYY-MM-DD) writes
every day category — flow, spotting, symptom and test tags, note with the
#849 privacy rules, PMS, BBT, weight, cycle corrections, life-stage mode —
through the same sync_push RPC the phones use, with client-generated ULID
ids and client-minted updated_at, so the server's last-writer-wins and
same-date merge rules treat the web as just another device. Reads go
through sync_pull (src/lib/day/day-data.ts), never raw day_entries
selects: the #849 private-note mask lives inside sync_pull, and a raw
select would hand a non-subject guardian text they must not hold. The
per-uid walk cache is page memory only; the storage-empty e2e guard
covers the day route.
src/lib/supabase.ts imports supabase/database.types.ts directly — the
web client is that snapshot's first consumer. A migration that changes a
shape this client reads breaks npm run typecheck here, and CI's
db-tests job breaks the other direction when the snapshot drifts from the
migrations. Zod schemas (src/lib/schemas.ts) strip unknown keys, so a
column addition is never a runtime failure — the field is picked up when
the UI wants it.
src/lib/domain.ts is the one module every web screen reads and writes
account data through; src/lib/queries.ts wraps it in TanStack Query.
Reads go through sync_pull - the app's authoritative, guardian-scoped,
server_version-cursor RPC - paged to exhaustion (500 rows/page) so a
profile's full cycle history loads; guardian notes are read per date through
their own RLS-scoped select (sharing.ts's fetchGuardianNotesForDate),
since sync_pull does not carry them. The sync_pull path is chosen
over raw selects because private-note masking lives only on the RPC paths
(mask_day_entry_note): a raw select on day_entries passes guardian RLS
but would return an unmasked private note. Writes go exactly where the
app's writes go — the sync_push RPC with client-generated ULID ids
(src/lib/ulid.ts, the Dart lib/data/db/ulid.dart algorithm ported) and
client-generated updated_at; per-row rejections come back mapped to the
offending payload row. Guardian-membership changes ride the sharing RPCs.
Live updates subscribe to public.sync_signals (the Realtime
publication's only table) for the visible profiles and invalidate the
synced-data query; the wake payload is never treated as data.
resetWebDataForSignOut clears the pull cursors, the merged snapshot, and
the TanStack cache — all strictly in page memory.
npm ci # pinned by package-lock.json
npm run generate # regenerate catalogue + tokens CSS (predev/prebuild run it)
npm run dev # Vite dev server
npm run lint # ESLint (storage + copy bans) and Prettier
npm run typecheck # tsc --noEmit
npm run test # Vitest unit tests
npm run build # typecheck + Vite production build -> dist/
npm run e2e # Playwright: smoke + axe + nothing-stored guard
The e2e suite runs against vite preview, which serves the build with the
staging Worker's exact security headers (worker/headers.ts — the single
source of truth, imported by both worker/index.ts and vite.config.ts).
.github/workflows/webapp-deploy.yml builds and deploys to the
lunarlog-app-staging Cloudflare Worker on every push to main that
touches webapp/**, supabase/database.types.ts, or
lib/l10n/app_en.arb. The Worker serves two origins: app.lunarlog.app (the
production origin, attached as a custom domain via wrangler.jsonc's
routes) and its workers.dev hostname (the per-account subdomain is
resolved from the Cloudflare API at deploy time), kept as the staging alias
(#1249). The workers.dev origin is backed by the production Supabase
project and used only with the fabricated accounts from #710. The cutover
of app.lunarlog.app from the Flutter build's lunarlog-app Pages project
happened on 2026-10-03 (#1258); the workflow's Pages-retirement and
cutover-probe steps were removed in #1624, so a deploy now only deploys the
Worker and smoke-checks both origins. The Worker also performs the #1248 retirement
duties on the production origin: Clear-Site-Data: "storage" on
the app shell (never "cache" (issue #1397: it would re-download every
hashed asset on every page load) or "cookies" - the refresh cookie must survive), a
301 from /privacy.html to the apex policy, and a self-unregistering
service worker at the old /flutter_service_worker.js path. Until the CLOUDFLARE_* secrets are
present the deploy steps warn and skip, like site-deploy.yml.
Every response carries the security headers declared in
worker/headers.ts (set by worker/index.ts, which owns all routes via
run_worker_first): CSP with script-src 'self' (no unsafe-inline,
unsafe-eval, or wasm-unsafe-eval), connect-src self plus the Supabase
project over https and wss, frame-ancestors 'none',
require-trusted-types-for 'script' (the stack passes with it — proven by
the e2e suite under real Chromium enforcement), HSTS, COOP same-origin,
X-Robots-Tag: noindex, and no third-party anything. After each deploy,
.github/scripts/check-webapp-staging.sh (the repurposed
check-web-deploy.sh header/beacon assertions) re-asserts the posture
against the live origin.
The Web app (lint, typecheck, unit, build, e2e) job in ci.yml is
path-filtered to webapp/**, supabase/database.types.ts, and
lib/l10n/app_en.arb (issue #1249; data-layer paths — webapp/src/lib/**
and webapp/test/integration/**, issue #1252 — additionally turn on the
database suite), and runs deno check/deno test on worker/ plus the
full npm suite and the Playwright tests. The data layer's integration tests
run in the db-tests job against the local Supabase stack that job
already boots for pgTAP (the suite skips itself when no stack is
reachable); realtime delivery itself is proven manually by
supabase/tests/manual/verify_realtime_delivery.mjs — the db-tests stack
deliberately excludes the realtime container (AGENTS.md, Migration Flow).
The webapp job is a required check of the store release gate — it sits
in .github/scripts/check-ci-gate.sh's REQUIRED_CHECKS and in the ruleset
rollup's needs: — promoted there at the app.lunarlog.app launch (issue
#1258, on the #1264 review's recorded recommendation: deliberately outside
the gate was correct while the webapp was a staging-only scaffold, but the
launch made it load-bearing). check-webapp-staging.test.sh pins both
surfaces; issue #1249's "not required" posture was retired at that launch.
Sign-in runs entirely through the same-origin Worker (worker/auth.ts),
which proxies Supabase Auth's GoTrue HTTP API. The browser's only credential
is the access token in page memory; the refresh token lives in the
rotating __Host-ll_refresh HttpOnly Secure SameSite=Strict cookie and is
never in a response body, and the PKCE verifier rides its own short-lived
__Host-ll_pkce HttpOnly SameSite=Lax cookie so the OAuth/emailed links
land in the same browser. Every state-changing route demands the app's
Origin plus an x-lunarlog-csrf custom header (the CSRF gate), on top of
the SameSite=Strict cookie. supabase-js is created with the accessToken
client option fed from src/lib/auth.ts's in-memory token, renewed through
GET /auth/session (which rotates the cookie) with a single in-flight
renewal per tab.
- Routes (
worker/auth.ts): password sign-in/sign-up, the emailed 8-digit code and magic link (send + verify), Google/Apple PKCE start and thePOST /auth/callbackexchange (the provider'sGETlands on the SPA page), password reset + update, session refresh, and sign-out on this device or everywhere. Failures carry GoTrue's own error codes so the UI can reuse the app's failure copy (src/lib/authCopy.ts). The recover email's PKCE cookie carries arecovery:marker beside the verifier that the callback echoes in its response (issue #1293) — GoTrue's redirect back carries only?code=, never?type=recovery, so the marker is what sends the callback page to the new-password step. - Upstream shapes mirror the installed @supabase/auth-js wire format
(grants at
/auth/v1/token?grant_type=…,redirect_toas a query parameter,code_challengein the body), verified againstnode_modules/@supabase/auth-js. - Tests:
worker/auth.test.ts(Deno) covers the cookie flags, rotation, CSRF rejection, the different-browserverifier_missingcallback error, and the OAuth start against a stubbed Supabase; the browser side is covered bytest/auth.test.ts,test/authCopy.test.ts, andtest/auth-pages.test.tsx. Real Google/Apple/email-link sign-in needs #1093's console steps; the live-provider checklist is #1258. - Deploy: the Worker's publishable key is set as the Wrangler secret
SUPABASE_PUBLISHABLE_KEYbywebapp-deploy.ymlfrom theSUPABASE_ANON_KEYrepository secret (warn-and-skip on forks; the auth routes answer503 auth_not_configureduntil it exists).
The web client does not re-implement the app's cycle logic in TypeScript:
lib/domain (pure Dart — no Flutter, dart:io, Drift, or Supabase imports)
is compiled to JavaScript once and both platforms run the same engine.
-
Build (from the repo root; needs the Flutter SDK's
dart, because the root pubspec resolves under the Flutter SDK):flutter pub get dart compile js -O2 tool/web_domain/main.dart \ -o webapp/public/domain/lunarlog_domain.jsThe artifact is not committed (gitignored); CI builds it before the suite runs. dart2js (not WASM) keeps the output a plain same-origin script — clean under the staging CSP's
script-src 'self'and no Trusted Types sink;index.htmlloads it with a static<script>tag ande2e/domain-module.spec.tsproves it answers under the real headers. -
The facade (
tool/web_domain/facade.dart) is JSON-in/JSON-out:window.lunarlogDomain.invoke(method, requestJson)answers{"ok": true, "data": ...} | {"ok": false, "error": "..."}. It covers prediction (life-stage/birth-control suppression and the confidence tier included), cycle history, the home-screen insights, day-entry date-bounds validation, the JSON export builder (app file format, phone-restorable), invite-link parsing, and what the Today log card says about a day (todayLog).todayand the browser's IANA zone are passed in on every date-math request; the tz database (latest_10y) is loaded explicitly. -
The typed client (
src/domain/client.ts) unwraps the envelope (DomainCallErroronok: false) and validates every output with the Zod schemas insrc/domain/schemas.ts— the same boundary discipline the Supabase schemas apply. -
Parity (
test/domain/parity.test.ts): the committedtest/domain/fixtures.jsonis generated by the Dart side (dart run tool/web_domain/generate_fixtures.dart), pinned in the Flutter suite bytest/domain/web_domain_fixtures_test.dart, and run through the compiled module here — Dart and JavaScript can never drift apart silently. A fixture change shows up in the PR diff; regenerate it only when a domain output change is intentional.
The web client's read side. / is the profile home: a ?profile=
switcher over the live profiles, a status card (cycle day / period-day
line, the next-period estimate with its confidence tier or its suppression
reason, the overdue line in the profile's own voice), the month calendar,
the cycle history with its statistics, and a compact comparison of the two
most recent completed cycles. /profiles is the picker's management half:
create, edit, archive/unarchive, and the two-step permanent delete, gated
by the caller's guardian role exactly as the server gates them (edit:
primary/co-parent; delete: primary only). Inside the edit form, who the
profile is for (the relationship) is the primary guardian's alone to
change: a co-parent sees the stored value, disabled, with a line saying
so, and her save does not send it (issue #1503; the server would put her
value back, issue #1499).
- Every domain computation is the compiled module's (
src/domain/, issue #1251):predict,cycleHistory, and - new in this issue -calendarForecast, which returns the app's ownforecastDayCellsmap, so the web grid paints the same predicted-period bands, fertile windows, PMS/cramps badges, and first-cycle numerals the app'smonth_calendar.dartdoes, plus ableedDaysenrichment oncycleHistory's items for the comparison. The request inputs are pure derivations of the #1252 snapshot (src/lib/profiles/profile-views.ts): live entries in export-row shape,cycle_overridesomissions, the stored facts, the profile_modes row's life-stage mode and birth control. - Framing is the app's composition (#853):
homeEstimateViewportsirregularFramingInEffectand the care-mode copy branches - a teen is never "late" (the quiet overdue line at every tier), the composed framing shows ranges, hides the tier caption and the fertile layer, and the estimate always carries the R17 disclaimer. Life-stage suppression and predictions-off render the suppression card in the mode's or the method's own words. - Calendar layers and legend match the app: Sunday-first grid,
flow marks by level (1-5, the spotting ring at the bottom),
symptom-layer dots ranked by
rankTagUsage's port with thekMaxSymptomLayerscap and the app's chooser, today's ring, and the legend rows ofCalendarLegendSheet(the PMS swatch only while the live estimate carries a band, issue #220). The calendar palette rides the generated tokens (--ll-cal-*, exported fromLunarLogColorsbytool/export_webapp_tokens.dart; the translucent band fills arecolor-mixderivatives of their own border tokens because the token export contract is fully opaque colours). - Profile management writes the app's paths:
sync_pushpayloads for create/edit/archive (an untouched irregular-framing control omits the key, so the server's containment guard preserves the stored tri-state),delete_profile_datafor the delete (then the membership re-pull), andrecord_minimum_age_acknowledgementbest-effort after the first creation when the account'saccount_consentsrow is missing or older than the current policy version - the #845 flow, mirrored fromlib/ui/profiles/first_run_screen.dart. - What is logged today (the app's Today log card, issue #1489): under
the status card, a "Logged today" card says what is logged for the
browser's today - the flow, the tags, the PMS marker, a temperature or
weight reading, and "Note added" - with an Edit link to today's day
page, or one quiet line when nothing is logged; the home's button reads
"Edit today" once something is. What it says is the compiled module's
answer (
todayLog, overlib/domain/logging/today_log.dart, the rules the app's own card calls): which tags are named and by what label, which are only counted (sex life, test results, any code the build does not know), and where "and N more" starts. The page only picks today's rows out of the snapshot (src/lib/profiles/today-log.ts) and lays the answer out. The note goes into the call and only "a note exists" comes back. Who sees the card is the app's lens rule (guardianLensFor, issue #850) on the membership row's server-stampedis_subject: the person the profile is about sees it, any other accepted member does not, and someone who cannot log sees the summary without Edit. The server sets that marker on the owner of a profile she created for herself (relationshipself, issue #1499), so she sees the card; an account that created a profile for someone else does not carry it and counts as a guardian here exactly as it does in the app. - Tests:
test/profile-views.test.tsandtest/calendar-cells.test.tspin the pure ports;test/today-page.test.tsxandtest/profiles-page.test.tsxrender the pages against a fake domain module (serving the committed parity fixtures) and a fake Supabase client;test/today-page-today-log.test.tsxrenders the home against the real compiled module (test/domain/load-module.ts), since what it checks is what the app's rules say about a snapshot;e2e/profile-home.spec.tsdrives the real built app with the real compiled module over an interceptedsync_pull- profile switching, month navigation, and a pregnancy-mode profile showing its suppression copy, all axe-clean. The suite self-skips on an unconfigured build (a fork'sVITE_SUPABASE_*are empty); this repo's CI always builds configured.
- The full insights screens: the home renders the history and a
compact two-cycle comparison from the domain view, but the app's
analysis tab (BBT chart, symptom-trend cards, the full cycle-comparison
screen, the phase-insights card) is still a later slice of the epic —
the
insightsfacade method is wired and fixture-pinned, only the screens wait. - The first-run onboarding flow: the profiles page creates profiles with the acknowledgement, but the app's full household walk (who / relationship defaults / cycle questions feeding #218's provisional facts) is not on the web yet; the stored facts are editable only through the day editor today.
Custom domain (#1258)— shipped 2026-10-04: the custom-domain attach runs in the deploy; that move also re-checks the live-provider flows and how Supabase counts the Worker's rate-limited requests (the Worker already forwardsCF-Connecting-IPasX-Forwarded-For).
