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 { try { return await verifyInner(raw, secret); } catch { return false; } } async function verifyInner(raw: string | undefined, secret: string): Promise { 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 { 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*", ], };