0910bc32c1
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).
129 lines
5.2 KiB
TypeScript
129 lines
5.2 KiB
TypeScript
import { cookies } from "next/headers";
|
|
import { createHash, createHmac, randomInt, timingSafeEqual } from "crypto";
|
|
import { prisma } from "./db";
|
|
|
|
/* Sessions for staff members looking at their own record.
|
|
*
|
|
* A wearer is not a coordinator, and this file is where that stops being a matter of remembering to
|
|
* check. Their session lives in its own cookie, signed with a key derived separately from the
|
|
* coordinator one, and its payload carries `sid` where a coordinator token carries `uid`. So a
|
|
* staff cookie pasted into `tc_session` fails signature verification; and even if it somehow
|
|
* didn't, readSessionToken() rejects a payload with no `uid`. There is no arrangement of a staff
|
|
* token that produces a coordinator session — not because a condition says no, but because the two
|
|
* are not the same shape.
|
|
*
|
|
* The reverse holds too: a coordinator's cookie is not a staff session, so /my shows a coordinator
|
|
* nothing until they activate their own staff record like anyone else.
|
|
*/
|
|
|
|
export const STAFF_COOKIE = "tc_staff";
|
|
const MAX_AGE = 60 * 60 * 24 * 30; // 30 days: read-only, and re-typing a password on a ward is a chore
|
|
|
|
function secret() {
|
|
const s = process.env.SESSION_SECRET;
|
|
if (!s) throw new Error("SESSION_SECRET not set");
|
|
// Domain separation. The same master secret signs both kinds of token, and without this a
|
|
// signature valid for one would be valid for the other.
|
|
return createHash("sha256").update("threadcount:staff:v1:" + s).digest();
|
|
}
|
|
|
|
function b64url(buf: Buffer) {
|
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
}
|
|
|
|
export function staffPwVersion(passwordHash: string) {
|
|
return createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12);
|
|
}
|
|
|
|
/** `sso` marks a session the facility's identity provider signed in — see lib/session.ts. */
|
|
export function signStaffSession(sid: string, passwordHash: string, maxAge = MAX_AGE, sso = false) {
|
|
const payload = b64url(Buffer.from(JSON.stringify({ sid, pv: staffPwVersion(passwordHash), exp: Date.now() + maxAge * 1000, ...(sso ? { sso: true } : {}) })));
|
|
const sig = b64url(createHmac("sha256", secret()).update(payload).digest());
|
|
return `${payload}.${sig}`;
|
|
}
|
|
|
|
export function readStaffToken(raw: string | undefined): { sid: string; pv: string } | 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.sid || !data.exp || data.exp < Date.now()) return null;
|
|
return { sid: data.sid, pv: String(data.pv || "") };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function setStaffCookie(sid: string, passwordHash: string, sso = false) {
|
|
const jar = await cookies();
|
|
jar.set(STAFF_COOKIE, signStaffSession(sid, passwordHash, MAX_AGE, sso), {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
maxAge: MAX_AGE,
|
|
});
|
|
}
|
|
|
|
export async function clearStaffCookie() {
|
|
const jar = await cookies();
|
|
jar.set(STAFF_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0 });
|
|
}
|
|
|
|
export type StaffSession = {
|
|
accountId: string;
|
|
staffId: string;
|
|
facilityId: string;
|
|
email: string;
|
|
first: string;
|
|
last: string;
|
|
num: string;
|
|
};
|
|
|
|
export async function currentStaff(): Promise<StaffSession | null> {
|
|
const jar = await cookies();
|
|
const tok = readStaffToken(jar.get(STAFF_COOKIE)?.value);
|
|
if (!tok) return null;
|
|
const acc = await prisma.staffAccount.findUnique({
|
|
where: { id: tok.sid },
|
|
select: {
|
|
id: true, facilityId: true, email: true, passwordHash: true,
|
|
staff: { select: { id: true, first: true, last: true, num: true, inactive: true } },
|
|
},
|
|
});
|
|
if (!acc || acc.staff.inactive) return null; // a person taken off the register loses the view with it
|
|
if (tok.pv !== staffPwVersion(acc.passwordHash)) return null;
|
|
return {
|
|
accountId: acc.id,
|
|
staffId: acc.staff.id,
|
|
facilityId: acc.facilityId,
|
|
email: acc.email,
|
|
first: acc.staff.first,
|
|
last: acc.staff.last,
|
|
num: acc.staff.num,
|
|
};
|
|
}
|
|
|
|
/* ---------- activation codes ---------- */
|
|
|
|
// No I, L, O, U, 0 or 1: these get printed on a slip, read off it and typed by someone who is not
|
|
// looking closely, and every removed character is one fewer support call.
|
|
const ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
|
|
/** Twelve characters in three groups — about 58 bits, and the throttle on /api/staff/activate does
|
|
* the rest. Grouped because people type grouped codes more accurately than a run of twelve. */
|
|
export function newActivateCode(): string {
|
|
const g = () => Array.from({ length: 4 }, () => ALPHABET[randomInt(ALPHABET.length)]).join("");
|
|
return `${g()}-${g()}-${g()}`;
|
|
}
|
|
|
|
/** Accept it however it was typed: lower case, spaces, missing or extra dashes. */
|
|
export function normaliseCode(raw: string): string {
|
|
const clean = String(raw || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 12);
|
|
return clean.length === 12 ? `${clean.slice(0, 4)}-${clean.slice(4, 8)}-${clean.slice(8, 12)}` : "";
|
|
}
|