b20145c215
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3.7 KiB
3.7 KiB
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.
publisheddefaults 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 }whereaggregate=ledgerService.getModelAggregate({ userId })— a newuserIdoption 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 metadataCLV-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 viaopengraph-image.tsxin the segment (Node runtime — NEVER edge, S53 rule)./settingsPUBLIC 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
- Migration committed, unapplied, documented in the report.
getModelAggregatedefault scope unchanged;userIdscope unit-tested both ways.- Unpublished and unknown handles return byte-identical 404 bodies.
- Handle regex enforced at API AND database CHECK.
/u/...reachable anonymous; aggregate renders no % under 20 settles.- Full jest suite green;
webbuild 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).