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