deckhearth/lib/rate-limit.js

132 lines
4.7 KiB
JavaScript
Raw Normal View History

fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
// Per-class limiter configuration. Distinct Redis prefix per class is
// REQUIRED — without it, a search-class hit would consume the auth-class
// budget for the same identifier. `slidingWindow` chosen across all
// classes to match Brief 4's existing algorithm; switching to
// `tokenBucket` per-class would be its own convoy.
const LIMITER_CONFIG = {
auth: { limit: 5, window: '15 m', prefix: 'tcgvault:auth' },
search: { limit: 60, window: '1 m', prefix: 'tcgvault:search' },
upload: { limit: 10, window: '1 h', prefix: 'tcgvault:upload' },
generate: { limit: 5, window: '1 h', prefix: 'tcgvault:generate' },
import: { limit: 5, window: '1 h', prefix: 'tcgvault:import' },
};
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
// Lazy singleton. Module-load init would throw in environments without
// Upstash env vars (local dev pre-onboarding, tests that transitively
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
// import the auth handlers, Vercel build-time bundling). Defer
// construction until the first request actually arrives.
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
let cached = null;
function init() {
// Env-var names match Vercel's Upstash Marketplace integration, which
// auto-provisions KV_REST_API_URL and KV_REST_API_TOKEN. See
// https://upstash.com/docs/redis/howto/vercelintegration. Single-source-
// of-truth — do NOT alias to UPSTASH_REDIS_REST_*.
const url = process.env.KV_REST_API_URL;
const token = process.env.KV_REST_API_TOKEN;
if (url && token) {
const redis = new Redis({ url, token });
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
const instances = new Map();
for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) {
instances.set(
name,
new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window),
prefix: cfg.prefix,
})
);
}
return { mode: 'live', instances };
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
}
if (process.env.NODE_ENV === 'production') {
// Fail-closed in production. A single failed login is a better outcome
// than silently disabling brute-force protection on the live site.
throw new Error(
'[rate-limit] Upstash not configured. Set KV_REST_API_URL and KV_REST_API_TOKEN in the deployment environment (auto-provisioned by the Vercel Upstash Marketplace integration) before serving auth traffic.'
);
}
console.warn(
'[rate-limit] KV_REST_API_URL / KV_REST_API_TOKEN not set — rate limiting disabled (dev/test only)'
);
return { mode: 'noop' };
}
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
function extractIpIdentifier(req) {
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
const xff = req.headers?.['x-forwarded-for'];
const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim();
return firstHop || req.socket?.remoteAddress || 'anonymous';
}
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
// THROWS on missing userId. Per-user limiters MUST sit AFTER the auth
// check in the handler body — silently falling back to IP here would
// convert a per-user limit into a per-IP limit, locking out other
// household members for one user's behavior. The throw surfaces the
// misordering immediately during development rather than at first
// production incident.
function extractUserIdentifier(userId) {
if (
userId === null ||
userId === undefined ||
userId === '' ||
(typeof userId === 'number' && Number.isNaN(userId))
) {
throw new Error(
'[rate-limit] extractUserIdentifier called without an authenticated userId. Place the rate-limit gate AFTER the auth check, never before.'
);
}
return `user:${userId}`;
}
async function check(className, identifier) {
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
if (!cached) {
cached = init();
}
if (cached.mode === 'noop') {
return { allowed: true, remaining: Infinity, reset: 0 };
}
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
const limiter = cached.instances.get(className);
if (!limiter) {
throw new Error(`[rate-limit] Unknown limiter class: ${className}`);
}
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
try {
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
const { success, remaining, reset } = await limiter.limit(identifier);
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
return { allowed: success, remaining, reset };
} catch (err) {
// Fail-open on Upstash outage. A hard outage at the rate-limit backend
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
// should not lock the entire user base out. Brute-force protection
// lives behind defense-in-depth (Vercel firewall, etc.).
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
console.error('[rate-limit]', err);
return { allowed: true, remaining: Infinity, reset: 0 };
}
}
feat(security): rate-limit search/upload/import + gate import routes (P0 #6) Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass Brief 4 shipped lib/rate-limit.js with a single 5/15min auth limiter wired into login + register; this brief extends the module to 5 named limiters (auth/search/upload/generate/import) and wires them into the remaining abusable surface. Per architect Decision 1 — Option A (gate all 3 import routes uniformly). The architect's investigation found a critical secondary bug: pages/admin/card-import.js's fetch sends NO Authorization header today. Adding getUserFromRequest to the import APIs without fixing the admin UI atomically would have returned 401 on every "Import Cards" click. Both edits ship in this single commit — API gating + admin UI Bearer fix — for atomic safety. Lorcana is dead in frontend today (only scripts/import-lorcana.js uses that path) but gated uniformly to future-proof per AGENTS.md § 1 status; a delete-dead-lorcana-import follow-up convoy is queued for later if we decide to drop Lorcana entirely. Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js. checkAuthRateLimit(req) signature + return shape preserved verbatim (don't break Brief 4's contract); 4 new named functions added (checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit). Map<className, Ratelimit> cache, per-class Redis prefix (tcgvault:auth, tcgvault:search, tcgvault:upload, tcgvault:generate, tcgvault:import) so each class has its own budget. Per Decision 3 — per-class limit values tuned with evidence: auth 5 / 15min IP-keyed (unchanged from Brief 4) search 60 / 1min IP-keyed (bumped from 30 — ShareModal has no debounce; 17-char email = 16 requests in <5s) upload 10 / 1hr user-keyed generate 5 / 1hr user-keyed (DiceBear is free, kept at 5) import 5 / 1hr user-keyed (admin-only; external APIs have their own limits) Per Decision 4 — two extractors. extractIpIdentifier (existing, unchanged) and extractUserIdentifier (new). The new one THROWS on null/undefined/empty/NaN userId to prevent silent fallback-to-IP (which would convert per-user limits into per-IP and lock out households). Architect's R-finding: places the gate AFTER the auth check on every per-user-keyed route, never before. Per Decision 5 — uniform 429 response shape verbatim matching login.js/register.js: Retry-After header + JSON { error: 'Too many attempts. Try again later.' }. Anti- fingerprinting (per-class messages would tell an attacker which classes have which limits). Per Decision 6 — no new per-route handler tests this convoy. Vitest 21/21 unchanged at merge. Verification: - npm run lint: 128 problems (baseline match) - npm run test:run: 21/21 vitest pass (no regression; auth-utils tests don't transitively load rate-limit per architect D6 evidence) - 5 named limiter exports verified via per-route grep counts - Admin UI sends Authorization: Bearer <token> from localStorage in the import fetch (matching pattern from other admin pages) - Brief 4's login.js + register.js byte-identical at HEAD - .cursor/rules/api-routes.mdc § Rate limiting extended with per-class table + gate-ordering rules No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis suffice). No workflow YAML changes. No AGENTS.md edits (doc- writer pass at convoy close handles Gotcha #12 update + § 6 testing update + ship-readiness Status summary 7/8 → 8/8). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 23:46:27 -04:00
export async function checkAuthRateLimit(req) {
return check('auth', extractIpIdentifier(req));
}
export async function checkSearchRateLimit(req) {
return check('search', extractIpIdentifier(req));
}
export async function checkUploadRateLimit(req, userId) {
return check('upload', extractUserIdentifier(userId));
}
export async function checkGenerateRateLimit(req, userId) {
return check('generate', extractUserIdentifier(userId));
}
export async function checkImportRateLimit(req, userId) {
return check('import', extractUserIdentifier(userId));
}