ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { cookies } from "next/headers";
import { createHash, createHmac, timingSafeEqual } from "crypto";
import { prisma } from "../db";
/* Sessions for the operations console.
*
* A third kind of session, kept apart from the other two the same way lib/staffsession.ts keeps
* the wearer's apart from the coordinator's: its own cookie, a signing key derived from a
* separate secret with a separate domain string, and a payload claim named `oid` where the others
* carry `uid` and `sid`. A coordinator or staff cookie pasted into tc_ops fails signature
* verification, and even if it somehow didn't, readOpsToken() rejects a payload with no `oid`.
* There is no arrangement of the other two tokens that becomes this one — not because a condition
* says no, but because the three are not the same shape.
*
* ⛔ The secret is OPS_SESSION_SECRET, never SESSION_SECRET. SESSION_SECRET also derives the key
* that encrypts every customer's TOTP secret, so if this console shared it, a compromised operator
* credential would force a rotation that destroys every facility's second factor. The console
* must be revocable on its own.
*
* ⛔ The cookie is host-only — no Domain attribute — so it is sent to ops.threadcount.tech and
* nowhere else. Domain=.threadcount.tech would hand it to analytics. and errors.threadcount.tech,
* both real applications this product's CSP already trusts.
*
* ⛔ Nothing in here, and nothing in the console, may mint a coordinator or staff session. The
* demo route shows how few lines that takes; an operator signing in as a customer is the data
* plane by another door, and the product's privacy page says it never happens.
*/
export const OPS_COOKIE = "tc_ops";
// Eight hours: an owner console is tuned the opposite way from the two convenience sessions.
const MAX_AGE = 60 * 60 * 8;
function secret() {
const s = process.env.OPS_SESSION_SECRET;
if (!s) throw new Error("OPS_SESSION_SECRET not set");
// Domain separation, and a different master secret underneath it.
return createHash("sha256").update("threadcount:ops:v1:" + s).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** Short fingerprint of the password hash: a password change invalidates every older token. */
export function opsPwVersion(passwordHash: string) {
return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12);
}
/** `sso` marks a session that Cloudflare Access already put a second factor behind. */
export function signOpsSession(oid: string, passwordHash: string, sso = false, maxAge = MAX_AGE) {
const payload = b64url(Buffer.from(JSON.stringify({ oid, pv: opsPwVersion(passwordHash), sso, exp: Date.now() + maxAge * 1000 })));
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
return `${payload}.${sig}`;
}
export function readOpsToken(raw: string | undefined): { oid: string; pv: string; sso: boolean } | null {
if (!raw) return null;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return null;
const expect = b64url(createHmac("sha256", secret()).update(payload).digest());
const a = Buffer.from(sig), b = Buffer.from(expect);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
if (!data.oid || !data.exp || data.exp < Date.now()) return null;
return { oid: data.oid, pv: String(data.pv || ""), sso: data.sso === true };
} catch {
return null;
}
}
export async function setOpsCookie(oid: string, passwordHash: string, sso = false) {
const jar = await cookies();
jar.set(OPS_COOKIE, signOpsSession(oid, passwordHash, sso), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: MAX_AGE,
});
}
export async function clearOpsCookie() {
const jar = await cookies();
jar.set(OPS_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
}
export type OperatorRole = "OWNER" | "SUPPORT";
export type OperatorSession = {
id: string;
email: string;
name: string;
role: OperatorRole;
/** The second factor was proven at the identity provider, not here. */
viaSso: boolean;
totpEnabled: boolean;
};
/** Re-read per request rather than trusting the token beyond an id, like currentUser(). */
export async function currentOperator(): Promise<OperatorSession | null> {
const jar = await cookies();
const tok = readOpsToken(jar.get(OPS_COOKIE)?.value);
if (!tok) return null;
const o = await prisma.operator.findUnique({
where: { id: tok.oid },
select: { id: true, email: true, name: true, role: true, inactive: true, passwordHash: true, totpEnabledAt: true },
});
if (!o || o.inactive) return null;
if (tok.pv !== opsPwVersion(o.passwordHash)) return null; // password changed since this token was minted
return { id: o.id, email: o.email, name: o.name, role: o.role, viaSso: tok.sso, totpEnabled: !!o.totpEnabledAt };
}
const RANK: Record<OperatorRole, number> = { SUPPORT: 1, OWNER: 2 };
/** Throws rather than returns null so a route cannot forget to check. */
export async function requireOperator(minRole: OperatorRole = "SUPPORT"): Promise<OperatorSession> {
const o = await currentOperator();
if (!o) throw new Error("UNAUTHENTICATED");
if (RANK[o.role] < RANK[minRole]) throw new Error("FORBIDDEN");
return o;
}
/* The trail. Every write the console makes goes through here, and so does every reveal and every
* sign-in. Never awaited by callers and never throws — a trail that could fail the action it
* records would be a reason to skip recording it. `facilityId` is a plain string on purpose (see
* the schema): a facility that leaves cannot take the record of what was looked at with it. */
export function logOperatorEvent(e: { operatorId: string; action: string; facilityId?: string; subject?: string; detail?: string; ip?: string }): void {
prisma.operatorEvent.create({
data: {
operatorId: e.operatorId,
action: e.action.slice(0, 60),
facilityId: (e.facilityId || "").slice(0, 40),
subject: (e.subject || "").slice(0, 200),
detail: (e.detail || "").slice(0, 400),
ip: (e.ip || "").slice(0, 60),
},
}).catch(() => { /* the trail must not fail the action */ });
}