ThreadCount Community edition

Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 22:54:09 +10:00
commit a6f1059ddf
424 changed files with 53535 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import type { Instrumentation } from "next";
import { report } from "@/lib/glitchtip";
import { turnstileConfig, turnstileRequired } from "@/lib/turnstile";
/* Startup checks.
*
* `register` runs once, before the server takes its first request, which makes it the only place a
* misconfiguration can be caught before it becomes someone else's problem. Everything checked here
* is silent at runtime if it is wrong: a missing Turnstile secret used to switch bot protection off
* across sign-in, sign-up, reset, contact and subscribe with nothing in the logs; a missing site key
* takes every one of those forms down with a message about a security check the person was never
* shown; and a session secret left at the published placeholder means anyone can forge a cookie.
*
* So production refuses to start. A deploy that stops with a named variable in the journal is a
* five-minute fix; the alternatives are an unprotected front door, an authentication outage
* discovered by whoever tried to sign in, or a facility whose sessions can be minted by a stranger.
* A local production-mode smoke test says TURNSTILE_OPTIONAL=1 for the Turnstile half and is left
* alone — see lib/turnstile.ts.
*/
export function register() {
// Only the Node runtime: `register` is called in the edge runtime too, and one refusal is enough.
if (process.env.NEXT_RUNTIME && process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.NODE_ENV !== "production") return;
const missing: string[] = [];
/* The two the whole product stands on.
*
* the hosted deploy checks exactly these before it restarts anything, so the deploy path is
* already covered — but a plain `systemctl restart threadcount` after a hand-edited
* the secrets file is not, and that is the shape the failure actually takes. Without
* a database URL every page 500s; with the .env.example placeholder for a session secret, every
* cookie in the facility is signed with a key that is published in the repository.
*/
if (!process.env.DATABASE_URL) missing.push("DATABASE_URL");
if (!process.env.SESSION_SECRET) missing.push("SESSION_SECRET");
else if (process.env.SESSION_SECRET === "change-me") {
missing.push("SESSION_SECRET (still the .env.example placeholder — anyone can forge a session)");
}
if (turnstileRequired()) {
const cf = turnstileConfig();
if (!cf.secret) missing.push("TURNSTILE_SECRET");
if (!cf.sitekey) missing.push("NEXT_PUBLIC_TURNSTILE_SITEKEY (set it before building — it is compiled in)");
}
if (missing.length) {
throw new Error(`Refusing to start: ${missing.join(", ")} missing or unsafe.`);
}
}
/* Server-side error reporting.
*
* `onRequestError` is where this Next version surfaces errors the server caught — a throw in a
* Server Component, a route handler, or a server action. Before this existed, a 500 in production
* left nothing behind but a line in journald that nobody was watching.
*
* It does not see an exception a route handler catches itself, so the two mutate endpoints report
* their own — see the catch in app/api/mutate/route.ts.
*
* The error instance may have been reprocessed by React rather than being the original throw, so
* the digest is carried through: it's the string the branded error page shows the person, which is
* what makes a support message ("reference 3f2a…") joinable to the event here. */
export const onRequestError: Instrumentation.onRequestError = (err, request, context) => {
report({
error: err,
where: "server",
url: request?.path,
tags: {
router: context?.routerKind || "unknown",
route_type: context?.routeType || "unknown",
// Never the request headers: they carry the session cookie.
},
});
};