Close the second leak path: /api/hero-prop bypassed the snapshot gate

The landing hero reads the snapshot from Redis DIRECTLY via heroPropService,
so it never passed through routes/snapshot.js and was still serving model_odds,
ev_pct, value and takeable to anonymous visitors after the first fix. Same
strip, same tier resolution, same private-cache rule for authenticated callers;
the Next proxy now forwards the bearer token.

PRODUCT CONSEQUENCE, FLAGGED RATHER THAN BURIED: the landing hero is served to
anonymous visitors, so it now renders BOOK and FAIR with the model leg LOCKED
instead of the full triplet it showed this morning. That follows the stated
free-tier rule exactly, but it does trade a strong marketing moment (VALUE
+21.1% VS FAIR on the shop window) for consistency of the gate. Reversing is
one line — add 'model_price' to the free tier in src/config/tiers.js, or
special-case the hero route — and is a product call, not a correctness one.

Tests 3557 passed / 291 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-20 19:52:00 -04:00
parent fbcb00b7b1
commit aaa41134d4
4 changed files with 23 additions and 5 deletions
+11 -2
View File
@@ -11,6 +11,11 @@
const express = require('express'); const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit'); const { createRateLimit } = require('../middleware/rateLimit');
const heroPropService = require('../services/heroPropService'); const heroPropService = require('../services/heroPropService');
// Session 67 — the hero reads the snapshot from Redis DIRECTLY, so it bypassed
// the gate on routes/snapshot.js and was still serving VYNDR's own price to
// anonymous visitors. Same strip, same rule: market legs free, model gated.
const { stripModelPrice } = require('../utils/snapshotGating');
const { resolveTierFromRequest } = require('../utils/requestTier');
const router = express.Router(); const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 })); router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
@@ -18,8 +23,12 @@ router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const hero = await heroPropService.pickHeroProp({}); const hero = await heroPropService.pickHeroProp({});
res.set('Cache-Control', 'public, max-age=300'); const tier = await resolveTierFromRequest(req);
return res.json(hero); // stripModelPrice operates on rows; the hero IS one row.
const [gated] = stripModelPrice([hero], tier);
// Varies by entitlement → never shared-cached for an authenticated caller.
res.set('Cache-Control', req.headers.authorization ? 'private, max-age=300' : 'public, max-age=300');
return res.json(gated);
} catch (err) { } catch (err) {
console.error('[hero-prop]', err.message); console.error('[hero-prop]', err.message);
return res.status(200).json({ available: false }); return res.status(200).json({ available: false });
+9
View File
@@ -137,6 +137,15 @@ describe('the route wiring', () => {
expect(bySport).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=30'\)/); expect(bySport).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=30'\)/);
}); });
it('the hero-prop route applies the SAME gate (it reads Redis directly)', () => {
const hero = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'heroProp.js'), 'utf8');
// The hero bypassed routes/snapshot.js entirely, so it needed its own strip
// or the landing page kept serving the model price to anonymous visitors.
expect(hero).toContain('stripModelPrice');
expect(hero).toContain('resolveTierFromRequest');
expect(hero).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=300'\);\n\s*return res\.json\(hero\)/);
});
it('the browser proxy forwards the bearer token', () => { it('the browser proxy forwards the bearer token', () => {
const proxy = fs.readFileSync( const proxy = fs.readFileSync(
path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'snapshot', '[sport]', 'route.ts'), path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'snapshot', '[sport]', 'route.ts'),
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,11 +12,11 @@ const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
* { available: false } so the card HIDES; there is NO hand-written fallback * { available: false } so the card HIDES; there is NO hand-written fallback
* (the old static Jokic card is gone). * (the old static Jokic card is gone).
*/ */
export async function GET() { export async function GET(req: Request) {
try { try {
const upstream = await fetch(`${BACKEND_URL}/api/hero-prop`, { const upstream = await fetch(`${BACKEND_URL}/api/hero-prop`, {
method: 'GET', method: 'GET',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json', ...(req.headers.get('authorization') ? { Authorization: req.headers.get('authorization')! } : {}) },
cache: 'no-store', cache: 'no-store',
}); });
const data = await upstream.json().catch(() => ({ available: false })); const data = await upstream.json().catch(() => ({ available: false }));