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

@@ -114,3 +114,30 @@ describe("GET /api/auth/registration-status", () => {
expect(body.data.registrationEnabled).toBe(false);
});
});
describe("GET /api/auth/registration-status — sessionCookieSecure (#1097)", () => {
it("publishes whether the server issues Secure session cookies, on every branch", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
} as never);
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
let body = await (await callGet()).json();
expect(body.data.sessionCookieSecure).toBe(true);
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
body = await (await callGet()).json();
expect(body.data.sessionCookieSecure).toBe(false);
vi.mocked(prisma.appSettings.findUnique).mockRejectedValue(
new Error("db down"),
);
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
body = await (await callGet()).json();
expect(body.data).toEqual({
registrationEnabled: false,
sessionCookieSecure: true,
});
vi.unstubAllEnvs();
});
});
@@ -239,6 +239,18 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/encryption/key-backup/confirm": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup/verify": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
@@ -71,6 +71,11 @@ const MANIFEST_NAMES: string[] = (
* entry a lie rather than an inconvenience.
*/
const PUBLISHED_TO_USERS = [
{
name: "HEALTHLOG_PLATFORM",
shownBy:
"the admin's encryption key backup step, which opens on that platform",
},
{
name: "AUDIT_LOG_RETENTION_DAYS",
shownBy: "the shared-access activity window and the revoke dialog",
@@ -0,0 +1,73 @@
/**
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
*
* The admin states that the active encryption key is backed up. The body
* names the key the admin was shown; when that is no longer the active key
* (rotated, or the server restarted with another key since the page loaded)
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
* No step-up: confirming is a low-risk write that reveals nothing.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
confirmKeyBackup,
KEY_BACKUP_STALE_CODE,
} from "@/lib/crypto/key-backup";
export const dynamic = "force-dynamic";
const confirmSchema = z
.object({
keyId: z.string().min(1).max(32),
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = confirmSchema.safeParse(body.data);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const result = await confirmKeyBackup({
keyId: parsed.data.keyId,
fingerprint: parsed.data.fingerprint,
userId: user.id,
});
if (!result.ok) {
annotate({ meta: { key_backup_stale: true } });
return apiError(
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
409,
{
errorCode: KEY_BACKUP_STALE_CODE,
activeKeyId: result.activeKeyId,
fingerprint: result.fingerprint,
},
);
}
await auditLog("encryption.keyBackup.confirmed", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
keyId: result.status.activeKeyId,
fingerprint: result.status.fingerprint,
},
});
return apiSuccess(result.status);
});
@@ -0,0 +1,22 @@
/**
* GET /api/admin/encryption/key-backup
*
* Whether the "Back up your encryption key" step is due, for the admin step,
* the overview tile and the dashboard banner. A light read (one settings row),
* separate from `/status`, which walks the whole encrypted corpus. Key id and
* fingerprint only, never key material. Cookie-only admin.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
const status = await readKeyBackupStatus();
annotate({ meta: { key_backup_due: status.due } });
return apiSuccess(status);
});
@@ -0,0 +1,64 @@
/**
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
*
* "Check my copy": the admin pastes the backed-up key and learns whether it
* is the active key. The value is compared in memory (constant time on the
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
* the Zod issue list is not returned for this body (a validation failure is a
* plain 422), and the field name is on the observability denylist.
*
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
* per admin is there to keep the route from being anyone's busy loop. Over
* plain HTTP the value crosses the network in clear, as the admin's password
* does; the client says so before sending.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
export const dynamic = "force-dynamic";
const KEY_BACKUP_VERIFY_LIMIT = 5;
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
const verifySchema = z
.object({ encryptionKey: z.string().min(1).max(256) })
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
const limit = await checkRateLimit(
`key-backup-verify:${user.id}`,
KEY_BACKUP_VERIFY_LIMIT,
KEY_BACKUP_VERIFY_WINDOW_MS,
);
if (!limit.allowed) {
return apiError("Too many checks. Try again in a minute.", 429);
}
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = verifySchema.safeParse(body.data);
if (!parsed.success) {
// Deliberately not `returnAllZodIssues`: nothing derived from this body
// goes back over the wire.
return apiError("Paste the key as 64 hex characters.", 422);
}
const activeKeyId = getActiveKeyId();
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
annotate({ meta: { key_backup_verify_matches: matches } });
await auditLog("encryption.keyBackup.verified", {
userId: user.id,
details: { keyId: activeKeyId, matches },
});
return apiSuccess({ matches, keyId: activeKeyId });
});
@@ -17,6 +17,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
import { annotate } from "@/lib/logging/context";
import { getConfiguredKeyIds } from "@/lib/crypto";
import {
@@ -213,6 +214,7 @@ export const GET = apiHandler(async () => {
const rotation = await readRotationState();
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
const keyBackup = await readKeyBackupStatus();
annotate({
meta: {
@@ -233,6 +235,8 @@ export const GET = apiHandler(async () => {
columns: scan.columns,
rotation,
backups,
// The "Back up your encryption key" step, bound to the active key.
keyBackup,
// Every row rotated AND no backup still needs a retired key: the one
// state in which dropping an old key loses nothing. Copies that recorded
// no key ids count against it, because they may hold any key that
+8
View File
@@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
@@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// A browser page on plain http:// with Secure-only cookies cannot keep the
// session. Refuse before the password is verified (the account throttle
// never counts it) and before an MFA ticket is minted.
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
+6
View File
@@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa";
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const dynamic = "force-dynamic";
@@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// Before the ticket is loaded, an attempt reserved, or a one-time code
// spent: a sign-in whose cookie cannot stick must not consume either.
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = mfaVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
@@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const dynamic = "force-dynamic";
@@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// Before the ticket is loaded or an attempt reserved (see mfa/verify).
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
+19
View File
@@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { transportVerdict } from "@/lib/auth/client-transport";
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
import {
@@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
return loginError("oidc_disabled");
}
// The SSO button states the page's transport. A browser on plain http://
// with Secure-only cookies would come back from the provider and drop the
// session cookie, so it is stopped here instead of after the round trip.
if (!isNative) {
const clientProto = req.nextUrl.searchParams.get("client_proto");
const clientHost = req.nextUrl.searchParams.get("client_host") ?? "";
if (
(clientProto === "http" || clientProto === "https") &&
transportVerdict(shouldEmitSecureCookie(), {
protocol: clientProto,
host: clientHost,
}) === "blocked"
) {
annotate({ meta: { reason: "insecure_transport" } });
return loginError("insecure_transport");
}
}
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:login",
@@ -1,10 +1,11 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isOidcOnly } from "@/lib/auth/oidc";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const POST = apiHandler(async (request: Request) => {
// OIDC_ONLY must block passkey login too, not just password login.
@@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => {
);
}
// The body is optional (the web client sends its page transport; nothing
// else needs to send anything). A missing or unreadable body is no body, as
// before; only a stated plain-http page refuses, before a challenge exists.
if (request.headers.get("content-type")?.includes("application/json")) {
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
}
const { options, challengeId } = await createAuthenticationOptions();
annotate({ action: { name: "auth.passkey.login-options" } });
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
export const dynamic = "force-dynamic";
@@ -15,10 +16,16 @@ export const GET = apiHandler(async () => {
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
// Whether this server issues `Secure` session cookies. The login page
// compares it with its own scheme before any credential is sent.
sessionCookieSecure: shouldEmitSecureCookie(),
});
} catch {
// Fail closed on backend errors.
annotate({ action: { name: "auth.registration-status" } });
return apiSuccess({ registrationEnabled: false });
return apiSuccess({
registrationEnabled: false,
sessionCookieSecure: shouldEmitSecureCookie(),
});
}
});
@@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => {
expect(ready.body).toEqual({ status: "ok" });
});
});
describe("GET /api/health while the encryption key does not match", () => {
it("answers 503 with the reason, for everyone, even with every component up", async () => {
const { setKeyMismatchState } =
await import("@/lib/boot/key-mismatch-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
try {
const { response, body } = await health();
expect(response.status).toBe(503);
expect(body).toEqual({
status: "degraded",
reason: "encryption_key_mismatch",
});
} finally {
setKeyMismatchState(null);
}
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
});
+12 -1
View File
@@ -6,6 +6,10 @@ import { getSession } from "@/lib/auth/session";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
isKeyMismatch,
KEY_MISMATCH_HEALTH_REASON,
} from "@/lib/boot/key-mismatch-state";
export const dynamic = "force-dynamic";
@@ -22,7 +26,13 @@ export const GET = apiHandler(async () => {
const worker = getWorkerStatus();
const producerReady = getGlobalBoss() !== null;
// A process whose encryption key cannot open the database refuses every
// other route; the probe says so in a word every caller may see, because
// it is the one place a catalog install's status page reads.
const keyMismatch = isKeyMismatch();
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
const status =
!keyMismatch &&
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
@@ -40,6 +50,7 @@ export const GET = apiHandler(async () => {
return NextResponse.json(
{
status,
...(reason ? { reason } : {}),
timestamp: new Date().toISOString(),
database: dbOk ? "connected" : "disconnected",
worker: worker.running ? "running" : "stopped",
@@ -52,7 +63,7 @@ export const GET = apiHandler(async () => {
}
return NextResponse.json(
{ status },
{ status, ...(reason ? { reason } : {}) },
{ status: statusCode, headers: cacheHeaders },
);
});
+68 -15
View File
@@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
import {
InsecureTransportBanner,
useClientTransport,
} from "@/components/auth/insecure-transport-banner";
import { transportVerdict } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
import { loadDocument } from "@/lib/navigation/load-document";
export default function LoginPage() {
@@ -44,7 +52,8 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(() => {
const oidcError = searchParams.get("error");
if (!oidcError) return null;
// The SSO start refused a page on plain http://; the banner says why.
if (!oidcError || oidcError === "insecure_transport") return null;
const key: Record<string, string> = {
oidc_denied: "auth.oidc.errorDenied",
oidc_no_email: "auth.oidc.errorNoEmail",
@@ -71,22 +80,55 @@ export default function LoginPage() {
// standalone alert detached from the form.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const { data: registrationEnabled } = useQuery({
const { data: registrationStatus } = useQuery({
queryKey: queryKeys.authRegistrationStatus(),
queryFn: async () => {
try {
const data = await apiGet<{ registrationEnabled?: boolean }>(
"/api/auth/registration-status",
{ cache: "no-store" },
);
return Boolean(data?.registrationEnabled ?? true);
const data = await apiGet<{
registrationEnabled?: boolean;
sessionCookieSecure?: boolean;
}>("/api/auth/registration-status", { cache: "no-store" });
return {
registrationEnabled: Boolean(data?.registrationEnabled ?? true),
sessionCookieSecure: data?.sessionCookieSecure === true,
};
} catch {
// Fail open, exactly as the raw `if (!res.ok) return true` did.
return true;
// Fail open, exactly as the raw `if (!res.ok) return true` did; and
// no transport gate without the server's word on its cookies.
return { registrationEnabled: true, sessionCookieSecure: false };
}
},
staleTime: 60 * 1000,
});
const registrationEnabled = registrationStatus?.registrationEnabled;
// #1097 — a server that sets its sign-in cookie only over https:// cannot
// sign anyone in on a plain http:// page: the browser drops the cookie. Say
// so before a credential leaves the browser, and stop the sign-in controls.
// The server refuses the same requests (409) if they are sent anyway, and
// the SSO start sends the browser back here with `insecure_transport`.
const clientTransport = useClientTransport();
const [transportRefused, setTransportRefused] = useState(
() => searchParams.get("error") === "insecure_transport",
);
const verdict = transportRefused
? "blocked"
: transportVerdict(
registrationStatus?.sessionCookieSecure === true,
clientTransport,
);
const signInBlocked = verdict === "blocked";
function noteRefusal(err: unknown): boolean {
if (
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
) {
setTransportRefused(true);
return true;
}
return false;
}
const { data: oidcStatus } = useQuery({
queryKey: queryKeys.authOidcStatus(),
@@ -106,6 +148,10 @@ export default function LoginPage() {
function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
if (clientTransport) {
params.set("client_proto", clientTransport.protocol);
params.set("client_host", clientTransport.host);
}
loadDocument(`/api/auth/oidc/login?${params}`);
}
@@ -176,7 +222,7 @@ export default function LoginPage() {
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
}>("/api/auth/passkey/login-options", { clientTransport });
const credential = await startAuthentication({ optionsJSON: options });
@@ -189,6 +235,7 @@ export default function LoginPage() {
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
// Route rejections carry the envelope error verbatim; everything
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
if (err instanceof ApiError) {
@@ -220,7 +267,7 @@ export default function LoginPage() {
>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, clientTransport }),
});
if (meta?.mfaRequired && meta.mfaTicket) {
@@ -235,6 +282,7 @@ export default function LoginPage() {
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
setError(
err instanceof ApiError && err.message
? err.message
@@ -289,6 +337,9 @@ export default function LoginPage() {
useEffect(() => {
if (oidcStatus === undefined) return;
if (oidcStatus.only === true) return;
// Wait for the server's word on its cookies, and never arm the browser's
// credential prompt for a sign-in that cannot keep its cookie.
if (registrationStatus === undefined || signInBlocked) return;
if (autofillStarted.current) return;
autofillStarted.current = true;
let cancelled = false;
@@ -302,7 +353,7 @@ export default function LoginPage() {
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
}>("/api/auth/passkey/login-options", { clientTransport });
const credential = await startAuthentication({
optionsJSON: options,
@@ -327,7 +378,7 @@ export default function LoginPage() {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [oidcStatus]);
}, [oidcStatus, registrationStatus, signInBlocked]);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
@@ -380,12 +431,14 @@ export default function LoginPage() {
data-slot="login-actions"
className="mt-8 space-y-4"
>
<InsecureTransportBanner verdict={verdict} />
{oidcStatus?.enabled && (
<Button
onClick={handleOidcLogin}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
disabled={signInBlocked}
>
<Shield className="h-4 w-4" />
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
@@ -414,7 +467,7 @@ export default function LoginPage() {
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
disabled={loading || signInBlocked}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -491,7 +544,7 @@ export default function LoginPage() {
data-testid="login-password-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
disabled={loading || signInBlocked}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
+43
View File
@@ -0,0 +1,43 @@
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
/**
* What every page renders while the boot key check has found that the
* configured encryption key cannot open this database (see
* `src/lib/crypto/canary.ts`). A static server-rendered page with no client
* code and no data reads: the app behind it would only fail on every value.
* The API answers 503 with `encryption.key_mismatch` at the same time, and
* `/api/health` names the reason.
*/
export function KeyMismatchPage({ locale }: { locale: Locale }) {
const { t } = getServerTranslator(locale);
return (
<main className="bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10">
<div className="border-border bg-card w-full max-w-xl space-y-4 rounded-xl border p-4 md:p-6">
<h1 className="text-xl font-semibold">
{t("admin.keyMismatch.title")}
</h1>
<p className="text-sm">{t("admin.keyMismatch.body")}</p>
<div className="space-y-2">
<h2 className="text-base font-semibold">
{t("admin.keyMismatch.fixTitle")}
</h2>
<ol className="list-decimal space-y-2 ps-5 text-sm">
<li>{t("admin.keyMismatch.fixRestore")}</li>
<li>{t("admin.keyMismatch.fixDatabase")}</li>
<li>{t("admin.keyMismatch.fixFresh")}</li>
</ol>
</div>
<p className="text-muted-foreground text-sm">
{t("admin.keyMismatch.restartNote")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.keyMismatch.codes", {
code: "encryption.key_mismatch",
reason: "encryption_key_mismatch",
})}
</p>
</div>
</main>
);
}
+16 -8
View File
@@ -8,6 +8,8 @@ import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
variable: "--font-sans",
@@ -131,17 +133,23 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
{isKeyMismatch() ? (
// The boot key check refused this process: no app shell, no data
// reads, one page that names the fix.
<KeyMismatchPage locale={initialLocale} />
) : (
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
block mutations. Resolve it here (server component) and
thread the boolean into the client shell so the demo
banner can render without a client-side detection path. */}
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
)}
</body>
</html>
);
+2
View File
@@ -50,6 +50,7 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import { TodayHero } from "@/components/daily/today-hero";
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
import {
@@ -913,6 +914,7 @@ export default function DashboardPageClient({
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
<KeyBackupBanner isAdmin={user?.role === "ADMIN"} />
{/* Records other people share with you are reached from the account
switcher in the top bar and the sidebar, not from a card here. The
@@ -0,0 +1,98 @@
/**
* The key backup step and its dashboard banner.
*
* 1. the card shows the key id and fingerprint and never anything shaped
* like key material; the platform hint picks the open tab;
* 2. a due step offers the confirmation, a done step does not;
* 3. the banner renders only for an admin and only while the step is due;
* 4. the copy resolves in German too.
*
* Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red;
* render the confirm button unconditionally and case 2 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { KeyBackupCard } from "@/components/admin/key-backup-card";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/encryption",
}));
const DUE: KeyBackupStatus = {
due: true,
activeKeyId: "v1",
fingerprint: "a1b2c3d4e5f6",
confirmedAt: null,
confirmedKeyId: null,
confirmedFingerprint: null,
confirmedBy: null,
platformHint: "truenas",
};
const DONE: KeyBackupStatus = {
...DUE,
due: false,
confirmedAt: "2026-10-03T10:00:00.000Z",
confirmedKeyId: "v1",
confirmedFingerprint: "a1b2c3d4e5f6",
confirmedBy: { id: "u1", email: "admin@example.test" },
};
function render(
node: React.ReactNode,
status: KeyBackupStatus | null,
locale: "en" | "de" = "en",
): string {
const client = new QueryClient();
if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("KeyBackupCard", () => {
it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => {
const html = render(<KeyBackupCard />, DUE);
expect(html).toContain("a1b2c3d4e5f6");
expect(html).toContain(">v1<");
expect(html).toContain("Apps, select HealthLog");
expect(html).not.toMatch(/[0-9a-f]{64}/);
});
it("offers the confirmation only while the step is due", () => {
expect(render(<KeyBackupCard />, DUE)).toContain(
"I have backed up this key",
);
const done = render(<KeyBackupCard />, DONE);
expect(done).not.toContain("I have backed up this key");
expect(done).toContain("admin@example.test");
});
it("speaks German", () => {
expect(render(<KeyBackupCard />, DUE, "de")).toContain(
"Sichere deinen Verschlüsselungsschlüssel",
);
});
});
describe("KeyBackupBanner", () => {
it("renders for an admin while the step is due", () => {
expect(render(<KeyBackupBanner isAdmin />, DUE)).toContain(
'data-testid="key-backup-banner"',
);
});
it("renders nothing for a non-admin, or once the step is done", () => {
expect(render(<KeyBackupBanner isAdmin={false} />, DUE)).toBe("");
expect(render(<KeyBackupBanner isAdmin />, DONE)).toBe("");
});
});
+25 -20
View File
@@ -42,6 +42,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import { KeyBackupCard } from "./key-backup-card";
interface ColumnScan {
model: string;
@@ -130,27 +131,30 @@ export function EncryptionSection() {
// the fix carried across.
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
<div className="space-y-6">
<KeyBackupCard />
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
)}
</SettingsCard>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
</div>
);
}
@@ -162,6 +166,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
<KeyBackupCard />
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
+309
View File
@@ -0,0 +1,309 @@
"use client";
/**
* `<KeyBackupCard>` — the "Back up your encryption key" step on the admin
* Encryption page.
*
* Shows the active key id and its fingerprint (never the key), where the key
* lives on each platform, what is lost without it, a "Check my copy" field
* and the confirmation. The confirmation is bound to the key id and
* fingerprint shown here: if the server's key changed since the page loaded,
* the server answers 409 and the card reloads.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import {
useKeyBackupStatus,
type KeyBackupPlatform,
type KeyBackupStatus,
} from "./use-key-backup-status";
const PLATFORMS: KeyBackupPlatform[] = [
"compose",
"truenas",
"coolify",
"unraid",
"portainer",
];
/** Literal keys per platform, so the i18n coverage guard sees every one. */
function platformCopy(
t: (key: string) => string,
p: KeyBackupPlatform,
): { label: string; where: string } {
switch (p) {
case "truenas":
return {
label: t("admin.keyBackup.platform.truenasLabel"),
where: t("admin.keyBackup.platform.truenasWhere"),
};
case "coolify":
return {
label: t("admin.keyBackup.platform.coolifyLabel"),
where: t("admin.keyBackup.platform.coolifyWhere"),
};
case "unraid":
return {
label: t("admin.keyBackup.platform.unraidLabel"),
where: t("admin.keyBackup.platform.unraidWhere"),
};
case "portainer":
return {
label: t("admin.keyBackup.platform.portainerLabel"),
where: t("admin.keyBackup.platform.portainerWhere"),
};
case "compose":
return {
label: t("admin.keyBackup.platform.composeLabel"),
where: t("admin.keyBackup.platform.composeWhere"),
};
}
}
export function KeyBackupCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useKeyBackupStatus();
const [candidate, setCandidate] = useState("");
const [checkResult, setCheckResult] = useState<boolean | null>(null);
const invalidate = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.adminEncryptionKeyBackup(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.adminEncryptionStatus(),
}),
]);
const confirm = useMutation({
mutationFn: (s: KeyBackupStatus) =>
apiPost<KeyBackupStatus>(
"/api/admin/encryption/key-backup/confirm",
{ keyId: s.activeKeyId, fingerprint: s.fingerprint },
{ credentials: "include" },
),
onSuccess: async () => {
toast(t("admin.keyBackup.confirmed"));
await invalidate();
},
onError: async (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
toast.error(t("admin.keyBackup.stale"));
await invalidate();
return;
}
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
},
});
const verify = useMutation({
// Not `apiPost`: no idempotency key, so no cached response row exists
// for a request whose body was a key.
mutationFn: (encryptionKey: string) =>
apiFetch<{ matches: boolean }>(
"/api/admin/encryption/key-backup/verify",
{
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ encryptionKey }),
},
),
onSuccess: (data) => {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
setCheckResult(null);
toast.error(err.message || t("admin.keyBackup.checkFailed"));
},
});
const insecure =
typeof window !== "undefined" && window.isSecureContext === false;
if (!status.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.keyBackup.title")}
description={t("admin.keyBackup.description")}
/>
{status.isError ? (
<QueryErrorRow
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.keyBackup.loading")}
</div>
)}
</SettingsCard>
);
}
const s = status.data;
return (
<SettingsCard data-testid="key-backup-card">
<SettingsCardHeader
icon={KeyRound}
title={t("admin.keyBackup.title")}
description={t("admin.keyBackup.description")}
status={
s.due ? (
<Badge variant="outline" className="border-warning/40 text-warning">
{t("admin.keyBackup.badgeDue")}
</Badge>
) : (
<Badge variant="outline">{t("admin.keyBackup.badgeDone")}</Badge>
)
}
/>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">
{t("admin.keyBackup.keyId")}
</dt>
<dd className="font-mono">{s.activeKeyId}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("admin.keyBackup.fingerprint")}
</dt>
<dd className="font-mono" data-testid="key-backup-fingerprint">
{s.fingerprint}
</dd>
</div>
</dl>
<p className="text-sm">{t("admin.keyBackup.whatIsLost")}</p>
<Tabs defaultValue={s.platformHint}>
<TabsList className="flex-wrap">
{PLATFORMS.map((p) => (
<TabsTrigger key={p} value={p}>
{platformCopy(t, p).label}
</TabsTrigger>
))}
</TabsList>
{PLATFORMS.map((p) => (
<TabsContent key={p} value={p} className="text-sm">
{platformCopy(t, p).where}
</TabsContent>
))}
</Tabs>
<p className="text-muted-foreground text-sm">
{t("admin.keyBackup.fingerprintHint")}
</p>
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (candidate.trim()) verify.mutate(candidate);
}}
>
<Label htmlFor="key-backup-candidate">
{t("admin.keyBackup.checkLabel")}
</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="key-backup-candidate"
type="password"
autoComplete="off"
spellCheck={false}
value={candidate}
onChange={(e) => {
setCandidate(e.target.value);
setCheckResult(null);
}}
className="font-mono"
/>
<Button
type="submit"
variant="outline"
disabled={!candidate.trim() || verify.isPending}
>
{verify.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.keyBackup.checkButton")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{insecure
? t("admin.keyBackup.checkInsecureNote")
: t("admin.keyBackup.checkNote")}
</p>
{checkResult !== null && (
<p
role="status"
data-testid="key-backup-check-result"
className={
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
}
>
{checkResult
? t("admin.keyBackup.checkMatches")
: t("admin.keyBackup.checkNoMatch")}
</p>
)}
</form>
{!s.due && s.confirmedAt && (
<p className="text-muted-foreground text-sm">
{s.confirmedBy?.email
? t("admin.keyBackup.confirmedAtBy", {
date: fmt.dateTime(new Date(s.confirmedAt)),
who: s.confirmedBy.email,
})
: t("admin.keyBackup.confirmedAt", {
date: fmt.dateTime(new Date(s.confirmedAt)),
})}
</p>
)}
{s.due && s.confirmedKeyId && (
<p className="text-sm">
{t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })}
</p>
)}
{s.due && (
<SettingsCardActions>
<Button
onClick={() => confirm.mutate(s)}
disabled={confirm.isPending}
>
{confirm.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.keyBackup.confirmButton")}
</Button>
</SettingsCardActions>
)}
</SettingsCard>
);
}
+24 -1
View File
@@ -12,18 +12,28 @@
* don't re-fetch.
*/
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
import {
Clock,
Cog,
Database,
Globe,
KeyRound,
Loader2,
Server,
} from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
import { useKeyBackupStatus } from "./use-key-backup-status";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError, refetch } = useSystemStatus();
const { data: version } = usePublicVersion();
const { data: keyBackup } = useKeyBackupStatus();
return (
<SettingsCard
@@ -63,6 +73,19 @@ export function SystemStatusSummary() {
label={t("admin.overview.snapshotStarted")}
value={formatDateTime(status.startTime)}
/>
{/* v1.40.0 — the encryption key backup step, warning while due. */}
{keyBackup && (
<StatusItem
icon={KeyRound}
label={t("admin.keyBackup.tileLabel")}
value={
keyBackup.due
? t("admin.keyBackup.tileDue")
: t("admin.keyBackup.tileDone")
}
tone={keyBackup.due ? "warning" : "success"}
/>
)}
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
The field is undefined on legacy responses; the row only
@@ -0,0 +1,36 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export type KeyBackupPlatform =
"compose" | "truenas" | "coolify" | "unraid" | "portainer";
/** `GET /api/admin/encryption/key-backup`; key id and fingerprint only. */
export interface KeyBackupStatus {
due: boolean;
activeKeyId: string;
fingerprint: string;
confirmedAt: string | null;
confirmedKeyId: string | null;
confirmedFingerprint: string | null;
confirmedBy: { id: string; email: string | null } | null;
platformHint: KeyBackupPlatform;
}
/**
* The key backup step's state. `enabled` lets the dashboard banner ask only
* for an admin; the route itself is admin-only and cookie-only.
*/
export function useKeyBackupStatus(enabled = true) {
return useQuery({
queryKey: queryKeys.adminEncryptionKeyBackup(),
queryFn: () =>
apiGet<KeyBackupStatus>("/api/admin/encryption/key-backup", {
credentials: "include",
}),
enabled,
staleTime: 60_000,
});
}
@@ -0,0 +1,46 @@
/**
* The login page's transport notice (#1097): the blocked banner names both
* operator fixes, the localhost note is soft, and "ok" renders nothing.
* Mutation check: render the banner for `soft` and the second case goes red.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsecureTransportBanner } from "@/components/auth/insecure-transport-banner";
import type { TransportVerdict } from "@/lib/auth/client-transport";
function render(verdict: TransportVerdict, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<InsecureTransportBanner verdict={verdict} />
</I18nProvider>,
);
}
describe("InsecureTransportBanner", () => {
it("blocked: an alert naming SESSION_COOKIE_SECURE and https", () => {
const html = render("blocked");
expect(html).toContain('data-testid="insecure-transport-banner"');
expect(html).toContain('role="alert"');
expect(html).toContain("SESSION_COOKIE_SECURE=false");
expect(html).toContain("https://");
});
it("soft: a note on localhost, no alert", () => {
const html = render("soft");
expect(html).toContain('data-testid="insecure-transport-note"');
expect(html).not.toContain('role="alert"');
});
it("ok: nothing", () => {
expect(render("ok")).toBe("");
});
it("speaks German", () => {
expect(render("blocked", "de")).toContain("https://");
expect(render("blocked", "de")).not.toContain(
"This server only sets its sign-in cookie",
);
});
});
@@ -0,0 +1,75 @@
"use client";
/**
* Login-page notice for a page whose session cookie cannot stick (#1097).
*
* `blocked`: the server sets its sign-in cookie only over https:// and this
* page is plain http:// on a network address. The browser would drop the
* cookie, so the sign-in controls are disabled and the two operator fixes are
* named. `soft`: the same on localhost, where most browsers accept the cookie;
* the form stays usable.
*/
import { useSyncExternalStore } from "react";
import { TriangleAlert } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import type {
ClientTransport,
TransportVerdict,
} from "@/lib/auth/client-transport";
const noopSubscribe = () => () => {};
/**
* The page's transport, read after hydration only (the server render has no
* `location`), so the first client render matches the server HTML.
*/
export function useClientTransport(): ClientTransport | undefined {
const raw = useSyncExternalStore(
noopSubscribe,
() => `${window.location.protocol}//${window.location.host}`,
() => null,
);
if (!raw) return undefined;
const [scheme, host] = raw.split("//");
return { protocol: scheme === "https:" ? "https" : "http", host };
}
export function InsecureTransportBanner({
verdict,
}: {
verdict: TransportVerdict;
}) {
const { t } = useTranslations();
if (verdict === "ok") return null;
if (verdict === "soft") {
return (
<p
data-testid="insecure-transport-note"
className="text-muted-foreground rounded-lg border p-3 text-xs"
>
{t("auth.insecureTransport.localhostNote")}
</p>
);
}
return (
<div
role="alert"
data-testid="insecure-transport-banner"
className="border-warning/40 bg-warning/10 space-y-2 rounded-lg border p-3 text-sm"
>
<p className="flex items-start gap-2 font-semibold">
<TriangleAlert
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("auth.insecureTransport.title")}
</p>
<p>{t("auth.insecureTransport.body")}</p>
<ul className="list-disc space-y-1 ps-5">
<li>{t("auth.insecureTransport.fixLan")}</li>
<li>{t("auth.insecureTransport.fixHttps")}</li>
</ul>
</div>
);
}
+12 -3
View File
@@ -9,6 +9,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { currentClientTransport } from "@/lib/auth/client-transport";
/** The server's refusal for a page whose sign-in cookie cannot stick. */
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
import { describePasskeyError } from "@/lib/passkey-errors";
export type MfaMethod = "totp" | "recovery" | "webauthn";
@@ -61,13 +65,17 @@ export function MfaLoginStep({
method: useRecovery ? "recovery" : "totp",
code: code.trim(),
rememberDevice: useRecovery ? false : rememberDevice,
clientTransport: currentClientTransport(),
});
onSuccess();
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
? t("auth.insecureTransport.title")
: err instanceof ApiError && err.message
? err.message
: t("auth.mfa.verifyFailed"),
);
} finally {
setLoading(false);
@@ -89,6 +97,7 @@ export function MfaLoginStep({
challengeId,
credential,
rememberDevice,
clientTransport: currentClientTransport(),
});
onSuccess();
} catch (err) {
@@ -0,0 +1,40 @@
"use client";
/**
* Dashboard banner for an admin while the encryption key backup step is due.
* Not dismissable by design: it goes away when the step is done, and comes
* back when the key changes. Renders nothing for anyone who is not an admin,
* and asks the server nothing for them either.
*/
import Link from "next/link";
import { KeyRound } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
const { data } = useKeyBackupStatus(isAdmin);
if (!isAdmin || !data?.due) return null;
return (
<div
role="status"
data-testid="key-backup-banner"
className="border-warning/40 bg-warning/10 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center"
>
<KeyRound className="text-warning h-5 w-5 shrink-0" aria-hidden="true" />
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold">
{t("dashboard.keyBackupBanner.title")}
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
</div>
<Link
href="/admin/encryption"
className="text-primary inline-flex min-h-11 items-center text-sm font-medium underline-offset-4 hover:underline"
>
{t("dashboard.keyBackupBanner.action")}
</Link>
</div>
);
}
+13
View File
@@ -15,6 +15,19 @@ export async function register() {
// A readiness summary must never break boot.
}
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
// neither the producer nor the worker (a wrong key must never write).
try {
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/key-check"),
import("@/lib/db"),
]);
if (await runBootKeyCheck(prisma)) return;
} catch (err) {
console.error("[instrumentation] Encryption key check crashed:", err);
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
@@ -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",