# A1 Session 10 — Public Ledger Profiles v1 Stage-3 seed (NORTH STAR): every bettor gets a CLV-verified public record — Strava for betting. v1 is the minimum honest version: a handle, one explicit publish toggle, and the user's ENTIRE settled record on a public page. ## Operating rules - Zero out-of-pocket. Existing Supabase + Express + Next only. - PRIVATE BY DEFAULT. `published` defaults false; publishing is one explicit toggle with copy that says exactly what it does. - Same n-gating as the model record: no percentage under 20 settles (`ledgerService.MIN_AGG_SAMPLE`). - Data semantics: only real settled ledger rows. Nothing curated — a public profile shows ALL settled reads including misses. That is the point. - No existence leak: unknown handle and unpublished handle return the SAME 404 body. ## Data Migration `supabase/migrations/022_public_profiles.sql` (committed, NOT applied — the founder applies migrations): ``` public_profiles ( user_id uuid PK REFERENCES auth.users ON DELETE CASCADE, handle text UNIQUE NOT NULL CHECK (handle ~ '^[a-z0-9_]{3,20}$'), published boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now() ) ``` RLS: anon + authenticated SELECT where `published = true`; owner SELECTs own row; NO client write policies — all writes via the service role. ## Endpoints (`src/routes/profiles.js`, mounted at /api/profiles) - `GET /api/profiles/me` (requireAuth) → `{ profile: {handle, published, created_at} | null }`. - `POST /api/profiles/me` (requireAuth) → upsert own `{ handle, published }`. Handle validated against `^[a-z0-9_]{3,20}$` (400 on invalid); a handle owned by another user → 409. Service-role write. - `GET /api/profiles/:handle` (public, 60/min) → published profile: `{ handle, aggregate, entries, min_sample }` where - `aggregate` = `ledgerService.getModelAggregate({ userId })` — a new `userId` option swaps the `.is('user_id', null)` public scoping for `.eq('user_id', uid)`. The public default is untouched. - `entries` = that user's SETTLED rows, newest 50, same columns as /api/ledger. Unknown OR unpublished → 404 with the identical body (no existence leak). Next proxies: `web/src/app/api/profiles/me/route.ts` (GET/POST, forwards Authorization) and `web/src/app/api/profiles/[handle]/route.ts` (GET). ## Frontend - `/u/[handle]` — PUBLIC (never gated). Server component shell with metadata `CLV-verified record — @handle · VYNDR` + client content: record header (hit% + beat-close% at n≥20, else RECORD BUILDING), settled rows with outcome + CLV chips (ledger card patterns), OG card via `opengraph-image.tsx` in the segment (Node runtime — NEVER edge, S53 rule). - `/settings` PUBLIC PROFILE section: claim handle + publish toggle. Copy makes public-by-choice explicit: "Publishing puts your ENTIRE settled record on a public page — wins and misses. Private by default." ## Acceptance criteria 1. Migration committed, unapplied, documented in the report. 2. `getModelAggregate` default scope unchanged; `userId` scope unit-tested both ways. 3. Unpublished and unknown handles return byte-identical 404 bodies. 4. Handle regex enforced at API AND database CHECK. 5. `/u/...` reachable anonymous; aggregate renders no % under 20 settles. 6. Full jest suite green; `web` build exit 0. ## Test plan - `tests/unit/ledgerAggregateScope.test.js` — public vs userId scoping. - `tests/integration/profilesRoutes.test.js` — handle validation, 409 on taken handle, publish flow, 404-no-leak (mock supabase per ledgerRoutes.test.js pattern). - `tests/unit/publicProfilePage.test.js` — page/OG/settings source assertions (metadata string, no edge runtime, RECORD BUILDING state, private-by-default copy, /u stays ungated).