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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:09:20 +10:00
commit 822c0b7c0b
406 changed files with 47742 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import bcrypt from "bcryptjs";
import { prisma } from "./db";
import { fail, over } from "./ratelimit";
import { setStaffCookie } from "./staffsession";
import { recordAuthEvent } from "./audit";
/* Signing a wearer in, from either door.
*
* There are two ways into the staff app now: /my/signin, which is where the printed slip and the
* Play app send people, and the ordinary Log in box on the website, which a wearer reaches by
* clicking Log in on the home page like anybody else. Both end in the same session, so both go
* through here rather than through two copies of the same twenty lines — the refusal wording, the
* failure buckets and the audit events have to say the same thing whichever door was used, and the
* way that stops being true is by living in two places.
*/
// A constant to compare against when there is no account, so a missing email and a wrong password
// take the same time.
const DUMMY = "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u";
/** The trail names the person on the register, not the account — see lib/audit.ts. */
type Acc = { facilityId: string; staff: { id: string; first: string; last: string } };
const actorFor = (a: Acc, email: string) =>
({ facilityId: a.facilityId, userId: a.staff.id, userName: `${a.staff.first} ${a.staff.last}`.trim() || email });
/* Failures only, not attempts: every wearer in the hospital arrives from one NAT address at shift
* change, so a ceiling on attempts would lock out the ward it is meant to protect.
*
* Its own function because the staff door asks it *before* verifying Turnstile, and a token is good
* for one use — asking afterwards would spend somebody's check to tell them they are throttled.
* `over` only reads the bucket, so asking twice on one request costs nothing. */
export function staffThrottled(email: string, ip: string): boolean {
return over("staff-login-ip:" + ip, 40, 15 * 60 * 1000) || (!!email && over("staff-login-email:" + email, 25, 15 * 60 * 1000));
}
export type StaffSignIn =
/** No staff account with this address. The caller decides what that means. */
| { kind: "none" }
| { kind: "error"; error: string; status: number }
| { kind: "ok"; name: string };
/**
* Check an address and password against the staff register and, if they match, set the session.
*
* `ownDoor` says whether this is /api/staff/login itself. It decides one thing only: what happens
* when the address has no staff account at all. At the staff door that is a plain wrong answer, so
* it costs a dummy compare and a counted failure like any other. At the shared Log in box it is
* ordinary — most people typing there are coordinators — so it returns `none` having touched
* nothing, and the coordinator path does its own compare and counts its own failure. Counting in
* both places would spend two of somebody's eight attempts on one wrong password.
*/
export async function signInStaff(email: string, password: string, ip: string, ownDoor: boolean): Promise<StaffSignIn> {
const acc = await prisma.staffAccount.findUnique({
where: { email },
select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } },
});
if (!acc && !ownDoor) return { kind: "none" };
if (staffThrottled(email, ip)) {
return { kind: "error", error: "Too many attempts — try again in 15 minutes.", status: 429 };
}
const ok = await bcrypt.compare(password, acc?.passwordHash ?? DUMMY);
if (!acc || !ok) {
fail("staff-login-ip:" + ip, 15 * 60 * 1000);
if (email) fail("staff-login-email:" + email, 15 * 60 * 1000);
// An address with no account here goes unrecorded: nothing names a facility to file it under.
if (acc) recordAuthEvent(actorFor(acc, email), "staff:signin.failed", ip);
return { kind: "error", error: "Email or password doesnt match.", status: 401 };
}
if (acc.staff.inactive) {
recordAuthEvent(actorFor(acc, email), "staff:signin.refused", ip, "inactive");
return { kind: "error", error: "You're no longer on the register at this facility. Ask the linen room.", status: 403 };
}
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
await setStaffCookie(acc.id, acc.passwordHash);
recordAuthEvent(actorFor(acc, email), "staff:signin", ip, "password");
return { kind: "ok", name: `${acc.staff.first} ${acc.staff.last}` };
}