76c289d4c1
Reserved house handle (default 'vyndr', env HOUSE_HANDLE) resolves to the PUBLIC model record — getModelAggregate() with no userId (user_id=NULL rows) — WITHOUT a public_profiles row. It is the ONLY special case; every other handle keeps the private-by-default, byte-identical-404 no-existence-leak contract. The house profile is always public and never 404s (a fetch failure degrades to an honest building state). - src/routes/profiles.js: house short-circuit + sendHouseProfile (public aggregate + by_tier + public settled entries), reserved before the publish lookup so a user claim is shadowed. - PublicProfile.tsx: house label 'VYNDR MODEL · PUBLIC RECORD' + hero/subtitle off data.house; keeps the CLV-VERIFIED record hero + TierRecord calibration + recent settled reads (misses included). - opengraph-image.tsx (1200x630): house-branded eyebrow/heading. - portrait/route.tsx: new 1080x1350 share crop (real aggregate or tagline fallback, never a fabricated number). - Discoverability: 'VIEW AS PUBLIC PAGE ->' on the ledger MODEL header + 'VIEW PUBLIC RECORD ->' under the landing ModelRecord, both to /u/vyndr. - tests/unit/houseProfile.test.js: house resolves to user_id=NULL aggregate (no public_profiles row) + by_tier; unknown/unpublished user handles stay byte-identical 404; page renders house label + TierRecord + portrait crop. 3019 tests green (3012 -> 3019); next build EXIT=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
8.1 KiB
JavaScript
198 lines
8.1 KiB
JavaScript
'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;
|