Files
ThreadCount 0910bc32c1 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

65 lines
3.4 KiB
TypeScript

// Small in-memory sliding-window limiter. Single-process deployment, so this is sufficient to blunt
// abuse (signup spam, photo floods, brute force) without external state. Keys are scoped by caller.
//
// Each bucket remembers the window it was created with, and the sweep uses that rather than the
// window of whoever happened to trigger it. The windows in use here run from 60 seconds to 24
// hours, and the 60-second one (/api/mutate) is by far the most frequent caller — so a sweep that
// used the caller's window would continuously evict the 15-minute sign-in lockouts and the 24-hour
// spam ceilings, which is the same as not having them.
type Bucket = { windowMs: number; hits: number[] };
const buckets = new Map<string, Bucket>();
let lastSweep = Date.now();
/** Returns true when the call is allowed; false once `max` calls have happened inside `windowMs`. */
export function allow(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
if (now - lastSweep > 60_000) {
lastSweep = now;
for (const [k, b] of buckets) if (!b.hits.length || now - b.hits[b.hits.length - 1] > b.windowMs) buckets.delete(k);
}
// The window travels with the bucket: a key is always asked about with the same window by the
// same caller, and taking the current one keeps a changed limit from being ignored until the
// bucket empties.
const prev = buckets.get(key);
const hits = (prev?.hits || []).filter((t) => now - t < windowMs);
if (hits.length >= max) { buckets.set(key, { windowMs, hits }); return false; }
hits.push(now); buckets.set(key, { windowMs, hits });
return true;
}
/** True when the bucket is already at its limit, WITHOUT recording an attempt against it. */
export function over(key: string, max: number, windowMs: number): boolean {
const now = Date.now();
const hits = (buckets.get(key)?.hits || []).filter((t) => now - t < windowMs);
return hits.length >= max;
}
/** Record one against the bucket. Pairs with `over` for limits that only count failures. */
export function fail(key: string, windowMs: number): void {
const now = Date.now();
const hits = (buckets.get(key)?.hits || []).filter((t) => now - t < windowMs);
hits.push(now);
buckets.set(key, { windowMs, hits });
}
/* The auth routes count FAILURES, not attempts, and that distinction is what makes the numbers
* defensible.
*
* Every wearer of a uniform in a hospital reaches this product from behind one NAT address, and
* they all arrive at once at shift change. A ceiling on *attempts* per address therefore has to
* choose between being a real brute-force defence and not locking out a ward on a Monday morning —
* there is no number that does both. Counting only the attempts that failed removes the conflict:
* six hundred nurses signing in successfully never touch the bucket, while an address producing
* forty failures against forty different accounts is credential-stuffing and is stopped.
*
* Until the sweep bug above was fixed none of these ceilings was ever actually reached — every
* bucket was forgotten within a minute — so their behaviour under a real deployment had never
* been observed.
*/
/** Client IP as nginx reports it (last X-Forwarded-For entry is the one nginx appended). */
export function clientIp(headers: Headers): string {
const xff = headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
return xff[xff.length - 1] || headers.get("x-real-ip") || "local";
}