Merge private Loki shipping and the service worker that leaves a foreign origin

This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:17:25 +02:00
commit f1aebcc3bf
8 files changed
+1183 -24

No files matched your search

+10 -4
View File
@@ -521,10 +521,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# LOG_SLOW_THRESHOLD_MS="3000"
# LOG_INCLUDE_STACK="false"
# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and
# is what turns shipping on; the username/password pair is only needed when
# your Loki sits behind basic auth. Leave all three unset and logs stay on
# stdout for the Docker log driver to rotate.
# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on.
# Either form works: the base URL (http://loki:3100) or the full push URL
# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine,
# private addresses and internal hostnames included; only link-local and
# cloud-metadata addresses are refused. The username/password pair is only
# needed when your Loki sits behind basic auth. When a push fails (Loki down,
# wrong path, rejected credentials) the app writes one line to stderr naming
# the Loki origin, the reason and how many events were dropped, at most once
# every five minutes per reason; the password never appears in it. Leave all
# three unset and logs stay on stdout for the Docker log driver to rotate.
# LOKI_ENDPOINT=""
# LOKI_USERNAME=""
# LOKI_PASSWORD=""
+5 -4
View File
@@ -345,10 +345,11 @@ x-healthlog-env: &healthlog_env
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push
# URL to turn it on; the username/password pair is only needed when your
# Loki is behind basic auth. Leave all three unset and logs stay on stdout
# for the Docker log driver to rotate.
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base
# URL or the full push URL to turn it on; a Loki on your own network works.
# The username/password pair is only needed when your Loki is behind basic
# auth. Push failures show up as a rate-limited line on stderr. Leave all
# three unset and logs stay on stdout for the Docker log driver to rotate.
LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}"
LOKI_USERNAME: "${LOKI_USERNAME:-}"
LOKI_PASSWORD: "${LOKI_PASSWORD:-}"
+154
View File
@@ -61,6 +61,21 @@ const CURRENT_HEALTHLOG_CACHES = new Set([
PAGE_CACHE,
DATA_CACHE,
]);
// Presence marker `src/proxy.ts` sets on every HealthLog page response. See
// `isForeignNavigationResponse` for why the worker needs it and the proxy for
// why it carries no version. Keep the name in lockstep with
// `HEALTHLOG_PAGE_MARKER_HEADER` there.
const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
// Set as soon as a navigation looks like another app (see
// `isForeignNavigationResponse`). While it is set the fetch handler declines
// every request, so the page and its assets come from the network. It is
// cleared again when `confirmForeignOrigin` finds HealthLog after all, and
// stays set for the rest of the worker's life when the origin is confirmed
// foreign and the worker retires.
let foreignOrigin = false;
// One confirmation at a time: a burst of navigations must not fire a burst of
// probes, or race one probe's "HealthLog after all" against another's retire.
let foreignOriginCheck = null;
const MAX_STATIC_ENTRIES = 150;
const MAX_PAGE_ENTRIES = 30;
const MAX_DATA_ENTRIES = 60;
@@ -168,6 +183,10 @@ self.addEventListener("activate", (event) => {
// ── Fetch: strategy per resource type ────────────────────────────────────────
self.addEventListener("fetch", (event) => {
// The origin serves another app now (see `retireFromForeignOrigin`): no
// `respondWith`, no cache, the browser's own fetch for everything.
if (foreignOrigin) return;
const { request } = event;
const url = new URL(request.url);
@@ -335,6 +354,134 @@ function isCacheableNavigation(request, response) {
return true;
}
/**
* Does this navigation response come from something other than HealthLog?
*
* The worker is registered with scope `/` because HealthLog serves everything
* from `/`. When HealthLog is stopped and a different app starts on the same
* origin (the usual case is `localhost:3000` on a developer machine), this
* worker kept answering: `/_next/static/*` is cache-first, so the other app
* ran on HealthLog's cached chunks, and the only way out was unregistering by
* hand in the browser's developer tools.
*
* Only a response that positively looks like another app counts:
*
* - a redirect is opaque to the worker (`opaqueredirect`, status 0, no
* readable headers), and HealthLog redirects every signed-out page, so it
* proves nothing; status 0 fails the `ok` check below;
* - a non-2xx answer proves nothing either: a reverse proxy or auth gateway
* in front of a stopped HealthLog answers 502 or 401 without the marker,
* and unregistering there would also drop the Web Push subscription;
* - a network failure never reaches this check (it throws into the offline
* fallback in `networkFirst`).
*
* What is left is a successful page without the marker HealthLog's proxy puts
* on every page response. That is a suspicion, not a verdict: a reverse proxy
* with a response-header allowlist strips the marker from every genuine page.
* `confirmForeignOrigin` settles it before anything is deleted.
*/
function isForeignNavigationResponse(response) {
if (!response || !response.ok) return false;
return !response.headers.has(HEALTHLOG_PAGE_MARKER_HEADER);
}
/**
* Is the origin positively not HealthLog? Asked of `/api/version`, which is
* public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers
* `{ data: { version, buildSha, ... } }`. A body check survives any proxy that
* rewrites headers.
*
* Retire only on a positive answer from something else: a 404, a 2xx that is
* not JSON (another app's HTML fallback), or JSON without the HealthLog shape.
* `buildSha` is `null` on an image not built by the release workflow, so the
* shape is "`version` is a string and `buildSha` is present", not "both are
* strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from
* an auth gateway, a redirect) proves nothing, and the worker resumes normal
* service.
*
* The probe is the worker's own `fetch`, which never passes through this
* worker's fetch handler; `no-store` keeps the HTTP cache out of it and
* `redirect: "manual"` keeps a login redirect from reading as an answer.
*/
async function confirmForeignOrigin() {
let response;
try {
response = await fetch("/api/version", {
cache: "no-store",
redirect: "manual",
credentials: "omit",
});
} catch {
return false;
}
if (response.status === 404) return true;
if (!response.ok) return false;
let body;
try {
body = JSON.parse(await response.text());
} catch {
return true;
}
const data = body && typeof body === "object" ? body.data : null;
const isHealthlog =
!!data &&
typeof data === "object" &&
typeof data.version === "string" &&
"buildSha" in data;
return !isHealthlog;
}
/**
* Confirm, then either retire or resume. Shared by concurrent navigations.
*/
function checkForeignOrigin() {
if (!foreignOriginCheck) {
foreignOriginCheck = (async () => {
let foreign = false;
try {
foreign = await confirmForeignOrigin();
} catch {
foreign = false;
}
if (foreign) {
await retireFromForeignOrigin();
return;
}
// HealthLog after all, or no answer: serve normally again, and let a
// later suspicious navigation ask again.
foreignOrigin = false;
foreignOriginCheck = null;
})();
}
return foreignOriginCheck;
}
/**
* Leave an origin that no longer serves HealthLog: delete every HealthLog
* cache (only ours, matched by name, never another app's CacheStorage) and
* unregister, so the next load of that origin runs without this worker. The
* `foreignOrigin` flag set by the caller covers the requests the current page
* still makes before it unloads. Each step is best-effort and independent: a
* failed cache delete must not keep the worker registered.
*/
async function retireFromForeignOrigin() {
try {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
.map((k) => caches.delete(k)),
);
} catch {
// CacheStorage unavailable; unregistering still matters more.
}
try {
await self.registration.unregister();
} catch {
// Nothing further to do from inside the worker.
}
}
/**
* Network-first: try network (preferring the navigation-preload response
* when the browser already started it), fall back to cache.
@@ -351,6 +498,13 @@ async function networkFirst(event, cacheName) {
const response =
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (isForeignNavigationResponse(response)) {
// Step aside at once (declining a request only sends it to the
// network), but delete nothing until the origin is confirmed foreign.
foreignOrigin = true;
event.waitUntil(checkForeignOrigin());
return response;
}
if (response.ok && isCacheableNavigation(request, response)) {
event.waitUntil(
bestEffortCacheWrite(
+72
View File
@@ -0,0 +1,72 @@
/**
* The page marker `public/sw.js` uses to recognise that its origin still
* serves HealthLog (#847). A successful navigation without it makes the
* worker delete its caches and unregister, so the marker has to be on every
* page the proxy lets through, signed in or not, and it must never carry a
* version.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { HEALTHLOG_PAGE_MARKER_HEADER, proxy } from "../proxy";
beforeEach(() => {
vi.stubEnv("NODE_ENV", "production");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function request(pathname: string, signedIn = true): NextRequest {
return new NextRequest(`http://localhost${pathname}`, {
headers: signedIn
? { cookie: "healthlog_session=sess-1", accept: "text/html" }
: { accept: "text/html" },
});
}
describe("proxy.ts page marker for the service worker", () => {
it.each(["/", "/measurements", "/settings/profile", "/c/hls_abc123"])(
"marks the signed-in page %s",
(path) => {
const res = proxy(request(path));
expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
},
);
it.each(["/auth/login", "/privacy", "/onboarding"])(
"marks the public page %s",
(path) => {
const res = proxy(request(path, false));
expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
},
);
it.each(["/api/version", "/api/measurements", "/api/auth/me"])(
"leaves the API response %s unmarked",
(path) => {
const res = proxy(request(path));
expect(res.headers.has(HEALTHLOG_PAGE_MARKER_HEADER)).toBe(false);
},
);
it("carries presence only, never a version or build id", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "9.9.9");
const res = proxy(request("/"));
expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1");
});
it("uses the header name public/sw.js checks", () => {
const sw = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8");
expect(sw).toContain(
`const HEALTHLOG_PAGE_MARKER_HEADER = "${HEALTHLOG_PAGE_MARKER_HEADER}";`,
);
});
});
+403 -7
View File
@@ -14,6 +14,11 @@
* `event.preloadResponse` when the browser supplies it;
* 4. `trimCache` reads the key list once and deletes the excess prefix
* in a single pass (the previous loop re-fetched all keys per delete).
*
* A HealthLog page response carries the `X-HealthLog-Page` marker the proxy
* sets, so the navigation fixtures below build pages with `healthlogPage()`.
* A successful page without it is another app on the same origin (#847),
* which the "foreign origin" block covers.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
@@ -38,6 +43,15 @@ const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`;
const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`;
const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`;
const PAGE_MARKER = "X-HealthLog-Page";
/** A page response as HealthLog's proxy delivers it: marker included. */
function healthlogPage(body: string, init: ResponseInit = {}): Response {
const headers = new Headers(init.headers);
headers.set(PAGE_MARKER, "1");
return new Response(body, { ...init, headers });
}
class FakeCache {
map = new Map<string, Response>();
keysCalls = 0;
@@ -121,6 +135,7 @@ interface SwHarness {
listeners: Map<string, (event: unknown) => void>;
cacheStorage: FakeCacheStorage;
navPreload: { enabled: boolean; enable: () => Promise<void> };
unregisterCalls: { count: number };
lifetimePromises: Promise<unknown>[];
context: Record<string, unknown>;
}
@@ -129,6 +144,7 @@ function bootServiceWorker(): SwHarness {
const listeners = new Map<string, (event: unknown) => void>();
const cacheStorage = new FakeCacheStorage();
const lifetimePromises: Promise<unknown>[] = [];
const unregisterCalls = { count: 0 };
const navPreload = {
enabled: false,
enable: async () => {
@@ -147,6 +163,10 @@ function bootServiceWorker(): SwHarness {
clients: { claim: async () => {} },
registration: {
navigationPreload: navPreload,
unregister: async () => {
unregisterCalls.count += 1;
return true;
},
showNotification: async (title: string, opts: { tag?: string }) => {
shown.push({ title, tag: opts?.tag, closed: false });
},
@@ -194,7 +214,14 @@ function bootServiceWorker(): SwHarness {
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
return { listeners, cacheStorage, navPreload, lifetimePromises, context };
return {
listeners,
cacheStorage,
navPreload,
unregisterCalls,
lifetimePromises,
context,
};
}
async function dispatchActivate(harness: SwHarness): Promise<void> {
@@ -389,7 +416,7 @@ describe("sw.js — best-effort cache writes", () => {
const harness = bootServiceWorker();
const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
pageCache.putError = new Error("CacheStorage quota exceeded");
const networkResponse = new Response("network shell", {
const networkResponse = healthlogPage("network shell", {
headers: { "X-Network-Response": "original" },
});
harness.context.fetch = async () => networkResponse;
@@ -413,7 +440,7 @@ describe("sw.js — best-effort cache writes", () => {
notifyPutStarted = resolve;
});
pageCache.onPut = notifyPutStarted;
const networkResponse = new Response("network shell");
const networkResponse = healthlogPage("network shell");
harness.context.fetch = async () => networkResponse;
const responsePromise = dispatchNavigationFetch(harness, "/measurements");
@@ -572,7 +599,7 @@ describe("sw.js — networkFirst offline fallback", () => {
const res = await dispatchNavigationFetch(
harness,
"/",
Promise.resolve(new Response("preloaded shell")),
Promise.resolve(healthlogPage("preloaded shell")),
);
expect(await res.clone().text()).toBe("preloaded shell");
@@ -587,7 +614,7 @@ describe("sw.js — networkFirst privacy gate", () => {
it("does not cache a navigation response that carries Cache-Control: no-store", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () =>
new Response("private shell", {
healthlogPage("private shell", {
headers: { "Cache-Control": "no-store" },
});
@@ -600,7 +627,7 @@ describe("sw.js — networkFirst privacy gate", () => {
it("does not cache the /c/ clinician-share view even without no-store", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () => new Response("share shell");
harness.context.fetch = async () => healthlogPage("share shell");
const res = await dispatchNavigationFetch(harness, "/c/hls_abc123");
expect(await res.clone().text()).toBe("share shell");
@@ -611,7 +638,7 @@ describe("sw.js — networkFirst privacy gate", () => {
it("still caches an ordinary navigation response", async () => {
const harness = bootServiceWorker();
harness.context.fetch = async () => new Response("app shell");
harness.context.fetch = async () => healthlogPage("app shell");
const res = await dispatchNavigationFetch(harness, "/measurements");
expect(await res.clone().text()).toBe("app shell");
@@ -621,6 +648,375 @@ describe("sw.js — networkFirst privacy gate", () => {
});
});
/**
* Dispatch any request and report whether the worker took it. Used where the
* assertion IS whether `respondWith` was called: a declined request goes to
* the network through the browser, untouched by this worker.
*/
function dispatchAnyFetch(
harness: SwHarness,
path: string,
headers: Record<string, string> = {},
): { responded: boolean; response: Promise<Response> | null } {
let captured: Promise<Response> | null = null;
let responded = false;
harness.listeners.get("fetch")!({
request: new Request(`${ORIGIN}${path}`, { headers }),
respondWith: (p: Promise<Response>) => {
responded = true;
captured = p;
},
waitUntil: (p: Promise<unknown>) => {
harness.lifetimePromises.push(p);
},
});
return { responded, response: captured };
}
const HEALTHLOG_VERSION_BODY = JSON.stringify({
data: { version: "1.38.21", buildSha: null, builtAt: null },
error: null,
});
/**
* Route the harness `fetch`: `/api/version` (the worker's confirmation probe)
* gets `version`, everything else gets `page`. Records every requested path.
*/
function routeFetch(
harness: SwHarness,
page: () => Response,
version: () => Response | Promise<Response>,
): string[] {
const calls: string[] = [];
harness.context.fetch = async (input: RequestInfo | URL) => {
const url = new URL(
typeof input === "string" || input instanceof URL ? input : input.url,
ORIGIN,
);
calls.push(url.pathname);
return url.pathname === "/api/version" ? version() : page();
};
return calls;
}
const foreignHtml = () =>
new Response("<html>another app</html>", {
headers: { "Content-Type": "text/html" },
});
async function seedHealthlogCaches(harness: SwHarness): Promise<void> {
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
await statics.put(
`${ORIGIN}/_next/static/chunks/app.js`,
new Response("healthlog chunk"),
);
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
await pages.put(`${ORIGIN}/`, healthlogPage("healthlog shell"));
const legacy = await harness.cacheStorage.open("healthlog-data-v0.0.1");
await legacy.put(`${ORIGIN}/api/version`, new Response("{}"));
// CacheStorage the other app owns on the same origin.
const other = await harness.cacheStorage.open("next-app-runtime");
await other.put(`${ORIGIN}/x`, new Response("theirs"));
}
describe("sw.js — foreign origin (#847)", () => {
it("keeps serving and caching while the page carries the HealthLog marker", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
harness.context.fetch = async () => healthlogPage("fresh shell");
const res = await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(await res.text()).toBe("fresh shell");
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
expect(asset.responded).toBe(true);
expect(await (await asset.response!).text()).toBe("healthlog chunk");
});
it("retires when the page lacks the marker and the version probe finds another app's HTML", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
const foreignPage = foreignHtml();
const calls = routeFetch(harness, () => foreignPage, foreignHtml);
const res = await dispatchNavigationFetch(harness, "/");
// The other app's page is returned untouched, and the worker declines
// requests at once, before the probe has answered.
expect(res).toBe(foreignPage);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(false);
await Promise.all(harness.lifetimePromises);
expect(calls).toEqual(["/", "/api/version"]);
expect(harness.unregisterCalls.count).toBe(1);
// Every HealthLog cache is gone, current and legacy; the other app's is not.
expect([...harness.cacheStorage.stores.keys()]).toEqual([
"next-app-runtime",
]);
// Still declining after the retire: static, HTML and API all go to the
// network through the browser.
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(false);
expect(
dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded,
).toBe(false);
expect(dispatchAnyFetch(harness, "/api/measurements").responded).toBe(
false,
);
});
it("retires when the version probe answers 404", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
routeFetch(
harness,
foreignHtml,
() => new Response("Not Found", { status: 404 }),
);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(1);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(false);
});
it("retires when the version probe answers JSON without the HealthLog shape", async () => {
const harness = bootServiceWorker();
routeFetch(
harness,
foreignHtml,
() => new Response(JSON.stringify({ version: "3.0.0" })),
);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(1);
});
it("probes with no-store and a manual redirect", async () => {
const harness = bootServiceWorker();
let probeInit: RequestInit | undefined;
harness.context.fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const path = new URL(
String(input instanceof Request ? input.url : input),
ORIGIN,
).pathname;
if (path === "/api/version") {
probeInit = init;
return new Response(HEALTHLOG_VERSION_BODY);
}
return foreignHtml();
};
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(probeInit?.cache).toBe("no-store");
expect(probeInit?.redirect).toBe("manual");
});
it("does not retire when a header-stripping proxy hides the marker from a real HealthLog", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
// The page is HealthLog's, minus the marker a proxy allowlist removed.
routeFetch(
harness,
() => new Response("healthlog page without marker"),
() =>
new Response(HEALTHLOG_VERSION_BODY, {
headers: { "Content-Type": "application/json" },
}),
);
const res = await dispatchNavigationFetch(harness, "/");
expect(await res.text()).toBe("healthlog page without marker");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
// Normal service resumed: the cached chunk is served cache-first again.
const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js");
expect(asset.responded).toBe(true);
expect(await (await asset.response!).text()).toBe("healthlog chunk");
});
it.each([
[
"the probe throws (network)",
() => Promise.reject(new TypeError("network down")),
],
["the probe answers 500", () => new Response("boom", { status: 500 })],
[
"the probe answers 401 from an auth gateway",
() => new Response("Unauthorized", { status: 401 }),
],
])(
"does not retire when %s, and resumes service",
async (_label, version) => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
routeFetch(harness, foreignHtml, version as () => Promise<Response>);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(true);
},
);
it("asks again on a later suspicious navigation after an inconclusive probe", async () => {
const harness = bootServiceWorker();
let probeStatus = 503;
const calls = routeFetch(harness, foreignHtml, () =>
probeStatus === 503
? new Response("down", { status: 503 })
: new Response("Not Found", { status: 404 }),
);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
probeStatus = 404;
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(calls.filter((p) => p === "/api/version")).toHaveLength(2);
expect(harness.unregisterCalls.count).toBe(1);
});
it("recognises a foreign page delivered through navigation preload", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
const calls = routeFetch(
harness,
() => healthlogPage("unexpected"),
() => new Response("Not Found", { status: 404 }),
);
const res = await dispatchNavigationFetch(
harness,
"/",
Promise.resolve(new Response("another app")),
);
await Promise.all(harness.lifetimePromises);
expect(await res.text()).toBe("another app");
// The page came from the preload; the only network call is the probe.
expect(calls).toEqual(["/api/version"]);
expect(harness.unregisterCalls.count).toBe(1);
});
it("still unregisters when deleting the caches fails", async () => {
const harness = bootServiceWorker();
harness.cacheStorage.keys = async () => {
throw new Error("CacheStorage unavailable");
};
routeFetch(harness, foreignHtml, foreignHtml);
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(1);
});
it("keeps the offline fallback when the network fails (HealthLog is down, not replaced)", async () => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
// Default harness fetch throws: network down.
const res = await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(await res.text()).toBe("healthlog shell");
expect(harness.unregisterCalls.count).toBe(0);
expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true);
});
it.each([
[
"an error page from HealthLog itself",
healthlogPage("boom", { status: 500 }),
],
[
"a gateway error from a proxy in front of a stopped HealthLog",
new Response("Bad Gateway", { status: 502 }),
],
[
"a 401 from an auth gateway",
new Response("Unauthorized", { status: 401 }),
],
])("does not treat %s as foreign", async (_label, response) => {
const harness = bootServiceWorker();
await seedHealthlogCaches(harness);
harness.context.fetch = async () => response;
const res = await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(res).toBe(response);
expect(harness.unregisterCalls.count).toBe(0);
expect(
dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded,
).toBe(true);
});
it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => {
const harness = bootServiceWorker();
const redirect = Response.redirect(`${ORIGIN}/auth/login`, 307);
// A navigation fetched with `redirect: "manual"` surfaces as an opaque
// redirect in a real worker; model the type the worker sees.
Object.defineProperty(redirect, "type", { value: "opaqueredirect" });
Object.defineProperty(redirect, "headers", { value: new Headers() });
Object.defineProperty(redirect, "ok", { value: false });
harness.context.fetch = async () => redirect;
await dispatchNavigationFetch(harness, "/");
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
});
it("leaves the one-shot auth navigation path unchanged", async () => {
const harness = bootServiceWorker();
let fetchCalls = 0;
harness.context.fetch = async () => {
fetchCalls += 1;
return new Response("network callback");
};
const { responded, response } = dispatchAuthNavigation(
harness,
"/api/auth/oidc/callback?code=abc&state=xyz",
Promise.resolve(new Response("preloaded callback")),
);
expect(responded).toBe(true);
expect(await (await response!).text()).toBe("preloaded callback");
expect(fetchCalls).toBe(0);
// An auth response has no page marker and must not retire the worker.
await Promise.all(harness.lifetimePromises);
expect(harness.unregisterCalls.count).toBe(0);
});
});
describe("sw.js — trimCache", () => {
it("reads the key list once and deletes only the oldest excess entries", async () => {
const harness = bootServiceWorker();
@@ -0,0 +1,352 @@
/**
* Loki shipping (#947 follow-up): the endpoint forms an operator types, the
* dial path a private Loki needs, the floor no configuration opens, and the
* stderr notice that replaces the silent catch.
*
* Two layers. The mocked-`safeFetch` block pins what the transport asks for
* and what it says when that fails. The loopback block runs the real
* `safeFetch` against a real HTTP server on 127.0.0.1, which is the case the
* public-host pin used to refuse: an address that is not public at all.
*/
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLoggingConfig } from "../config";
import type { WideEvent } from "../types";
const safeFetchMock = vi.hoisted(() => vi.fn());
const useRealSafeFetch = vi.hoisted(() => ({ value: false }));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return {
...actual,
safeFetch: (...args: Parameters<typeof actual.safeFetch>) =>
useRealSafeFetch.value
? actual.safeFetch(...args)
: safeFetchMock(...args),
};
});
import { SafeFetchError } from "@/lib/safe-fetch";
import {
_resetLokiTransportForTests,
emitEvent,
flushLokiBuffer,
LOKI_FAILURE_NOTICE_INTERVAL_MS,
resolveLokiPushTarget,
} from "../transports";
const USERNAME = "loki-user";
const PASSWORD = "s3cret-loki-password";
const BASIC = Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64");
function makeEvent(): WideEvent {
return {
timestamp: new Date().toISOString(),
duration_ms: 1,
request_id: "req-loki",
trace_id: "trace-loki",
level: "info",
kind: "http",
service: "healthlog",
environment: "test",
};
}
let stderrLines: string[];
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
function configure(endpoint: string, withAuth = true): void {
vi.stubEnv("LOKI_ENDPOINT", endpoint);
vi.stubEnv("LOKI_USERNAME", withAuth ? USERNAME : "");
vi.stubEnv("LOKI_PASSWORD", withAuth ? PASSWORD : "");
resetLoggingConfig();
}
function buffer(count: number): void {
for (let i = 0; i < count; i++) emitEvent(makeEvent());
}
beforeEach(() => {
_resetLokiTransportForTests();
safeFetchMock.mockReset();
useRealSafeFetch.value = false;
stderrLines = [];
stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
stderrLines.push(String(chunk));
return true;
});
stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
vi.unstubAllEnvs();
vi.useRealTimers();
resetLoggingConfig();
_resetLokiTransportForTests();
});
describe("resolveLokiPushTarget", () => {
it.each([
["http://loki:3100", "http://loki:3100/loki/api/v1/push"],
["http://loki:3100/", "http://loki:3100/loki/api/v1/push"],
["http://loki:3100/loki/api/v1/push", "http://loki:3100/loki/api/v1/push"],
["http://loki:3100/loki/api/v1/push/", "http://loki:3100/loki/api/v1/push"],
[
"https://logs.example.com/tenant-a",
"https://logs.example.com/tenant-a/loki/api/v1/push",
],
[
"https://logs.example.com/tenant-a/loki/api/v1/push",
"https://logs.example.com/tenant-a/loki/api/v1/push",
],
[" http://10.0.0.5:3100 ", "http://10.0.0.5:3100/loki/api/v1/push"],
])("%s pushes to %s", (endpoint, expected) => {
const resolved = resolveLokiPushTarget(endpoint);
expect(resolved.ok).toBe(true);
if (resolved.ok) expect(resolved.target.url).toBe(expected);
});
it("approves exactly the endpoint origin, private addresses included", () => {
const resolved = resolveLokiPushTarget("http://192.168.1.20:3100/");
expect(resolved).toEqual({
ok: true,
target: {
url: "http://192.168.1.20:3100/loki/api/v1/push",
origin: "http://192.168.1.20:3100",
},
});
});
it.each([
"http://169.254.169.254",
"http://169.254.10.1:3100",
"http://0.0.0.0:3100",
"http://[fe80::1]:3100",
])("refuses the never-grantable host %s", (endpoint) => {
const resolved = resolveLokiPushTarget(endpoint);
expect(resolved.ok).toBe(false);
if (!resolved.ok) expect(resolved.reason).toBe("never_grantable");
});
it.each([
"loki:3100",
"ftp://loki:3100",
"http://user:pw@loki:3100",
"http://loki:3100?token=abc",
])("refuses the malformed endpoint %s", (endpoint) => {
const resolved = resolveLokiPushTarget(endpoint);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.reason).toBe("invalid_endpoint");
expect(resolved.shown).not.toContain("pw");
expect(resolved.shown).not.toContain("abc");
}
});
});
describe("flushLokiBuffer — dial path", () => {
it("dials a private endpoint as an operator-approved origin, not through the public pin", async () => {
configure("http://10.0.0.5:3100/loki/api/v1/push");
safeFetchMock.mockResolvedValue(new Response(null, { status: 204 }));
buffer(2);
await flushLokiBuffer();
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const [url, init, opts] = safeFetchMock.mock.calls[0];
expect(url).toBe("http://10.0.0.5:3100/loki/api/v1/push");
expect(init.headers.Authorization).toBe(`Basic ${BASIC}`);
expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.5:3100");
expect(opts.requirePublicHost).toBeUndefined();
expect(JSON.parse(init.body).streams[0].values).toHaveLength(2);
expect(stderrLines).toEqual([]);
});
it("never dials a metadata endpoint and says why", async () => {
configure("http://169.254.169.254:3100");
buffer(3);
await flushLokiBuffer();
expect(safeFetchMock).not.toHaveBeenCalled();
expect(stderrLines).toHaveLength(1);
expect(stderrLines[0]).toContain("never_grantable");
expect(stderrLines[0]).toContain("3 events dropped");
});
});
describe("flushLokiBuffer — failures reach stderr, once per window", () => {
it("reports a non-2xx answer with origin, status and dropped count", async () => {
configure("https://logs.example.com/loki/api/v1/push/loki/api/v1/push");
safeFetchMock.mockResolvedValue(
new Response("404 page not found", { status: 404 }),
);
buffer(4);
await flushLokiBuffer();
expect(stderrLines).toHaveLength(1);
expect(stderrLines[0]).toContain("https://logs.example.com");
expect(stderrLines[0]).toContain("HTTP 404");
expect(stderrLines[0]).toContain("4 events dropped");
});
it("reports a thrown error by class and socket code, never by message", async () => {
configure("http://loki.lan:3100");
const cause = Object.assign(new Error("connect ECONNREFUSED"), {
code: "ECONNREFUSED",
});
safeFetchMock.mockRejectedValue(
new SafeFetchError(
`safeFetch network error: http://${USERNAME}:${PASSWORD}@loki.lan`,
"network",
{ cause },
),
);
buffer(1);
await flushLokiBuffer();
expect(stderrLines).toHaveLength(1);
expect(stderrLines[0]).toContain("http://loki.lan:3100");
expect(stderrLines[0]).toContain("network ECONNREFUSED");
expect(stderrLines[0]).toContain("1 event dropped");
});
it("writes one line per reason per window and carries the suppressed count forward", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
configure("http://loki.lan:3100");
safeFetchMock.mockImplementation(
async () => new Response(null, { status: 503 }),
);
for (let i = 0; i < 5; i++) {
buffer(2);
await flushLokiBuffer();
vi.advanceTimersByTime(5_000);
}
expect(stderrLines).toHaveLength(1);
expect(stderrLines[0]).toContain("2 events dropped");
// A different reason is not muted by the first one's window.
safeFetchMock.mockImplementationOnce(
async () => new Response(null, { status: 401 }),
);
buffer(1);
await flushLokiBuffer();
expect(stderrLines).toHaveLength(2);
expect(stderrLines[1]).toContain("HTTP 401");
vi.advanceTimersByTime(LOKI_FAILURE_NOTICE_INTERVAL_MS);
buffer(2);
await flushLokiBuffer();
expect(stderrLines).toHaveLength(3);
expect(stderrLines[2]).toContain("HTTP 503");
// Four suppressed batches of two plus this batch of two.
expect(stderrLines[2]).toContain("10 events dropped");
});
it("never prints the credentials or the basic-auth header", async () => {
configure("http://loki.lan:3100");
safeFetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }));
safeFetchMock.mockRejectedValueOnce(
new SafeFetchError(`failed with Basic ${BASIC} ${PASSWORD}`, "timeout"),
);
buffer(1);
await flushLokiBuffer();
buffer(1);
await flushLokiBuffer();
expect(stderrLines).toHaveLength(2);
const all = stderrLines.join("");
expect(all).not.toContain(PASSWORD);
expect(all).not.toContain(USERNAME);
expect(all).not.toContain(BASIC);
});
it("stays silent on success", async () => {
configure("http://loki.lan:3100");
safeFetchMock.mockResolvedValue(new Response(null, { status: 204 }));
buffer(1);
await flushLokiBuffer();
expect(stderrLines).toEqual([]);
});
it("does not feed its own notice back into the Loki buffer", async () => {
configure("http://loki.lan:3100");
safeFetchMock.mockResolvedValue(new Response(null, { status: 500 }));
buffer(1);
await flushLokiBuffer();
expect(stderrLines).toHaveLength(1);
// Nothing was buffered by the failure, so the next flush has nothing
// to send.
safeFetchMock.mockClear();
await flushLokiBuffer();
expect(safeFetchMock).not.toHaveBeenCalled();
});
});
describe("flushLokiBuffer — real dial to a loopback Loki", () => {
let server: Server;
let received: Array<{ url: string; auth: string | undefined }>;
let status: number;
beforeEach(async () => {
useRealSafeFetch.value = true;
received = [];
status = 204;
server = createServer((req, res) => {
received.push({ url: req.url ?? "", auth: req.headers.authorization });
req.resume();
req.on("end", () => {
res.statusCode = status;
res.end();
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function endpoint(path = ""): string {
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}${path}`;
}
it("delivers to a non-public address given as the full push URL", async () => {
configure(endpoint("/loki/api/v1/push"));
buffer(1);
await flushLokiBuffer();
expect(received).toEqual([
{ url: "/loki/api/v1/push", auth: `Basic ${BASIC}` },
]);
expect(stderrLines).toEqual([]);
});
it("reports the 404 a wrong path produces instead of swallowing it", async () => {
status = 404;
configure(endpoint("/wrong"));
buffer(2);
await flushLokiBuffer();
expect(received).toHaveLength(1);
expect(received[0].url).toBe("/wrong/loki/api/v1/push");
expect(stderrLines).toHaveLength(1);
expect(stderrLines[0]).toContain(`${endpoint()} failed (HTTP 404)`);
});
});
+163 -9
View File
@@ -2,7 +2,8 @@ import type { WideEvent } from "./types";
import { getLoggingConfig } from "./config";
import { shouldEmit } from "./sampler";
import { appendLogEvent } from "./in-memory-buffer";
import { safeFetch } from "@/lib/safe-fetch";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { canonicalOrigin } from "@/lib/private-origin-policy";
/** Event auf stdout als einzelne JSON-Zeile schreiben */
function emitToStdout(event: WideEvent): void {
@@ -31,7 +32,133 @@ function initLokiTransport(): void {
if (lokiFlushTimer.unref) lokiFlushTimer.unref();
}
async function flushLokiBuffer(): Promise<void> {
const LOKI_PUSH_PATH = "/loki/api/v1/push";
const LOKI_PUSH_TIMEOUT_MS = 10_000;
export interface LokiPushTarget {
/** The URL the batch is POSTed to. */
url: string;
/**
* The exact origin handed to `safeFetch` as the operator approval. Also the
* only part of the endpoint that ever appears in a failure line: it carries
* no userinfo, path or query by construction.
*/
origin: string;
}
export type LokiTargetResolution =
| { ok: true; target: LokiPushTarget }
| {
ok: false;
reason: "invalid_endpoint" | "never_grantable";
shown: string;
};
/**
* Turn `LOKI_ENDPOINT` into the push URL and the origin it may dial.
*
* Both spellings an operator reaches for work: the base URL
* (`http://loki:3100`, the push path is appended) and the full push URL
* (`http://loki:3100/loki/api/v1/push`, used as is, trailing slash or not).
* Appending unconditionally turned the second form into
* `/loki/api/v1/push/loki/api/v1/push` and a 404.
*
* Why the endpoint dials as an operator-approved origin rather than through
* the public-host pin: `LOKI_ENDPOINT` is read from the server environment in
* `config.ts` and nowhere else. No request field, settings row or admin form
* can set it, so there is no user-supplied host to defend against, and the
* public-only pin was refusing the common deployment (a Loki on the same LAN
* or Docker network) without a word. The exact origin still goes through the
* pinned operator resolver in `safeFetch` with redirects forbidden, and the
* never-grantable floor holds: an endpoint whose literal host is the
* unspecified address, link-local or the metadata range is refused here
* (`canonicalOrigin`), and a name that resolves there is dropped at dial time.
*/
export function resolveLokiPushTarget(endpoint: string): LokiTargetResolution {
let url: URL;
try {
url = new URL(endpoint.trim());
} catch {
return { ok: false, reason: "invalid_endpoint", shown: "(unparseable)" };
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { ok: false, reason: "invalid_endpoint", shown: url.protocol };
}
// `url.origin` never carries userinfo, so it is safe to print even when
// the endpoint itself is refused for carrying credentials.
if (url.username || url.password || url.search || url.hash) {
return { ok: false, reason: "invalid_endpoint", shown: url.origin };
}
const origin = canonicalOrigin(url.origin);
if (!origin) {
return { ok: false, reason: "never_grantable", shown: url.origin };
}
const basePath = url.pathname.replace(/\/+$/, "");
const pushPath = basePath.endsWith(LOKI_PUSH_PATH)
? basePath
: `${basePath}${LOKI_PUSH_PATH}`;
return { ok: true, target: { url: `${origin}${pushPath}`, origin } };
}
/**
* Failure notices for the Loki push, written straight to stderr.
*
* Never through `emitEvent`: a failing Loki push that reported itself as a
* wide event would buffer that event for the same failing push. At most one
* line per reason per window, so a Loki that is down for an hour costs twelve
* lines, not seven hundred. Events dropped while a reason is quiet are counted
* and reported on its next line, so the numbers add up.
*/
export const LOKI_FAILURE_NOTICE_INTERVAL_MS = 5 * 60 * 1000;
const lokiFailureNotices = new Map<
string,
{ lastAt: number; droppedSinceLast: number }
>();
function reportLokiFailure(
shownEndpoint: string,
reason: string,
droppedEvents: number,
): void {
const now = Date.now();
const entry = lokiFailureNotices.get(reason);
if (entry && now - entry.lastAt < LOKI_FAILURE_NOTICE_INTERVAL_MS) {
entry.droppedSinceLast += droppedEvents;
return;
}
const dropped = droppedEvents + (entry?.droppedSinceLast ?? 0);
lokiFailureNotices.set(reason, { lastAt: now, droppedSinceLast: 0 });
process.stderr.write(
`[logging] Loki push to ${shownEndpoint} failed (${reason}); ` +
`${dropped} event${dropped === 1 ? "" : "s"} dropped. ` +
`Repeats of this failure are reported at most every 5 minutes.\n`,
);
}
/**
* The failure reason as a short class, never the error message: messages
* from the fetch stack can embed the target URL, and this line goes to stderr
* unredacted. A socket error code (`ECONNREFUSED`, `ENOTFOUND`) is kept
* because it is the part that tells an operator what to fix.
*/
function classifyLokiError(err: unknown): string {
const kind = err instanceof SafeFetchError ? err.kind : "error";
let cause: unknown = err instanceof SafeFetchError ? err.cause : err;
for (
let depth = 0;
depth < 4 && cause && typeof cause === "object";
depth++
) {
const code = (cause as { code?: unknown }).code;
if (typeof code === "string" && /^[A-Z][A-Z0-9_]{1,40}$/.test(code)) {
return `${kind} ${code}`;
}
cause = (cause as { cause?: unknown }).cause;
}
return kind;
}
export async function flushLokiBuffer(): Promise<void> {
if (lokiBuffer.length === 0) return;
const config = getLoggingConfig();
if (!config.lokiEndpoint) return;
@@ -39,6 +166,13 @@ async function flushLokiBuffer(): Promise<void> {
const batch = lokiBuffer;
lokiBuffer = [];
const resolved = resolveLokiPushTarget(config.lokiEndpoint);
if (!resolved.ok) {
reportLokiFailure(resolved.shown, resolved.reason, batch.length);
return;
}
const { target } = resolved;
const streams = [
{
stream: {
@@ -64,21 +198,41 @@ async function flushLokiBuffer(): Promise<void> {
);
}
let res: Response;
try {
await safeFetch(
`${config.lokiEndpoint}/loki/api/v1/push`,
res = await safeFetch(
target.url,
{
method: "POST",
headers,
body: JSON.stringify({ streams }),
},
// Operator-configured Loki endpoint — pin the connect-time IP
// against DNS rebinding.
{ timeoutMs: 10_000, requirePublicHost: true },
// Operator-configured endpoint: dial exactly its origin through the
// pinned operator resolver (see `resolveLokiPushTarget`).
{
timeoutMs: LOKI_PUSH_TIMEOUT_MS,
operatorApprovedPrivateOrigin: target.origin,
},
);
} catch {
// Events gehen verloren — akzeptabel fuer Logging
} catch (err) {
// The batch is already detached from the buffer; say so rather than
// losing it without a trace.
reportLokiFailure(target.origin, classifyLokiError(err), batch.length);
return;
}
// Loki answers 204. Anything else, including a 3xx the forbidden redirect
// left unfollowed, means the batch did not land.
if (!res.ok) {
reportLokiFailure(target.origin, `HTTP ${res.status}`, batch.length);
}
await res.body?.cancel().catch(() => {});
}
/** Test helper: clear the buffer and the failure-notice windows. */
export function _resetLokiTransportForTests(): void {
lokiBuffer = [];
lokiFailureNotices.clear();
}
/**
+24
View File
@@ -244,6 +244,26 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
"/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 = "";
@@ -484,6 +504,10 @@ export function proxy(request: NextRequest) {
// 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 <iframe> for inline-class PDFs). The
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would