ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 */ });
|
||||
}
|
||||
@@ -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, ""));
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user