Merge remote-tracking branch 'origin/main' into feat/v140-fixes

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 20:06:16 +02:00
commit cd3c0b7de2
74 files changed
+4140 -508

No files matched your search

@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { setKeyMismatchState } from "@/lib/boot/key-mismatch-state";
const handler = vi.fn(async (_request: NextRequest) =>
Response.json({ data: "ran", error: null }),
);
const route = apiHandler(handler);
function call(path: string) {
return route(new NextRequest(`http://localhost${path}`));
}
afterEach(() => {
setKeyMismatchState(null);
handler.mockClear();
});
describe("apiHandler while the encryption key does not match", () => {
it("refuses every route with 503 encryption.key_mismatch before the handler runs", async () => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
const response = await call("/api/measurements");
expect(response.status).toBe(503);
const body = await response.json();
expect(body.data).toBeNull();
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
expect(response.headers.get("Retry-After")).toBe("300");
expect(handler).not.toHaveBeenCalled();
});
it("still serves /api/health and /api/version", async () => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
for (const path of ["/api/health", "/api/version"]) {
const response = await call(path);
expect(response.status).toBe(200);
}
expect(handler).toHaveBeenCalledTimes(2);
});
it("runs the handler when the key check passed", async () => {
const response = await call("/api/measurements");
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
});
+22 -1
View File
@@ -53,6 +53,11 @@ import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
import {
isKeyMismatch,
KEY_MISMATCH_ERROR_CODE,
KEY_MISMATCH_EXEMPT_PATHS,
} from "./boot/key-mismatch-state";
import {
capturedRateLimit,
rateLimitResponseHeaders,
@@ -258,7 +263,23 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
response = await handler(...args);
// The boot key check found that the configured encryption key
// cannot open this database. Refuse before auth, before the
// handler: nothing here may read or write encrypted data.
if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) {
annotate({ action: { name: "encryption.key_mismatch.refused" } });
response = NextResponse.json(
{
data: null,
error:
"The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.",
meta: { errorCode: KEY_MISMATCH_ERROR_CODE },
},
{ status: 503, headers: { "Retry-After": "300" } },
);
} else {
response = await handler(...args);
}
} catch (error) {
if (error instanceof AiUnavailableError) {
// An AI capability is unavailable for this record. One envelope
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { isLocalhostHost, transportVerdict } from "@/lib/auth/client-transport";
import {
cookieCannotStick,
INSECURE_TRANSPORT_CODE,
refuseWhenCookieCannotStick,
} from "@/lib/auth/transport-refusal";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("isLocalhostHost", () => {
it.each([
"localhost",
"localhost:3000",
"app.localhost:8080",
"127.0.0.1",
"127.0.0.1:3000",
"127.1.2.3",
"[::1]",
"[::1]:3000",
"LOCALHOST",
])("treats %s as loopback", (host) => {
expect(isLocalhostHost(host)).toBe(true);
});
it.each([
"192.168.1.20:3000",
"truenas.local:30180",
"healthlog.example.com",
"10.0.0.5",
"127.0.0.1.nip.io",
"localhost.example.com",
"[fe80::1]:3000",
])("treats %s as a network address", (host) => {
expect(isLocalhostHost(host)).toBe(false);
});
});
describe("transportVerdict", () => {
const http = (host: string) => ({ protocol: "http" as const, host });
it("blocks plain http on a network address when cookies are Secure", () => {
expect(transportVerdict(true, http("192.168.1.20:3000"))).toBe("blocked");
});
it("only notes plain http on localhost", () => {
expect(transportVerdict(true, http("localhost:3000"))).toBe("soft");
});
it("passes https, non-Secure cookies, and an unknown transport", () => {
expect(
transportVerdict(true, { protocol: "https", host: "nas.local" }),
).toBe("ok");
expect(transportVerdict(false, http("192.168.1.20:3000"))).toBe("ok");
expect(transportVerdict(true, undefined)).toBe("ok");
});
});
describe("refuseWhenCookieCannotStick", () => {
const body = (protocol: string, host = "192.168.1.20:3000") => ({
email: "a",
password: "b",
clientTransport: { protocol, host },
});
it("refuses http + Secure with 409 and the code", async () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
const response = refuseWhenCookieCannotStick(body("http"));
expect(response?.status).toBe(409);
const json = await response!.json();
expect(json.meta).toEqual({
errorCode: INSECURE_TRANSPORT_CODE,
sessionCookieSecure: true,
});
});
it("passes https, localhost, a body without the field, and SESSION_COOKIE_SECURE=false", () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
expect(refuseWhenCookieCannotStick(body("https"))).toBeNull();
expect(
refuseWhenCookieCannotStick(body("http", "localhost:3000")),
).toBeNull();
expect(
refuseWhenCookieCannotStick({ email: "a", password: "b" }),
).toBeNull();
expect(refuseWhenCookieCannotStick(null)).toBeNull();
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
expect(cookieCannotStick(body("http"))).toBe(false);
});
it("ignores a malformed field rather than refusing on it", () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
expect(
cookieCannotStick({ clientTransport: { protocol: "gopher", host: "x" } }),
).toBe(false);
expect(cookieCannotStick({ clientTransport: "http" })).toBe(false);
});
it("follows NODE_ENV when SESSION_COOKIE_SECURE is unset", () => {
vi.stubEnv("SESSION_COOKIE_SECURE", "");
vi.stubEnv("NODE_ENV", "production");
expect(cookieCannotStick(body("http"))).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* What the browser knows and the server does not: whether this page was
* opened over https://. A `Secure` cookie only sticks on an https:// page
* (and, in most browsers, on http://localhost). The server knows whether it
* will issue `Secure` cookies (`shouldEmitSecureCookie`, published as
* `sessionCookieSecure` on `GET /api/auth/registration-status`); combining the
* two facts says, before any credential leaves the browser, whether a sign-in
* here can finish.
*
* Pure and dependency-free: imported by the login page and by the server
* refusal (`./transport-refusal.ts`) so both sides apply the same rule.
*/
export interface ClientTransport {
protocol: "http" | "https";
/** `location.host`: hostname plus an optional port. */
host: string;
}
/**
* Loopback names a browser treats as a secure context. A `Secure` cookie set
* on one of them usually sticks even over http://, so a sign-in there gets a
* soft note, never a refusal.
*/
export function isLocalhostHost(host: string): boolean {
let name = host.trim().toLowerCase();
if (name.startsWith("[")) {
// [::1] or [::1]:3000
name = name.slice(
1,
name.indexOf("]") === -1 ? undefined : name.indexOf("]"),
);
} else if (name.split(":").length === 2) {
name = name.split(":")[0];
}
if (name === "localhost" || name.endsWith(".localhost")) return true;
if (name === "::1") return true;
return /^127(?:\.\d{1,3}){3}$/.test(name);
}
/**
* - `blocked`: the server sets `Secure` cookies and this page is plain http on
* a non-loopback host; a sign-in cannot keep its cookie, so it must not start.
* - `soft`: the same on a loopback host; most browsers accept it, say so.
* - `ok`: nothing to say.
*/
export type TransportVerdict = "ok" | "soft" | "blocked";
export function transportVerdict(
sessionCookieSecure: boolean,
transport: ClientTransport | undefined,
): TransportVerdict {
if (!sessionCookieSecure || !transport || transport.protocol !== "http") {
return "ok";
}
return isLocalhostHost(transport.host) ? "soft" : "blocked";
}
/** The current page's transport, for the request bodies the server checks. */
export function currentClientTransport(): ClientTransport | undefined {
if (typeof window === "undefined") return undefined;
const protocol = window.location.protocol === "https:" ? "https" : "http";
return { protocol, host: window.location.host };
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Refuse a browser sign-in whose session cookie cannot stick, BEFORE it spends
* anything: before the password is verified (so the account throttle never
* counts it), before an MFA ticket is minted, before a ticket or a one-time
* code is consumed, before a passkey challenge is issued.
*
* The fact comes from the client (`clientTransport`), not from proxy headers:
* a TLS proxy that omits `X-Forwarded-Proto` would make a header heuristic
* refuse a correctly served install. A body without the field is never
* refused. The header heuristic in `secure-cookie.ts` stays as the operator
* log line for clients that do not send it.
*/
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { transportVerdict } from "@/lib/auth/client-transport";
import { clientTransportSchema } from "@/lib/validations/client-transport";
export const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
/** True when the body states a transport the session cookie cannot survive. */
export function cookieCannotStick(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const parsed = clientTransportSchema.safeParse(
(body as { clientTransport?: unknown }).clientTransport,
);
if (!parsed.success) return false;
return transportVerdict(shouldEmitSecureCookie(), parsed.data) === "blocked";
}
/** The 409 to return, or null when the sign-in may proceed. */
export function refuseWhenCookieCannotStick(body: unknown): Response | null {
if (!cookieCannotStick(body)) return null;
annotate({ action: { name: "auth.session.insecure_transport.refused" } });
return apiError(
"This server only sets its sign-in cookie over https://, but this page was opened over http://, so signing in cannot complete here. Open the https:// address, or set SESSION_COOKIE_SECURE=false for a server that is only reached over plain HTTP on a private network.",
409,
{ errorCode: INSECURE_TRANSPORT_CODE, sessionCookieSecure: true },
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Runs the encryption key check once per process at boot and records the
* verdict for the request path (`./key-mismatch-state.ts`).
*
* Returns true when the process must refuse: the caller then starts neither
* the queue producer nor the worker, because a process holding the wrong key
* must never write. The process keeps running so `/api/health` and the
* explanation page can say why; a crash loop would hide the reason in a log
* that the people most likely to hit this (a NAS app catalog install) never
* open.
*/
import {
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
} from "@/lib/crypto/canary";
import { setKeyMismatchState } from "./key-mismatch-state";
export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
const outcome = await checkEncryptionKeyCanaries(client);
if (outcome.state === "mismatch") {
setKeyMismatchState({
keyIds: outcome.keyIds,
detectedAt: new Date().toISOString(),
});
console.error(keyMismatchLogBlock(outcome.keyIds));
return true;
}
setKeyMismatchState(null);
if (outcome.state === "error") {
// The check could not run. The loaders and the database each have their
// own loud signal for that; serving stays on.
console.warn(
`[boot] Encryption key check skipped: ${outcome.message.slice(0, 300)}`,
);
} else {
if (outcome.inconclusive.length > 0) {
console.warn(
`[boot] Encryption key check inconclusive for key id(s) ${outcome.inconclusive.join(", ")}: ` +
"the only stored value found under that id did not open. Serving; " +
"no canary written; the check runs again at the next start.",
);
}
if (outcome.written.length > 0) {
console.info(
`[boot] Encryption key check: recorded key id(s) ${outcome.written.join(", ")}`,
);
}
}
return false;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Process-wide record of the boot key check's verdict.
*
* The check runs once in `instrumentation.ts`; the readers are `apiHandler`
* (every API route), `/api/health` and the root layout. Those live in
* different bundles of the same Node process, so module state is not shared
* between them and the verdict is pinned on `globalThis` under a registry
* symbol instead. This module imports nothing, so pulling it into the request
* path costs nothing.
*
* Only a positive mismatch is recorded. A check that could not run (database
* unreachable, keys not configured) leaves the process serving: those failures
* already have their own loud signals, and refusing on them would turn a
* transient database hiccup at boot into an outage.
*/
export interface KeyMismatchState {
/** Configured key ids whose canary did not open under the configured key. */
keyIds: string[];
/** When the check found it (ISO). */
detectedAt: string;
}
const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch");
type GlobalWithState = typeof globalThis & {
[STATE_SYMBOL]?: KeyMismatchState | null;
};
/** The paths that keep answering while the process refuses everything else. */
export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet<string> = new Set([
"/api/health",
"/api/version",
]);
/** Wire code for the refusal; catalogued in `src/lib/openapi/error-codes.ts`. */
export const KEY_MISMATCH_ERROR_CODE = "encryption.key_mismatch";
/** The `/api/health` reason while the process refuses. */
export const KEY_MISMATCH_HEALTH_REASON = "encryption_key_mismatch";
export function getKeyMismatchState(): KeyMismatchState | null {
return (globalThis as GlobalWithState)[STATE_SYMBOL] ?? null;
}
export function isKeyMismatch(): boolean {
return getKeyMismatchState() !== null;
}
export function setKeyMismatchState(state: KeyMismatchState | null): void {
(globalThis as GlobalWithState)[STATE_SYMBOL] = state;
}
+51
View File
@@ -22,6 +22,7 @@ import {
createHash,
hkdfSync,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { Buffer } from "node:buffer";
import { getEvent } from "@/lib/logging/context";
@@ -195,6 +196,56 @@ export function getConfiguredKeyIds(): string[] {
return [...keys.keys()];
}
/**
* A short, public fingerprint of one configured key: the first twelve hex
* characters of SHA-256 over the raw key bytes. Lets an operator tell which key
* a confirmation or a copy belongs to without the key ever leaving the process
* (48 bits of a hash of a 256-bit key; nothing to invert). Null for an id that
* is not configured.
*/
export function getKeyFingerprint(id: string): string | null {
const key = getKeyById(id);
return key ? fingerprintKeyBytes(key) : null;
}
/** The fingerprint rule itself, for a key that is already decoded. */
export function fingerprintKeyBytes(key: Buffer): string {
return createHash("sha256").update(key).digest("hex").slice(0, 12);
}
/**
* Whether `candidate` (as an operator would paste it: 64 hex characters or a
* base64 32-byte value) decodes to the configured key `id`. Constant-time on
* the decoded bytes. A value that does not decode is simply not a match; the
* decoder's error text is never surfaced, because it can quote the input's
* length.
*/
export function candidateMatchesKey(candidate: string, id: string): boolean {
const key = getKeyById(id);
if (!key) return false;
let decoded: Buffer;
try {
decoded = decodeKey(candidate.trim(), id);
} catch {
return false;
}
return decoded.length === key.length && timingSafeEqual(decoded, key);
}
/**
* Seal `plaintext` under a SPECIFIC configured key id rather than the active
* one. Exists for one caller, the boot key check (`src/lib/crypto/canary.ts`),
* which keeps one known value per key id; everything else writes with
* `encrypt()`. Throws for an id that is not configured.
*/
export function encryptUnderKeyId(plaintext: string, id: string): string {
const key = getKeyById(id);
if (!key) {
throw new Error(`Encryption key id '${id}' is not configured.`);
}
return `${id}.${encryptWithKey(plaintext, key)}`;
}
function encryptWithKey(plaintext: string, key: Buffer): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
+247
View File
@@ -0,0 +1,247 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetCryptoCacheForTests,
candidateMatchesKey,
encrypt,
encryptUnderKeyId,
fingerprintKeyBytes,
getKeyFingerprint,
} from "@/lib/crypto";
import {
canaryPlaintext,
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
} from "../canary";
const KEY_A =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
/** An in-memory stand-in for the two raw statements and the probe delegates. */
function fakeClient(opts: {
canaries?: Record<string, string>;
/** delegate → field → stored values (in row order). */
probe?: Record<string, Record<string, string[]>>;
}) {
const canaries = new Map(Object.entries(opts.canaries ?? {}));
const inserts: string[] = [];
const client = {
async $queryRaw() {
return [...canaries.entries()].map(([key_id, ciphertext]) => ({
key_id,
ciphertext,
}));
},
async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) {
const [keyId, ciphertext] = values as [string, string];
inserts.push(keyId);
if (!canaries.has(keyId)) canaries.set(keyId, ciphertext);
return 1;
},
} as unknown as CanaryClient & Record<string, unknown>;
for (const [delegate, fields] of Object.entries(opts.probe ?? {})) {
client[delegate] = {
async findMany(args: {
select: Record<string, boolean>;
take: number;
skip?: number;
}) {
const field = Object.keys(args.select)[0];
const skip = args.skip ?? 0;
return (fields[field] ?? [])
.slice(skip, skip + args.take)
.map((value) => ({ [field]: value }));
},
};
}
return { client, canaries, inserts };
}
describe("encryption key canary", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
});
it("writes a canary for a configured key with no row and no data", async () => {
const { client, canaries, inserts } = fakeClient({});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: ["v1"],
verified: [],
inconclusive: [],
});
expect(inserts).toEqual(["v1"]);
expect(canaries.get("v1")?.startsWith("v1.")).toBe(true);
});
it("verifies a canary written under the same key", async () => {
const { client, inserts } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v1"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: [],
verified: ["v1"],
inconclusive: [],
});
expect(inserts).toEqual([]);
});
it("reports a mismatch when the key changed under the same id", async () => {
const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
useKey(KEY_B);
const { client, inserts } = fakeClient({ canaries: { v1: sealedUnderA } });
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("reports a mismatch for a canary that opens to the wrong value", async () => {
// A row from another key id copied over: opens, but says the wrong thing.
const { client } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v2"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("mismatch");
});
it("refuses to seal a wrong key when existing data predates the canary", async () => {
const existing = [encrypt("a stored token"), encrypt("another token")];
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: {
user: {
codexAccessTokenEncrypted: [existing[0]],
codexRefreshTokenEncrypted: [existing[1]],
},
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("writes the canary when existing data opens under the key", async () => {
const { client, inserts } = fakeClient({
probe: {
user: { codexAccessTokenEncrypted: [encrypt("a stored token")] },
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
it("one damaged row among good ones does not take the server down", async () => {
// A row that claims key id v1 but does not open (damaged, or sealed by
// something else), found FIRST, then values that open.
const damaged = "v1." + Buffer.alloc(40, 7).toString("base64");
const { client, inserts } = fakeClient({
probe: {
user: {
codexAccessTokenEncrypted: [damaged],
codexRefreshTokenEncrypted: [encrypt("a stored token")],
},
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
it("several values in one column count, so a wrong key with one busy column still refuses", async () => {
const values = [encrypt("one"), encrypt("two"), encrypt("three")];
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: values } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("a single value that does not open is inconclusive: no mismatch, no canary", async () => {
const existing = encrypt("the only stored token");
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: [existing] } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: [],
verified: [],
inconclusive: ["v1"],
});
expect(inserts).toEqual([]);
});
it("reports an error, not a mismatch, when the table cannot be read", async () => {
const client = {
$queryRaw: async () => {
throw new Error('relation "encryption_key_canaries" does not exist');
},
$executeRaw: async () => 0,
} as unknown as CanaryClient;
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("error");
});
it("reports an error when no key is configured", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
const outcome = await checkEncryptionKeyCanaries(fakeClient({}).client);
expect(outcome.state).toBe("error");
});
it("names the key ids and never a key in the log block", () => {
const block = keyMismatchLogBlock(["v1"]);
expect(block).toContain("'v1'");
expect(block).toContain("encryption.key_mismatch");
expect(block).not.toContain(KEY_A);
});
});
describe("key fingerprint and copy check", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
});
it("is the first 12 hex of SHA-256 over the raw key bytes", () => {
expect(getKeyFingerprint("v1")).toBe(
fingerprintKeyBytes(Buffer.from(KEY_A, "hex")),
);
expect(getKeyFingerprint("v1")).toMatch(/^[0-9a-f]{12}$/);
expect(getKeyFingerprint("v1")).not.toBe(
fingerprintKeyBytes(Buffer.from(KEY_B, "hex")),
);
expect(getKeyFingerprint("nope")).toBeNull();
});
it("matches the configured key in hex, base64 and with whitespace", () => {
expect(candidateMatchesKey(KEY_A, "v1")).toBe(true);
expect(candidateMatchesKey(` ${KEY_A.toUpperCase()}\n`, "v1")).toBe(true);
expect(
candidateMatchesKey(Buffer.from(KEY_A, "hex").toString("base64"), "v1"),
).toBe(true);
});
it("does not match another key, garbage, or an unknown id", () => {
expect(candidateMatchesKey(KEY_B, "v1")).toBe(false);
expect(candidateMatchesKey("not a key", "v1")).toBe(false);
expect(candidateMatchesKey(KEY_A, "v9")).toBe(false);
});
});
@@ -60,11 +60,19 @@ const RELATION_ENVELOPE = new Set([
]);
/**
* Columns this scan derives that are deliberately NOT in the rotation
* registry. Empty today. An entry here is a written decision with a reason,
* never a way to quiet the guard.
* Columns that hold ciphertext and are deliberately NOT in the rotation
* registry. An entry here is a written decision with a reason, never a way to
* quiet the guard.
*
* `EncryptionKeyCanary.ciphertext` — one known value per key id, sealed under
* THAT key (`encryptUnderKeyId`, written by raw SQL in
* `src/lib/crypto/canary.ts`). Re-sealing it under the active key would make
* every id's boot check test the active key instead of its own. A retired
* key's row is never read again: only configured ids are checked.
*/
const NOT_ROTATED: ReadonlySet<string> = new Set<string>();
const NOT_ROTATED: ReadonlySet<string> = new Set<string>([
"EncryptionKeyCanary.ciphertext",
]);
/**
* Ciphertext written where the Prisma-call walk below cannot see it: by raw
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { isKeyBackupDue, resolvePlatformHint } from "../key-backup";
const confirmed = (keyId: string, fingerprint: string) => ({
encryptionKeyBackupConfirmedAt: new Date("2026-10-03T10:00:00Z"),
encryptionKeyBackupConfirmedKeyId: keyId,
encryptionKeyBackupConfirmedFingerprint: fingerprint,
});
describe("key backup step", () => {
it("is due without any confirmation", () => {
expect(isKeyBackupDue(null, "v1", "abcdefabcdef")).toBe(true);
expect(
isKeyBackupDue(
{
encryptionKeyBackupConfirmedAt: null,
encryptionKeyBackupConfirmedKeyId: null,
encryptionKeyBackupConfirmedFingerprint: null,
},
"v1",
"abcdefabcdef",
),
).toBe(true);
});
it("is done only for the exact key that was confirmed", () => {
expect(
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "abcdefabcdef"),
).toBe(false);
});
it("is due again after a rotation (new active id)", () => {
expect(
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v2", "abcdefabcdef"),
).toBe(true);
});
it("is due again after a re-keyed install (same id, other key)", () => {
expect(
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "123456123456"),
).toBe(true);
});
it("reads the platform hint and falls back to compose", () => {
expect(resolvePlatformHint("truenas")).toBe("truenas");
expect(resolvePlatformHint(" Unraid ")).toBe("unraid");
expect(resolvePlatformHint(undefined)).toBe("compose");
expect(resolvePlatformHint("kubernetes")).toBe("compose");
});
});
+271
View File
@@ -0,0 +1,271 @@
/**
* Boot check: does the configured encryption key open this database?
*
* Without it, a server started with a different `ENCRYPTION_KEY` than the one
* its database was written with (a reinstalled NAS app over an old dataset, a
* restored dump under a fresh key) signs people in normally, because sessions
* hash with `API_TOKEN_HMAC_KEY`, and then answers random 500s wherever a value
* has to be decrypted. This module turns that into one clear refusal.
*
* For every configured key id, `encryption_key_canaries` holds a known
* plaintext sealed under that key:
*
* - row present: it must decrypt to the expected value, else the key is not
* the one the row was written with;
* - row absent: before writing one, sample existing ciphertext under that
* key id from the registered string columns and try it. Without this
* probe, the first boot after an upgrade (or a re-keyed boot over a
* database that predates the canary) would seal the wrong key as the
* right one.
*
* The probe samples up to `PROBE_SAMPLE_LIMIT` values, one per column first
* (different columns are different writers, so one bad writer cannot speak
* for the whole database), then further values from columns that had any.
* Its verdict, and what each one does:
*
* - `opens`: at least one value opened. A wrong key opens nothing, so the
* key is right and any value that failed is a damaged or foreign row, not
* a key problem. The canary is written.
* - `fails`: two or more values were tried and none opened. That is what a
* wrong key looks like; the process refuses (mismatch).
* - `inconclusive`: exactly one value was found and it did not open. One
* row cannot tell a wrong key from one damaged row, and the two possible
* mistakes are not equal: declaring a mismatch takes a correctly keyed
* server down with 503 on every request, while serving keeps today's
* behaviour. So the process serves, but writes NO canary either (that
* would seal a key that may be wrong), logs a warning, and probes again
* at the next boot, by which time more data may decide it.
* - `none`: nothing found inside the time budget. The canary is written,
* which is exactly the behaviour before this check existed.
*
* Writes are `ON CONFLICT DO NOTHING`, so two processes racing is fine.
*
* Never logs or returns key material. The canary is written by raw SQL and is
* deliberately not in the rotation registry: it belongs to its key id.
*/
import {
decrypt,
encryptUnderKeyId,
extractKeyId,
getConfiguredKeyIds,
} from "@/lib/crypto";
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
export const CANARY_PREFIX = "healthlog-canary:";
export function canaryPlaintext(keyId: string): string {
return `${CANARY_PREFIX}${keyId}`;
}
/** How many existing values the probe tries at most per key id. */
export const PROBE_SAMPLE_LIMIT = 5;
/** How long the existing-data probe may take before the canary is written anyway. */
export const PROBE_BUDGET_MS = 10_000;
/** The slice of a Prisma client this module needs. */
export interface CanaryClient {
$queryRaw<T = unknown>(
query: TemplateStringsArray,
...values: unknown[]
): Promise<T>;
$executeRaw(
query: TemplateStringsArray,
...values: unknown[]
): Promise<number>;
}
type ProbeDelegate = {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean>;
take: number;
skip?: number;
}) => Promise<Array<Record<string, unknown>>>;
};
export type ProbeVerdict = "opens" | "fails" | "inconclusive" | "none";
export type KeyCheckOutcome =
| {
state: "ok";
/** Key ids whose canary this run wrote. */
written: string[];
/** Key ids whose canary existed and opened. */
verified: string[];
/**
* Key ids with no canary whose only existing value did not open: not a
* mismatch, no canary written, probed again at the next boot.
*/
inconclusive: string[];
}
| { state: "mismatch"; keyIds: string[] }
| { state: "error"; message: string };
function opensAs(ciphertext: string, keyId: string, expected?: string) {
try {
if (extractKeyId(ciphertext) !== keyId) return false;
const plain = decrypt(ciphertext);
return expected === undefined ? true : plain === expected;
} catch {
return false;
}
}
/**
* Sample existing values sealed under `keyId` and try them; see the module
* comment for what each verdict means and why.
*/
export async function probeExistingData(
client: unknown,
keyId: string,
deadline: number,
): Promise<ProbeVerdict> {
const delegates = client as Record<string, ProbeDelegate | undefined>;
const columns = ENCRYPTED_COLUMNS.filter(
(c) => c.kind === "string" && !c.codec && !c.codecField,
);
let tried = 0;
const columnsWithData: typeof columns = [];
const sample = async (
column: (typeof columns)[number],
skip: number,
take: number,
): Promise<string[]> => {
const delegate =
delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)];
if (!delegate || typeof delegate.findMany !== "function") return [];
try {
const rows = await delegate.findMany({
where: { [column.field]: { startsWith: `${keyId}.` } },
select: { [column.field]: true },
take,
...(skip > 0 ? { skip } : {}),
});
return rows
.map((row) => row[column.field])
.filter((v): v is string => typeof v === "string");
} catch {
return [];
}
};
// Pass 1: one value per column, so different writers are heard first.
for (const column of columns) {
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
const values = await sample(column, 0, 1);
if (values.length === 0) continue;
columnsWithData.push(column);
tried += 1;
if (opensAs(values[0], keyId)) return "opens";
}
// Pass 2: more values from the columns that had any, up to the limit.
for (const column of columnsWithData) {
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
const values = await sample(column, 1, PROBE_SAMPLE_LIMIT - tried);
for (const value of values) {
tried += 1;
if (opensAs(value, keyId)) return "opens";
}
}
if (tried >= 2) return "fails";
if (tried === 1) return "inconclusive";
return "none";
}
/**
* Run the check for every configured key id. Never throws: a failure to run
* the check at all is reported as `error`, and only a value that provably does
* not open is reported as `mismatch`.
*/
export async function checkEncryptionKeyCanaries(
client: CanaryClient,
options: { probeBudgetMs?: number } = {},
): Promise<KeyCheckOutcome> {
let keyIds: string[];
try {
keyIds = getConfiguredKeyIds();
} catch (err) {
return { state: "error", message: (err as Error).message };
}
let rows: Array<{ key_id: string; ciphertext: string }>;
try {
rows = await client.$queryRaw<
Array<{ key_id: string; ciphertext: string }>
>`SELECT key_id, ciphertext FROM encryption_key_canaries WHERE key_id = ANY(${keyIds})`;
} catch (err) {
return { state: "error", message: (err as Error).message };
}
const stored = new Map(rows.map((r) => [r.key_id, r.ciphertext]));
const mismatched: string[] = [];
const verified: string[] = [];
const missing: string[] = [];
for (const keyId of keyIds) {
const ciphertext = stored.get(keyId);
if (ciphertext === undefined) {
missing.push(keyId);
} else if (opensAs(ciphertext, keyId, canaryPlaintext(keyId))) {
verified.push(keyId);
} else {
mismatched.push(keyId);
}
}
const deadline = Date.now() + (options.probeBudgetMs ?? PROBE_BUDGET_MS);
const toWrite: string[] = [];
const inconclusive: string[] = [];
for (const keyId of missing) {
const probe = await probeExistingData(client, keyId, deadline);
if (probe === "fails") mismatched.push(keyId);
else if (probe === "inconclusive") inconclusive.push(keyId);
else toWrite.push(keyId);
}
if (mismatched.length > 0) {
// Nothing is written while any key fails: a process in this state must
// not leave anything behind sealed under a key that may be wrong.
return { state: "mismatch", keyIds: mismatched.sort() };
}
try {
for (const keyId of toWrite) {
const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId);
await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`;
}
} catch (err) {
return { state: "error", message: (err as Error).message };
}
return { state: "ok", written: toWrite, verified, inconclusive };
}
/**
* The one block an operator reads in the container log. English literal on
* purpose: logs are not translated. Names the key ids, never a key.
*/
export function keyMismatchLogBlock(keyIds: string[]): string {
const ids = keyIds.map((id) => `'${id}'`).join(", ");
return [
"============================================================",
" HealthLog refuses to serve: encryption key does not match",
"============================================================",
` The encryption key configured for key id ${ids} cannot open the`,
" data already stored in this database. Serving anyway would fail on",
" every encrypted value, so every API request answers 503 with",
" errorCode 'encryption.key_mismatch' and /api/health reports",
" reason 'encryption_key_mismatch'. Background jobs are not started.",
"",
" Fix it one of three ways, then restart:",
" 1. Restore the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry)",
" this database was written with.",
" 2. Point DATABASE_URL at the database that belongs to this key.",
" 3. Start fresh: an empty database with this key. Stored data and",
" in-database backups of the old database cannot be read without",
" the old key.",
"============================================================",
].join("\n");
}
+142
View File
@@ -0,0 +1,142 @@
/**
* The "Back up your encryption key" step: instance state and its rules.
*
* The confirmation lives on the `AppSettings` singleton, bound to the active
* key id and that key's fingerprint, so it is an instance fact (it survives
* the admin who gave it) and a rotation or a re-keyed install makes the step
* due again. Nothing here ever reads, returns or stores the key itself.
*/
import { prisma } from "@/lib/db";
import { envValue } from "@/lib/env";
import { getActiveKeyId, getKeyFingerprint } from "@/lib/crypto";
/** Where the operator sets the key; picks the tab the step opens on. */
export const KEY_BACKUP_PLATFORMS = [
"compose",
"truenas",
"coolify",
"unraid",
"portainer",
] as const;
export type KeyBackupPlatform = (typeof KEY_BACKUP_PLATFORMS)[number];
/** Wire code for a confirmation given for a key that is no longer active. */
export const KEY_BACKUP_STALE_CODE = "encryption.keyBackup.stale";
/**
* `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise.
* Catalog templates set it so the step opens on the right instructions.
*/
export function resolvePlatformHint(
raw: string | undefined = envValue("HEALTHLOG_PLATFORM"),
): KeyBackupPlatform {
const value = raw?.trim().toLowerCase();
return (KEY_BACKUP_PLATFORMS as readonly string[]).includes(value ?? "")
? (value as KeyBackupPlatform)
: "compose";
}
export interface KeyBackupStatus {
/** True until a confirmation exists for the active key id AND fingerprint. */
due: boolean;
activeKeyId: string;
/** Fingerprint of the active key (first 12 hex of SHA-256 over its bytes). */
fingerprint: string;
confirmedAt: string | null;
confirmedKeyId: string | null;
confirmedFingerprint: string | null;
/** The confirming admin, while that account exists. */
confirmedBy: { id: string; email: string | null } | null;
platformHint: KeyBackupPlatform;
}
interface StoredConfirmation {
encryptionKeyBackupConfirmedAt: Date | null;
encryptionKeyBackupConfirmedKeyId: string | null;
encryptionKeyBackupConfirmedFingerprint: string | null;
}
/** Pure rule: is the stored confirmation for this exact key? */
export function isKeyBackupDue(
stored: StoredConfirmation | null,
activeKeyId: string,
fingerprint: string,
): boolean {
return !(
stored?.encryptionKeyBackupConfirmedAt &&
stored.encryptionKeyBackupConfirmedKeyId === activeKeyId &&
stored.encryptionKeyBackupConfirmedFingerprint === fingerprint
);
}
function activeKey(): { activeKeyId: string; fingerprint: string } {
const activeKeyId = getActiveKeyId();
// The active id is always configured, so the fingerprint is never null.
return { activeKeyId, fingerprint: getKeyFingerprint(activeKeyId) ?? "" };
}
export async function readKeyBackupStatus(): Promise<KeyBackupStatus> {
const { activeKeyId, fingerprint } = activeKey();
const row = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
encryptionKeyBackupConfirmedAt: true,
encryptionKeyBackupConfirmedKeyId: true,
encryptionKeyBackupConfirmedFingerprint: true,
encryptionKeyBackupConfirmedByUserId: true,
},
});
const confirmedBy = row?.encryptionKeyBackupConfirmedByUserId
? await prisma.user.findUnique({
where: { id: row.encryptionKeyBackupConfirmedByUserId },
select: { id: true, email: true },
})
: null;
return {
due: isKeyBackupDue(row, activeKeyId, fingerprint),
activeKeyId,
fingerprint,
confirmedAt: row?.encryptionKeyBackupConfirmedAt?.toISOString() ?? null,
confirmedKeyId: row?.encryptionKeyBackupConfirmedKeyId ?? null,
confirmedFingerprint: row?.encryptionKeyBackupConfirmedFingerprint ?? null,
confirmedBy: confirmedBy ?? null,
platformHint: resolvePlatformHint(),
};
}
export type ConfirmKeyBackupResult =
| { ok: true; status: KeyBackupStatus }
| { ok: false; activeKeyId: string; fingerprint: string };
/**
* Record the confirmation, but only for the key the admin was shown: a page
* opened before a rotation must not confirm the new key.
*/
export async function confirmKeyBackup(input: {
keyId: string;
fingerprint: string;
userId: string;
}): Promise<ConfirmKeyBackupResult> {
const { activeKeyId, fingerprint } = activeKey();
if (input.keyId !== activeKeyId || input.fingerprint !== fingerprint) {
return { ok: false, activeKeyId, fingerprint };
}
const now = new Date();
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
encryptionKeyBackupConfirmedAt: now,
encryptionKeyBackupConfirmedKeyId: activeKeyId,
encryptionKeyBackupConfirmedFingerprint: fingerprint,
encryptionKeyBackupConfirmedByUserId: input.userId,
},
update: {
encryptionKeyBackupConfirmedAt: now,
encryptionKeyBackupConfirmedKeyId: activeKeyId,
encryptionKeyBackupConfirmedFingerprint: fingerprint,
encryptionKeyBackupConfirmedByUserId: input.userId,
},
});
return { ok: true, status: await readKeyBackupStatus() };
}
+2
View File
@@ -299,6 +299,8 @@ export const WIPE_EXEMPT: Readonly<Record<string, string>> = {
*/
export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
AppSettings: "instance-wide configuration singleton",
EncryptionKeyCanary:
"one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account",
RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
HostMetric: "instance-wide host telemetry, not attributable to an account",
OffhostBackupKeyUse:
+5
View File
@@ -96,6 +96,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
// `client_secret` are already covered by `/token/i` + `/secret/i`; the
// verifier is the one secret-shaped key those patterns miss.
/verifier/i,
// v1.40.0 — the encryption key an admin pastes into "Check my copy"
// (`POST /api/admin/encryption/key-backup/verify`). The route never logs its
// body; this keeps it out of any excerpt a future change might take.
// Anchored so `encryptionKeyId` and friends stay readable.
/^encryption[_-]?key$/i,
];
/**
+2
View File
@@ -70,6 +70,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.reproof.required",
"auth.reproof.too_weak",
"auth.scope.insufficient",
"auth.session.insecure_transport",
"auth.stepup.required",
"auth.token.expired",
"auth.token.invalid",
@@ -172,6 +173,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"encounter.reminder-conflict",
"encounter.reminder-not-found",
],
encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"],
environment: [
"environment.invalid",
"environment.no_home",
+41 -2
View File
@@ -47,6 +47,7 @@ import {
errorEnvelope,
loginPasswordSchema,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
@@ -438,9 +439,24 @@ const registrationStatusResponse = z
.describe(
"Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token.",
),
sessionCookieSecure: z
.boolean()
.describe(
"Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this with its own scheme before any credential is sent.",
),
})
.meta({ id: "RegistrationStatusResponse" });
/**
* The browser sign-in refusal for a page whose session cookie cannot stick.
* Only ever returned to a caller that sent `clientTransport`.
*/
const insecureTransportResponse = {
description:
'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the iOS app, scripts) is never refused this way.',
content: { "application/json": { schema: errorEnvelope } },
};
const apiTokenInfo = z
.object({
id: z.string().describe("Row id; the handle for DELETE."),
@@ -701,6 +717,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: loginPasswordSchema } },
},
responses: {
"409": insecureTransportResponse,
"200": {
description:
"Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).",
@@ -733,6 +750,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: mfaVerifySchema } },
},
responses: {
"409": insecureTransportResponse,
"200": {
description:
"Second factor verified — session + optional bearer issued.",
@@ -1247,6 +1265,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
"409": insecureTransportResponse,
"200": {
description:
"Second factor verified — session + optional bearer issued.",
@@ -1613,8 +1632,15 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Begin a passkey sign-in (discoverable credentials)",
description:
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body, and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
requestBody: {
required: false,
content: {
"application/json": { schema: passkeyLoginOptionsSchema },
},
},
responses: {
"409": insecureTransportResponse,
"200": {
description: "Assertion options issued.",
content: {
@@ -2117,12 +2143,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.describe(
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
),
client_proto: z
.enum(["http", "https"])
.optional()
.describe(
"Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback `client_host` while the server issues `Secure` session cookies, the start redirects to `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped on return.",
),
client_host: z
.string()
.max(255)
.optional()
.describe(
"Browser leg only: `location.host` of that page, read together with `client_proto`.",
),
}),
},
responses: {
"302": {
description:
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
},
},
},
+12 -3
View File
@@ -25,15 +25,24 @@ const healthStatusEnum = z.enum(["ok", "degraded"]).meta({
"`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing.",
});
const healthPublicResponse = z.object({ status: healthStatusEnum }).meta({
id: "HealthPublicResponse",
const healthReason = z.enum(["encryption_key_mismatch"]).optional().meta({
id: "HealthReason",
description:
"What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.",
"Present only when the process refuses to serve for a reason a person has to fix. `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart.",
});
const healthPublicResponse = z
.object({ status: healthStatusEnum, reason: healthReason })
.meta({
id: "HealthPublicResponse",
description:
"What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.",
});
const healthAdminResponse = z
.object({
status: healthStatusEnum,
reason: healthReason,
timestamp: z.iso
.datetime({ offset: true })
.describe("When the probe ran. UTC (`Z`) — no user context is loaded."),
+3
View File
@@ -48,6 +48,9 @@ export const adminKeys = {
adminBackupRestores: () => ["admin", "backup-restores"] as const,
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
adminEncryptionKeyBackup: () =>
["admin", "encryption", "key-backup"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
adminFeedbackRoot: () => ["admin", "feedback"] as const,
+11
View File
@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr";
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
import { clientTransportSchema } from "@/lib/validations/client-transport";
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
const PASSWORD_MIN_LENGTH = 12;
@@ -40,8 +41,18 @@ export const registerSchema = z.object({
export const loginPasswordSchema = z.object({
email: z.string().trim().min(1, "Email or username required"),
password: z.string().min(1),
clientTransport: clientTransportSchema.optional(),
});
/** `POST /api/auth/passkey/login-options`: an optional body, web client only. */
export const passkeyLoginOptionsSchema = z
.object({ clientTransport: clientTransportSchema.optional() })
.meta({
id: "PasskeyLoginOptionsRequest",
description:
"Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused before a challenge is issued. Any other caller may send no body at all.",
});
export const profileSchema = z.object({
email: z.email("Invalid email address").nullable().optional(),
heightCm: z.number().min(50).max(300).nullable().optional(),
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod/v4";
/**
* The optional `clientTransport` field the web client sends on the browser
* sign-in requests. Optional everywhere: a client that does not send it (the
* iOS app, scripts) is never refused because of it. It can only ever cause a
* refusal of its own request, so a forged value harms nobody but its sender.
*/
export const clientTransportSchema = z
.object({
protocol: z
.enum(["http", "https"])
.describe("The scheme of the page the request comes from."),
host: z
.string()
.max(255)
.describe("`location.host` of that page: hostname and optional port."),
})
.meta({
id: "ClientTransport",
description:
"Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` before any credential, ticket or code is checked or used, because the browser would drop the session cookie.",
});
+3
View File
@@ -4,6 +4,7 @@
* auth.ts`) describe the identical wire contract.
*/
import { z } from "zod/v4";
import { clientTransportSchema } from "@/lib/validations/client-transport";
/** A 6-digit TOTP code. */
export const totpCodeSchema = z
@@ -42,6 +43,7 @@ export const mfaVerifySchema = z
.describe(
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
),
clientTransport: clientTransportSchema.optional(),
})
.meta({
id: "MfaVerifyRequest",
@@ -111,6 +113,7 @@ export const mfaWebauthnLoginVerifySchema = z
.describe(
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
),
clientTransport: clientTransportSchema.optional(),
})
.meta({
id: "MfaWebauthnLoginVerifyRequest",