deckhearth/lib/rate-limit.js
Randall Stillwell 1c18d214c1 feat(brand): infrastructure + email migration for Deck Hearth (B2 of 2)
Closes the pick-a-name convoy. Applies D1-D5 + Risk 4 PRESERVE per
operator gate-1 ratification.

Infrastructure renames:
- lib/rate-limit.js: 5 Redis key prefixes tcgvault:* → deckhearth:* (D5).
  One-time per-15-min / per-1-hour counter reset accepted; no user impact
  because counter windows are short anyway. Existing rate-limit state in
  Upstash will accumulate at the new prefix on first request.
- package.json: name field tcg-vault → deck-hearth (D2)
- package-lock.json: regenerated for the name change; STOP-on-churn
  protocol confirmed only the two name lines changed (no dep churn)
- All three test users (admin/alice/bob) renamed to @deckhearth.com (D4)
- One-off migration script scripts/migrations/2026-05-24-rename-admin-
  email.js (NEW): ESM, idempotent, UNIQUE-collision-safe. Per the
  no-go-zones rule for new migrations. Operator MUST run post-deploy.
- README.md + TESTING_GUIDE.md operator-caveat blockquotes flagged
- pages/login.js demo-credential pre-fill updated

PRESERVED per Risk 4:
- test/lib/permission-middleware.test.js literal admin@tcgvault.com
  with 7-line architect-authored "why" comment block. This is the
  documented pre-fix-auth-bypass bug shape; the regression-lock
  literal stays as historical truth.

Verification:
- npm run lint: 128 problems (baseline preserved)
- npm run test:run: 21/21 pass (preserved literal keeps green)
- Grep across full repo: 0 hits for TCG Vault / tcgvault / tcg-vault
  except the explicit preserve in the test file + .convoys/ historical
- lib/rate-limit.js: 5 deckhearth: prefixes, 0 tcgvault: prefixes
- node --check on the new migration script: exit 0
- git diff package-lock.json: only the 2 "name": lines changed (no churn)

Operator post-merge action:
- Run `node scripts/migrations/2026-05-24-rename-admin-email.js` against
  the production Neon DB. Order matters: migration FIRST, then any
  subsequent `npm run setup-db` invocation. Migration script will refuse
  to run if collision detected (means setup-db already ran post-rename).

Architect brief: .convoys/pick-a-name/brief-2-infrastructure-and-email-migration.md
Architect commit: 50ce9ab
Operator gate-1: D1-D5 + Risk 4 PRESERVE ratified.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 01:58:48 -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));
}