- Locks no longer fail open: an unreachable backend throws LockUnavailableError and withLock skips the run (or opts into running unlocked, as template seeding does). The lnbits payment poller keeps polling through an outage, made safe by only sending confirmation emails when a row actually transitioned. - Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing counters stranded without a TTL. - Per-email login lockout moves to a Redis-backed store shared across replicas (in-memory fallback preserved), case-insensitive on email. - Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the server, force-close lingering SSE sockets, close Redis. - Active PING probe backs the health flag; /health reports last ping. SSE payment streams also poll the DB as a pub/sub-gap fallback. - Scale compose gains requirepass, maxmemory 256mb with noeviction, and an authenticated healthcheck; .env.example documents passwords, TLS (rediss://), and DB-index selection. - First tests in the repo: vitest + ioredis-mock covering the lock, rate limiter, and login lockout stores (27 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
// Rate limiter abstraction with two implementations:
|
|
// - memory: per-process fixed window (the original behavior)
|
|
// - redis: shared fixed window across all instances, implemented as a single
|
|
// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
|
|
// would otherwise strand a counter with no expiry)
|
|
//
|
|
// Selection happens once based on REDIS_URL. On any Redis error the limiter
|
|
// fails open (allows the request) so a Redis blip never takes the API down.
|
|
|
|
import type Redis from 'ioredis';
|
|
import { getRedis, isRedisEnabled } from '../redis.js';
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
retryAfter?: number;
|
|
}
|
|
|
|
export interface RateLimiter {
|
|
readonly backend: 'memory' | 'redis';
|
|
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
|
|
}
|
|
|
|
// ==================== Memory implementation ====================
|
|
|
|
interface Bucket {
|
|
count: number;
|
|
resetAt: number;
|
|
}
|
|
|
|
export class MemoryRateLimiter implements RateLimiter {
|
|
readonly backend = 'memory' as const;
|
|
private buckets = new Map<string, Bucket>();
|
|
|
|
constructor() {
|
|
// Periodically drop expired buckets so the Map does not grow unbounded.
|
|
const cleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, bucket] of this.buckets) {
|
|
if (now > bucket.resetAt) this.buckets.delete(key);
|
|
}
|
|
}, 60_000);
|
|
(cleanup as any).unref?.();
|
|
}
|
|
|
|
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
|
const now = Date.now();
|
|
const bucket = this.buckets.get(key);
|
|
|
|
if (!bucket || now > bucket.resetAt) {
|
|
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
return { allowed: true };
|
|
}
|
|
|
|
bucket.count++;
|
|
if (bucket.count > max) {
|
|
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
|
|
}
|
|
return { allowed: true };
|
|
}
|
|
}
|
|
|
|
// ==================== Redis implementation ====================
|
|
|
|
// Atomically increments the window counter, sets the expiry on the first hit,
|
|
// and repairs any counter left without a TTL (self-heals keys stranded by the
|
|
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
|
|
export const CONSUME_SCRIPT = `
|
|
local count = redis.call('INCR', KEYS[1])
|
|
if count == 1 then
|
|
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
end
|
|
local ttl = redis.call('PTTL', KEYS[1])
|
|
if ttl < 0 then
|
|
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
ttl = tonumber(ARGV[1])
|
|
end
|
|
return {count, ttl}
|
|
`;
|
|
|
|
type RedisWithConsume = Redis & {
|
|
rlConsume(key: string, windowMs: number): Promise<[number, number]>;
|
|
};
|
|
|
|
function withConsumeCommand(redis: Redis): RedisWithConsume {
|
|
if (typeof (redis as any).rlConsume !== 'function') {
|
|
// ioredis caches the script SHA and transparently handles NOSCRIPT.
|
|
redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
|
|
}
|
|
return redis as RedisWithConsume;
|
|
}
|
|
|
|
export class RedisRateLimiter implements RateLimiter {
|
|
readonly backend = 'redis' as const;
|
|
|
|
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
|
const redis = getRedis();
|
|
if (!redis) return { allowed: true };
|
|
|
|
try {
|
|
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
|
|
if (count > max) {
|
|
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
|
return { allowed: false, retryAfter };
|
|
}
|
|
return { allowed: true };
|
|
} catch (err: any) {
|
|
// Fail open: never block traffic because Redis is unavailable.
|
|
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
|
|
return { allowed: true };
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== Selection ====================
|
|
|
|
let instance: RateLimiter | null = null;
|
|
|
|
export function getRateLimiter(): RateLimiter {
|
|
if (!instance) {
|
|
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
|
|
}
|
|
return instance;
|
|
}
|