Files
vyndr/specs/a1-s10-public-profiles.md
2026-07-11 19:32:22 -04:00

78 lines
3.7 KiB
Markdown

# 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).