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

+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."),