deckhearth/lib/rate-limit.js
varutasu 9abbab6c21
feat(brand): unify on Deck Hearth across in-repo strings + infra (P1 brand decision)
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-commit 50ce9ab, B1 ac8c998, B2 1c18d21.
2026-05-25 02:28:29 -05:00

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));
}