diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts new file mode 100644 index 000000000..e7a21c0a0 --- /dev/null +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -0,0 +1,118 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Structural guard over the server-prefetching pages. + * + * A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the + * caller's own health record into the HTML document. Two things must hold for + * every such page, and neither is enforced by the type system: + * + * 1. It must be session-gated at the edge, so `src/proxy.ts` stamps + * `Cache-Control: private, no-store` on the document. A prefetching page + * placed on a public path would ship a record with no cache directive at + * all. + * 2. It must not opt into static or revalidated rendering. `export const + * revalidate = ` or `dynamic = "force-static"` would let Next serve one + * account's prefetched HTML to the next caller out of its own cache, + * before the request ever reaches a header. + * + * The guard walks the app tree rather than naming files, so a NEW prefetching + * page is covered the moment it lands. + */ + +const APP_DIR = join(process.cwd(), "src", "app"); + +/** Mirrors the public-path allowlist in `src/proxy.ts`. */ +const PUBLIC_ROUTE_PREFIXES = [ + "/auth/", + "/privacy", + "/about", + "/c/", + "/invite/", + "/onboarding", + "/mcp", + "/i18n/", + "/.well-known/", +]; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === "__tests__") continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walk(full)); + } else if (entry === "page.tsx" || entry === "page.ts") { + out.push(full); + } + } + return out; +} + +/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */ +function routePathFor(file: string): string { + const rel = relative(APP_DIR, file).split(sep).slice(0, -1); + const segments = rel.filter( + // Route groups `(name)` and parallel/intercepting slots contribute no + // URL segment. + (s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"), + ); + return "/" + segments.join("/"); +} + +function isPublicRoute(routePath: string): boolean { + return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p)); +} + +const prefetchingPages = walk(APP_DIR) + .map((file) => ({ file, source: readFileSync(file, "utf8") })) + .filter( + ({ source }) => + source.includes("HydrationBoundary") && source.includes("dehydrate"), + ) + .map(({ file, source }) => ({ + file, + source, + routePath: routePathFor(file), + rel: relative(process.cwd(), file), + })); + +describe("server-prefetching pages cannot leak a cacheable record", () => { + it("finds the prefetching pages at all (guard is not vacuous)", () => { + expect(prefetchingPages.length).toBeGreaterThan(0); + // The dashboard is the canonical one; if it stops matching, the detection + // heuristic has drifted and every assertion below is silently skipped. + expect(prefetchingPages.map((p) => p.routePath)).toContain("/"); + }); + + it("keeps every prefetching page behind the session gate", () => { + const publicOnes = prefetchingPages.filter((p) => + isPublicRoute(p.routePath), + ); + expect( + publicOnes.map((p) => `${p.rel} (${p.routePath})`), + "a page that dehydrates a record onto a public path gets no no-store header from the proxy", + ).toEqual([]); + }); + + it("lets no prefetching page opt into static or revalidated rendering", () => { + const offenders: string[] = []; + for (const page of prefetchingPages) { + if (/export\s+const\s+revalidate\s*=/.test(page.source)) { + offenders.push(`${page.rel}: exports revalidate`); + } + const dynamicMatch = page.source.match( + /export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/, + ); + if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") { + offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`); + } + } + expect( + offenders, + "a prefetched page must never be served from Next's own render cache", + ).toEqual([]); + }); +}); diff --git a/src/__tests__/proxy-page-no-store.test.ts b/src/__tests__/proxy-page-no-store.test.ts new file mode 100644 index 000000000..c96283da3 --- /dev/null +++ b/src/__tests__/proxy-page-no-store.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Session-gated pages serialise one account's health record into the HTML + * document — the dashboard, Insights, the workouts list, the medications + * list and the Coach all server-prefetch their data through a TanStack + * `HydrationBoundary`, so the record ships inside the payload rather than + * arriving over a later fetch. + * + * Those pages are dynamic today only because they read the session cookie. + * That is a property of the page code, not a guarantee — so the proxy pins + * `Cache-Control: private, no-store` on every session-gated page response. + * `private` bars shared caches (a CDN, a reverse proxy); `no-store` also + * bars the browser disk cache and the bfcache snapshot. + * + * These tests pin both sides: gated pages carry the header, and the public + * surfaces that are deliberately cacheable do not. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function makeRequest(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }); +} + +/** The pages that server-prefetch and dehydrate a record into the HTML. */ +const PREFETCHING_PAGES = [ + "/", + "/coach", + "/insights", + "/insights/workouts", + "/medications", +]; + +/** Other session-gated surfaces that render account data. */ +const OTHER_GATED_PAGES = [ + "/documents", + "/settings/profile", + "/admin/users", + "/mood", +]; + +describe("proxy.ts no-store on session-gated pages", () => { + it.each(PREFETCHING_PAGES)( + "sets private, no-store on the prefetching page %s", + (path) => { + const res = proxy(makeRequest(path)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("private"); + }, + ); + + it.each(OTHER_GATED_PAGES)( + "sets private, no-store on the gated page %s", + (path) => { + const res = proxy(makeRequest(path)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("private"); + }, + ); + + it("still emits no-store when the request carries no session cookie", () => { + // The unauthenticated caller gets a redirect to /auth/login, which never + // reaches the header block — assert the redirect rather than a header, so + // the test states what actually happens on that arm. + const res = proxy(new NextRequest("http://localhost/medications")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/auth/login"); + }); + + it("leaves the public legal + auth surfaces cacheable", () => { + for (const path of ["/privacy", "/about", "/auth/login"]) { + const res = proxy(makeRequest(path)); + expect(res.headers.get("cache-control"), path).toBeNull(); + } + }); + + it("leaves the immutable locale catalog cacheable", () => { + const res = proxy(makeRequest("/i18n/en")); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("does not blanket API routes, which manage their own caching", () => { + const res = proxy(makeRequest("/api/user/avatar/user-1")); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("keeps the stricter share-link posture on /c/", () => { + const token = "hls_000000000000000000000000000000000000000000000000"; + const res = proxy(makeRequest(`/c/${token}`)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("must-revalidate"); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index e1ede731a..f8712927d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -245,7 +245,11 @@ export function proxy(request: NextRequest) { const isApiRoute = pathname.startsWith("/api/"); const isStaticFile = /\.\w+$/.test(pathname); const isPublic = isPublicPath(pathname); - if (!isApiRoute && !isStaticFile && !isPublic) { + // 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 NextResponse.redirect(new URL("/auth/login", request.url)); @@ -335,6 +339,27 @@ export function proxy(request: NextRequest) { // Both serve routes take the narrow, document-only header posture. const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute; + // Session-gated pages carry one account's health record in the document + // itself. Several of them server-prefetch their data and serialise it into + // the HTML through a TanStack `HydrationBoundary` — the dashboard, Insights, + // the workouts list, the medications list, the Coach. Those pages are + // dynamic today only as a side effect of reading the session cookie, which + // is a property of the page code, not a guarantee: a stray `revalidate` + // export, a `force-static` segment, or a caching proxy in front of the app + // would be enough to retain one account's record and hand it to the next + // caller. Pin the guarantee at the edge instead, where no page-level change + // can weaken it. + // + // `private` bars shared caches (CDN, reverse proxy) outright; `no-store` + // additionally stops the browser's own disk cache and the bfcache snapshot, + // so a back-navigation after logout cannot repaint the record. The public + // surfaces (`/auth/*`, `/privacy`, `/about`, `/i18n/*`) are excluded by the + // same predicate that governs the auth redirect, so the statically + // rendered marketing/legal pages stay cacheable. + if (isSessionGatedPage) { + response.headers.set("Cache-Control", "private, no-store"); + } + // Security headers response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");