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:45:19 +10:00
commit 1bc2de655a
505 changed files with 56223 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server";
const COOKIE_NAME = "tc_session";
function base64urlToBytes(b64url: string): Uint8Array {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(b64url.length / 4) * 4, "=");
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
async function verify(raw: string | undefined, secret: string): Promise<boolean> {
try { return await verifyInner(raw, secret); } catch { return false; }
}
async function verifyInner(raw: string | undefined, secret: string): Promise<boolean> {
if (!raw) return false;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return false;
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
const ok = await crypto.subtle.verify("HMAC", key, base64urlToBytes(sig).buffer as ArrayBuffer, new TextEncoder().encode(payload));
if (!ok) return false;
try {
const data = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)));
return !!data.uid && data.exp > Date.now();
} catch {
return false;
}
}
const OPS_COOKIE_NAME = "tc_ops";
/* The operator token, verified the way the coordinator one is above, with two differences that
* are the whole point: the key is derived from OPS_SESSION_SECRET through the domain string
* lib/ops/session.ts uses, and the claim it requires is `oid`, not `uid`. A coordinator or staff
* cookie presented here fails the signature; and if it somehow didn't, it has no `oid`. */
async function verifyOps(raw: string | undefined, secret: string): Promise<boolean> {
try {
if (!raw) return false;
const [payload, sig] = raw.split(".");
if (!payload || !sig) return false;
const keyBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode("threadcount:ops:v1:" + secret));
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
const ok = await crypto.subtle.verify("HMAC", key, base64urlToBytes(sig).buffer as ArrayBuffer, new TextEncoder().encode(payload));
if (!ok) return false;
const data = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload)));
return !!data.oid && data.exp > Date.now();
} catch {
return false;
}
}
/* The operations console's hostname.
*
* Compared as an exact, lower-cased, port-stripped string. Never `includes`, and never Next's
* own `has: [{ type: "host" }]` matcher: that reads `req.headers.host` and FAILS OPEN when the
* header is absent, and it compiles its value as an unescaped regular expression, so
* "ops.threadcount.tech" would also match "opsXthreadcountYtech". A security control that
* silently does nothing when a reverse proxy omits a directive is the failure shape this
* codebase has spent effort eliminating elsewhere. */
const OPS_HOST = "ops.threadcount.tech";
export async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
/* Which site is being asked for.
*
* nginx passes the real Host through (`proxy_set_header Host $host` in both vhosts), and Next
* backfills x-forwarded-host FROM host and never the reverse — so preferring x-forwarded-host
* with host as the fallback is right. An unrecognised host is treated as the product, which is
* the safe default: the console is opt-in by exact match, and /ops is refused unless it hits. */
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "").split(":")[0].toLowerCase();
// The console exists only where its own secret does. A Community instance that happens to be
// reached as ops.threadcount.tech (it cannot be, but the check is cheap) is the product.
const isOps = host === OPS_HOST && !!process.env.OPS_SESSION_SECRET;
/* The ops hostname is reachable and fronted by Cloudflare Access, and for now it serves
* nothing at all. The console is built behind that door in later phases.
*
* Two paths make this more than tidiness. /.well-known/assetlinks.json is served
* unconditionally, and on this hostname it would declare both Android apps authorised for
* `get_login_creds` — so a tapped ops link could open inside the staff app and password
* managers would offer ops credentials there. And /api/auth/demo mints a coordinator session
* and redirects to /app, which would hand anyone reaching this host a working product.
*
* The fence runs the other way too: /ops must not exist on the product hostname. */
if (isOps) {
if (pathname === "/") { const u = req.nextUrl.clone(); u.pathname = "/ops"; return NextResponse.redirect(u); }
// Public on this host: the sign-in page and the routes that create or end a session. They
// carry their own throttles and origin checks.
if (pathname === "/ops/login" || pathname.startsWith("/api/ops/auth/")) return NextResponse.next();
const opsPath = pathname === "/ops" || pathname.startsWith("/ops/") || pathname.startsWith("/api/ops/");
if (!opsPath) return new NextResponse(null, { status: 404 });
// Console paths need an operator session. Verified at the edge with the ops key — its own
// secret, its own domain string, its own claim name — and re-read from the database by the
// console layout, so a deactivated operator or a changed password still ends it there.
const osecret = process.env.OPS_SESSION_SECRET;
const ok = osecret ? await verifyOps(req.cookies.get(OPS_COOKIE_NAME)?.value, osecret) : false;
if (ok) return NextResponse.next();
if (pathname.startsWith("/api")) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const url = req.nextUrl.clone();
url.pathname = "/ops/login"; url.search = "";
const res = NextResponse.redirect(url);
if (req.cookies.get(OPS_COOKIE_NAME)) res.cookies.set(OPS_COOKIE_NAME, "", { maxAge: 0, path: "/" });
return res;
}
if (pathname === "/ops" || pathname.startsWith("/ops/") || pathname.startsWith("/api/ops/")) return new NextResponse(null, { status: 404 });
// /api/contact is the public marketing form and /api/subscribe the newsletter sign-up. Both have
// their own same-origin gate, Turnstile check and rate limits, and by definition the people using
// them don't have an account yet.
// Everything under /api/staff belongs to the staff app, which has its own session in its own
// cookie — so a coordinator session can't be the precondition for reaching it. Each of these
// routes carries its own authorisation: activate/login/logout are how a wearer gets a session at
// all, /decide is authorised by a signed single-use token from a manager's email, and everything
// else calls currentStaff() first. scripts/e2e-staffapp.sh walks the routes and asserts each one
// refuses an anonymous caller, so this is a tested invariant rather than a convention to
// remember when adding the next one.
const publicStaff = pathname.startsWith("/api/staff/");
// /api/health is watched by an uptime monitor that has no account and never will. It returns a
// status code and nothing else — no facility, no schema, no error text.
// /api/rev answers "has anything changed here" for a coordinator OR a wearer, so it cannot sit
// behind the coordinator-only gate — it checks both session kinds itself and 401s on neither.
const publicApi = pathname.startsWith("/api/auth") || pathname === "/api/contact" || pathname === "/api/subscribe" || pathname === "/api/health" || pathname === "/api/rev" || publicStaff;
// /api/2fa manages your own account and checks the session itself; it is not public.
// Sign in and create account sit under /m but are how someone gets a session in the first place.
const publicM = pathname === "/m/login" || pathname === "/m/signup";
const needsAuth = pathname.startsWith("/app") || (pathname.startsWith("/m") && !publicM) || pathname.startsWith("/print") || (pathname.startsWith("/api") && !publicApi);
if (!needsAuth) return NextResponse.next();
const secret = process.env.SESSION_SECRET;
const ok = secret ? await verify(req.cookies.get(COOKIE_NAME)?.value, secret) : false;
if (ok) return NextResponse.next();
if (pathname.startsWith("/api")) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const url = req.nextUrl.clone();
if (pathname.startsWith("/m")) {
url.pathname = "/m/login";
url.searchParams.set("next", pathname);
} else {
url.pathname = "/auth";
url.searchParams.set("next", pathname);
}
const res = NextResponse.redirect(url);
if (req.cookies.get(COOKIE_NAME)) res.cookies.set(COOKIE_NAME, "", { maxAge: 0, path: "/" }); // drop a stale/invalid cookie
return res;
}
export const config = {
/* ⛔ NEVER widen this to a catch-all.
*
* The handler tests `pathname.startsWith("/m")`, and "/my" starts with "/m". The staff app is
* safe today only because no pattern here matches /my — add one and every wearer, every ward
* manager and the staff Android app are redirected to /m/login, the COORDINATOR sign-in, with
* no session they could ever obtain. scripts/e2e-ops.sh asserts /my/signin still answers.
*
* The last five entries exist so `proxy()` runs at all on the ops hostname. Marketing routes
* are deliberately not listed: they are public content, they sit entirely behind Cloudflare
* Access on that host, and enumerating twenty of them would rot. What is listed is what would
* be harmful there — the root, every API route, and .well-known. */
matcher: [
"/app/:path*", "/m", "/m/:path*", "/print/:path*", "/api/:path*",
"/", "/ops", "/ops/:path*", "/robots.txt", "/sitemap.xml", "/.well-known/:path*",
],
};