Session 7h: Stripe products, tier config, scan limits, response gating, free tier
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Tier access matrix — single source of truth for what each tier unlocks.
|
||||
*
|
||||
* The tier set matches the DB CHECK constraint in migrations 001 + 011:
|
||||
* tier IN ('free', 'analyst', 'desk')
|
||||
*
|
||||
* Free is the top of the funnel. Users see the grade (the hook) but the
|
||||
* reasoning + kill-condition details stay locked. Analyst opens the
|
||||
* intelligence. Desk adds engine2 (LLM) and portfolio tracking.
|
||||
*
|
||||
* `api_access` is FALSE on every tier and must stay false. VYNDR is a
|
||||
* consumer product; the proprietary engine is never exposed externally.
|
||||
*
|
||||
* `scans_per_day` is the per-tier daily limit. The scan-limit middleware
|
||||
* reads this and 429s on overflow. Anonymous callers fall back to the
|
||||
* 'free' bucket, IP-keyed.
|
||||
*/
|
||||
|
||||
const TIERS = Object.freeze({
|
||||
free: Object.freeze({
|
||||
scans_per_day: 3,
|
||||
grade_visible: true,
|
||||
reasoning_visible: false, // blurred — frontend renders tier-locked
|
||||
kill_conditions_detail: false, // count only, not codes/reasons
|
||||
kill_conditions_count: true,
|
||||
alerts: false,
|
||||
portfolio: false,
|
||||
engine2: false,
|
||||
stat_dashboard: true,
|
||||
api_access: false,
|
||||
}),
|
||||
analyst: Object.freeze({
|
||||
scans_per_day: 15,
|
||||
grade_visible: true,
|
||||
reasoning_visible: true,
|
||||
kill_conditions_detail: true,
|
||||
kill_conditions_count: true,
|
||||
alerts: true,
|
||||
portfolio: false,
|
||||
engine2: false,
|
||||
stat_dashboard: true,
|
||||
api_access: false,
|
||||
}),
|
||||
desk: Object.freeze({
|
||||
scans_per_day: Infinity,
|
||||
grade_visible: true,
|
||||
reasoning_visible: true,
|
||||
kill_conditions_detail: true,
|
||||
kill_conditions_count: true,
|
||||
alerts: true,
|
||||
portfolio: true,
|
||||
engine2: true, // LLM deep analysis
|
||||
stat_dashboard: true,
|
||||
api_access: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const VALID_TIERS = Object.freeze(Object.keys(TIERS));
|
||||
|
||||
function getTier(tierName) {
|
||||
const key = String(tierName || 'free').toLowerCase();
|
||||
return TIERS[key] || TIERS.free;
|
||||
}
|
||||
|
||||
function getScanLimit(tierName) {
|
||||
return getTier(tierName).scans_per_day;
|
||||
}
|
||||
|
||||
function canAccess(tierName, feature) {
|
||||
const t = getTier(tierName);
|
||||
if (!(feature in t)) return false;
|
||||
return !!t[feature];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TIERS,
|
||||
VALID_TIERS,
|
||||
getTier,
|
||||
getScanLimit,
|
||||
canAccess,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Per-tier daily scan limit middleware.
|
||||
*
|
||||
* Reads `req.user.tier` (set by requireAuth) and counts scans in a
|
||||
* rolling 24h window. Anonymous callers fall through to a 'free'
|
||||
* bucket keyed by IP so the open demo can't be DOS'd via shell.
|
||||
*
|
||||
* The middleware is separate from the IP rate limiter (SEC-1, Session
|
||||
* 7d) on /api/analyze: that one caps RAW request rate (10/min) per IP
|
||||
* regardless of who's authenticated. This one caps PAID-OPERATION
|
||||
* counts per user per day per tier.
|
||||
*
|
||||
* Counts live in-memory (Map<userOrIpKey, Array<timestamp>>) — same
|
||||
* trade-off as rateLimit.js. Survives a Coolify redeploy by resetting
|
||||
* to zero, which is the user-friendly fallback.
|
||||
*/
|
||||
|
||||
const { getScanLimit } = require('../config/tiers');
|
||||
|
||||
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_TRACKED = 50_000;
|
||||
|
||||
const hits = new Map();
|
||||
|
||||
function clientKey(req) {
|
||||
if (req.user?.id) return `u:${req.user.id}`;
|
||||
return `ip:${req.ip || req.socket?.remoteAddress || 'unknown'}`;
|
||||
}
|
||||
|
||||
function evictIfFull() {
|
||||
if (hits.size <= MAX_TRACKED) return;
|
||||
const oldest = hits.keys().next().value;
|
||||
if (oldest !== undefined) hits.delete(oldest);
|
||||
}
|
||||
|
||||
function pruneOlderThan(arr, cutoff) {
|
||||
let w = 0;
|
||||
for (let i = 0; i < arr.length; i += 1) {
|
||||
if (arr[i] > cutoff) {
|
||||
arr[w] = arr[i];
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
arr.length = w;
|
||||
return w;
|
||||
}
|
||||
|
||||
function scanLimit() {
|
||||
return function scanLimitMiddleware(req, res, next) {
|
||||
const tier = req.user?.tier || 'free';
|
||||
const limit = getScanLimit(tier);
|
||||
// Infinity → never block; skip the bookkeeping entirely so the
|
||||
// hot Desk path doesn't allocate Map entries it'll never read.
|
||||
if (limit === Infinity) return next();
|
||||
|
||||
const key = clientKey(req);
|
||||
const now = Date.now();
|
||||
const cutoff = now - WINDOW_MS;
|
||||
|
||||
let ts = hits.get(key);
|
||||
if (!ts) {
|
||||
ts = [];
|
||||
hits.set(key, ts);
|
||||
evictIfFull();
|
||||
}
|
||||
|
||||
const used = pruneOlderThan(ts, cutoff);
|
||||
if (used >= limit) {
|
||||
const oldest = ts[0];
|
||||
const retryAfterSec = Math.max(1, Math.ceil((oldest + WINDOW_MS - now) / 1000));
|
||||
res.set('Retry-After', String(retryAfterSec));
|
||||
res.set('X-Scans-Used', String(used));
|
||||
res.set('X-Scans-Limit', String(limit));
|
||||
return res.status(429).json({
|
||||
error: 'Daily scan limit reached. Upgrade for more scans.',
|
||||
scans_used: used,
|
||||
scans_limit: limit,
|
||||
retry_after_seconds: retryAfterSec,
|
||||
tier,
|
||||
});
|
||||
}
|
||||
|
||||
ts.push(now);
|
||||
res.set('X-Scans-Used', String(used + 1));
|
||||
res.set('X-Scans-Limit', String(limit));
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
// Test helper — drop all tracked counts. Not exported in module.exports
|
||||
// to keep prod surface clean; reachable via the __internals bag.
|
||||
function resetForTests() {
|
||||
hits.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scanLimit,
|
||||
__internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED },
|
||||
};
|
||||
+14
-2
@@ -8,6 +8,8 @@ const express = require('express');
|
||||
const { analyzeViaEngine1 } = require('../services/intelligence/analyzeViaEngine1');
|
||||
const { cacheGet, cacheSet } = require('../utils/redis');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { scanLimit } = require('../middleware/scanLimit');
|
||||
const { applyTierGating } = require('../utils/tierGating');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -17,6 +19,11 @@ const router = express.Router();
|
||||
const analyzeLimit = createRateLimit({ windowMs: 60_000, max: 10 });
|
||||
router.use(analyzeLimit);
|
||||
|
||||
// Tier scan-limit (this session): per-tier daily quota. Free users
|
||||
// (including anonymous demo via IP) are capped at 3 scans/day. Paid
|
||||
// tiers get higher caps from src/config/tiers.js.
|
||||
router.use(scanLimit());
|
||||
|
||||
// PERF-1 (Session 7d): cache analyze results in Redis. Same prop hit
|
||||
// twice within 60s reuses the previous analysis instead of re-doing the
|
||||
// upstream chain. 60s is short enough that line moves still surface.
|
||||
@@ -70,7 +77,12 @@ router.post('/prop', async (req, res) => {
|
||||
|
||||
try {
|
||||
const result = await cachedAnalyze(req.body);
|
||||
return res.json(result);
|
||||
// Tier gating — unauthenticated demo callers and free-tier users
|
||||
// get the grade + confidence + edge_pct, but the reasoning and
|
||||
// kill condition details are redacted. Paid tiers pass through
|
||||
// untouched.
|
||||
const gated = applyTierGating(result, req.user?.tier || 'free');
|
||||
return res.json(gated);
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
return res.status(404).json({ error: `Player not found: ${req.body.player}` });
|
||||
@@ -99,7 +111,7 @@ router.post('/batch', async (req, res) => {
|
||||
|
||||
try {
|
||||
const result = await cachedAnalyze(prop);
|
||||
results.push(result);
|
||||
results.push(applyTierGating(result, req.user?.tier || 'free'));
|
||||
} catch (err) {
|
||||
results.push({
|
||||
error: err.response?.status === 404
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { scanLimit } = require('../middleware/scanLimit');
|
||||
const { scanParlay } = require('../services/parlayScanService');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -33,7 +34,7 @@ function validateLegs(legs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
router.post('/parlay', requireAuth, async (req, res) => {
|
||||
router.post('/parlay', requireAuth, scanLimit(), async (req, res) => {
|
||||
const { legs } = req.body;
|
||||
|
||||
const validationError = validateLegs(legs);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Tier-based response gating.
|
||||
*
|
||||
* Free-tier users see the grade letter + confidence + edge (the hook)
|
||||
* but the explanation stays locked. Paid tiers see everything. The
|
||||
* frontend already has a `tier-locked` CSS class and `BlurredText`
|
||||
* component — we just need to mark the locked fields with
|
||||
* `locked: true` so the UI knows what to blur.
|
||||
*
|
||||
* This module is import-once / pure-function — it never reaches into
|
||||
* Supabase or Redis. Callers pass the user's tier string; the function
|
||||
* returns a new object (does not mutate input).
|
||||
*/
|
||||
|
||||
const { canAccess } = require('../config/tiers');
|
||||
|
||||
const LOCKED_REASONING_SUMMARY = 'Upgrade to see full analysis.';
|
||||
const LOCKED_KILL_REASON = 'Upgrade to see details.';
|
||||
|
||||
function lockReasoning(reasoning) {
|
||||
if (!reasoning || typeof reasoning !== 'object') {
|
||||
return { summary: LOCKED_REASONING_SUMMARY, steps: null, locked: true };
|
||||
}
|
||||
return { summary: LOCKED_REASONING_SUMMARY, steps: null, locked: true };
|
||||
}
|
||||
|
||||
function lockKillConditions(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map((kc) => ({
|
||||
code: kc?.code ?? 'LOCKED',
|
||||
reason: LOCKED_KILL_REASON,
|
||||
locked: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* applyTierGating — translate a fully-shaped analyzer result into the
|
||||
* gated shape appropriate for the caller's tier. The legacy fields
|
||||
* (grade, confidence, edge_pct, player, stat_type, line, direction,
|
||||
* book) survive untouched on every tier. Only the explanation surface
|
||||
* is redacted for free.
|
||||
*
|
||||
* @param {Object} result — legacy-shaped analyzer output
|
||||
* @param {string} tierName — 'free' | 'analyst' | 'desk' | undefined
|
||||
* @returns {Object} gated result (new object — input not mutated)
|
||||
*/
|
||||
function applyTierGating(result, tierName) {
|
||||
if (!result || typeof result !== 'object') return result;
|
||||
if (canAccess(tierName, 'reasoning_visible')) {
|
||||
// Paid tier — pass through unchanged.
|
||||
return result;
|
||||
}
|
||||
// Free tier: keep grade + confidence + edge_pct (the hook), redact
|
||||
// reasoning + kill condition explanations.
|
||||
return {
|
||||
...result,
|
||||
reasoning: lockReasoning(result.reasoning),
|
||||
kill_conditions_triggered: lockKillConditions(result.kill_conditions_triggered),
|
||||
tier_gated: true,
|
||||
upgrade_hint: 'Upgrade to see full analysis + kill condition details.',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyTierGating,
|
||||
__internals: {
|
||||
lockReasoning,
|
||||
lockKillConditions,
|
||||
LOCKED_REASONING_SUMMARY,
|
||||
LOCKED_KILL_REASON,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user