Content studio API + preview page; widen the reachability guard; correct

two inventory errors

INVENTORY CORRECTION, and it was mine. Phase 2's two "orphans" are NOT
orphans -- my board grepped only web/src/app and missed component-level
mounting. The transitive check says both are already mounted:

  BookComparisonPanel -> GradeResultCard -> app/scan/page.tsx
  NewsWire            -> ExploreHub      -> app/explore/page.tsx

So book comparison is DONE (wired to /api/books, rendering on the grade
card) and THE WIRE is DONE-BY-DESIGN, mounted in ExploreHub. Its header
names an "Offseason Hub" as its home, and that hub genuinely does not
exist -- but that is board item #8, not a mounting bug, and inventing a
surface to satisfy a comment would be the wrong fix.

The lesson is the same one this session keeps teaching: I checked one
directory and reported a conclusion the check could not support.

ALSO CAUGHT: I overwrote src/routes/content.js, which was the Session-29
content-templates route, by picking a filename without looking. Restored
from git with no work lost; the new surface lives at
/api/content-studio and both now coexist.

PHASE 0/1 — /api/content-studio serves finished posts (copy, branded card,
card_svg, the fact_contract each was REQUIRED to have, and the facts that
actually backed it) plus a POST for editorial status in Redis. Private via
internal key; the Next proxy holds the key server-side so the browser
never does. /studio renders it as a thin client -- copy and card side by
side with the fact contract visible, because reviewing copy by reading it
is exactly how a wrong number ships. Never-blank: a night with nothing
generated says so.

API-FIRST is the point: the endpoint an autonomous poster will call is the
one the page already renders, so the agent handoff is a pointer change,
not a rebuild. Contract documented at docs/CONTENT-STUDIO-API.md.

EXPRESS 5 BROKE 23 SUITES at first: `router.get('/:date?')` throws at
mount time in Express 5, taking down everything that imports app.js. Two
explicit routes instead.

PHASE 3 — the reachability guard is widened from grade-fields-only to a
general built-but-unread check. Book comparison, THE WIRE and the content
studio are now registered surfaces; a page counts as its own entry point
(Next mounts it by convention) while everything else must trace to one.
22 checks green; a registered-but-unimported surface still goes red.

