Resolves the launch-blocking 'TCG Vault vs Deck Hearth' inconsistency called out in AGENTS.md line 5 since project setup. Operator gate-0 decision: Deck Hearth wins. Two briefs applied serially. B1 (mechanical): 7-file display + comment sweep. B2 (infrastructure): Redis prefix rename in lib/rate-limit.js (5 prefixes, accept one-time counter reset), package.json + lockfile regen (STOP-on-churn confirmed only name lines changed), admin/alice/bob email rename in seed scripts + login pre-fill + NEW idempotent migration script scripts/migrations/2026-05-24-rename-admin-email.js. Risk 4 PRESERVE applied: test/lib/permission-middleware.test.js retains admin@tcgvault.com literal with 7-line architect-authored why comment (documents pre-fix-auth-bypass bug shape; preserves historical truth per project's gotcha-documentation convention). All 5 D-decisions ratified at gate-1 (Deck Hearth / deck-hearth / deckhearth / admin@deckhearth.com / full deckhearth Redis prefix). Local: lint 128 baseline (B1 + B2), vitest 21/21 (B1 + B2). CI all green: Playwright smoke 3/3 against rebranded preview in 1m4s, forbidden-cors-headers pass, forbidden-endpoints pass, Screenshot diff pass, Vercel deployment complete. Cross-validation lineage: 4th convoy where the same 3-test smoke spec defends auth surface through sweeping change (after PR #15 Layout default-user, PR #19 CORS, PR #20 rate-limit, now this PR #21 brand rename). OPERATOR POST-MERGE ACTION REQUIRED: run 'node scripts/migrations/2026-05-24-rename-admin-email.js' against prod Neon DB before next admin login (ordering: migration FIRST, then any subsequent setup-db invocation). Migration is ESM, idempotent, UNIQUE-collision-safe. PR #21 architect-commit50ce9ab, B1ac8c998, B21c18d21.
131 lines
4.7 KiB
JavaScript
131 lines
4.7 KiB
JavaScript
import { Ratelimit } from '@upstash/ratelimit';
|
|
import { Redis } from '@upstash/redis';
|
|
|
|
// 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: 'deckhearth:auth' },
|
|
search: { limit: 60, window: '1 m', prefix: 'deckhearth:search' },
|
|
upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' },
|
|
generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' },
|
|
import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' },
|
|
};
|
|
|
|
// Lazy singleton. Module-load init would throw in environments without
|
|
// Upstash env vars (local dev pre-onboarding, tests that transitively
|
|
// import the auth handlers, Vercel build-time bundling). Defer
|
|
// construction until the first request actually arrives.
|
|
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 });
|
|
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 };
|
|
}
|
|
|
|
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' };
|
|
}
|
|
|
|
function extractIpIdentifier(req) {
|
|
const xff = req.headers?.['x-forwarded-for'];
|
|
const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim();
|
|
return firstHop || req.socket?.remoteAddress || 'anonymous';
|
|
}
|
|
|
|
// 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) {
|
|
if (!cached) {
|
|
cached = init();
|
|
}
|
|
|
|
if (cached.mode === 'noop') {
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
|
}
|
|
|
|
const limiter = cached.instances.get(className);
|
|
if (!limiter) {
|
|
throw new Error(`[rate-limit] Unknown limiter class: ${className}`);
|
|
}
|
|
|
|
try {
|
|
const { success, remaining, reset } = await limiter.limit(identifier);
|
|
return { allowed: success, remaining, reset };
|
|
} catch (err) {
|
|
// Fail-open on Upstash outage. A hard outage at the rate-limit backend
|
|
// should not lock the entire user base out. Brute-force protection
|
|
// lives behind defense-in-depth (Vercel firewall, etc.).
|
|
console.error('[rate-limit]', err);
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|