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

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