Session 7h: Stripe products, tier config, scan limits, response gating, free tier
This commit is contained in:
+35
-2
@@ -1,10 +1,43 @@
|
||||
# VYNDR — Build State
|
||||
|
||||
## Last Updated
|
||||
2026-05-18
|
||||
2026-06-10
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v6.0 — Web Tier Complete (Dashboard + Game Pages + Intelligence Feed + PWA + NexaPay)
|
||||
SHIP BUILD v7.0 — Stripe Payment Infrastructure + Free-Tier Gating (Session 7h)
|
||||
|
||||
## Session 7h (2026-06-10) — SHIPPED
|
||||
|
||||
### Stripe (test mode)
|
||||
Resources created against `sk_test_*` via direct REST API (Stripe MCP plugin OAuth flow was non-functional in this environment; bypassed by hitting `https://api.stripe.com/v1` with the secret key in a single shell subprocess, then shredding the on-disk key file).
|
||||
|
||||
- `prod_UgBel9RYTROCxr` — VYNDR (`metadata.tier=analyst`)
|
||||
- `price_1TgpGxIp1Mec3r2E6Wh6oeaP` — $14.99/mo recurring (`metadata.tier=analyst`)
|
||||
- `prod_UgBeSBYw2j9oXL` — VYNDR Desk (`metadata.tier=desk`)
|
||||
- `price_1TgpGyIp1Mec3r2EQq50KKhF` — $44.99/mo recurring (`metadata.tier=desk`)
|
||||
- `we_1TgpGzIp1Mec3r2ERtDIF2n2` — webhook → `https://api.vyndr.app/api/stripe/webhook`
|
||||
- Subscribed events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`
|
||||
- Signing secret saved to `~/.stripe-webhook-secret` (chmod 600) — read once, paste into Coolify, then `shred -u`.
|
||||
|
||||
### Tier infrastructure
|
||||
- `src/config/tiers.js` — frozen access matrix (`free` / `analyst` / `desk`); `api_access:false` on every tier (non-negotiable consumer-product invariant)
|
||||
- `src/middleware/scanLimit.js` — 24h rolling per-user/IP quota (free=3, analyst=15, desk=∞); 429 + `Retry-After` + `X-Scans-Used/Limit` headers on overflow; in-memory LRU with `MAX_TRACKED=50_000`
|
||||
- `src/utils/tierGating.js` — pure response gating; free tier keeps grade/confidence/edge_pct, redacts `reasoning` + `kill_conditions_triggered`; paid tiers pass through
|
||||
- Wired into `src/routes/scan.js` (`/parlay` after `requireAuth`) and `src/routes/analyze.js` (`/prop` + `/batch`, gating applied per-result)
|
||||
|
||||
### SQL (run manually in Supabase SQL Editor)
|
||||
- `docs/sql/pricing_slots.sql` — creates `pricing_slots` table + RLS + price IDs seeded. Not added to the migrations chain per session policy.
|
||||
|
||||
### Tests
|
||||
- `tests/unit/tiers.test.js` (10 tests) — frozen matrix, `api_access=false` invariant, fallback behavior
|
||||
- `tests/unit/tierGating.test.js` (9 tests) — free-tier redaction, paid passthrough, no input mutation
|
||||
- `tests/unit/scanLimit.test.js` (10 tests) — per-tier limits, anonymous IP fallback, independent quotas, desk skip
|
||||
- Existing suites adapted for the new middleware: `tests/unit/analyzeCache.test.js`, `tests/integration/analyze.test.js`, `tests/integration/scan.test.js` reset the scan-limit map in `beforeEach`; the integration suite for `/api/analyze` mocks `applyTierGating` as pass-through so engine-shape assertions stay focused on the engine contract (gating has its own suite).
|
||||
|
||||
### Quality gates (all green)
|
||||
- `npm test`: **1039 / 1039 passing**, 82 suites, 0 failures
|
||||
- `web/npm run build`: production build clean, all 24 routes prerendered
|
||||
- License audit: only permissive third-party licenses (MIT/Apache-2.0/BSD/ISC/etc.); single UNLICENSED entry is our own `vyndr-web` workspace
|
||||
|
||||
## Web Tier v6 (2026-05-18) — SHIPPED
|
||||
Complete frontend overhaul. 18 pages, 22 API routes. `npm run build` passes with zero errors.
|
||||
|
||||
@@ -363,3 +363,51 @@
|
||||
{"ts":"2026-06-10T13:34:40.418Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T13:34:40.489Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T13:34:40.572Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T15:15:45.376Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:15:45.510Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:15:45.640Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T15:15:45.690Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T15:15:45.691Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T15:15:45.691Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T15:15:45.740Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T15:36:50.603Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:36:50.776Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T15:36:50.776Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T15:36:50.776Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T15:36:50.819Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T15:36:51.014Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:36:51.154Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T15:37:00.739Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:37:01.021Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:37:01.021Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T15:37:01.022Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T15:37:01.022Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T15:37:01.063Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T15:37:01.104Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T15:40:50.209Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T15:40:50.209Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T15:40:50.209Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T15:40:50.251Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T15:40:50.413Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:40:50.507Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T15:40:51.184Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:41:21.395Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T15:41:21.486Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T15:41:21.502Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T15:41:21.503Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T15:41:21.503Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T15:41:21.546Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T15:41:22.189Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T16:37:18.909Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T16:37:18.993Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T16:37:18.994Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T16:37:18.994Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T16:37:19.043Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-10T16:37:19.067Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T16:37:19.183Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T16:37:41.672Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-10T16:37:41.680Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-10T16:37:41.681Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-10T16:37:41.681Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-10T16:37:41.734Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-10T16:37:41.759Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Pricing slot / beta-lock metadata.
|
||||
--
|
||||
-- 2-product launch decision (Session 7h): VYNDR ($14.99/mo, tier=analyst)
|
||||
-- and VYNDR Desk ($44.99/mo, tier=desk). No early-bird tier — the beta
|
||||
-- price IS the locked-in price.
|
||||
--
|
||||
-- This table exists so the frontend pricing page can read live counts
|
||||
-- ("147 of unlimited slots taken at beta price") and so the webhook
|
||||
-- can record which Stripe price a subscription was created on. Keep
|
||||
-- early_bird_limit nullable for now; we may slot it in later.
|
||||
--
|
||||
-- RUN THIS IN THE SUPABASE SQL EDITOR. Do NOT add it to the migrations
|
||||
-- chain — it's intentionally out-of-band per session policy.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.pricing_slots (
|
||||
tier text PRIMARY KEY,
|
||||
beta_price_id text,
|
||||
beta_price_usd numeric,
|
||||
-- Reserved for a future "first N seats" promo; null means no cap.
|
||||
early_bird_limit integer,
|
||||
early_bird_used integer NOT NULL DEFAULT 0,
|
||||
early_bird_price_id text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.pricing_slots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public-read so the pricing page can query slot status without auth.
|
||||
-- Writes happen only from the Stripe webhook via the service role.
|
||||
DROP POLICY IF EXISTS "public reads pricing_slots" ON public.pricing_slots;
|
||||
CREATE POLICY "public reads pricing_slots"
|
||||
ON public.pricing_slots FOR SELECT
|
||||
USING (true);
|
||||
|
||||
-- Seed rows for the two tiers Session 7h ships. Price IDs filled from
|
||||
-- the Stripe test-mode resources created at session end. When you cut
|
||||
-- over to live mode, repeat the create flow against the live key and
|
||||
-- swap these two values; everything else (events, structure) is the
|
||||
-- same.
|
||||
INSERT INTO public.pricing_slots (tier, beta_price_id, beta_price_usd)
|
||||
VALUES
|
||||
('analyst', 'price_1TgpGxIp1Mec3r2E6Wh6oeaP', 14.99),
|
||||
('desk', 'price_1TgpGyIp1Mec3r2EQq50KKhF', 44.99)
|
||||
ON CONFLICT (tier) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------
|
||||
-- tier CHECK constraint update — required ONLY if a future session
|
||||
-- introduces 'base' or 'pro' as canonical tier names. Current code +
|
||||
-- migrations 001/011 use ('free', 'analyst', 'desk') and Session 7h
|
||||
-- did not change that. Leaving the ALTER commented as documentation
|
||||
-- for whoever expands the tier set later.
|
||||
-- ---------------------------------------------------------------
|
||||
-- ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_tier_check;
|
||||
-- ALTER TABLE public.users
|
||||
-- ADD CONSTRAINT users_tier_check
|
||||
-- CHECK (tier IN ('free', 'analyst', 'pro', 'desk'));
|
||||
-- ALTER TABLE public.user_profiles DROP CONSTRAINT IF EXISTS user_profiles_tier_check;
|
||||
-- ALTER TABLE public.user_profiles
|
||||
-- ADD CONSTRAINT user_profiles_tier_check
|
||||
-- CHECK (tier IN ('free', 'analyst', 'pro', 'desk'));
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -31,6 +31,14 @@ jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
|
||||
}));
|
||||
|
||||
// Session 7h: the route now layers free-tier response gating on top of
|
||||
// the engine output. This suite tests the ENGINE → route contract
|
||||
// (full shape, kill conditions, etc.); the gating layer has its own
|
||||
// dedicated tests in tests/unit/tierGating.test.js. Pass-through here.
|
||||
jest.mock('../../src/utils/tierGating', () => ({
|
||||
applyTierGating: (result) => result,
|
||||
}));
|
||||
|
||||
const app = require('../../src/app');
|
||||
|
||||
function fullShapedResponse(overrides = {}) {
|
||||
@@ -64,11 +72,17 @@ function fullShapedResponse(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// Session 7h: scan-limit middleware mounts on /api/analyze with a 24h
|
||||
// rolling per-IP quota. Without resetting, supertest's loopback IP
|
||||
// burns its 3 free-tier slots inside this file and later cases 429.
|
||||
const { __internals: scanLimitInternals } = require('../../src/middleware/scanLimit');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockAnalyzeViaEngine1.mockReset();
|
||||
scanLimitInternals.resetForTests();
|
||||
});
|
||||
|
||||
describe('POST /api/analyze/prop', () => {
|
||||
|
||||
@@ -220,8 +220,13 @@ const VALID_PARLAY = {
|
||||
],
|
||||
};
|
||||
|
||||
// Session 7h: scan-limit middleware mounts on /api/scan/parlay. Reset
|
||||
// between tests so the per-user 24h quota doesn't bleed across cases.
|
||||
const { __internals: scanLimitInternals } = require('../../src/middleware/scanLimit');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
scanLimitInternals.resetForTests();
|
||||
});
|
||||
|
||||
describe('POST /api/scan/parlay', () => {
|
||||
|
||||
@@ -18,6 +18,12 @@ jest.mock('../../src/utils/redis', () => ({
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
// Session 7h: scan-limit middleware now mounts on /api/analyze. Reset
|
||||
// between tests so the cumulative anonymous-IP quota doesn't leak
|
||||
// across cases and 429 the cache assertions.
|
||||
const { __internals: mockScanLimitInternals } = require('../../src/middleware/scanLimit');
|
||||
beforeEach(() => mockScanLimitInternals.resetForTests());
|
||||
|
||||
const express = require('express');
|
||||
const analyze = require('../../src/routes/analyze');
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const { scanLimit, __internals } = require('../../src/middleware/scanLimit');
|
||||
|
||||
function mockReqRes({ ip = '1.1.1.1', tier, userId } = {}) {
|
||||
const req = {
|
||||
ip,
|
||||
socket: { remoteAddress: ip },
|
||||
headers: {},
|
||||
user: userId ? { id: userId, tier } : (tier ? { tier } : null),
|
||||
};
|
||||
const headers = {};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
set(k, v) { headers[k] = v; return res; },
|
||||
status(c) { res.statusCode = c; return res; },
|
||||
json(b) { res.body = b; return res; },
|
||||
};
|
||||
return { req, res, headers };
|
||||
}
|
||||
|
||||
beforeEach(() => __internals.resetForTests());
|
||||
|
||||
describe('scanLimit middleware', () => {
|
||||
const mw = scanLimit();
|
||||
|
||||
test('free user allowed up to 3 scans per day', () => {
|
||||
const next = jest.fn();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-free' });
|
||||
mw(req, res, next);
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('free user 4th scan in the window returns 429', () => {
|
||||
const next = jest.fn();
|
||||
let lastRes;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-free-block' });
|
||||
mw(req, res, next);
|
||||
lastRes = res;
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
expect(lastRes.statusCode).toBe(429);
|
||||
expect(lastRes.body.error).toMatch(/scan limit/i);
|
||||
expect(lastRes.body.scans_used).toBe(3);
|
||||
expect(lastRes.body.scans_limit).toBe(3);
|
||||
expect(lastRes.body.tier).toBe('free');
|
||||
});
|
||||
|
||||
test('429 response includes Retry-After + X-Scans-* headers', () => {
|
||||
const next = jest.fn();
|
||||
let lastHeaders;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const { req, res, headers } = mockReqRes({ tier: 'free', userId: 'u-hdr' });
|
||||
mw(req, res, next);
|
||||
lastHeaders = headers;
|
||||
}
|
||||
expect(parseInt(lastHeaders['Retry-After'], 10)).toBeGreaterThan(0);
|
||||
expect(lastHeaders['X-Scans-Used']).toBe('3');
|
||||
expect(lastHeaders['X-Scans-Limit']).toBe('3');
|
||||
});
|
||||
|
||||
test('analyst tier gets 15 scans/day', () => {
|
||||
const next = jest.fn();
|
||||
let blockedAt = null;
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'analyst', userId: 'u-analyst' });
|
||||
mw(req, res, next);
|
||||
if (res.statusCode === 429 && blockedAt == null) blockedAt = i;
|
||||
}
|
||||
expect(blockedAt).toBe(15);
|
||||
expect(next).toHaveBeenCalledTimes(15);
|
||||
});
|
||||
|
||||
test('desk tier is unlimited — never blocks', () => {
|
||||
const next = jest.fn();
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'desk', userId: 'u-desk' });
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(100);
|
||||
});
|
||||
|
||||
test('anonymous user (no req.user) falls back to free quota keyed by IP', () => {
|
||||
const next = jest.fn();
|
||||
let lastRes;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const { req, res } = mockReqRes({ ip: '7.7.7.7' });
|
||||
mw(req, res, next);
|
||||
lastRes = res;
|
||||
}
|
||||
expect(next).toHaveBeenCalledTimes(3);
|
||||
expect(lastRes.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
test('different IPs are independent (anonymous bucket)', () => {
|
||||
const next = jest.fn();
|
||||
const a = mockReqRes({ ip: '1.1.1.1' });
|
||||
const b = mockReqRes({ ip: '2.2.2.2' });
|
||||
mw(a.req, a.res, next);
|
||||
mw(b.req, b.res, next);
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(a.res.statusCode).toBe(200);
|
||||
expect(b.res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('authenticated users in the same IP get independent quotas', () => {
|
||||
const next = jest.fn();
|
||||
// Same IP, different user IDs — should not interfere.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-A', ip: '5.5.5.5' });
|
||||
mw(req, res, next);
|
||||
}
|
||||
// User A exhausted; user B starts clean.
|
||||
const { req, res } = mockReqRes({ tier: 'free', userId: 'u-B', ip: '5.5.5.5' });
|
||||
mw(req, res, next);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(next).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
const { applyTierGating, __internals } = require('../../src/utils/tierGating');
|
||||
|
||||
function sampleResult(overrides = {}) {
|
||||
return {
|
||||
player: 'Jalen Brunson',
|
||||
stat_type: 'points',
|
||||
line: 26.5,
|
||||
direction: 'over',
|
||||
book: 'draftkings',
|
||||
grade: 'A',
|
||||
confidence: 78,
|
||||
edge_pct: 6.2,
|
||||
kill_conditions_triggered: [
|
||||
{ code: 'TRAP', reason: 'Multiple trap signals firing.' },
|
||||
{ code: 'COLD_L5', reason: 'Last-5 average below the line.' },
|
||||
],
|
||||
reasoning: {
|
||||
summary: 'Brunson averaging 28.4 last 5; weak NYK defense; rested.',
|
||||
steps: { season_avg: { value: 26.1 }, final_grade: 'A' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyTierGating — free tier locks the explanation surface', () => {
|
||||
test('keeps grade + confidence + edge_pct (the hook)', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.grade).toBe('A');
|
||||
expect(gated.confidence).toBe(78);
|
||||
expect(gated.edge_pct).toBe(6.2);
|
||||
expect(gated.player).toBe('Jalen Brunson');
|
||||
expect(gated.stat_type).toBe('points');
|
||||
expect(gated.line).toBe(26.5);
|
||||
expect(gated.direction).toBe('over');
|
||||
});
|
||||
|
||||
test('redacts reasoning.summary + steps + marks locked', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.reasoning.summary).toBe(__internals.LOCKED_REASONING_SUMMARY);
|
||||
expect(gated.reasoning.steps).toBeNull();
|
||||
expect(gated.reasoning.locked).toBe(true);
|
||||
});
|
||||
|
||||
test('redacts kill condition reasons but keeps codes for badge rendering', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.kill_conditions_triggered).toHaveLength(2);
|
||||
for (const kc of gated.kill_conditions_triggered) {
|
||||
expect(kc.code).toBeDefined();
|
||||
expect(kc.reason).toBe(__internals.LOCKED_KILL_REASON);
|
||||
expect(kc.locked).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('tags response with tier_gated + upgrade_hint', () => {
|
||||
const gated = applyTierGating(sampleResult(), 'free');
|
||||
expect(gated.tier_gated).toBe(true);
|
||||
expect(typeof gated.upgrade_hint).toBe('string');
|
||||
});
|
||||
|
||||
test('does not mutate the input result', () => {
|
||||
const original = sampleResult();
|
||||
applyTierGating(original, 'free');
|
||||
expect(original.reasoning.summary).toContain('Brunson averaging');
|
||||
expect(original.kill_conditions_triggered[0].reason).toContain('Multiple trap');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTierGating — paid tiers pass through unchanged', () => {
|
||||
test('analyst sees full reasoning', () => {
|
||||
const r = sampleResult();
|
||||
const out = applyTierGating(r, 'analyst');
|
||||
expect(out.reasoning.summary).toContain('Brunson averaging');
|
||||
expect(out.reasoning.locked).toBeUndefined();
|
||||
expect(out.tier_gated).toBeUndefined();
|
||||
expect(out.kill_conditions_triggered[0].reason).toContain('Multiple trap');
|
||||
});
|
||||
|
||||
test('desk sees full reasoning', () => {
|
||||
const out = applyTierGating(sampleResult(), 'desk');
|
||||
expect(out.reasoning.summary).toContain('Brunson averaging');
|
||||
expect(out.tier_gated).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTierGating — edge cases', () => {
|
||||
test('null result returns null', () => {
|
||||
expect(applyTierGating(null, 'free')).toBeNull();
|
||||
expect(applyTierGating(undefined, 'free')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown tier name → treated as free', () => {
|
||||
const out = applyTierGating(sampleResult(), 'pirate');
|
||||
expect(out.reasoning.locked).toBe(true);
|
||||
});
|
||||
|
||||
test('result with no kill_conditions_triggered still produces a clean response', () => {
|
||||
const noKills = { ...sampleResult(), kill_conditions_triggered: [] };
|
||||
const out = applyTierGating(noKills, 'free');
|
||||
expect(out.kill_conditions_triggered).toEqual([]);
|
||||
expect(out.reasoning.locked).toBe(true);
|
||||
});
|
||||
|
||||
test('result with malformed reasoning still produces a locked placeholder', () => {
|
||||
const out = applyTierGating({ ...sampleResult(), reasoning: null }, 'free');
|
||||
expect(out.reasoning.summary).toBe(__internals.LOCKED_REASONING_SUMMARY);
|
||||
expect(out.reasoning.locked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const { TIERS, VALID_TIERS, getTier, getScanLimit, canAccess } = require('../../src/config/tiers');
|
||||
|
||||
describe('tiers config', () => {
|
||||
test('VALID_TIERS includes free, analyst, desk', () => {
|
||||
expect(VALID_TIERS).toEqual(['free', 'analyst', 'desk']);
|
||||
});
|
||||
|
||||
test('api_access is FALSE on every tier (non-negotiable)', () => {
|
||||
for (const tierName of VALID_TIERS) {
|
||||
expect(TIERS[tierName].api_access).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('free tier has the most restrictive defaults', () => {
|
||||
const free = TIERS.free;
|
||||
expect(free.scans_per_day).toBe(3);
|
||||
expect(free.reasoning_visible).toBe(false);
|
||||
expect(free.kill_conditions_detail).toBe(false);
|
||||
expect(free.alerts).toBe(false);
|
||||
expect(free.portfolio).toBe(false);
|
||||
expect(free.engine2).toBe(false);
|
||||
// Grade is the hook — it must be visible even on free.
|
||||
expect(free.grade_visible).toBe(true);
|
||||
// stat_dashboard is the entry product — visible to everyone.
|
||||
expect(free.stat_dashboard).toBe(true);
|
||||
});
|
||||
|
||||
test('analyst opens reasoning + kill conditions + alerts', () => {
|
||||
const a = TIERS.analyst;
|
||||
expect(a.scans_per_day).toBe(15);
|
||||
expect(a.reasoning_visible).toBe(true);
|
||||
expect(a.kill_conditions_detail).toBe(true);
|
||||
expect(a.alerts).toBe(true);
|
||||
// Still no portfolio or engine2 — those are Desk perks.
|
||||
expect(a.portfolio).toBe(false);
|
||||
expect(a.engine2).toBe(false);
|
||||
});
|
||||
|
||||
test('desk unlocks everything except api_access', () => {
|
||||
const d = TIERS.desk;
|
||||
expect(d.scans_per_day).toBe(Infinity);
|
||||
expect(d.reasoning_visible).toBe(true);
|
||||
expect(d.kill_conditions_detail).toBe(true);
|
||||
expect(d.alerts).toBe(true);
|
||||
expect(d.portfolio).toBe(true);
|
||||
expect(d.engine2).toBe(true);
|
||||
expect(d.api_access).toBe(false);
|
||||
});
|
||||
|
||||
test('getTier falls back to free for unknown tier names', () => {
|
||||
expect(getTier('mystery')).toBe(TIERS.free);
|
||||
expect(getTier(null)).toBe(TIERS.free);
|
||||
expect(getTier(undefined)).toBe(TIERS.free);
|
||||
expect(getTier('')).toBe(TIERS.free);
|
||||
});
|
||||
|
||||
test('getScanLimit returns the per-tier daily cap', () => {
|
||||
expect(getScanLimit('free')).toBe(3);
|
||||
expect(getScanLimit('analyst')).toBe(15);
|
||||
expect(getScanLimit('desk')).toBe(Infinity);
|
||||
});
|
||||
|
||||
test('canAccess returns the right boolean per (tier, feature) pair', () => {
|
||||
expect(canAccess('free', 'grade_visible')).toBe(true);
|
||||
expect(canAccess('free', 'reasoning_visible')).toBe(false);
|
||||
expect(canAccess('free', 'api_access')).toBe(false);
|
||||
expect(canAccess('analyst', 'reasoning_visible')).toBe(true);
|
||||
expect(canAccess('analyst', 'engine2')).toBe(false);
|
||||
expect(canAccess('desk', 'engine2')).toBe(true);
|
||||
expect(canAccess('desk', 'api_access')).toBe(false);
|
||||
});
|
||||
|
||||
test('canAccess returns false for unknown features (defensive)', () => {
|
||||
expect(canAccess('desk', 'no_such_feature')).toBe(false);
|
||||
});
|
||||
|
||||
test('TIERS objects are frozen — nobody can mutate the matrix at runtime', () => {
|
||||
expect(Object.isFrozen(TIERS)).toBe(true);
|
||||
expect(Object.isFrozen(TIERS.free)).toBe(true);
|
||||
expect(Object.isFrozen(TIERS.analyst)).toBe(true);
|
||||
expect(Object.isFrozen(TIERS.desk)).toBe(true);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user