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
+57
View File
@@ -0,0 +1,57 @@
import { prisma } from "../db";
import { sendTo } from "../mail";
/* Notices to the owner, by email, over the SMTP the product already has.
*
* Who gets them: OPS_ALERT_TO when set, otherwise every active OWNER operator. What they carry:
* the name of a facility and what happened to it — never a coordinator's name or email, never a
* wearer, never a record. A notice must be safe to sit in a mailbox. Each caller names its own
* condition; nothing here fires on a timer, so there is nothing to dedupe — one event, one mail.
*
* Never throws and never awaited for its own sake: a notice that could fail the action it
* describes would be a reason to skip sending it. */
export async function ownerAddresses(): Promise<string[]> {
const fixed = (process.env.OPS_ALERT_TO || "").split(",").map((s) => s.trim()).filter(Boolean);
if (fixed.length) return fixed;
try {
const owners = await prisma.operator.findMany({ where: { role: "OWNER", inactive: false }, select: { email: true } });
return owners.map((o) => o.email);
} catch {
return [];
}
}
export function notifyOwners(subject: string, text: string): void {
ownerAddresses()
.then((to) => Promise.all(to.map((a) => sendTo(a, subject, text))))
.catch(() => { /* a notice must not fail the action */ });
}
const when = () => new Date().toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
/** A facility signed up. Name only — the person who did it is a contact, and contacts are revealed, not mailed. */
export function alertNewSignup(facility: { id: string; name: string }): void {
notifyOwners(`[ops] New facility — ${facility.name}`, [
`A facility signed up for ThreadCount.`,
"",
`Facility: ${facility.name}`,
`When: ${when()} (Brisbane)`,
`Console: https://ops.threadcount.tech/ops/facilities/${facility.id}`,
"",
"It appears on the console as \"never set up\" until its staff groups are named.",
].join("\n"));
}
/** A facility was deleted from the console. Sent to every owner so a second operator's deletion reaches the first. */
export function alertFacilityDeleted(args: { name: string; by: string; ip: string; counts: string }): void {
notifyOwners(`[ops] Facility deleted — ${args.name}`, [
`${args.by} deleted a facility from the ThreadCount operations console.`,
"",
`Facility: ${args.name}`,
`Had: ${args.counts}`,
`When: ${when()} (Brisbane)`,
`From: ${args.ip || "unknown address"}`,
"",
"Every record, image and account belonging to it is gone. The pre-deploy database dumps on the box are the only copies left.",
].join("\n"));
}
+111
View File
@@ -0,0 +1,111 @@
import crypto from "node:crypto";
/* Cloudflare Access application-JWT verification for the operations console.
*
* ops.threadcount.tech sits behind a Cloudflare Access application whose login methods are the
* ThreadCount brand of Authentik (OIDC) and a one-time-PIN break-glass. Access puts a signed
* assertion on every request it lets through (`Cf-Access-Jwt-Assertion`). Trusting that assertion
* is what makes the console single-sign-on: the app mints an operator session from the verified
* email instead of asking for a second password.
*
* Hand-rolled on node:crypto, the way lib/session.ts and lib/totp.ts are, and RS256-ONLY: the
* only operation ever performed is an RSA verify, so the "alg: none" / HS256-with-a-public-key
* confusion cannot apply. Every check fails CLOSED — a malformed token, wrong issuer or audience,
* expired claim or bad signature returns null and the caller falls back to the password door. It
* never throws. Active only when both variables are set; without them the console simply has no
* SSO, and the fire escape is the whole door. */
const TEAM = process.env.CF_ACCESS_TEAM_DOMAIN; // e.g. example.cloudflareaccess.com
const AUD = process.env.CF_ACCESS_AUD; // the Access application's AUD tag
const ISSUER = TEAM ? `https://${TEAM}` : null;
const CERTS_URL = TEAM ? `https://${TEAM}/cdn-cgi/access/certs` : null;
export function accessSsoConfigured(): boolean {
return Boolean(TEAM && AUD);
}
/** Where to land after single sign-on: only a path under /ops, never the sign-in page (a loop),
* never a scheme or a protocol-relative host (an open redirect). Anything else → /ops. */
export function safeOpsNext(raw: string | null | undefined): string {
if (!raw || (raw !== "/ops" && !raw.startsWith("/ops/")) || raw.startsWith("/ops/login")) return "/ops";
if (/[\\\s]/.test(raw) || raw.includes("://")) return "/ops";
return raw;
}
type Jwk = { kid?: string; kty?: string; n?: string; e?: string };
let jwksCache: { keys: Jwk[]; fetchedAt: number } | null = null;
const JWKS_TTL_MS = 10 * 60 * 1000;
async function getKeys(force = false): Promise<Jwk[]> {
if (!force && jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) return jwksCache.keys;
const r = await fetch(CERTS_URL!, { cache: "no-store" });
if (!r.ok) throw new Error("access certs fetch failed");
const j = (await r.json()) as { keys?: Jwk[] };
const keys = Array.isArray(j.keys) ? j.keys : [];
jwksCache = { keys, fetchedAt: Date.now() };
return keys;
}
function decodeSeg(seg: string): Record<string, unknown> | null {
try {
return JSON.parse(Buffer.from(seg, "base64url").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
/** The verified, lower-cased email, or null on any failure whatsoever. */
export async function verifyAccessJwt(token: string | null | undefined): Promise<string | null> {
if (!token || !TEAM || !AUD || !ISSUER || !CERTS_URL) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [h, p, s] = parts;
const header = decodeSeg(h);
const payload = decodeSeg(p);
if (!header || !payload) return null;
if (header.alg !== "RS256" || typeof header.kid !== "string") return null;
const now = Math.floor(Date.now() / 1000);
const exp = payload.exp;
const iat = payload.iat;
if (typeof exp !== "number" || exp < now - 5) return null; // expired (small skew)
if (typeof iat === "number" && iat > now + 60) return null; // issued in the future
if (payload.iss !== ISSUER) return null;
const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!auds.includes(AUD)) return null;
const rawEmail = payload.email;
const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : null;
if (!email) return null;
let keys: Jwk[];
try {
keys = await getKeys();
} catch {
return null;
}
let jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) {
// Unknown kid — Access rotates keys; refresh once before giving up.
try {
keys = await getKeys(true);
} catch {
return null;
}
jwk = keys.find((k) => k.kid === header.kid);
}
if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e) return null;
let pub: crypto.KeyObject;
try {
pub = crypto.createPublicKey({ key: jwk as crypto.JsonWebKey, format: "jwk" });
} catch {
return null;
}
let ok = false;
try {
ok = crypto.verify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, Buffer.from(s, "base64url"));
} catch {
return null;
}
return ok ? email : null;
}
+144
View File
@@ -0,0 +1,144 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { resetDemo } from "../demo";
import { PLANS, isPlanCode } from "../plan";
import { deletePhotoDir } from "../photostore";
import { SWITCH_ROW } from "../switches";
import { alertFacilityDeleted } from "./alerts";
import { logOperatorEvent, type OperatorSession } from "./session";
import { verifyOperatorCode } from "./totp";
/* The console's writes. Four of them, and every one is a control-plane act on the console's own
* records or on a facility's configuration — never on a facility's content.
*
* switches — sign-ups open/closed, demo in/out of service. One row; the environment overrides.
* demo reset — the product's own resetDemo(), on demand instead of on the twenty-minute clock.
* plan — a label and a note on a facility: the billing scaffold. Display only, elsewhere.
* delete — a whole facility, the way the product's own "delete my account" does it: the
* cascade from Facility, then the images. Owner, typed name, live code, and a
* notice to every owner. The one act here that cannot be undone.
*
* All go through the main client on purpose: ops_ro cannot write, and these are the writes the
* console is for. Everything is written to the trail. */
export class ControlError extends Error {
constructor(message: string, public status = 400) { super(message); }
}
export type SwitchKey = "signupsDisabled" | "demoDisabled" | "plansLive";
export async function setSwitch(op: OperatorSession, key: SwitchKey, value: boolean, ip: string) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can change a platform switch.", 403);
await prisma.platformSwitch.upsert({
where: { id: SWITCH_ROW },
create: { id: SWITCH_ROW, [key]: value },
update: { [key]: value },
});
const detail = key === "plansLive" ? (value ? "live" : "off") : value ? "closed" : "open";
logOperatorEvent({ operatorId: op.id, action: "ops:switch", subject: key, detail, ip });
}
export async function resetDemoNow(op: OperatorSession, ip: string) {
await resetDemo();
logOperatorEvent({ operatorId: op.id, action: "ops:demo.reset", ip });
}
export const PLAN_NOTE_MAX = 400;
/* The billing desk. Five acts, each one a change to the six plan columns lib/plan.ts reads, and
* each one written to the trail with what it did. No money moves here: an invoice is raised in the
* accounting tool and its payment recorded with `paid`. Dates are set relative to today so the
* operator never types one. */
export type PlanAct =
| { act: "set"; plan: string; planNote: string; grandfathered?: boolean }
| { act: "trial"; days: number } // start, or extend by, this many days
| { act: "paid"; months: number } // a payment covering this many months from today or from paidUntil
| { act: "readonly"; on: boolean }
| { act: "free" }; // back to free on the current plan (a pilot ended, a refund)
const DAY = 86_400_000;
async function planFacility(facilityId: string) {
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true, plan: true, planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true } });
if (!f) throw new ControlError("No such facility", 404);
if (f.isDemo) throw new ControlError("The demo facility has no plan.");
return f;
}
export async function planControl(op: OperatorSession, facilityId: string, a: PlanAct, ip: string) {
const f = await planFacility(facilityId);
let data: Prisma.FacilityUpdateInput = {};
let detail = "";
switch (a.act) {
case "set": {
if (a.plan !== "" && !isPlanCode(a.plan)) throw new ControlError("Not a plan.");
const n = a.planNote.trim().slice(0, PLAN_NOTE_MAX);
data = { plan: a.plan, planNote: n };
// The promise is an owner's to give or take; a support operator may change the plan and the note.
if (a.grandfathered !== undefined && a.grandfathered !== f.grandfathered) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can change whether a facility is grandfathered.", 403);
data.grandfathered = a.grandfathered;
}
detail = `${a.plan ? PLANS[a.plan as keyof typeof PLANS].label : "no plan"}${data.grandfathered !== undefined ? (data.grandfathered ? " · grandfathered" : " · grandfathering removed") : ""}${n ? " — " + n : ""}`;
break;
}
case "trial": {
const days = Math.min(365, Math.max(1, Math.floor(a.days || 0)));
// Extends a trial still running; starts one from today otherwise. A facility with no paid
// plan code yet gets Hosted Facility — a trial of Hosted Small would be a trial of free.
const from = f.planStatus === "trial" && f.trialEndsAt && f.trialEndsAt > new Date() ? f.trialEndsAt : new Date();
const ends = new Date(from.getTime() + days * DAY);
data = { planStatus: "trial", trialEndsAt: ends, plan: f.plan && f.plan !== "hosted_small" ? f.plan : "hosted_facility" };
detail = `trial to ${ends.toISOString().slice(0, 10)}`;
break;
}
case "paid": {
const months = Math.min(36, Math.max(1, Math.floor(a.months || 0)));
const from = f.planStatus === "active" && f.paidUntil && f.paidUntil > new Date() ? f.paidUntil : new Date();
const until = new Date(from); until.setUTCMonth(until.getUTCMonth() + months);
data = { planStatus: "active", paidUntil: until, plan: f.plan && f.plan !== "hosted_small" ? f.plan : "hosted_facility" };
detail = `paid to ${until.toISOString().slice(0, 10)}`;
break;
}
case "readonly": {
if (a.on) { data = { planStatus: "read_only" }; detail = "read-only"; }
else {
// Back to whatever the dates say: a paid year still running is active, a trial still
// running is a trial, anything else is free.
const now = new Date();
const status = f.paidUntil && f.paidUntil > now ? "active" : f.trialEndsAt && f.trialEndsAt > now ? "trial" : "free";
data = { planStatus: status }; detail = `writable again · ${status}`;
}
break;
}
case "free":
data = { planStatus: "free", trialEndsAt: null, paidUntil: null };
detail = "set free";
break;
}
await prisma.facility.update({ where: { id: f.id }, data });
logOperatorEvent({ operatorId: op.id, action: "ops:plan", facilityId: f.id, subject: f.name, detail, ip });
}
/** Owner + the facility's exact name + a live code from the operator's own second factor. */
export async function deleteFacility(op: OperatorSession, facilityId: string, confirm: string, code: string, ip: string) {
if (op.role !== "OWNER") throw new ControlError("Only an owner can delete a facility.", 403);
const o = await prisma.operator.findUnique({ where: { id: op.id }, select: { totpSecret: true, totpEnabledAt: true } });
if (!o?.totpEnabledAt) throw new ControlError("Enrol a second factor before deleting anything.", 403);
if (!verifyOperatorCode(o, code.replace(/\s+/g, ""))) throw new ControlError("That code isn't right. Use the current one from your app.", 401);
const f = await prisma.facility.findUnique({
where: { id: facilityId },
select: { id: true, name: true, isDemo: true, _count: { select: { users: true, staff: true, issues: true } } },
});
if (!f) throw new ControlError("No such facility", 404);
if (f.isDemo) throw new ControlError("The demo facility is rebuilt, not deleted — use Reset demo.");
if (confirm.trim() !== f.name) throw new ControlError(`Type the facility name exactly — ${f.name} — to confirm.`);
const counts = `${f._count.users} coordinators, ${f._count.staff} staff, ${f._count.issues} issues`;
// Trail first: if the delete fails half-way the record says it was attempted; if it succeeds the
// record outlives the row, because facilityId here is a plain string and not a relation.
logOperatorEvent({ operatorId: op.id, action: "ops:facility.delete", facilityId: f.id, subject: f.name, detail: counts, ip });
await prisma.facility.delete({ where: { id: f.id } }); // every relation cascades from Facility
await deletePhotoDir(f.id);
alertFacilityDeleted({ name: f.name, by: `${op.name} (${op.email})`, ip, counts });
return { name: f.name };
}
+41
View File
@@ -0,0 +1,41 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
/* The console's database clients. Two, and the difference between them is the whole design.
*
* opsDb() connects as `ops_ro`, a Postgres role granted SELECT on control-plane columns only —
* counts, dates, configuration — and nothing on customer content: no wearer's name, no photo, no
* request text, no coordinator's email. The grants are in the ops_ro_grants migration and are
* proven on production by scripts/ops-ro-probe.cjs. The point is that the guard is the database,
* not code review: there is no row-level security in this product and no linter, so a query
* written against the wrong table in some future screen must fail with a permission error rather
* than return data.
*
* revealDb() connects as `ops_reveal`, which may read exactly four columns of one table: a
* facility's id and its three coordinator contacts. It exists so that revealing a contact is not an
* exception carved into code but a second, narrower door with its own key — widening the reveal's
* SELECT fails at the database too. Only lib/ops/reveal.ts may use it.
*
* Both are read-only by construction. The console's own tables — Operator, OperatorEvent,
* RevealGrant — are written through lib/db.ts, because they are the console's records.
*
* Both are lazy: nothing runs at import time, so a box without the variables still starts the
* product; only the console fails, and it fails closed. Small pools — this is a small box, and
* lib/db.ts already holds the main one. */
const g = globalThis as unknown as { __opsDb?: PrismaClient; __revealDb?: PrismaClient };
function make(envVar: "OPS_DATABASE_URL" | "OPS_REVEAL_DATABASE_URL", max: number): PrismaClient {
const url = process.env[envVar];
if (!url) throw new Error(`${envVar} not set`);
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url, max }), log: ["error"] });
}
export function opsDb(): PrismaClient {
if (!g.__opsDb) g.__opsDb = make("OPS_DATABASE_URL", 2);
return g.__opsDb;
}
export function revealDb(): PrismaClient {
if (!g.__revealDb) g.__revealDb = make("OPS_REVEAL_DATABASE_URL", 1);
return g.__revealDb;
}
+181
View File
@@ -0,0 +1,181 @@
import { readdirSync } from "fs";
import path from "path";
import { opsDb } from "./db";
import { entitlements, type Entitlements, type PlanRow } from "../plan";
/* Everything the operations console knows about the facilities, in one place.
*
* This is the only module in the product that reads across facilities, and it reads through
* opsDb() — the ops_ro role, which the database itself restricts to control-plane columns. So the
* shape of what leaves here is decided twice: by the SELECTs below, and by grants that would
* refuse a wider one. Nothing in a projection is a person: no name, no email, no photo, no note.
* Group lists come out as lengths, the GL account as set-or-not, and contacts do not come out at
* all — a reveal is a separate, narrow, trailed path.
*
* The demo facility is rebuilt every twenty minutes and would otherwise dominate every count, so
* every projection splits it out rather than mixing it in.
*
* scripts/check-ops.sh asserts this file never imports the main client. */
const DAY = 24 * 60 * 60 * 1000;
/** The facility columns the console may read — the same allow-list as the ops_ro grant. */
const FACILITY_COLS = {
id: true, name: true, timezone: true, defaultEntitlement: true, initialSets: true, capSets: true,
defaultReorder: true, exceptionHigh: true, varianceReason: true, glAccount: true, journalDesc: true,
lastBackup: true, barcodeLookup: true, staffGroups: true, nursingGroups: true, kitGroups: true,
orderSeq: true, catalogSeq: true, requestSeq: true, rev: true, barcodeSeq: true, slipOrg: true,
isDemo: true, demoResetAt: true, createdAt: true, plan: true, planNote: true, ssoEnabled: true, ssoRequired: true, ssoStaff: true,
planStatus: true, trialEndsAt: true, paidUntil: true, grandfathered: true,
} as const;
/** The plan as the console shows it — the raw columns for the form, and what they mean today. */
export type PlanView = {
plan: string; planNote: string; planStatus: string; trialEndsAt: Date | null; paidUntil: Date | null; grandfathered: boolean;
label: string; state: Entitlements["state"]; readOnly: boolean; maxStaff: number | null;
endsAt: Date | null; graceEndsAt: Date | null;
};
function planView(f: PlanRow & { plan: string; planNote: string }): PlanView {
const e = entitlements(f);
return {
plan: f.plan, planNote: f.planNote, planStatus: f.planStatus, trialEndsAt: f.trialEndsAt, paidUntil: f.paidUntil, grandfathered: f.grandfathered,
label: e.label, state: e.state, readOnly: e.readOnly, maxStaff: e.maxStaff, endsAt: e.endsAt, graceEndsAt: e.graceEndsAt,
};
}
export type FacilityRow = {
id: string; name: string; createdAt: Date; isDemo: boolean; timezone: string;
rev: number; lastBackup: string; backupAgeDays: number | null;
plan: string; planNote: string;
planView: PlanView;
users: number; staff: number; items: number; issues: number; requests: number;
groups: number; setUp: boolean;
state: "live" | "dormant" | "unconfigured" | "demo";
};
/** "YYYY-MM-DD" in the facility's zone → whole days ago, or null when never. */
function ageDays(lastBackup: string): number | null {
if (!lastBackup) return null;
const t = Date.parse(lastBackup + "T00:00:00Z");
return Number.isFinite(t) ? Math.max(0, Math.floor((Date.now() - t) / DAY)) : null;
}
function stateOf(f: { isDemo: boolean; rev: number; staffGroups: string[] }): FacilityRow["state"] {
if (f.isDemo) return "demo";
if (f.staffGroups.length === 0) return "unconfigured";
return f.rev > 0 ? "live" : "dormant";
}
export async function facilities(): Promise<FacilityRow[]> {
const rows = await opsDb().facility.findMany({
select: { ...FACILITY_COLS, _count: { select: { users: true, staff: true, items: true, issues: true, requests: true } } },
orderBy: { createdAt: "desc" },
});
return rows.map((f) => ({
id: f.id, name: f.name, createdAt: f.createdAt, isDemo: f.isDemo, timezone: f.timezone,
rev: f.rev, lastBackup: f.lastBackup, backupAgeDays: ageDays(f.lastBackup), plan: f.plan, planNote: f.planNote, planView: planView(f),
users: f._count.users, staff: f._count.staff, items: f._count.items, issues: f._count.issues, requests: f._count.requests,
groups: f.staffGroups.length, setUp: f.staffGroups.length > 0,
state: stateOf(f),
}));
}
export type FacilityDetail = FacilityRow & {
config: {
capSets: number; initialSets: number; defaultEntitlement: number; defaultReorder: number;
exceptionHigh: number; varianceReason: number;
fteGroups: number; kitGroups: number; groups: number;
glAccountSet: boolean; journalDesc: string; barcodeLookup: boolean; slipOrgSet: boolean;
sso: "off" | "optional" | "required"; ssoStaff: boolean;
};
counts: {
users: number; admins: number; usersWith2fa: number; inactiveUsers: number;
staff: number; activeStaff: number; staffAccounts: number; accountsSeen7d: number;
items: number; issues: number; orders: number; requests: number; events24h: number;
};
seq: { orders: number; catalogue: number; requests: number; barcodes: number };
};
export async function facility(id: string): Promise<FacilityDetail | null> {
const db = opsDb();
const f = await db.facility.findUnique({ where: { id }, select: FACILITY_COLS });
if (!f) return null;
const since7d = new Date(Date.now() - 7 * DAY);
const since24h = new Date(Date.now() - DAY);
const [users, admins, usersWith2fa, inactiveUsers, staff, activeStaff, staffAccounts, accountsSeen7d, items, issues, orders, requests, events24h] = await Promise.all([
db.user.count({ where: { facilityId: id } }),
db.user.count({ where: { facilityId: id, role: "ADMIN", inactive: false } }),
db.user.count({ where: { facilityId: id, totpEnabledAt: { not: null } } }),
db.user.count({ where: { facilityId: id, inactive: true } }),
db.staff.count({ where: { facilityId: id } }),
db.staff.count({ where: { facilityId: id, inactive: false } }),
db.staffAccount.count({ where: { facilityId: id } }),
db.staffAccount.count({ where: { facilityId: id, lastSeenAt: { gte: since7d } } }),
db.catalogItem.count({ where: { facilityId: id } }),
db.issue.count({ where: { facilityId: id } }),
db.order.count({ where: { facilityId: id } }),
db.request.count({ where: { facilityId: id } }),
db.auditEvent.count({ where: { facilityId: id, at: { gte: since24h } } }),
]);
return {
id: f.id, name: f.name, createdAt: f.createdAt, isDemo: f.isDemo, timezone: f.timezone,
rev: f.rev, lastBackup: f.lastBackup, backupAgeDays: ageDays(f.lastBackup), plan: f.plan, planNote: f.planNote, planView: planView(f),
users, staff, items, issues, requests,
groups: f.staffGroups.length, setUp: f.staffGroups.length > 0, state: stateOf(f),
config: {
capSets: f.capSets, initialSets: f.initialSets, defaultEntitlement: f.defaultEntitlement,
defaultReorder: f.defaultReorder, exceptionHigh: f.exceptionHigh, varianceReason: f.varianceReason,
fteGroups: f.nursingGroups.length, kitGroups: f.kitGroups.length, groups: f.staffGroups.length,
glAccountSet: !!f.glAccount, journalDesc: f.journalDesc, barcodeLookup: f.barcodeLookup, slipOrgSet: !!f.slipOrg,
sso: f.ssoEnabled ? (f.ssoRequired ? "required" : "optional") : "off", ssoStaff: f.ssoStaff,
},
counts: { users, admins, usersWith2fa, inactiveUsers, staff, activeStaff, staffAccounts, accountsSeen7d, items, issues, orders, requests, events24h },
seq: { orders: f.orderSeq, catalogue: f.catalogSeq, requests: f.requestSeq, barcodes: f.barcodeSeq },
};
}
export type Overview = {
facilities: number; live: number; dormant: number; unconfigured: number; demo: number;
signupsThisMonth: number; reachedFirstIssue: number;
overdueBackups: FacilityRow[]; neverIssued: FacilityRow[]; unconfiguredList: FacilityRow[];
enquiriesUnhandled: number;
migrations: { applied: number; inRepo: number };
demoRebuiltAgoMin: number | null;
};
/** How many migration folders the repo ships, read from disk on the box that is running. */
function migrationsInRepo(): number {
try {
return readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true }).filter((d) => d.isDirectory()).length;
} catch { return 0; }
}
export async function overview(): Promise<Overview> {
const db = opsDb();
const all = await facilities();
const real = all.filter((f) => !f.isDemo);
const monthStart = new Date(); monthStart.setUTCDate(1); monthStart.setUTCHours(0, 0, 0, 0);
const [enquiriesUnhandled, appliedRows, demo] = await Promise.all([
db.contactMessage.count({ where: { handled: false } }),
db.$queryRaw<{ n: bigint }[]>`SELECT count(*)::bigint AS n FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL`,
db.facility.findFirst({ where: { isDemo: true }, select: { demoResetAt: true } }),
]);
const signups = real.filter((f) => f.createdAt >= monthStart);
return {
facilities: real.length,
live: real.filter((f) => f.state === "live").length,
dormant: real.filter((f) => f.state === "dormant").length,
unconfigured: real.filter((f) => f.state === "unconfigured").length,
demo: all.length - real.length,
signupsThisMonth: signups.length,
reachedFirstIssue: signups.filter((f) => f.issues > 0).length,
// Overdue: live facilities with no export in seven days, or never — the figure the Terms used to promise a reminder on.
overdueBackups: real.filter((f) => f.state === "live" && (f.backupAgeDays === null || f.backupAgeDays > 7)),
neverIssued: real.filter((f) => f.state === "dormant"),
unconfiguredList: real.filter((f) => f.state === "unconfigured"),
enquiriesUnhandled,
migrations: { applied: Number(appliedRows[0]?.n ?? 0), inRepo: migrationsInRepo() },
demoRebuiltAgoMin: demo?.demoResetAt ? Math.floor((Date.now() - demo.demoResetAt.getTime()) / 60000) : null,
};
}
+103
View File
@@ -0,0 +1,103 @@
import { prisma } from "../db";
import { revealDb } from "./db";
import { logOperatorEvent, type OperatorSession } from "./session";
import { sendTo } from "../mail";
/* Revealing a facility's coordinator contacts.
*
* The console's ordinary role cannot read a coordinator's name, email or phone — the production
* probe proves it. Reading one is therefore not a display option but a separate act with four
* parts, all of them here and nowhere else:
*
* 1. a typed reason, kept in full;
* 2. a RevealGrant row on the server — the window is thirty minutes and it is the row that says
* so, not a claim in a cookie, so it can be shortened or revoked by deleting it;
* 3. an OperatorEvent, in the trail that outlives the facility;
* 4. an email to the owner, every time. A trail nobody reads is a record, not a control.
*
* The read itself goes through revealDb(), the ops_reveal role: four columns of one table. A
* future edit that widens the select fails at the database, exactly as it would in a projection.
*
* Scope is the three contact columns of a Facility. Nothing about a wearer is revealable at any
* level, and nothing in this module knows a wearer's table exists. */
export const REVEAL_MINUTES = 30;
/** One grant covers the three contact columns together; they are one decision and one email. */
export const REVEAL_FIELD = "contacts";
export const REASON_MIN = 8;
export const REASON_MAX = 400;
export type Revealed = {
coordinator: string;
coordinatorEmail: string;
coordinatorPhone: string;
reason: string;
at: Date;
expiresAt: Date;
};
/** The operator's own unexpired grant for this facility, if any. Grants are per operator: one
* person's reason does not open the contacts for another. */
export async function activeReveal(operatorId: string, facilityId: string) {
return prisma.revealGrant.findFirst({
where: { operatorId, facilityId, field: REVEAL_FIELD, expiresAt: { gt: new Date() } },
orderBy: { expiresAt: "desc" },
select: { id: true, reason: true, at: true, expiresAt: true },
});
}
/** The contacts, only while a grant is active. Without one this returns null without touching
* the reveal role at all — the grant check comes first, and the read is conditional on it. */
export async function revealedContacts(operatorId: string, facilityId: string): Promise<Revealed | null> {
const g = await activeReveal(operatorId, facilityId);
if (!g) return null;
const f = await revealDb().facility.findUnique({
where: { id: facilityId },
select: { coordinator: true, coordinatorEmail: true, coordinatorPhone: true },
});
if (!f) return null;
return { ...f, reason: g.reason, at: g.at, expiresAt: g.expiresAt };
}
/** Where the copy of every reveal goes. The owner's address, settable apart from the operator
* who did the revealing so that a second operator's reveals still reach the owner. */
export function alertAddress(fallback: string) {
return (process.env.OPS_ALERT_TO || fallback).trim();
}
export async function grantReveal(args: {
operator: OperatorSession;
facilityId: string;
facilityName: string;
reason: string;
ip: string;
}): Promise<{ expiresAt: Date; mailed: boolean }> {
const { operator, facilityId, facilityName, ip } = args;
const reason = args.reason.trim().slice(0, REASON_MAX);
const now = new Date();
const expiresAt = new Date(now.getTime() + REVEAL_MINUTES * 60 * 1000);
await prisma.revealGrant.create({
data: { operatorId: operator.id, facilityId, field: REVEAL_FIELD, reason, at: now, expiresAt },
});
logOperatorEvent({ operatorId: operator.id, action: "ops:reveal", facilityId, subject: facilityName, detail: reason, ip });
// The email says who, which facility, why and until when — never the contacts themselves. It
// is a notice that a reveal happened, and it must be safe to sit in a mailbox.
const when = now.toLocaleString("en-AU", { timeZone: "Australia/Brisbane", dateStyle: "medium", timeStyle: "short" });
const until = expiresAt.toLocaleTimeString("en-AU", { timeZone: "Australia/Brisbane", hour: "2-digit", minute: "2-digit" });
const text = [
`${operator.name} (${operator.email}) revealed the coordinator contacts of a facility on the ThreadCount operations console.`,
"",
`Facility: ${facilityName}`,
`When: ${when} (Brisbane)`,
`Until: ${until}`,
`From: ${ip || "unknown address"}`,
`Reason: ${reason}`,
"",
"This is written to the operator trail. If it wasn't you, sign the operator out and change the password.",
].join("\n");
const mailed = await sendTo(alertAddress(operator.email), `[ops] Contacts revealed — ${facilityName}`, text);
if (!mailed) logOperatorEvent({ operatorId: operator.id, action: "ops:reveal.unmailed", facilityId, subject: facilityName, ip });
return { expiresAt, mailed };
}
+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 */ });
}
+44
View File
@@ -0,0 +1,44 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { totpVerify } from "../totp";
/* An operator's second factor, stored under the console's own key.
*
* lib/totp.ts encrypts a coordinator's TOTP secret with a key derived from SESSION_SECRET, and
* that is the right blast radius there: rotating the product's secret invalidates customer
* sessions and customer second factors together. It is the wrong blast radius here. An
* operator's secret is kept under a key derived from OPS_SESSION_SECRET, so rotating either
* secret touches only its own side. The cipher, the format and the verification arithmetic are
* the product's own — nothing new is invented, only the key underneath it. */
function key(): Buffer {
const s = process.env.OPS_SESSION_SECRET;
if (!s) throw new Error("OPS_SESSION_SECRET is required to store an operator's TOTP secret");
return createHash("sha256").update(`ops-totp:${s}`).digest();
}
export function encryptOpsSecret(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${enc.toString("base64url")}`;
}
export function decryptOpsSecret(stored: string): string | null {
try {
const [v, iv, tag, enc] = stored.split(".");
if (v !== "v1") return null;
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([d.update(Buffer.from(enc, "base64url")), d.final()]).toString("utf8");
} catch {
return null;
}
}
/** True only when the operator has a second factor enrolled AND the code matches it. */
export function verifyOperatorCode(op: { totpSecret: string; totpEnabledAt: Date | null }, code: string): boolean {
if (!op.totpEnabledAt) return false;
const secret = decryptOpsSecret(op.totpSecret);
if (!secret) return false;
return totpVerify(secret, String(code || "").replace(/\s+/g, ""));
}
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync } from "fs";
import path from "path";
/* What is deployed, and what is running. Two different questions.
*
* `.release.json` is written by scripts/deploy.sh just before the build: the sha it pulled, the
* sha it replaced, and when. `NEXT_PUBLIC_RELEASE` is compiled into the bundle by that same build.
* Normally the two agree. When they don't — the file names a newer sha than the running bundle —
* a deploy pulled and built but the process was never restarted, which is the failure the deploy
* script's own health check exists to catch and the console should show anyway. */
export type Version = {
deployed: { sha: string; from: string; at: Date } | null;
running: string | null;
node: string;
uptimeMin: number;
};
export function version(): Version {
let deployed: Version["deployed"] = null;
try {
const j = JSON.parse(readFileSync(path.join(process.cwd(), ".release.json"), "utf8")) as { sha?: string; from?: string; at?: string };
if (j.sha && j.at) deployed = { sha: String(j.sha), from: String(j.from || ""), at: new Date(j.at) };
} catch { /* no file: a box deployed before the script wrote one, or local dev */ }
return {
deployed,
running: process.env.NEXT_PUBLIC_RELEASE || null,
node: process.version,
uptimeMin: Math.floor(process.uptime() / 60),
};
}