Files
wizwar6e/packages/server/src/ratelimit.ts
T
Eric WagonerandClaude Fable 5.1 e1a740119c Credibility pass: the session seams sanded from the clips-and-camera batch
Two blind reviews of everything since the last pass (afa0e17), every
finding checked against the code, no behavior changed: all thirty
scene goldens match without a re-bless and the engine suite is
untouched.

Reel and renderer: the camera's look-down rule is stated once, beside
LOOK_DOWN, instead of twice in the effect; the empty aim branch that
stood where a cutaway used to be is gone (the guard it implied is now
explicit); the pit events and the punch are handled by their own
types, not through "in" casts; smoothstep is one export used by every
tween instead of eleven inline copies; the two floor rings share one
painter; project() takes a Billboard instead of a third hand-typed
copy of its fields; the strides-left figure and the web rim no longer
shadow the reel's steps and the pane's fx; the die card's verdict is
built from events, not by matching an emoji; the workshop asks for the
hover cue by name instead of passing an empty click handler.

Server and engine: one requestBase() for the origin, one slug pattern
in store.ts gating both the clip page and its files, one 404 for both;
LOOPBACK sits above its only caller; doCounteract names what a counter
is played against once; fearCells sits beside its own docblock rather
than between sightedCellsFor and its.

Deploy: chromiumExe, the private server, ffmpeg, and the reel rewind
live in deploy/lib/harness.mjs, shared by the gate, the recorder, and
the card cutter instead of pasted three times; the recorder drops its
duplicate frame counters and names its poster settle; the card uses the
gallery's exact gold; the one-time Sentry URL bootstrap leaves
deploy.sh; the backup comment states the rule rather than the incident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 11:55:57 -04:00

48 lines
1.9 KiB
TypeScript

// A per-key sliding-window limiter for the two doors anyone may walk
// through unseated: creating rooms and filing reports. Per-connection
// caps reset on reconnect; these are keyed by client address and hold
// for the window. Memory is bounded by pruning keys whose windows have
// emptied.
export class SlidingLimit {
private hits = new Map<string, number[]>();
private lastSweep = 0;
constructor(private readonly max: number, private readonly windowMs: number) {}
/** Record a hit for `key` if it is under the limit; false if it is not. */
allow(key: string, now = Date.now()): boolean {
this.sweep(now);
const cutoff = now - this.windowMs;
const times = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (times.length >= this.max) { this.hits.set(key, times); return false; }
times.push(now);
this.hits.set(key, times);
return true;
}
private sweep(now: number): void {
if (now - this.lastSweep < this.windowMs) return;
this.lastSweep = now;
const cutoff = now - this.windowMs;
for (const [k, times] of this.hits) {
if (!times.some((t) => t > cutoff)) this.hits.delete(k);
}
}
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
/** The client's address as Caddy reports it. The proxy APPENDS the true
* peer to X-Forwarded-For, so the last entry is the trustworthy one; a
* client can write anything into the first. And the header is believed
* only when the socket peer is the proxy itself (loopback) — reached
* any other way, the peer address is the client. */
export function clientAddress(headers: Record<string, string | string[] | undefined>, remote: string | undefined): string {
const peer = remote || "unknown";
if (!LOOPBACK.has(peer)) return peer;
const fwd = headers["x-forwarded-for"];
const parts = (Array.isArray(fwd) ? fwd.join(",") : fwd ?? "").split(",").map((s) => s.trim()).filter(Boolean);
return parts[parts.length - 1] || peer;
}