trails/apps/journal/app/lib/rate-limit.server.ts
Ullrich Schäfer 5fef45fb68
fix: bypass rate limiter when E2E=true
E2E suite drives registrations from one IP at parallel volume;
production limits trip and flake tests. Same opt-out pattern as the
fail-loud secret/DB-URL guards.
2026-05-24 11:57:01 +02:00

95 lines
3.3 KiB
TypeScript

// Per-process in-memory rate limiter. Single-instance is fine for the
// flagship's current topology (one journal container); when we
// horizontally scale we revisit with a Postgres- or Redis-backed store.
//
// Algorithm: fixed-window counter. Each `(scope, key)` pair tracks the
// first-attempt timestamp and a counter. The next attempt after the
// window resets the counter. Simpler than token-bucket and good enough
// for "5 logins per minute" / "3 magic links per 5 minutes" semantics.
interface Bucket {
windowStart: number;
count: number;
}
const buckets = new Map<string, Bucket>();
// Periodic sweep so stale entries don't grow unbounded. 5-minute interval
// is fine — entries are tiny and expire on next check anyway.
let sweepTimer: ReturnType<typeof setInterval> | undefined;
function ensureSweeper(): void {
if (sweepTimer || process.env.NODE_ENV === "test" || process.env.VITEST) return;
sweepTimer = setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) {
// Conservative: drop entries older than 1 hour regardless of
// configured window. Active limiters re-create their entry.
if (now - b.windowStart > 60 * 60 * 1000) buckets.delete(k);
}
}, 5 * 60 * 1000);
sweepTimer.unref?.();
}
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetMs: number;
}
export interface RateLimitOptions {
scope: string;
key: string;
limit: number;
windowMs: number;
}
/**
* Check (and consume) one slot from the given limiter. Returns whether
* the call is allowed plus how many slots remain in the current window.
*
* The limiter is keyed by `${scope}:${key}` — `scope` distinguishes the
* route family (e.g. "login-attempt", "magic-link-send") so a flood on
* one endpoint doesn't lock out the others.
*/
export function consumeRateLimit(opts: RateLimitOptions): RateLimitResult {
// E2E suite drives auth flows from one IP at high volume; production
// limits would trip and flake tests. Same opt-out pattern as
// requireSecret() / getDatabaseUrl().
if (process.env.E2E === "true") {
return { allowed: true, remaining: opts.limit, resetMs: opts.windowMs };
}
ensureSweeper();
const now = Date.now();
const id = `${opts.scope}:${opts.key}`;
const existing = buckets.get(id);
if (!existing || now - existing.windowStart >= opts.windowMs) {
buckets.set(id, { windowStart: now, count: 1 });
return { allowed: true, remaining: opts.limit - 1, resetMs: opts.windowMs };
}
existing.count += 1;
const resetMs = opts.windowMs - (now - existing.windowStart);
if (existing.count > opts.limit) {
return { allowed: false, remaining: 0, resetMs };
}
return { allowed: true, remaining: opts.limit - existing.count, resetMs };
}
/**
* Extract the client IP from a Request. Honors `X-Forwarded-For`
* (Caddy in front of the journal sets it) and falls back to a stable
* "unknown" bucket so the limiter still meaningfully rate-limits when
* the header is missing.
*/
export function clientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) {
// First entry is the original client; everything after is proxies.
return xff.split(",")[0]!.trim();
}
return "unknown";
}
/** Test-only: reset all buckets between tests. */
export function _resetRateLimitsForTest(): void {
buckets.clear();
}