S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,3 +206,29 @@ The system's track record — settled grades vs real results. Written by
|
||||
`GET /api/snapshot/:sport` grades may carry `outcome: { result:'hit'|'miss'|
|
||||
'push', actual:number }` once the game is final. `AccuracyBadge` +
|
||||
`StatStrip.OutcomeChip` render these. Frontend proxy: `web/src/app/api/accuracy`.
|
||||
|
||||
## 8. Public ledger profiles (`src/routes/profiles.js`, A1 Session 10)
|
||||
|
||||
Strava-for-betting v1. Table `public_profiles` (migration 022 — apply
|
||||
before deploy). PRIVATE BY DEFAULT; one explicit publish toggle in Settings.
|
||||
|
||||
### `GET /api/profiles/:handle` (public, cached 60s)
|
||||
Published only. Unknown AND unpublished return the SAME 404 body
|
||||
(`{ error: 'Profile not found' }`) — no existence leak.
|
||||
```
|
||||
{
|
||||
handle: string,
|
||||
aggregate: <getModelAggregate shape, scoped to the user via userId>,
|
||||
entries: [<same columns as /api/ledger, SETTLED rows only, newest 50>],
|
||||
min_sample: 20 // below this, RECORD BUILDING — never a %
|
||||
}
|
||||
```
|
||||
|
||||
### `GET|POST /api/profiles/me` (requireAuth)
|
||||
GET → `{ profile: { handle, published, created_at } | null }`.
|
||||
POST `{ handle, published }` → upsert own row (service role). Handle must
|
||||
match `^[a-z0-9_]{3,20}$` (400); a handle owned by another user → 409.
|
||||
`published` flips ONLY on boolean `true`.
|
||||
|
||||
Frontend: `/u/[handle]` (OPEN route, server shell + client record + OG card,
|
||||
Node runtime) via proxies `web/src/app/api/profiles/me|[handle]`.
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
## Last Updated
|
||||
2026-07-11
|
||||
|
||||
## Session S10 (a1 board, 2026-07-11) — Public Ledger Profiles v1 ✅
|
||||
Stage-3 seed (Strava for betting), zero out-of-pocket. Branch off
|
||||
day1/a1-board (aaafc3e). 2574 → **2612 tests** (220 suites), web build
|
||||
exit 0. Spec: `specs/a1-s10-public-profiles.md`.
|
||||
- **Migration `022_public_profiles.sql`** — COMMITTED, NOT APPLIED (founder
|
||||
applies migrations). `public_profiles` (user_id PK → auth.users, handle
|
||||
UNIQUE + regex CHECK, `published` DEFAULT FALSE). RLS: published rows
|
||||
readable by anyone, own row by owner, writes service-role only.
|
||||
- **API** — `/api/profiles`: `GET/POST /me` (requireAuth; handle regex 400,
|
||||
taken handle 409, published only flips on explicit boolean true) and
|
||||
public `GET /:handle` (published → user-scoped 30d aggregate + settled
|
||||
rows newest 50, same columns as /api/ledger; unknown AND unpublished →
|
||||
byte-identical 404 — no existence leak). `getModelAggregate` gained a
|
||||
`userId` option (public `.is('user_id', null)` default untouched).
|
||||
- **Frontend** — `/u/[handle]` (PUBLIC, in OPEN_ROUTES): server shell with
|
||||
"CLV-verified record — @handle · VYNDR" metadata + client record (hit% +
|
||||
beat-close% at n≥20 else RECORD BUILDING; outcome + CLV chips) + OG card
|
||||
(Node runtime, never edge). Settings PUBLIC PROFILE section: claim handle
|
||||
+ one explicit publish toggle with the private-by-default copy.
|
||||
- **Next**: profile OG card could embed the live record once n≥20 profiles
|
||||
exist; user-scan settlement coverage beyond MLB is the limiting factor.
|
||||
|
||||
## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅
|
||||
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.
|
||||
2398 → **2439 tests** (209 suites), web build exit 0.
|
||||
|
||||
@@ -880,6 +880,27 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
||||
- Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone
|
||||
setup) = `docs/OPS-RUNBOOK.md`.
|
||||
|
||||
## Public Ledger Profiles v1 (Session 10, A1 board — non-obvious)
|
||||
- **`public_profiles` (migration 022) is PRIVATE BY DEFAULT** — `published`
|
||||
DEFAULT FALSE, one explicit toggle in Settings, service-role writes only.
|
||||
`GET /api/profiles/:handle` returns the SAME 404 body for unknown AND
|
||||
unpublished handles (no existence leak) — keep them byte-identical; a
|
||||
test diffs the two responses. A malformed handle 404s WITHOUT a DB query.
|
||||
- **`getModelAggregate({ userId })`** swaps `.is('user_id', null)` for
|
||||
`.eq('user_id', uid)` on BOTH the settled and pending queries — the same
|
||||
30d window + n≥20 gate over one user's ledger. The no-userId default is
|
||||
the public model record and must stay untouched.
|
||||
- **The user's public entries are SETTLED rows only** (`.not('outcome',
|
||||
'is', null)`, newest 50, same columns as /api/ledger) — pending reads are
|
||||
not public until they settle, and nothing is curated: misses included.
|
||||
- **`/u` is in OPEN_ROUTES on purpose** — the share surface must load
|
||||
anonymous; the privacy gate is the API's 404, never the router.
|
||||
- **Jest from a worktree gotcha:** the repo config's `testPathIgnorePatterns`
|
||||
includes `/.claude/`, which matches EVERY path inside
|
||||
`.claude/worktrees/...` → "No tests found". Run with
|
||||
`npx jest --testPathIgnorePatterns "/node_modules/"` from a worktree
|
||||
(CLI replaces the config array).
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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).
|
||||
@@ -165,6 +165,9 @@ app.use('/api/team', require('./routes/team'));
|
||||
// (settled snapshot grades vs real results). Public, cache-only.
|
||||
app.use('/api/accuracy', require('./routes/accuracy'));
|
||||
app.use('/api/ledger', require('./routes/ledger'));
|
||||
// A1 Session 10 — public ledger profiles: claim a handle, one explicit
|
||||
// publish toggle, and the ENTIRE settled record on a public page.
|
||||
app.use('/api/profiles', require('./routes/profiles'));
|
||||
const gameLinesRoutes = require('./routes/gameLines');
|
||||
app.use('/api/gamelines', gameLinesRoutes);
|
||||
const streaksRoutes = require('./routes/streaks');
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/profiles — public ledger profiles v1 (A1 Session 10).
|
||||
*
|
||||
* Stage-3 seed: a user claims a handle and publishes their ENTIRE settled
|
||||
* ledger record — wins and misses, nothing curated. Strava for betting.
|
||||
*
|
||||
* GET /me (auth) — own public_profiles row (or null).
|
||||
* POST /me (auth) — upsert own { handle, published }. Handle must
|
||||
* match ^[a-z0-9_]{3,20}$; a handle owned by
|
||||
* another user → 409. Service-role write only
|
||||
* (the table has no client write policies).
|
||||
* GET /:handle (public) — the published record: the user-scoped 30d
|
||||
* aggregate (same n≥20 gate as the model record)
|
||||
* + settled rows, newest 50, same columns as
|
||||
* /api/ledger.
|
||||
*
|
||||
* PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit
|
||||
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
|
||||
* return the byte-identical 404 body.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const ledgerService = require('../services/ledgerService');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
|
||||
// Same 404 body for unknown AND unpublished — never confirm a handle exists.
|
||||
const NOT_FOUND = { error: 'Profile not found' };
|
||||
// Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS).
|
||||
const ROW_COLUMNS = 'id, player_key, player_name, sport, stat, line, side, locked_odds, book, grade, edge, confidence, model_value, graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade';
|
||||
const ENTRY_LIMIT = 50;
|
||||
|
||||
function sbOrNull() {
|
||||
try {
|
||||
if (!ledgerService.__internals.isConfigured()) return null;
|
||||
return require('../utils/supabase').getSupabaseServiceClient();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Own row — the owner sees it published or not.
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const sb = sbOrNull();
|
||||
if (!sb) return res.json({ profile: null });
|
||||
try {
|
||||
const { data, error } = await sb.from('public_profiles')
|
||||
.select('handle, published, created_at')
|
||||
.eq('user_id', req.user.id)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(error.message);
|
||||
return res.json({ profile: data || null });
|
||||
} catch (err) {
|
||||
console.error('[profiles/me]', err.message);
|
||||
return res.status(200).json({ profile: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Claim/update the handle + the ONE explicit publish toggle.
|
||||
router.post('/me', requireAuth, async (req, res) => {
|
||||
const sb = sbOrNull();
|
||||
if (!sb) return res.status(503).json({ error: 'Profiles are unavailable right now' });
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const handle = String(body.handle || '').trim().toLowerCase();
|
||||
if (!HANDLE_RE.test(handle)) {
|
||||
return res.status(400).json({ error: 'Handle must be 3-20 characters: a-z, 0-9, underscore' });
|
||||
}
|
||||
// PRIVATE BY DEFAULT: published only flips when the body says true.
|
||||
const published = body.published === true;
|
||||
const { error } = await sb.from('public_profiles')
|
||||
.upsert({ user_id: req.user.id, handle, published }, { onConflict: 'user_id' });
|
||||
if (error) {
|
||||
// Unique violation on `handle` → someone else owns it.
|
||||
if (error.code === '23505' || /duplicate|unique/i.test(error.message || '')) {
|
||||
return res.status(409).json({ error: 'That handle is taken' });
|
||||
}
|
||||
throw new Error(error.message);
|
||||
}
|
||||
return res.json({ ok: true, profile: { handle, published } });
|
||||
} catch (err) {
|
||||
console.error('[profiles/post]', err.message);
|
||||
return res.status(503).json({ error: 'Could not save profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// The public record. Cache lightly; the settle pass updates rows a few
|
||||
// times a day, not per-second.
|
||||
router.get('/:handle', async (req, res) => {
|
||||
const handle = String(req.params.handle || '').trim().toLowerCase();
|
||||
// Invalid shape can't exist (DB CHECK) → same 404, no query needed.
|
||||
if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND);
|
||||
const sb = sbOrNull();
|
||||
if (!sb) return res.status(404).json(NOT_FOUND);
|
||||
try {
|
||||
const { data: row, error } = await sb.from('public_profiles')
|
||||
.select('user_id, handle, published')
|
||||
.eq('handle', handle)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(error.message);
|
||||
// Unknown and unpublished are indistinguishable from outside.
|
||||
if (!row || row.published !== true) return res.status(404).json(NOT_FOUND);
|
||||
|
||||
// Same aggregate machinery as the model record — same window, same
|
||||
// n≥20 gate — scoped to this user's own rows.
|
||||
const aggregate = await ledgerService.getModelAggregate({ userId: row.user_id, sb });
|
||||
|
||||
// ALL settled reads, misses included — nothing curated.
|
||||
const { data: entries, error: entriesErr } = await sb.from('ledger_entries')
|
||||
.select(ROW_COLUMNS)
|
||||
.eq('user_id', row.user_id)
|
||||
.not('outcome', 'is', null)
|
||||
.order('graded_at', { ascending: false })
|
||||
.limit(ENTRY_LIMIT);
|
||||
if (entriesErr) throw new Error(entriesErr.message);
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=60');
|
||||
return res.json({
|
||||
handle: row.handle,
|
||||
aggregate,
|
||||
entries: entries || [],
|
||||
min_sample: ledgerService.MIN_AGG_SAMPLE,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[profiles/handle]', err.message);
|
||||
return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -386,6 +386,11 @@ async function countRowsForDate(gameDate, opts = {}) {
|
||||
* 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
|
||||
* beat-the-close rate, pending count. Percentages are null below
|
||||
* MIN_AGG_SAMPLE — the UI must show "record building" instead.
|
||||
*
|
||||
* A1 Session 10 — `opts.userId` swaps the public `.is('user_id', null)`
|
||||
* scoping for `.eq('user_id', uid)`: the SAME aggregate (same window, same
|
||||
* n≥20 gate) over one user's own ledger, powering public profiles. The
|
||||
* public default is untouched.
|
||||
*/
|
||||
async function getModelAggregate(opts = {}) {
|
||||
const empty = {
|
||||
@@ -400,8 +405,9 @@ async function getModelAggregate(opts = {}) {
|
||||
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
let settledQ = sb.from('ledger_entries')
|
||||
.select('outcome, clv_result, player_key, grade')
|
||||
.is('user_id', null)
|
||||
.select('outcome, clv_result, player_key, grade');
|
||||
settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
|
||||
settledQ = settledQ
|
||||
.not('outcome', 'is', null)
|
||||
.gte('game_date', since)
|
||||
.limit(AGG_FETCH_LIMIT);
|
||||
@@ -412,9 +418,9 @@ async function getModelAggregate(opts = {}) {
|
||||
if (error) return { ...empty, error: error.message };
|
||||
|
||||
let pendingQ = sb.from('ledger_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.is('user_id', null)
|
||||
.is('outcome', null);
|
||||
.select('id', { count: 'exact', head: true });
|
||||
pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null);
|
||||
pendingQ = pendingQ.is('outcome', null);
|
||||
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
|
||||
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
|
||||
if (opts.team) pendingQ = pendingQ.eq('team', opts.team);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ---------------------------------------------------------------
|
||||
-- 022 — public_profiles (A1 Session 10, public ledger profiles v1).
|
||||
--
|
||||
-- Stage-3 seed: a user may claim a handle and publish their ENTIRE settled
|
||||
-- ledger record on a public page — wins and misses. Strava for betting.
|
||||
--
|
||||
-- PRIVACY: PRIVATE BY DEFAULT. `published` defaults false and only flips
|
||||
-- via one explicit toggle in Settings. The profile API returns the SAME
|
||||
-- 404 for an unknown handle and an unpublished one (no existence leak).
|
||||
--
|
||||
-- All writes go through the service role (the /api/profiles/me route).
|
||||
-- Clients only read: published rows (anyone) + their own row (owner).
|
||||
-- ---------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.public_profiles (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users (id) 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()
|
||||
);
|
||||
|
||||
ALTER TABLE public.public_profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Anyone (anon + authenticated) reads PUBLISHED profiles only.
|
||||
CREATE POLICY public_profiles_select_published ON public.public_profiles
|
||||
FOR SELECT TO anon, authenticated
|
||||
USING (published = true);
|
||||
|
||||
-- The owner reads their own row (published or not).
|
||||
CREATE POLICY public_profiles_select_own ON public.public_profiles
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- No INSERT/UPDATE/DELETE policies: client-side writes are impossible.
|
||||
-- The API writes via the service role, which bypasses RLS.
|
||||
@@ -0,0 +1,219 @@
|
||||
// A1 Session 10 — /api/profiles: claim handle + publish toggle (auth) and the
|
||||
// public record read. Supabase + auth are mocked (ledgerRoutes.test.js
|
||||
// pattern); under test: handle validation, 409 on a taken handle,
|
||||
// private-by-default writes, and the no-existence-leak 404 (unknown and
|
||||
// unpublished handles are byte-identical).
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
requireAuth: (req, res, next) => {
|
||||
if (!req.headers.authorization) return res.status(401).json({ error: 'auth required' });
|
||||
req.user = { id: 'u1', tier: 'analyst' };
|
||||
return next();
|
||||
},
|
||||
}));
|
||||
|
||||
// Table-aware Supabase stub. Configure via mockState; every query records
|
||||
// its filters so scoping is assertable.
|
||||
const mockState = {
|
||||
profileRow: null, // what public_profiles maybeSingle resolves to
|
||||
upsertError: null,
|
||||
upserts: [],
|
||||
ledgerRows: [],
|
||||
filters: [], // [table, filters[]]
|
||||
};
|
||||
|
||||
function mockChain(table) {
|
||||
const b = { _filters: [] };
|
||||
const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; };
|
||||
b.select = () => b;
|
||||
b.eq = rec('eq');
|
||||
b.is = rec('is');
|
||||
b.not = rec('not');
|
||||
b.gte = rec('gte');
|
||||
b.order = () => b;
|
||||
b.maybeSingle = () => {
|
||||
mockState.filters.push([table, b._filters]);
|
||||
return Promise.resolve({ data: mockState.profileRow, error: null });
|
||||
};
|
||||
b.limit = () => {
|
||||
mockState.filters.push([table, b._filters]);
|
||||
return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 });
|
||||
};
|
||||
b.then = (resolve, reject) => {
|
||||
mockState.filters.push([table, b._filters]);
|
||||
return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 }).then(resolve, reject);
|
||||
};
|
||||
b.upsert = (row, opts) => {
|
||||
mockState.upserts.push({ table, row, opts });
|
||||
return Promise.resolve({ error: mockState.upsertError });
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => ({ from: (table) => mockChain(table) }),
|
||||
}));
|
||||
|
||||
process.env.SUPABASE_URL = 'https://test.supabase.co';
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key';
|
||||
|
||||
function mountApp() {
|
||||
delete require.cache[require.resolve('../../src/routes/profiles')];
|
||||
const routes = require('../../src/routes/profiles');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profiles', routes);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.profileRow = null;
|
||||
mockState.upsertError = null;
|
||||
mockState.upserts.length = 0;
|
||||
mockState.ledgerRows.length = 0;
|
||||
mockState.filters.length = 0;
|
||||
});
|
||||
|
||||
describe('GET /api/profiles/me', () => {
|
||||
test('401 without auth', async () => {
|
||||
const res = await request(mountApp()).get('/api/profiles/me');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('returns the own row scoped to the authenticated user', async () => {
|
||||
mockState.profileRow = { handle: 'kev', published: false, created_at: '2026-07-10' };
|
||||
const res = await request(mountApp())
|
||||
.get('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.profile).toEqual({ handle: 'kev', published: false, created_at: '2026-07-10' });
|
||||
const [table, filters] = mockState.filters[0];
|
||||
expect(table).toBe('public_profiles');
|
||||
expect(filters).toContainEqual(['eq', 'user_id', 'u1']);
|
||||
});
|
||||
|
||||
test('no row yet → profile null (not an error)', async () => {
|
||||
const res = await request(mountApp())
|
||||
.get('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.profile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/profiles/me', () => {
|
||||
test('401 without auth', async () => {
|
||||
const res = await request(mountApp()).post('/api/profiles/me').send({ handle: 'kev' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test.each(['ab', 'way_too_long_for_a_handle', 'has space', 'bad-dash', 'dot.dot', ''])(
|
||||
'rejects invalid handle %j with 400 and writes nothing',
|
||||
async (handle) => {
|
||||
const res = await request(mountApp())
|
||||
.post('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t')
|
||||
.send({ handle });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockState.upserts).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
test('upserts own row; PRIVATE BY DEFAULT — published false unless explicitly true', async () => {
|
||||
const res = await request(mountApp())
|
||||
.post('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t')
|
||||
.send({ handle: 'Kev_2026' }); // no published flag at all
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.profile).toEqual({ handle: 'kev_2026', published: false });
|
||||
expect(mockState.upserts).toHaveLength(1);
|
||||
expect(mockState.upserts[0].table).toBe('public_profiles');
|
||||
expect(mockState.upserts[0].row).toEqual({ user_id: 'u1', handle: 'kev_2026', published: false });
|
||||
expect(mockState.upserts[0].opts).toEqual({ onConflict: 'user_id' });
|
||||
});
|
||||
|
||||
test('published only flips on an explicit boolean true', async () => {
|
||||
const res = await request(mountApp())
|
||||
.post('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t')
|
||||
.send({ handle: 'kev', published: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockState.upserts[0].row.published).toBe(true);
|
||||
|
||||
// truthy-but-not-true must NOT publish
|
||||
const res2 = await request(mountApp())
|
||||
.post('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t')
|
||||
.send({ handle: 'kev', published: 'yes' });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(mockState.upserts[1].row.published).toBe(false);
|
||||
});
|
||||
|
||||
test('handle owned by another user → 409', async () => {
|
||||
mockState.upsertError = { code: '23505', message: 'duplicate key value violates unique constraint "public_profiles_handle_key"' };
|
||||
const res = await request(mountApp())
|
||||
.post('/api/profiles/me')
|
||||
.set('Authorization', 'Bearer t')
|
||||
.send({ handle: 'taken_one' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/profiles/:handle (public)', () => {
|
||||
test('published profile → handle + user-scoped aggregate + settled entries', async () => {
|
||||
mockState.profileRow = { user_id: 'u2', handle: 'kev', published: true };
|
||||
mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A' });
|
||||
const res = await request(mountApp()).get('/api/profiles/kev');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.handle).toBe('kev');
|
||||
expect(res.body.min_sample).toBe(20);
|
||||
expect(res.body.aggregate).toBeTruthy();
|
||||
expect(res.body.aggregate.hit_pct).not.toBeUndefined();
|
||||
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||
|
||||
// Every ledger query is scoped to THAT user, never the public record.
|
||||
const ledgerQueries = mockState.filters.filter(([t]) => t === 'ledger_entries');
|
||||
expect(ledgerQueries.length).toBeGreaterThan(0);
|
||||
for (const [, filters] of ledgerQueries) {
|
||||
expect(filters).toContainEqual(['eq', 'user_id', 'u2']);
|
||||
expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id')).toBe(false);
|
||||
}
|
||||
// The entries list is settled rows only.
|
||||
const entriesQ = ledgerQueries.find(([, f]) => f.some((x) => x[0] === 'not' && x[1] === 'outcome'));
|
||||
expect(entriesQ).toBeTruthy();
|
||||
});
|
||||
|
||||
test('NO EXISTENCE LEAK — unknown and unpublished return the identical 404', async () => {
|
||||
// Unknown handle: no row at all.
|
||||
mockState.profileRow = null;
|
||||
const unknown = await request(mountApp()).get('/api/profiles/ghost_handle');
|
||||
|
||||
// Unpublished handle: row exists, published false.
|
||||
mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false };
|
||||
const unpublished = await request(mountApp()).get('/api/profiles/private_kev');
|
||||
|
||||
expect(unknown.status).toBe(404);
|
||||
expect(unpublished.status).toBe(404);
|
||||
expect(unknown.body).toEqual(unpublished.body);
|
||||
});
|
||||
|
||||
test('an unpublished profile leaks NO ledger data', async () => {
|
||||
mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false };
|
||||
mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A' });
|
||||
const res = await request(mountApp()).get('/api/profiles/private_kev');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.entries).toBeUndefined();
|
||||
expect(res.body.aggregate).toBeUndefined();
|
||||
// The ledger table was never even queried.
|
||||
expect(mockState.filters.filter(([t]) => t === 'ledger_entries')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a malformed handle shape gets the same 404 without touching the database', async () => {
|
||||
const res = await request(mountApp()).get('/api/profiles/NOT%20A%20HANDLE');
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockState.filters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// A1 Session 10 — getModelAggregate scoping. The public default MUST stay
|
||||
// `.is('user_id', null)` (the model record); the new `userId` option swaps
|
||||
// it for `.eq('user_id', uid)` (a user's own public-profile aggregate).
|
||||
// Same window, same n≥20 gate in both scopes.
|
||||
|
||||
const ledgerService = require('../../src/services/ledgerService');
|
||||
|
||||
function makeSb(captured, rows) {
|
||||
return {
|
||||
from() {
|
||||
const b = { _filters: [] };
|
||||
const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; };
|
||||
b.select = () => b;
|
||||
b.eq = rec('eq');
|
||||
b.is = rec('is');
|
||||
b.not = rec('not');
|
||||
b.gte = rec('gte');
|
||||
b.order = () => b;
|
||||
b.limit = () => { captured.push(b._filters); return Promise.resolve({ data: rows, error: null, count: 0 }); };
|
||||
b.then = (resolve, reject) => {
|
||||
captured.push(b._filters);
|
||||
return Promise.resolve({ data: rows, error: null, count: 2 }).then(resolve, reject);
|
||||
};
|
||||
return b;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('getModelAggregate scoping', () => {
|
||||
test('default (no userId) scopes BOTH queries to the public record (user_id IS NULL)', async () => {
|
||||
const captured = [];
|
||||
const agg = await ledgerService.getModelAggregate({ sb: makeSb(captured, []) });
|
||||
expect(captured.length).toBe(2); // settled + pending
|
||||
for (const filters of captured) {
|
||||
expect(filters).toContainEqual(['is', 'user_id', null]);
|
||||
expect(filters.some((f) => f[0] === 'eq' && f[1] === 'user_id')).toBe(false);
|
||||
}
|
||||
expect(agg.hit_pct).toBeNull();
|
||||
expect(agg.min_sample).toBe(ledgerService.MIN_AGG_SAMPLE);
|
||||
});
|
||||
|
||||
test('userId scopes BOTH queries to that user and never touches the public scope', async () => {
|
||||
const captured = [];
|
||||
await ledgerService.getModelAggregate({ sb: makeSb(captured, []), userId: 'u-42' });
|
||||
expect(captured.length).toBe(2);
|
||||
for (const filters of captured) {
|
||||
expect(filters).toContainEqual(['eq', 'user_id', 'u-42']);
|
||||
expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('user scope keeps the n≥20 gate — no percentage on a small sample', async () => {
|
||||
const rows = Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' }));
|
||||
const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' });
|
||||
expect(agg.settled).toBe(5);
|
||||
expect(agg.hit_pct).toBeNull();
|
||||
expect(agg.beat_close_pct).toBeNull();
|
||||
});
|
||||
|
||||
test('user scope renders percentages at n≥20 like the public record', async () => {
|
||||
const rows = [
|
||||
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' })),
|
||||
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded', grade: 'B' })),
|
||||
];
|
||||
const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' });
|
||||
expect(agg.settled).toBe(21);
|
||||
expect(agg.hit_pct).toBe(Math.round((14 / 21) * 100));
|
||||
expect(agg.beat_close_pct).toBe(Math.round((14 / 21) * 100));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// A1 Session 10 — public ledger profiles: /u/[handle] page + OG card +
|
||||
// settings claim/publish UI + proxies + migration. The .tsx surfaces are
|
||||
// asserted against source (plain-JS Jest, no TS transform) — same pattern as
|
||||
// vyndrAppShell/socialPreview.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const WEB = path.join(ROOT, 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const exists = (rel) => fs.existsSync(path.join(WEB, rel));
|
||||
|
||||
const routes = require('../../web/src/lib/routes');
|
||||
|
||||
describe('/u routing — the share surface stays public', () => {
|
||||
it('never gates /u (a public record must load anonymous)', () => {
|
||||
expect(routes.isGatedRoute('/u')).toBe(false);
|
||||
expect(routes.isGatedRoute('/u/kev')).toBe(false);
|
||||
});
|
||||
it('declares /u in OPEN_ROUTES', () => {
|
||||
expect(routes.OPEN_ROUTES).toContain('/u');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/u/[handle] server shell', () => {
|
||||
const src = read('app/u/[handle]/page.tsx');
|
||||
it('carries the CLV-verified metadata title', () => {
|
||||
expect(src).toContain('CLV-verified record — @');
|
||||
expect(src).toContain('· VYNDR');
|
||||
});
|
||||
it('renders the client record component', () => {
|
||||
expect(src).toContain('PublicProfile');
|
||||
expect(exists('app/u/[handle]/PublicProfile.tsx')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/u/[handle] client record', () => {
|
||||
const src = read('app/u/[handle]/PublicProfile.tsx');
|
||||
it('honors the n-gate: RECORD BUILDING under min_sample, never a small-sample %', () => {
|
||||
expect(src).toContain('RECORD BUILDING');
|
||||
expect(src).toMatch(/settled >= minSample/);
|
||||
});
|
||||
it('shows outcome + CLV chips on settled rows (ledger card patterns)', () => {
|
||||
expect(src).toContain('✓ HIT');
|
||||
expect(src).toContain('✕ MISS');
|
||||
expect(src).toContain('BEAT CLOSE');
|
||||
expect(src).toContain('clv_result');
|
||||
});
|
||||
it('states the uncurated contract and one not-found state for unknown AND unpublished', () => {
|
||||
expect(src).toContain('Nothing curated');
|
||||
expect(src).toContain('private by default');
|
||||
expect(src).toMatch(/notfound/);
|
||||
});
|
||||
it('fetches through the Next proxy (S25 rule)', () => {
|
||||
expect(src).toContain('/api/profiles/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/u/[handle] OG image', () => {
|
||||
const og = read('app/u/[handle]/opengraph-image.tsx');
|
||||
it('is a 1200x630 PNG record card', () => {
|
||||
expect(og).toContain('width: 1200, height: 630');
|
||||
expect(og).toContain("contentType = 'image/png'");
|
||||
expect(og).toContain('PUBLIC LEDGER');
|
||||
expect(og).toContain('misses included');
|
||||
});
|
||||
it('does NOT use the edge runtime (self-hosted standalone, S53 rule)', () => {
|
||||
expect(og).not.toContain("runtime = 'edge'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings — claim handle + publish toggle', () => {
|
||||
const src = read('app/settings/page.tsx');
|
||||
it('carries the explicit private-by-default publish copy verbatim', () => {
|
||||
expect(src).toContain('Publishing puts your ENTIRE settled record on a public page — wins and misses. Private by default.');
|
||||
});
|
||||
it('reads and writes /api/profiles/me with the bearer token', () => {
|
||||
expect(src).toContain("fetch('/api/profiles/me'");
|
||||
expect(src).toMatch(/method: 'POST'[\s\S]*?\/api\/profiles\/me|\/api\/profiles\/me[\s\S]*?method: 'POST'/);
|
||||
});
|
||||
it('sanitizes the handle input to the API shape (a-z 0-9 _ , max 20)', () => {
|
||||
expect(src).toContain("replace(/[^a-z0-9_]/g, '')");
|
||||
expect(src).toContain('.slice(0, 20)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Next proxies (S25 rule — Express is not browser-reachable)', () => {
|
||||
it('me proxy forwards GET/POST with Authorization', () => {
|
||||
const src = read('app/api/profiles/me/route.ts');
|
||||
expect(src).toContain('BACKEND_URL');
|
||||
expect(src).toContain('/api/profiles/me');
|
||||
expect(src).toContain('authorization');
|
||||
expect(src).toContain('export async function POST');
|
||||
});
|
||||
it('handle proxy forwards the public read and passes the 404 through untouched', () => {
|
||||
const src = read('app/api/profiles/[handle]/route.ts');
|
||||
expect(src).toContain('BACKEND_URL');
|
||||
expect(src).toContain('/api/profiles/');
|
||||
expect(src).toContain('status: upstream.status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration 022 — public_profiles (committed, applied by the founder)', () => {
|
||||
const sql = fs.readFileSync(path.join(ROOT, 'supabase', 'migrations', '022_public_profiles.sql'), 'utf8');
|
||||
it('creates the table with the handle CHECK + private-by-default published', () => {
|
||||
expect(sql).toContain('public.public_profiles');
|
||||
expect(sql).toContain("handle ~ '^[a-z0-9_]{3,20}$'");
|
||||
expect(sql).toMatch(/published\s+boolean NOT NULL DEFAULT false/);
|
||||
expect(sql).toContain('REFERENCES auth.users');
|
||||
});
|
||||
it('RLS: published rows readable by anyone, own row by owner, writes service-role only', () => {
|
||||
expect(sql).toContain('ENABLE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('USING (published = true)');
|
||||
expect(sql).toContain('USING (user_id = auth.uid())');
|
||||
expect(sql).not.toMatch(/FOR (INSERT|UPDATE|DELETE)/);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Public profile proxy (A1 Session 10) — forwards GET /api/profiles/:handle.
|
||||
* Express owns the privacy rules: unpublished and unknown handles come back
|
||||
* as the SAME 404 body (no existence leak) — this proxy passes it through
|
||||
* untouched.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ handle: string }> },
|
||||
) {
|
||||
const { handle } = await params;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ error: 'Profile not found' }));
|
||||
return NextResponse.json(data, {
|
||||
status: upstream.status,
|
||||
headers: upstream.ok ? { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' } : undefined,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Own-profile proxy (A1 Session 10) — forwards GET/POST /api/profiles/me
|
||||
* with the caller's Authorization header (Express requireAuth resolves the
|
||||
* user; the publish toggle + handle claim live behind it).
|
||||
*/
|
||||
function authHeaders(req: NextRequest): HeadersInit {
|
||||
const auth = req.headers.get('authorization');
|
||||
return { Accept: 'application/json', 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!req.headers.get('authorization')) return NextResponse.json({ profile: null }, { status: 401 });
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/profiles/me`, { headers: authHeaders(req), cache: 'no-store' });
|
||||
const data = await upstream.json().catch(() => ({ profile: null }));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ profile: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/profiles/me`, { method: 'POST', headers: authHeaders(req), body });
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Profile service unreachable.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,54 @@ export default function SettingsPage() {
|
||||
} catch { /* best-effort */ } finally { setPrefSaving(false); }
|
||||
};
|
||||
|
||||
// A1 Session 10 — public ledger profile: claim a handle + the ONE explicit
|
||||
// publish toggle. PRIVATE BY DEFAULT — nothing is public until the user
|
||||
// flips the toggle and saves.
|
||||
const [pfHandle, setPfHandle] = useState('');
|
||||
const [pfPublished, setPfPublished] = useState(false);
|
||||
const [pfSaving, setPfSaving] = useState(false);
|
||||
const [pfMsg, setPfMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [pfLive, setPfLive] = useState<{ handle: string; published: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.access_token) return;
|
||||
fetch('/api/profiles/me', { headers: { Authorization: `Bearer ${session.access_token}` } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
const p = d?.profile;
|
||||
if (!p) return;
|
||||
setPfHandle(p.handle || '');
|
||||
setPfPublished(Boolean(p.published));
|
||||
setPfLive({ handle: p.handle || '', published: Boolean(p.published) });
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [session]);
|
||||
|
||||
const saveProfile = async () => {
|
||||
setPfSaving(true);
|
||||
setPfMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/profiles/me', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}) },
|
||||
body: JSON.stringify({ handle: pfHandle, published: pfPublished }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setPfLive({ handle: pfHandle, published: pfPublished });
|
||||
setPfMsg({ ok: true, text: pfPublished ? 'Published. Your settled record is live.' : 'Saved. Your record stays private.' });
|
||||
} else if (res.status === 409) {
|
||||
setPfMsg({ ok: false, text: 'That handle is taken.' });
|
||||
} else {
|
||||
setPfMsg({ ok: false, text: data?.error || 'Could not save profile.' });
|
||||
}
|
||||
} catch {
|
||||
setPfMsg({ ok: false, text: 'Network error. Try again.' });
|
||||
} finally {
|
||||
setPfSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const plan = tierLabel(tier || 'free');
|
||||
const canDelete = deleteText === 'DELETE';
|
||||
|
||||
@@ -231,6 +279,46 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* PUBLIC PROFILE (A1 Session 10) */}
|
||||
<Section label="PUBLIC PROFILE">
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, lineHeight: 1.55, color: 'var(--text-1)' }}>
|
||||
Publishing puts your ENTIRE settled record on a public page — wins and misses. Private by default.
|
||||
</p>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)', marginBottom: 8 }}>Handle</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>vyndr.app/u/</span>
|
||||
<input
|
||||
value={pfHandle}
|
||||
onChange={(e) => setPfHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20))}
|
||||
placeholder="your_handle"
|
||||
aria-label="Public profile handle"
|
||||
className="mono"
|
||||
style={{ width: 200, padding: '9px 12px', borderRadius: 8, background: 'var(--bg-2)', border: '1px solid var(--border-hi)', color: '#fff', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<Row>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Publish my record</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Every settled read, nothing curated</div>
|
||||
</div>
|
||||
<Toggle on={pfPublished} onClick={() => setPfPublished((v) => !v)} />
|
||||
</Row>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={saveProfile} disabled={pfSaving || pfHandle.length < 3} className="mono"
|
||||
style={{ cursor: pfHandle.length >= 3 ? 'pointer' : 'not-allowed', padding: '9px 16px', borderRadius: 8, fontWeight: 700, fontSize: 11, letterSpacing: '0.04em', border: '1px solid var(--g-a)', background: pfHandle.length >= 3 ? 'var(--g-a)' : 'transparent', color: pfHandle.length >= 3 ? '#06060B' : 'var(--text-1)' }}>
|
||||
{pfSaving ? 'SAVING…' : 'SAVE PROFILE'}
|
||||
</button>
|
||||
{pfMsg && (
|
||||
<span className="mono" style={{ fontSize: 12, color: pfMsg.ok ? 'var(--g-a)' : 'var(--miss)' }}>{pfMsg.text}</span>
|
||||
)}
|
||||
{pfLive?.published && pfLive.handle && (
|
||||
<a href={`/u/${pfLive.handle}`} className="mono" style={{ fontSize: 12, color: 'var(--g-a)', textDecoration: 'none' }}>
|
||||
VIEW PUBLIC PAGE →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* NOTIFICATIONS */}
|
||||
<Section label="NOTIFICATIONS">
|
||||
<Row>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
|
||||
/**
|
||||
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
|
||||
*
|
||||
* DATA SEMANTICS: every row here is a REAL settled ledger entry — outcome,
|
||||
* actual value, closing-line value. Nothing curated: publishing shows the
|
||||
* ENTIRE settled record, misses included. The header NEVER renders a
|
||||
* percentage under min_sample (20) settles — it shows RECORD BUILDING.
|
||||
*
|
||||
* Unknown and unpublished handles arrive as the same 404 — the page renders
|
||||
* one indistinguishable not-found state for both.
|
||||
*/
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
player_name: string;
|
||||
sport: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
side: 'over' | 'under';
|
||||
locked_odds?: string | null;
|
||||
book?: string | null;
|
||||
grade: string;
|
||||
model_value?: number | null;
|
||||
game_date: string;
|
||||
closing_line?: number | null;
|
||||
clv?: number | null;
|
||||
clv_result?: 'beat' | 'faded' | 'flat' | null;
|
||||
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||
actual_value?: number | null;
|
||||
revised_from_grade?: string | null;
|
||||
}
|
||||
|
||||
interface ProfileAggregate {
|
||||
settled: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
pushes: number;
|
||||
hit_pct: number | null;
|
||||
clv_sample: number;
|
||||
clv_beat: number;
|
||||
beat_close_pct: number | null;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
interface ProfilePayload {
|
||||
handle: string;
|
||||
aggregate: ProfileAggregate | null;
|
||||
entries: ProfileRow[];
|
||||
min_sample?: number;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
nba: '#E94B3C',
|
||||
mlb: '#1E90FF',
|
||||
wnba: '#FFB347',
|
||||
soccer: '#7BC96F',
|
||||
};
|
||||
|
||||
export default function PublicProfile({ handle }: { handle: string }) {
|
||||
const [data, setData] = useState<ProfilePayload | null>(null);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'notfound'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState('loading');
|
||||
fetch(`/api/profiles/${encodeURIComponent(handle)}`)
|
||||
.then(async (r) => {
|
||||
if (!active) return;
|
||||
if (!r.ok) { setState('notfound'); return; }
|
||||
const d = await r.json();
|
||||
if (!active) return;
|
||||
setData(d);
|
||||
setState('ready');
|
||||
})
|
||||
.catch(() => { if (active) setState('notfound'); });
|
||||
return () => { active = false; };
|
||||
}, [handle]);
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading record…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'notfound' || !data) {
|
||||
// Same state for unknown AND unpublished — the API never tells us which.
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '64px 16px 120px', textAlign: 'center' }}>
|
||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber, #FFB347)', marginBottom: 10 }}>
|
||||
NO RECORD HERE
|
||||
</p>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>This profile does not exist or is not published.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
VYNDR ledgers are private by default. A record only appears here when its owner publishes it.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const agg = data.aggregate;
|
||||
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
|
||||
PUBLIC LEDGER
|
||||
</p>
|
||||
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
|
||||
@{data.handle}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
|
||||
Every settled read. Wins and misses. Nothing curated.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Record header — never a percentage under min_sample settles. */}
|
||||
<RecordHeader agg={agg} minSample={minSample} />
|
||||
|
||||
{data.entries.length === 0 ? (
|
||||
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
|
||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||
NO SETTLED READS YET
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Reads land here as they settle against real results.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
|
||||
{data.entries.map((row, i) => (
|
||||
<ProfileCard key={row.id} row={row} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) {
|
||||
const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null);
|
||||
return (
|
||||
<div
|
||||
className="surface diagonal-cut"
|
||||
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
|
||||
>
|
||||
{ready && agg ? (
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
|
||||
RECORD · LAST 30D
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
|
||||
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
|
||||
</span>
|
||||
{agg.beat_close_pct != null && (
|
||||
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
|
||||
{agg.beat_close_pct}% BEAT CLOSE
|
||||
</span>
|
||||
)}
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{agg.pending} pending
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
|
||||
RECORD BUILDING
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
Percentages render at {minSample} settled reads.
|
||||
{agg && (
|
||||
<span className="mono" style={{ color: 'var(--text-primary)' }}>
|
||||
{' '}{agg.settled} settled · {agg.pending} pending
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeChip({ row }: { row: ProfileRow }) {
|
||||
if (!row.outcome) {
|
||||
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
|
||||
}
|
||||
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
|
||||
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
|
||||
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : '– PUSH';
|
||||
return (
|
||||
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
|
||||
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ClvChip({ row }: { row: ProfileRow }) {
|
||||
if (!row.clv_result || row.clv == null) return null;
|
||||
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
|
||||
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
|
||||
return (
|
||||
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
|
||||
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
|
||||
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
|
||||
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
|
||||
return (
|
||||
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
|
||||
{row.sport.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{row.revised_from_grade && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
|
||||
{row.revised_from_grade}
|
||||
</span>
|
||||
)}
|
||||
<GradePill grade={row.grade} />
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
|
||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
|
||||
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
|
||||
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
||||
<OutcomeChip row={row} />
|
||||
<ClvChip row={row} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
// A1 Session 10 — every shared /u/:handle link unfurls as a record card.
|
||||
// Self-hosted standalone build → Node runtime (NOT edge; Session-53 rule —
|
||||
// next/og breaks under edge off Vercel).
|
||||
export const alt = 'VYNDR Public Ledger';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ handle: string }> }) {
|
||||
const { handle } = await params;
|
||||
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle';
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#06060B',
|
||||
color: '#E8E8F0',
|
||||
padding: 72,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', position: 'absolute', top: 0, left: 0, right: 0, height: 6, background: '#00D4A0' }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', fontSize: 26, letterSpacing: '0.22em', color: '#00D4A0', fontWeight: 700 }}>
|
||||
PUBLIC LEDGER
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 84, fontWeight: 900, letterSpacing: '-0.02em', marginTop: 18, color: '#FFFFFF' }}>
|
||||
@{h}
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 28, color: '#7A7A8E', marginTop: 20 }}>
|
||||
CLV-verified record · every settled read · misses included
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', fontSize: 44, fontWeight: 900, letterSpacing: '0.08em' }}>
|
||||
<span style={{ color: '#FFFFFF' }}>VYND</span>
|
||||
<span style={{ color: '#00D4A0' }}>R</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 22, color: '#4A4A5E', letterSpacing: '0.12em' }}>
|
||||
NOTHING CURATED · NOTHING DELETED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from 'next';
|
||||
import PublicProfile from './PublicProfile';
|
||||
|
||||
/**
|
||||
* /u/[handle] (A1 Session 10) — a user's PUBLIC ledger profile. Thin server
|
||||
* wrapper for metadata (+ the segment's opengraph-image.tsx share card);
|
||||
* the record itself renders in the PublicProfile client component.
|
||||
*
|
||||
* PUBLIC route — never gated. Publishing is the owner's explicit choice;
|
||||
* once published, the whole settled record is the page. Misses included.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase();
|
||||
const title = `CLV-verified record — @${h} · VYNDR`;
|
||||
const description = `@${h}'s settled betting record on VYNDR — every read, every result, closing-line value included. Nothing curated, nothing deleted.`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description },
|
||||
twitter: { card: 'summary_large_image', title, description },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicProfilePage({ params }: { params: Promise<{ handle: string }> }) {
|
||||
const { handle } = await params;
|
||||
return <PublicProfile handle={decodeURIComponent(handle || '')} />;
|
||||
}
|
||||
@@ -53,6 +53,11 @@ const OPEN_ROUTES = [
|
||||
'/welcome',
|
||||
'/offline',
|
||||
'/upgrade',
|
||||
/* A1 Session 10 — public ledger profiles. /u/:handle is the SHARE surface:
|
||||
it must load anonymous (the whole point is a public record). Publishing
|
||||
is the owner's explicit opt-in; the privacy gate lives in the API
|
||||
(unpublished → 404), never in the router. */
|
||||
'/u',
|
||||
];
|
||||
|
||||
/* Hash deep-link aliases (§C.3.4). The prototype was a single HTML file using
|
||||
|
||||
Reference in New Issue
Block a user