FULLY ISOLATED: read-only on model/slate/ledger, serving fingerprint
verified unchanged, accrual clock unchanged at 0 eligible dates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-07 18:51:52 -04:00
parent 74aa75945e
commit c575a708c7
9 changed files with 435 additions and 26 deletions
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Next proxy for the private content API.
*
* The browser cannot reach Express directly (the S25 rule), and the internal key
* must never reach the client — so it is attached here, server-side. The preview
* page therefore holds no credential and the same upstream contract serves an
* autonomous poster unchanged.
*/
const BACKEND = process.env.BACKEND_URL || 'http://localhost:3001';
async function forward(req: NextRequest, path: string[], init?: RequestInit) {
const key = process.env.VYNDR_INTERNAL_KEY;
if (!key) {
return NextResponse.json({ error: 'content preview is not configured' }, { status: 503 });
}
const url = `${BACKEND}/api/content-studio/${path.join('/')}`;
const res = await fetch(url, {
...init,
headers: { 'x-internal-key': key, 'content-type': 'application/json' },
cache: 'no-store',
});
const body = await res.json().catch(() => ({ error: 'upstream returned no JSON' }));
return NextResponse.json(body, { status: res.status });
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
return forward(req, path);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const body = await req.text();
return forward(req, path, { method: 'POST', body });
}
@@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Content proxy (Session 29). Forwards /api/content/* to Express
* (slate thread / POTD / recap / matchup preview). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/content/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'Content service unreachable.' }, { status: 502 });
}
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
/**
* /studio — the daily content desk.
*
* A THIN CLIENT over `/api/content-studio`. No posting logic and no fact
* handling live here: the same endpoint an autonomous poster will call is the
* one this page renders, so the agent handoff is a pointer change, not a
* rebuild.
*
* The fact-contract is shown beside every post on purpose. Reviewing copy by
* reading it is how a wrong number ships — the reviewer needs to see WHAT backs
* each claim, not just that the sentence scans.
*/
type Post = {
id: string; label: string; sport: string | null; status: string;
ok: boolean; skipped: boolean; reason: string | null; honest_absence: boolean;
copy: string | null; card_svg: string | null;
fact_contract: string[]; facts: Record<string, unknown> | null;
};
const STATUS_COLOR: Record<string, string> = {
approved: 'var(--hit, #00D4A0)', skipped: 'var(--miss, #FF6B6B)',
regenerate_requested: 'var(--amber, #FFB347)', pending: 'var(--text-3, #6B7A8D)',
};
export default function StudioPage() {
const [date, setDate] = useState('');
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const load = useCallback(async (d?: string) => {
setLoading(true); setErr(null);
try {
const res = await fetch(`/api/content-studio/${d || ''}`, { cache: 'no-store' });
const j = await res.json();
if (!res.ok) throw new Error(j?.error || 'could not load');
setDate(j.date); setPosts(j.posts || []);
} catch (e) { setErr(e instanceof Error ? e.message : 'could not load'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const setStatus = async (id: string, status: string) => {
await fetch(`/api/content-studio/${date}/${id}/status`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status }),
});
setPosts((p) => p.map((x) => (x.id === id ? { ...x, status } : x)));
};
return (
<main style={{ padding: '24px 20px', maxWidth: 1120, margin: '0 auto' }}>
<h1 className="mono" style={{ fontSize: 22, fontWeight: 800, letterSpacing: '.1em', marginBottom: 4 }}>
CONTENT STUDIO
</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 20 }}>
{date || '—'} · every claim below traces to a pulled field. Nothing here is written by a model.
</p>
{loading && <p className="mono" style={{ fontSize: 12 }}>loading tonight&apos;s posts</p>}
{err && <p className="mono" style={{ fontSize: 12, color: 'var(--miss)' }}>{err}</p>}
{/* NEVER BLANK: a night with nothing to say says so. */}
{!loading && !err && posts.length === 0 && (
<div className="mono" style={{ padding: 20, border: '1px solid var(--line)', fontSize: 13 }}>
No posts generated for {date}. Not an error the engine found nothing it could back with real
data, and it will not invent any.
</div>
)}
{posts.map((p) => (
<section key={p.id} style={{ border: '1px solid var(--line)', marginBottom: 18 }}>
<header style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--line)' }}>
<strong className="mono" style={{ fontSize: 13, letterSpacing: '.06em' }}>{p.label}</strong>
{p.sport && <span className="mono" style={{ fontSize: 10, color: 'var(--text-3)' }}>{p.sport.toUpperCase()}</span>}
<span className="mono" style={{ fontSize: 10, color: STATUS_COLOR[p.status] }}>{p.status.toUpperCase()}</span>
{p.honest_absence && <span className="mono" style={{ fontSize: 10, color: 'var(--amber)' }}>HONEST ABSENCE</span>}
{p.skipped && <span className="mono" style={{ fontSize: 10, color: 'var(--miss)' }}>SKIPPED</span>}
</header>
{p.skipped ? (
<div className="mono" style={{ padding: 14, fontSize: 12, color: 'var(--text-2)' }}>
Not generated {p.reason}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 0 }}>
<div style={{ padding: 14 }}>
<pre className="mono" style={{ whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.7, margin: 0 }}>
{p.copy}
</pre>
{/* WHAT BACKS THIS — the reason a reviewer can catch a wrong number. */}
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px dashed var(--line)' }}>
<div className="mono" style={{ fontSize: 10, letterSpacing: '.08em', color: 'var(--text-3)', marginBottom: 6 }}>
FACT CONTRACT {p.fact_contract.length} required field{p.fact_contract.length === 1 ? '' : 's'}
</div>
{p.fact_contract.map((f) => (
<div key={f} className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>
{f} = {JSON.stringify(p.facts?.[f.split('.')[0]] ?? null)?.slice(0, 90)}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
{(['approved', 'skipped', 'regenerate_requested'] as const).map((s) => (
<button key={s} onClick={() => setStatus(p.id, s)} className="mono"
style={{ padding: '6px 12px', fontSize: 11, border: '1px solid var(--line)', background: 'transparent', color: STATUS_COLOR[s], cursor: 'pointer' }}>
{s.replace('_', ' ').toUpperCase()}
</button>
))}
</div>
</div>
<div style={{ borderLeft: '1px solid var(--line)', padding: 12 }}>
{p.card_svg
? <div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: p.card_svg.replace('<svg', '<svg style="width:100%;height:auto"') }} />
: <span className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>no card</span>}
</div>
</div>
)}
</section>
))}
</main>
);
}
+1
View File
@@ -16,6 +16,7 @@
gating them would be a monetization regression. We gate only the genuinely
personal surfaces (a user's own ledger, bets, account, alerts). */
const GATED_ROUTES = [
'/studio', // the content desk: private review before posting
'/desk', // Session 63 (A1-S4) — the founder's media surface (+ backend allowlist)
'/ledger',
'/tracker',
File diff suppressed because one or more lines are too long