'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. * * HOUSE PROFILE (Wave 5A, D2) — the reserved `HOUSE_HANDLE` (default `vyndr`) * resolves to the PUBLIC model record (`getModelAggregate()` with NO userId → * the `user_id = NULL` ledger rows). It needs NO public_profiles row and is * ALWAYS public (it's the partner-pitch weapon: the real house record). It is * the ONLY special case; every OTHER handle keeps the private-by-default, * no-existence-leak contract intact. */ 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}$/; // Reserved house handle → the public model record (user_id = NULL). Operators // can override via env; it must still satisfy HANDLE_RE to be reachable. const HOUSE_HANDLE = String(process.env.HOUSE_HANDLE || 'vyndr').trim().toLowerCase(); const HOUSE_LABEL = 'VYNDR MODEL · PUBLIC RECORD'; // 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); // HOUSE handle → the public model record. Checked BEFORE the // public_profiles lookup so the handle is reserved (a user who claims it is // shadowed). This is the ONLY handle that bypasses the publish gate. if (handle === HOUSE_HANDLE) return sendHouseProfile(res); 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); } }); /** * The house/model profile — the PUBLIC model record (user_id = NULL), served * as a shareable /u profile WITHOUT a public_profiles row. Same response shape * the page already consumes (aggregate + by_tier + settled entries), plus * `house: true` + a label so the UI can distinguish it from a user profile. * It always "exists" → a fetch failure degrades to an honest empty/building * state, never a 404. */ async function sendHouseProfile(res) { const sb = sbOrNull(); try { // NO userId → the public `user_id = NULL` aggregate (the real house // record + Wave-3 by_tier). getModelAggregate self-empties without env. const aggregate = await ledgerService.getModelAggregate(sb ? { sb } : {}); let entries = []; if (sb) { // ALL public settled reads, misses included — nothing curated. const { data, error } = await sb.from('ledger_entries') .select(ROW_COLUMNS) .is('user_id', null) .not('outcome', 'is', null) .order('graded_at', { ascending: false }) .limit(ENTRY_LIMIT); if (error) throw new Error(error.message); entries = data || []; } res.set('Cache-Control', 'public, max-age=60'); return res.json({ handle: HOUSE_HANDLE, house: true, label: HOUSE_LABEL, aggregate, entries, min_sample: ledgerService.MIN_AGG_SAMPLE, }); } catch (err) { console.error('[profiles/house]', err.message); return res.status(200).json({ handle: HOUSE_HANDLE, house: true, label: HOUSE_LABEL, aggregate: null, entries: [], min_sample: ledgerService.MIN_AGG_SAMPLE, }); } } module.exports = router;