import { NextResponse, type NextRequest } from "next/server"; import { shouldRunWeb } from "@/lib/process-type"; import { findRetiredRoute, retiredRouteEnvelope, RETIRED_ROUTE_STATUS, } from "@/lib/http/retired-routes"; import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes"; import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal"; import { resolveRequestId } from "@/lib/http/request-id"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). * * `/api/version` is intentionally public — both the in-app About page and the * compose healthcheck rely on it. `/api/health` stays public for the same * reason. */ export const PUBLIC_PATHS = [ "/auth/", "/api/auth/login", "/api/auth/register", "/api/auth/registration-status", "/api/auth/passkey/login-options", "/api/auth/passkey/login-verify", // OIDC SSO login. `login` starts the redirect to the IdP, `callback` // completes it and mints the session — both must be reachable with no // existing session, since they authenticate the user in the first // place. `status` is the public flag the login page reads to decide // whether to render the SSO button. "/api/auth/oidc/login", "/api/auth/oidc/callback", "/api/auth/oidc/status", // v1.32.11 — first-party web-handoff login (iOS #65). `login` sets the state // cookie and redirects to the web login page, `complete` mints the handoff // code after an interactive login, `token` exchanges it. All three // authenticate the flow themselves (state cookie / session / handoff code), // never by a pre-existing session, so they bypass the page auth gate. The // TRAILING SLASH is load-bearing: `isPublicPath` is a bare `startsWith`, so // `/api/auth/native/` admits exactly this family and never `/api/auth/native-…`. "/api/auth/native/", "/api/health", "/api/version", "/api/notifications/vapid", // v1.42.2 — notification quick answers. A notification app (ntfy's `http` // action) calls this with no session; the signed, single-use, short-lived // token in the path is the whole credential, verified by the route after // its rate limit. Trailing slash: admits only `/action/`. "/api/notifications/action/", "/api/monitoring/", "/api/withings/webhook", // v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` / // `workout.updated`, + `*.deleted`). Authenticated by the path-segment // secret + the HMAC body signature, never by a session cookie, so it // must bypass the auth gate (mirrors the Withings webhook entry). "/api/whoop/webhook", "/api/telegram/webhook", "/api/ingest/", // v1.4.26 — `/privacy` is a public legal page. iOS App Store Connect // requires a publicly reachable Privacy-Policy URL during submission; // GDPR Art. 13 expects the same for any visitor before they sign up. "/privacy", // v1.4.27 — `/about` carries the GeoLite2 CC BY-SA 4.0 attribution // alongside the project credits. The CC licence requires the // attribution to be reachable without a sign-in. "/about", // v1.11.0 — `/c/` is the public clinician view (Epic C). It is // authenticated solely by the unguessable `hls_` token in the path, NOT // by a session cookie, so it must reach the page without an auth gate. // The page renders a flat 404 for any unknown / revoked / expired token. "/c/", // v1.18.7 — `/api/c//unlock` is the public passphrase-gate verifier // for a protected share link. Like the view it carries no session — the raw // path token plus the submitted passphrase are the only credentials — so it // must reach the route handler without an auth gate. It is rate-limited and // answers one blunt error for every failure class. "/api/c/", // v1.17.0 — `/invite/` is the invite universal-link landing // (iOS #16). It is a thin shape-validated redirect onto // `/auth/register?invite=…`, carries no session, touches no database, // and is not an enumeration oracle — so it must reach the page without // an auth gate, like the `/auth/` register surface it forwards to. "/invite/", // v1.42 (#959) — `/claim/` is the managed-profile handover link. // Like `/invite/` it is a shape-validated edge redirect onto // `/auth/claim?token=…` (handled below), carries no session and touches no // database. `/api/auth/claim` (preview + claim) authenticates by the // one-time token in the request body, never by a session: the person // claiming the profile has no credentials yet. "/claim/", "/api/auth/claim", // Locale-catalog boot script (`/i18n/?v=…`). The login page loads // it pre-auth, and the body is the same public catalog JSON that ships in // the repository — no tenant data, no secrets. Immutable-cacheable. "/i18n/", // `/onboarding` itself + its subroutes are matched exactly via // `isPublicPath()` so we don't admit `/onboarding-export` etc. "/robots.txt", ]; /** * Exact-match allowlist for IETF-registered discovery endpoints * (RFC 8615). Apple reads `/.well-known/apple-app-site-association` * without credentials to wire passkey sharing and Universal Links; * the bare JSON body has to answer 200 before any auth gate runs. * * Exact match (not prefix) — a future `/.well-known/openid-configuration` * or `/.well-known/security.txt` must be added here explicitly so a new * sub-path doesn't auto-inherit "no auth" status. */ const WELL_KNOWN_PUBLIC_PATHS = new Set([ "/.well-known/apple-app-site-association", // v1.22.0 — OAuth discovery for the remote MCP connector. A remote client // (Claude.ai / ChatGPT) reads these unauthenticated to bootstrap the OAuth // 2.1 + PKCE flow from the pasted `/mcp` URL alone: RFC 9728 Protected // Resource Metadata + RFC 8414 Authorization Server Metadata. Both are // public, deterministic, secret-free discovery documents. "/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server", ]); /** * v1.4.22 — `/onboarding` matches exact * page + subroutes (`/onboarding`, `/onboarding/*`) only, NOT the * loose `pathname.startsWith("/onboarding")` family that would admit * `/onboarding-export`, `/onboarding.json`, etc. Every other entry * is either a literal terminal path (`/api/version`, `/robots.txt`) * or already trailing-slashed (`/auth/`, `/api/auth/`); the lone * exception was the unsegmented `/onboarding` literal. */ function isPublicPath(pathname: string): boolean { if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { return true; } // v1.22.0 — the remote MCP endpoint authenticates by `Authorization: // Bearer hlk_…`, never by a session cookie, so it must skip the page // cookie-redirect (mirroring the webhook / `/api/c/` header-authed // surfaces). The route handler resolves the Bearer token itself, gates on // the off-by-default `mcp` module, and rate-limits per credential. Exact // `/mcp` plus `/mcp/` only — a `/mcp-something` page can never inherit // the bypass. if (pathname === "/mcp" || pathname.startsWith("/mcp/")) { return true; } if (WELL_KNOWN_PUBLIC_PATHS.has(pathname)) { return true; } return PUBLIC_PATHS.some((p) => pathname.startsWith(p)); } /** * API paths that are allowed to accept mutations (POST/PUT/PATCH/DELETE) * even when DEMO_MODE is enabled. Everything else is read-only. * * Each entry pins the exact path AND the exact method, so admitting one * verb on a route never opens its siblings (e.g. the dashboard-widgets * PUT must not drag in the layout-reset DELETE on the same path). * * The login family is the historical baseline — a demo visitor still has * to authenticate. The rest are idempotent, Zod-validated writes that carry * no health data and no free text, and that land only on the acting record: * the two dashboard display-preference blobs (chart-overlay toggles + * comparison-baseline selector), the setup flow's closed-enum answers, its * step ledger and disclaimer/tour checkpoints, and the baseline profile * fields the confirm screen submits. * * The demo is ONE published account that every visitor signs into, so * "user-scoped" is not privacy here — whatever one visitor writes is what * the next one finds until the instance is reseeded. That is tolerable for * a unit toggle and a date of birth, and it is why free text, anything that * reaches an AI prompt, and anything creating a second record are refused. * * This whole block only runs under `DEMO_MODE=true`, so production (apps01) * is unaffected by construction. */ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = [ { path: "/api/auth/login", method: "POST" }, { path: "/api/auth/passkey/login-options", method: "POST" }, { path: "/api/auth/passkey/login-verify", method: "POST" }, // Chart display-pref toggles above the charts (dashboard + insights): { path: "/api/dashboard/chart-overlay-prefs", method: "PUT" }, { path: "/api/dashboard/widgets", method: "PUT" }, // v1.39 — the needs-based setup flow, which the demo has to be able // to complete with preset answers (design spec §Principles 5). All three // are user-scoped preference writes: closed enum answers, the step ledger, // and the module map derived from them. No health data is created by any // of them, and the demo account's own record is the only thing they touch. { path: "/api/onboarding/answers", method: "PATCH" }, { path: "/api/onboarding/complete", method: "POST" }, { path: "/api/onboarding/restart", method: "POST" }, // v1.39 — the two writes the flow's welcome and exit make: the // one-time disclaimer acknowledgment gates both welcome buttons, and the // tour's progress checkpoint is what the done screen's offer leads to. // Without the first, a demo account on a fresh seed (or after a version // bump of the disclaimer) got a 403 from "Set up" and "Skip for now" both. // Neither creates health data. { path: "/api/onboarding/disclaimer", method: "POST" }, { path: "/api/onboarding/tour", method: "POST" }, // v1.39 — the baseline write the confirm screen makes. Without it a demo // visitor who typed a height got a 403 and a generic toast, so only the // empty-form/Skip path completed and the demo did not in fact walk the // same flow (design spec §Principles 5). // // Read the HANDLER's field list, not the caller's. This route writes // `email`, `displayName`, `fullName`, `insurerName`, `insurerIkNumber`, // `insuranceNumber`, `locale`, `timezone`, `timeFormat`, `dateFormat` and // `moodReminderEnabled` as well as the three the baseline step collects — // admitting the path admits all of them, and on one shared published // account each is the next visitor's starting state. So the write is // narrowed on the SERVER: `applyProfileUpdate` drops everything outside // `DEMO_WRITABLE_PROFILE_FIELDS` (height, date of birth, sex) while // `DEMO_MODE` is set. What this entry admits is those three fields. // // Two neighbours of it are deliberately NOT here. The demo is one shared, // published account, so "the caller's own" is every visitor at once: // `PUT /api/coach/about-me` is free text that persists for the next // visitor AND is fed into the Coach prompt — an anonymous prompt- // injection surface on a public instance. The flow's anamnesis card // renders read-only under `DEMO_MODE` instead, so no visitor meets it // as a failed save. // `POST /api/managed-profiles` opens with `requireFreshMfa`, and the // demo account has no second factor, so admitting it here would buy // the guardian arm nothing — the call is refused a layer later either // way, and the confirm screen already says so. // The per-profile routes (`/api/managed-profiles/{id}`, its guardians) // are different paths and stay closed too. { path: "/api/auth/profile", method: "PUT" }, // v1.42.2 — the record search is a read that travels as a POST so the // query stays out of URLs and access logs. It writes nothing. { path: "/api/search", method: "POST" }, ]; // Legacy route redirects (German → English) const LEGACY_REDIRECTS: Record = { "/stimmung": "/mood", }; /** * v1.5 admin refactor — sections moved from `/admin#section-` anchors * to per-section dynamic routes under `/admin/`. Pure URL fragments * (`#foo`) are never sent to the server, so we cannot redirect those * here; the in-app callsites (status-card-grid, anything else linking * into admin) have all been rewritten to use the new routes directly. * * What we *can* redirect server-side is the path-style form, in case * anyone bookmarked `/admin/section-XYZ` (which never existed but is a * plausible mis-typing) or is following an old URL where the fragment * was somehow promoted to a path segment by a referrer rewriter. */ const LEGACY_ADMIN_ANCHORS: Record = { "/admin/section-system-status": "/admin/system-status", "/admin/section-admin-general": "/admin/general", "/admin/section-admin-services": "/admin/services", "/admin/section-admin-umami": "/admin/integrations", "/admin/section-admin-glitchtip": "/admin/integrations", "/admin/section-admin-webpush": "/admin/integrations", "/admin/section-admin-reminders": "/admin/reminders", "/admin/section-user-management": "/admin/users", "/admin/section-api-tokens": "/admin/api-tokens", "/admin/section-login-overview": "/admin/login-overview", "/admin/section-danger-zone": "/admin/danger-zone", }; /** * Presence marker on every HealthLog page response, read by `public/sw.js`. * * The service worker is registered with scope `/` and serves `/_next/static/*` * cache-first. When HealthLog is stopped and a different app starts on the * same origin (`localhost:3000` on a developer machine is the usual case), the * worker went on serving HealthLog's cached chunks to that app (#847). It * needs a way to tell a HealthLog page from someone else's, and nothing * HealthLog already sends is specific enough: `x-request-id`, the nonce CSP * and the COOP/COEP set are headers any Next.js app may emit. So the proxy * adds one of its own. * * Presence only, value `1`: no version and no build id, because a version on * every page is a free fingerprint for anyone scanning for an outdated * instance and the worker does not need it. Pages only, not `/api/*`: the * worker checks navigations and nothing else. Keep the name in lockstep with * `HEALTHLOG_PAGE_MARKER_HEADER` in `public/sw.js`. */ export const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page"; function generateNonce(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary); } /** * Transport-level security headers every response carries, including the early * exits (worker-only 503, legacy 301s, demo-mode 403, and the login / * onboarding / MFA redirects) that return before the full header block below. * * Those exits used to answer with no HSTS, no framing refusal and no nosniff at * all. The gap was one hop — a redirect has no rendered body and the follow-up * navigation re-enters this function and does get the full set — but "the next * request will be protected" is not a reason for this one not to be, and HSTS * in particular is worth the most on the first hop over a hostile network. * * Deliberately the transport subset, not the whole block: the per-route CSP * below is nonce-bound and carries carve-outs for the document-serve and share * routes that only make sense for a response with a body to protect. */ function applyBaselineSecurityHeaders(response: NextResponse): NextResponse { setBaselineSecurityHeaders(response.headers); return response; } /** * The notification quick-answer route (`/api/notifications/action/`). * Its caller is a notification app on a phone, not a browser, so when a tap * does nothing the operator's only evidence is the server log. Every refusal * this proxy makes on that path therefore writes one line with a reason, * because the route's own wide event never runs for them. The token is never * written: the path is logged as the prefix plus a placeholder. */ const NOTIFICATION_ACTION_PREFIX = "/api/notifications/action/"; type NotificationActionRefusal = "method_not_allowed" | "worker_only" | "demo_mode"; function logNotificationActionRefusal( request: NextRequest, reason: NotificationActionRefusal, status: number, ): void { if (!request.nextUrl.pathname.startsWith(NOTIFICATION_ACTION_PREFIX)) return; console.warn( JSON.stringify({ timestamp: new Date().toISOString(), level: "warn", kind: "http", message: reason === "method_not_allowed" ? "Notification answer refused: the route takes POST only. A redirect in front of the app (http to https, or a sign-in gateway) turns the POST into a GET." : "Notification answer refused before the route ran.", action: { name: "notification.action.refused" }, meta: { reason }, http: { method: request.method, path: `${NOTIFICATION_ACTION_PREFIX}[REDACTED]`, status, user_agent: request.headers.get("user-agent") ?? undefined, }, }), ); } export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; // Worker-only container: refuse HTTP traffic with a clear hint instead of // serving requests that would either crash (no DB pool ready for writes from // the worker) or duplicate work that the dedicated web container is doing. if (!shouldRunWeb() && pathname !== "/api/health") { logNotificationActionRefusal(request, "worker_only", 503); return applyBaselineSecurityHeaders( NextResponse.json( { data: null, error: "This container runs the worker only — point HTTP at the web service.", }, { status: 503, headers: { "X-HealthLog-Process-Type": "worker" } }, ), ); } // 301 redirects for renamed routes const redirect = LEGACY_REDIRECTS[pathname]; if (redirect) { return applyBaselineSecurityHeaders( NextResponse.redirect(new URL(redirect, request.url), 301), ); } // 301 for the v1.5 admin section-anchor → dynamic-route migration. const legacyAdmin = LEGACY_ADMIN_ANCHORS[pathname]; if (legacyAdmin) { return applyBaselineSecurityHeaders( NextResponse.redirect(new URL(legacyAdmin, request.url), 301), ); } // A route that existed and is gone answers 410, not 404 — see // `src/lib/http/retired-routes.ts` for why the status carries the message // and the error code only fills in the detail. // // Placed before the demo-mode block and before the page session gate on // purpose. A retired path is retired on a demo deploy too, and answering a // logged-out caller with a login redirect would send them to authenticate // for something that no longer exists. It reads nothing off the request but // the path, so it cannot answer differently for an authenticated caller than // for an anonymous one. const retired = findRetiredRoute(pathname); if (retired) { return applyBaselineSecurityHeaders( NextResponse.json(retiredRouteEnvelope(retired), { status: RETIRED_ROUTE_STATUS, }), ); } // A notification quick answer is a POST and nothing else. ntfy's `http` // action sends one; anything else here is a request a redirect rewrote (an // HTTP client follows a 301/302 with a GET) or a stray probe. Next would // answer 405 on its own, but before any wide event, so the refusal is made // here where it can be logged. `OPTIONS` stays with Next. if ( pathname.startsWith(NOTIFICATION_ACTION_PREFIX) && request.method !== "POST" && request.method !== "OPTIONS" ) { logNotificationActionRefusal(request, "method_not_allowed", 405); return applyBaselineSecurityHeaders( NextResponse.json( { data: null, error: "Method not allowed", meta: { errorCode: "notification.action.method_not_allowed" }, }, { status: 405, headers: { Allow: "POST" } }, ), ); } // Demo mode: block all mutations except login if (process.env.DEMO_MODE === "true") { const method = request.method.toUpperCase(); const isMutation = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; const isApi = pathname.startsWith("/api/"); if ( isApi && isMutation && !DEMO_MUTATION_ALLOWLIST.some( (entry) => pathname === entry.path && method === entry.method, ) ) { logNotificationActionRefusal(request, "demo_mode", 403); return applyBaselineSecurityHeaders( NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }), ); } } // v1.38.19 — the invite universal-link landing answers at the EDGE. // // `src/app/invite/[token]/page.tsx` has always redirected onto // `/auth/register?invite=…`, but Next does not deliver that as a server 307: // it streams the root layout first and puts the redirect in the RSC flight // payload. `` therefore mounted on the pathname `/invite/…`, and // before the entry added beside this one it read the route as protected, // took a 401 from `/api/auth/me` and raced its own // `router.replace("/auth/login")` against the redirect — logged out the // login bounce won, and the visitor held a one-time invite on a page that // cannot use it. Answering here means no HTML, no hydration, no race. // // The page stays as the fallback for any path that bypasses the matcher. // The shape gate is the twin of `looksLikeInviteToken` in // `src/lib/auth/invite-token.ts`, restated rather than imported: that module // pulls the Prisma client, which has no business in the proxy bundle. A // segment that fails it is dropped rather than reflected, so the target can // never echo attacker-controlled text. if (pathname.startsWith("/invite/")) { const token = pathname.slice("/invite/".length).split("/")[0] ?? ""; const target = new URL("/auth/register", request.url); if (/^hlv_[0-9a-f]{64}$/.test(token)) { target.searchParams.set("invite", token); } const redirected = applyBaselineSecurityHeaders( NextResponse.redirect(target, 307), ); // The secret rides in the path of the request that produced this hop, so // the hop itself carries the same defence the landing page earned in // v1.17.0: uncacheable, unindexable, and never leaked as a `Referer`. redirected.headers.set( "Cache-Control", "no-store, no-cache, must-revalidate", ); redirected.headers.set("X-Robots-Tag", "noindex, nofollow"); redirected.headers.set("Referrer-Policy", "no-referrer"); return redirected; } // v1.42 (#959) — the managed-profile handover link answers at the edge for // the same reasons as the invite link above: no HTML, no hydration race, the // shape gate drops anything that is not a handover token rather than // reflecting it, and the hop is uncacheable, unindexable and never leaked as // a `Referer`. The shape is restated from `src/lib/auth/handover-token.ts`. if (pathname.startsWith("/claim/")) { const token = pathname.slice("/claim/".length).split("/")[0] ?? ""; const target = new URL("/auth/claim", request.url); if (/^hlp_[0-9a-f]{64}$/.test(token)) { target.searchParams.set("token", token); } const redirected = applyBaselineSecurityHeaders( NextResponse.redirect(target, 307), ); redirected.headers.set( "Cache-Control", "no-store, no-cache, must-revalidate", ); redirected.headers.set("X-Robots-Tag", "noindex, nofollow"); redirected.headers.set("Referrer-Policy", "no-referrer"); return redirected; } // Server-side route protection for pages (not API routes — those have their own getSession checks) const isApiRoute = pathname.startsWith("/api/"); const isStaticFile = /\.\w+$/.test(pathname); const isPublic = isPublicPath(pathname); // Every page behind the session gate. The same predicate drives the // auth redirect below and the `no-store` header further down, so a page // can never be session-gated without also being uncacheable. const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic; if (isSessionGatedPage) { const hasSession = request.cookies.has("healthlog_session"); if (!hasSession) { return applyBaselineSecurityHeaders( NextResponse.redirect(new URL("/auth/login", request.url)), ); } // v1.4.22 — server-side onboarding redirect. Previously the // `` ran this in a post-hydration `useEffect`, so a user // with `onboardingCompletedAt === null` briefly saw the dashboard // flash before the redirect fired. The auth flows (login, passkey, // register, /api/auth/me) keep an `hl_onboarding` cookie in sync // with the DB state; the proxy reads it without a DB roundtrip so // the redirect lands on the first server response. The /onboarding // page itself is in PUBLIC_PATHS above, so this branch only runs // for the surfaces that need to redirect away. const onboardingPending = request.cookies.get("hl_onboarding")?.value === "pending"; // v1.4.22 — exact match the redirect // short-circuit so a hypothetical `/onboarding-export` route can // never inherit the silent-pass-through. const isOnboardingSurface = pathname === "/onboarding" || pathname.startsWith("/onboarding/"); if (onboardingPending && !isOnboardingSurface) { return applyBaselineSecurityHeaders( NextResponse.redirect(new URL("/onboarding", request.url)), ); } // v1.23 — admin-enforced MFA forced-enrollment gate. When the operator // requires a second factor and the account has none, `/api/auth/me` (and // every auth surface) sets `hl_mfa_enroll=required`; the proxy reads it // without a DB round-trip — mirroring the onboarding hint — and walls the // app behind the enrollment surface. Only `/enroll-mfa` and // `/settings/security` stay reachable so the user can actually enrol; // logout + the enrollment APIs are `/api/*` and never page-gated here. // Onboarding takes precedence (the branch above already returned), so a // brand-new account finishes onboarding first. const mfaEnrollRequired = request.cookies.get("hl_mfa_enroll")?.value === "required"; const isMfaEnrollSurface = pathname === "/enroll-mfa" || pathname.startsWith("/settings/security"); if (mfaEnrollRequired && !isMfaEnrollSurface && !isOnboardingSurface) { return applyBaselineSecurityHeaders( NextResponse.redirect(new URL("/enroll-mfa", request.url)), ); } } // Generate or propagate x-request-id for request correlation. An incoming // id is adopted only when it is a plain token (`resolveRequestId`); it is // echoed and logged, so anything else is replaced with a fresh one. const requestId = resolveRequestId(request.headers.get("x-request-id")); // 128 bits of random, base64-encoded. randomUUID().toString() only carries // ~122 bits and has a predictable structure that base64-encodes to a // partially guessable pattern. const nonce = generateNonce(); const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set("x-request-id", requestId); const response = NextResponse.next({ request: { headers: requestHeaders, }, }); // Request ID for log correlation response.headers.set("x-request-id", requestId); if (!isApiRoute) { response.headers.set(HEALTHLOG_PAGE_MARKER_HEADER, "1"); } // The document vault's decrypt-and-serve route is framed SAME-ORIGIN by // the document detail preview (an