From b6efc6cb5ab08ecf482f73813bf5b426f68869eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 12:24:52 +0200 Subject: [PATCH 1/4] Register the sixteen auth, settings and discovery routes the spec never had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm openapi:check` compares the registry against the committed YAML and never the routes against the registry, so a route that was simply never registered stayed invisible to the gate. Sixteen of them are called by the native client and describe nothing: sign-out, the three per-user preference scalars, the two pre-session discovery reads, passkey login options, the passkey rename and delete, registration status, the consent latest-receipt reader and its revoke, account deletion, the instance service switches, the API-token list and revoke, the threshold overrides, the version read, and the assistant feature matrix. Each contract is derived from the handler rather than from its docblock, so the published statuses are the ones the code can actually produce — the 413 on a 1 KB body cap, the 400 the consent family answers where the rest of the API answers 422, the 403 an instance-wide API switch produces on a token READ, the last-admin and last-Guardian refusals on account deletion, and the fresh-factor arm that a Bearer transport cannot clear at any scope. Four request bodies moved to the validation modules the handlers now parse with, so the published wire cannot drift from the runtime one: the three `/api/auth/me` preference scalars into a new `user-prefs` module, and the passkey rename beside the login schemas. Behaviour is unchanged. The generated YAML is left out; it is regenerated once the remaining groups land. --- .../auth/me/documents-auto-ai-read/route.ts | 9 +- .../api/auth/me/injection-site-prefs/route.ts | 18 +- src/app/api/auth/me/unit-preference/route.ts | 9 +- src/app/api/auth/passkeys/[id]/route.ts | 5 +- src/lib/openapi/routes/auth.ts | 290 +++++++++++++- src/lib/openapi/routes/consent.ts | 144 ++++++- src/lib/openapi/routes/meta.ts | 127 +++++- src/lib/openapi/routes/profile.ts | 379 ++++++++++++++++++ src/lib/openapi/routes/settings.ts | 120 +++++- src/lib/validations/auth.ts | 9 + src/lib/validations/user-prefs.ts | 39 ++ 11 files changed, 1109 insertions(+), 40 deletions(-) create mode 100644 src/lib/validations/user-prefs.ts diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index 37a79d4cd..8fef3da24 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -20,8 +20,6 @@ * field-by-field write (no mass assignment). Idempotent — always returns the * resolved next state so the client can hard-set the optimistic update. */ -import { z } from "zod"; - import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -36,10 +34,7 @@ import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; import { prisma } from "@/lib/db"; import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; - -const patchBodySchema = z.object({ - documentsAutoAiRead: z.boolean(), -}); +import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs"; export const dynamic = "force-dynamic"; @@ -87,7 +82,7 @@ export const PATCH = apiHandler(async (req: Request) => { }); if (jsonError) return jsonError; - const parsed = patchBodySchema.safeParse(body); + const parsed = documentsAutoAiReadPatchSchema.safeParse(body); if (!parsed.success) { annotate({ action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" }, diff --git a/src/app/api/auth/me/injection-site-prefs/route.ts b/src/app/api/auth/me/injection-site-prefs/route.ts index 3a1fb6360..2d38d5d2c 100644 --- a/src/app/api/auth/me/injection-site-prefs/route.ts +++ b/src/app/api/auth/me/injection-site-prefs/route.ts @@ -18,8 +18,6 @@ * limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). */ -import { z } from "zod/v4"; - import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -33,10 +31,10 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { - injectionSiteEnum, INJECTION_SITE_VALUES, type InjectionSiteValue, } from "@/lib/validations/medication"; +import { injectionSitePrefsPatchSchema } from "@/lib/validations/user-prefs"; import { NextRequest } from "next/server"; export const dynamic = "force-dynamic"; @@ -44,18 +42,6 @@ export const dynamic = "force-dynamic"; const PATCH_RATE_LIMIT = 60; const PATCH_WINDOW_MS = 60_000; -const patchBodySchema = z.object({ - // Dedup at write time; cap at the full enum size. An empty array - // clears the exclusion. - globalExcludedInjectionSites: z - .array(injectionSiteEnum) - .max(INJECTION_SITE_VALUES.length) - .meta({ - description: - "User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.", - }), -}); - interface InjectionSitePrefsResponse { globalExcludedInjectionSites: InjectionSiteValue[]; } @@ -96,7 +82,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; - const parsed = patchBodySchema.safeParse(body); + const parsed = injectionSitePrefsPatchSchema.safeParse(body); if (!parsed.success) { annotate({ action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" }, diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index 854fdbd91..e101378b8 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -16,8 +16,6 @@ * limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). */ -import { z } from "zod"; - import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, @@ -30,10 +28,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform"; - -const patchBodySchema = z.object({ - unitPreference: z.enum(["metric", "imperial"]), -}); +import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs"; export const dynamic = "force-dynamic"; @@ -85,7 +80,7 @@ export const PATCH = apiHandler(async (req: Request) => { throw new HttpError(422, "unit-preference.body.invalid_json"); } - const parsed = patchBodySchema.safeParse(body); + const parsed = unitPreferencePatchSchema.safeParse(body); if (!parsed.success) { annotate({ action: { name: "auth.me.unit-preference.patch.invalid_shape" }, diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 4c9aadf2f..eb95c324b 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -9,10 +9,7 @@ import { import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { webauthnKeyNameSchema } from "@/lib/validations/mfa"; -import { z } from "zod/v4"; - -const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema }); +import { passkeyRenameSchema } from "@/lib/validations/auth"; export const PATCH = apiHandler( async ( diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 71675aad7..e804bc6a6 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1,13 +1,21 @@ /** * OpenAPI route table — auth surface (login, passkey verify, token refresh). * + * Also the home of `/api/tokens` and `/api/tokens/{id}`: they sit outside the + * `/api/auth` prefix but a Bearer token is a sign-in credential like a passkey + * or a session, and splitting the credential lifecycle across two modules is + * how one half of it stops being maintained. + * * Part of the OpenAPI route table; aggregated in `./index.ts`. * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { loginPasswordSchema } from "@/lib/validations/auth"; +import { + loginPasswordSchema, + passkeyRenameSchema, +} from "@/lib/validations/auth"; import { mfaVerifySchema, totpConfirmSchema, @@ -361,6 +369,77 @@ const stepUpMintResponse = z }) .meta({ id: "StepUpMintResponse" }); +// ── Sign-out, pre-session discovery, API tokens ────────────────────── +// Seven operations that shipped without ever entering the registry. The drift +// gate compares the registry against the YAML and never the ROUTES against the +// registry, so a route that was simply never registered stayed invisible to it. + +const logoutResponse = z + .object({ + loggedOut: z + .literal(true) + .describe( + "Always true. The endpoint has no failure the caller acts on: it answers the same whether a cookie was cleared, a token was revoked, or neither was there.", + ), + }) + .meta({ id: "LogoutResponse" }); + +const oidcStatusResponse = z + .object({ + enabled: z + .boolean() + .describe("An OIDC provider is configured on this instance."), + buttonLabel: z + .string() + .nullable() + .describe( + "Operator-set label for the SSO button (`OIDC_BUTTON_LABEL`, defaulting to “Single Sign-On”). Null when no provider is configured.", + ), + only: z + .boolean() + .describe( + "`OIDC_ONLY=true` — password and passkey login are refused with 403 `oidc_only`. A client that offers those controls anyway strands the user.", + ), + }) + .meta({ id: "OidcStatusResponse" }); + +const registrationStatusResponse = z + .object({ + registrationEnabled: z + .boolean() + .describe( + "Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token.", + ), + }) + .meta({ id: "RegistrationStatusResponse" }); + +const apiTokenInfo = z + .object({ + id: z.string().describe("Row id; the handle for DELETE."), + name: z.string(), + permissions: z + .array(z.string()) + .describe( + 'The token\'s scope list. `["*"]` is cookie-equivalent (what a native login mints); anything else is narrow and reaches only the routes that name it. Admin endpoints are cookie-only and are reachable by no token at any scope.', + ), + lastUsedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("Stamped fire-and-forget on use; null for an unused token."), + expiresAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: z.iso.datetime({ offset: true }), + revoked: z + .boolean() + .describe( + "Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity.", + ), + }) + .meta({ id: "ApiTokenInfo" }); + +const apiTokenListResponse = z + .array(apiTokenInfo) + .meta({ id: "ApiTokenListResponse" }); + export const authPaths: NonNullable = { "/api/auth/check-user": { post: { @@ -1143,4 +1222,213 @@ export const authPaths: NonNullable = { }, }, }, + // ── Appended: routes the iOS client calls that the registry never carried. + "/api/auth/logout": { + post: { + tags: ["Auth"], + summary: "End the calling session, and the calling token with it", + description: + "Clears the browser session cookie. When the request ALSO carries `Authorization: Bearer hlk_…` it revokes that access token and the refresh sibling paired with it, so a native client signs out in one call rather than through the refresh endpoint's `revoke: true` arm.\n\n" + + "Takes no body and needs no credential: an unauthenticated call answers 200 exactly like an authenticated one. Whether the Bearer revocation actually found a row is recorded server-side and is deliberately absent from the response — a caller that could read it would learn whether a token it presented was live.", + responses: { + "200": { + description: + "Sign-out performed as far as the presented credentials allowed.", + content: { + "application/json": { + schema: dataEnvelope(logoutResponse, "LogoutEnvelope"), + }, + }, + }, + }, + }, + }, + "/api/auth/oidc/status": { + get: { + tags: ["Auth"], + summary: "Whether SSO is configured, and whether it is the only way in", + description: + "Public and pre-session: the login surface reads it to decide whether to render the SSO button and whether to hide the password / passkey controls. Pure environment reads, no database, so it has no failure mode — it answers 200 on every call.", + responses: { + "200": { + description: "The SSO posture of this instance.", + content: { + "application/json": { + schema: dataEnvelope(oidcStatusResponse, "OidcStatusEnvelope"), + }, + }, + }, + }, + }, + }, + "/api/auth/registration-status": { + get: { + tags: ["Auth"], + summary: "Whether self-registration is open", + description: + "Public and pre-session: the sign-up surface reads it before offering a registration form. Fails CLOSED — when the settings read throws, the response is a well-formed 200 carrying `registrationEnabled: false` rather than an error, so a client cannot tell a genuinely closed instance from a database blip and must treat both as closed.", + responses: { + "200": { + description: + "Registration posture. Also the shape returned when the settings read failed.", + content: { + "application/json": { + schema: dataEnvelope( + registrationStatusResponse, + "RegistrationStatusEnvelope", + ), + }, + }, + }, + }, + }, + }, + "/api/auth/passkey/login-options": { + post: { + 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.", + responses: { + "200": { + description: "Assertion options issued.", + content: { + "application/json": { + schema: dataEnvelope( + webauthnOptionsResponse, + "PasskeyLoginOptionsEnvelope", + ), + }, + }, + }, + "403": { + description: + "Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. Checked BEFORE the rate limit, so an instance in this mode answers 403 rather than 429 however hard it is polled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": stdResponses["429"], + }, + }, + }, + "/api/auth/passkeys/{id}": { + patch: { + tags: ["Auth"], + summary: "Rename a registered passkey", + description: + "Relabels one primary sign-in credential, scoped to the authenticated user. Returns the full passkey row, so the client can replace its list entry rather than re-read.\n\n" + + 'Unlike the rest of this surface the 422 carries a single flat message rather than the multi-issue envelope `returnAllZodIssues` produces — the handler answers `apiError("Invalid request", 422)` and discards the Zod issues, so a client cannot tell an empty name from an over-long one.', + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: passkeyRenameSchema } }, + }, + responses: { + "200": { + description: "Passkey renamed.", + content: { + "application/json": { + schema: dataEnvelope(passkeyInfo, "PasskeyRenameEnvelope"), + }, + }, + }, + "404": { + description: "No such passkey for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Remove a registered passkey", + description: + "Deletes one primary sign-in credential, scoped to the authenticated user. Refuses to remove the LAST one when the account has no password — an account must keep at least one way in.\n\n" + + "No step-up: this endpoint takes a plain cookie session or a wildcard Bearer token, where removing a second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}) requires a fresh factor proof. The asymmetry is in the code as it stands, not a description of intent.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Passkey removed.", + content: { + "application/json": { + schema: dataEnvelope( + successFlagResponse, + "PasskeyRemoveEnvelope", + ), + }, + }, + }, + "400": { + description: + "This is the account's only passkey and it has no password — at least one authentication method must remain. Set a password, or register a second passkey, before retrying.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "No such passkey for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/tokens": { + get: { + tags: ["Auth"], + summary: "List the caller's API tokens", + description: + "Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" + + 'Two things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. And there is no mint on this path any more — the generic POST that issued `["medication:ingest"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The working credential comes from the per-medication API-endpoint toggle.', + responses: { + "200": { + description: "The caller's tokens, newest first.", + content: { + "application/json": { + schema: dataEnvelope( + apiTokenListResponse, + "ApiTokenListEnvelope", + ), + }, + }, + }, + "403": { + description: + "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Checked after authentication, carries no `meta.errorCode`, and applies to the read as well as the revoke — a user cannot inspect their own tokens while the switch is off.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/tokens/{id}": { + delete: { + tags: ["Auth"], + summary: "Revoke an API token", + description: + "Marks one token revoked, scoped to the authenticated user (a foreign id returns 404, never another user's row). The row is kept rather than deleted so it stays visible in the list with `revoked: true`.\n\n" + + "Nothing exempts the caller's own credential: a Bearer client that revokes the token it is presenting completes the call and is unauthenticated from the next one onwards.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Token revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.boolean() }), + "ApiTokenRevokeEnvelope", + ), + }, + }, + }, + "403": { + description: + "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "Token not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts index 3fbe3be20..ccaf55697 100644 --- a/src/lib/openapi/routes/consent.ts +++ b/src/lib/openapi/routes/consent.ts @@ -29,7 +29,7 @@ import { consentPostBody, webConsentGrantBody, } from "@/lib/validations/consent"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; consentPostBody.meta({ id: "ConsentPostBody", @@ -76,6 +76,67 @@ const webConsentGrantResponse = z "Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.", }); +const serialisedReceipt = z + .object({ + id: z.string(), + userId: z.string(), + kind: consentKindEnum, + signedAt: z.iso.datetime({ offset: true }), + revokedAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "SerialisedConsentReceipt", + description: + "A consent receipt as the API returns it. `artefact` is stripped — it is opaque, large, and only read at audit time straight from the database.", + }); + +const consentLatestOneKind = z + .object({ + kind: consentKindEnum, + receipt: serialisedReceipt + .nullable() + .describe("Null when no active receipt of that kind exists."), + }) + .meta({ id: "ConsentLatestForKind" }); + +const consentLatestAllKinds = z + .object({ + ai_full: serialisedReceipt.nullable(), + ai_insights_only: serialisedReceipt.nullable(), + ai_coach: serialisedReceipt.nullable(), + }) + .meta({ + id: "ConsentLatestByKind", + description: + "The latest active receipt for every kind. All three keys are always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.", + }); + +const consentRevokeAllKinds = z + .object({ + revoked: z + .array(z.object({ kind: consentKindEnum, receipt: serialisedReceipt })) + .describe( + "One entry per kind that HAD an active receipt. A kind already revoked contributes nothing, so an empty array means there was nothing left to revoke.", + ), + }) + .meta({ id: "ConsentRevokeAllResponse" }); + +/** + * The 400 the two `/latest` verbs answer for a bad `kind`. + * + * Spread AFTER `...stdResponses`: the consent family validates to 400 rather + * than the 422 the rest of the API uses, and the generic entry would otherwise + * leave the reader looking for a status this route never sends. + */ +const consentQueryValidation400 = { + "400": { + description: + "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + export const consentPaths: NonNullable = { "/api/consent/ai": { post: { @@ -141,4 +202,85 @@ export const consentPaths: NonNullable = { }, }, }, + // ── Appended: the latest-receipt reader and the revoke, both long shipped + // and neither ever registered. + "/api/consent/ai/latest": { + get: { + tags: ["Consent"], + summary: "Read the latest active consent receipt(s)", + description: + "The response SHAPE depends on the query, which is the one thing a client has to get right here. With `?kind=` it is `{ kind, receipt }` for that kind alone; WITHOUT it, a map keyed by all three kinds. There is no shape that covers both — decode on whether you sent the parameter.\n" + + "\n" + + "Only non-revoked receipts are considered: a revoked one is never returned, and the reader does not distinguish “revoked” from “never granted”. Auth via cookie or a wildcard Bearer token; rate-limited on the shared per-user consent bucket.", + requestParams: { + query: z.object({ + kind: consentKindEnum + .optional() + .describe( + "Narrow to one consent kind. Omit for the full keyspace.", + ), + }), + }, + responses: { + "200": { + description: + "The receipt for the requested kind, or the map across all three when `kind` was omitted.", + content: { + "application/json": { + schema: z.union([ + dataEnvelope( + consentLatestOneKind, + "ConsentLatestForKindEnvelope", + ), + dataEnvelope( + consentLatestAllKinds, + "ConsentLatestByKindEnvelope", + ), + ]), + }, + }, + }, + ...stdResponses, + ...consentQueryValidation400, + }, + }, + delete: { + tags: ["Consent"], + summary: "Revoke the latest consent receipt, or all of them", + description: + "With `?kind=` it revokes the latest active receipt of that kind; WITHOUT it, it revokes the latest active receipt of EVERY kind — the master “turn AI off” switch. As with the GET, the two arms answer different shapes.\n" + + "\n" + + "Idempotent on purpose, and it is worth knowing why: revoking a kind that has nothing active answers 200 with `receipt: null` rather than 404, because the client toggle fires this on every flip and a 404 would render as a failure the user cannot act on. Revocation is append-only in effect — the row is stamped, never deleted — and each revocation is audit-logged.\n" + + "\n" + + "Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls should show them as the two separate things they are.", + requestParams: { + query: z.object({ + kind: consentKindEnum + .optional() + .describe( + "Revoke one kind. Omit to revoke the latest active receipt across every kind.", + ), + }), + }, + responses: { + "200": { + description: + "The revoked receipt for the named kind (or `receipt: null` when there was none), or the list of everything revoked when `kind` was omitted.", + content: { + "application/json": { + schema: z.union([ + dataEnvelope( + consentLatestOneKind, + "ConsentRevokeForKindEnvelope", + ), + dataEnvelope(consentRevokeAllKinds, "ConsentRevokeAllEnvelope"), + ]), + }, + }, + }, + ...stdResponses, + ...consentQueryValidation400, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index f8cd49c40..f04974bd0 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -1,5 +1,6 @@ /** - * OpenAPI route table — server capabilities discovery. + * OpenAPI route table — server capabilities discovery, build identity, and the + * operator's assistant feature matrix. * * Part of the OpenAPI route table; aggregated in `./index.ts`. * Schemas come from `src/lib/validations/*` where shared with the @@ -7,7 +8,7 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // v1.10.2 — live capability / discovery response. Every list is sourced // server-side from the canonical registry it documents, so the wire shape @@ -176,6 +177,79 @@ const measurementCategoriesResponse = z "The measurement categorisation overlay: the ordered category list plus the MeasurementType → category assignments. Derived server-side from the canonical map; cached `public, max-age=600`.", }); +// ── Build identity ─────────────────────────────────────────────────── +// The route every deploy is verified against. `version` prefers the build-arg +// the release workflow injects over the package.json fallback, so a cached +// build layer cannot re-ship the previous release's version string. + +const versionResponse = z + .object({ + version: z + .string() + .describe( + "The running build's version. From the image build arg when present, falling back to package.json for a local dev run.", + ), + buildSha: z + .string() + .nullable() + .describe( + "Short Git SHA baked at image build time. Null for a local dev run, which is how a client tells a released image from one.", + ), + builtAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("ISO-8601 build timestamp. Null for a local dev run."), + license: z.string(), + repository: z.url(), + changelog: z.url(), + docs: z.url(), + offlineGeoEnabled: z + .boolean() + .describe( + "A GeoLite2-City database is present and in use. When false, login-IP geolocation falls back to the online provider named below — every login IP then leaves the host.", + ), + geoProviderHost: z + .string() + .describe( + "Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default.", + ), + }) + .meta({ + id: "VersionResponse", + description: + "The running build's identity plus the offline-geolocation state. Public — no authentication, no account data.", + }); + +// ── Assistant feature flags ────────────────────────────────────────── + +const assistantFlagsResponse = z + .object({ + assistant: z + .object({ + enabled: z + .boolean() + .describe("Master kill-switch for every assistant surface."), + coach: z.boolean().describe("Coach drawer, chat stream, history."), + briefing: z + .boolean() + .describe("Daily Briefing card and its recommendations."), + insightStatus: z + .boolean() + .describe("Per-metric status cards on the insight sub-pages."), + correlations: z + .boolean() + .describe("Correlation narration on the insights page."), + }) + .describe( + "The resolved matrix. The master is already applied: when `enabled` is false every sub-flag is false in this payload, so a client reads `coach` directly and never composes `enabled && coach`.", + ), + }) + .meta({ + id: "AssistantFlagsResponse", + description: + "The operator's assistant visibility matrix, resolved. Gates the server-routed AND the on-device assistant surfaces, so a client hides the surface end-to-end rather than degrading it.", + }); + export const metaPaths: NonNullable = { "/api/measurement-categories": { get: { @@ -221,4 +295,53 @@ export const metaPaths: NonNullable = { }, }, }, + // ── Appended: two discovery reads the registry never carried. + "/api/version": { + get: { + tags: ["Meta"], + summary: "The running build's version, and how it was built", + description: + "Public — no session, no token, no account data. This is the endpoint a deploy is verified against: after pushing a new image, read `version` plus `buildSha` on every target, because the `:latest` pull is the source of truth and a queued deploy status is not.\n\n" + + "`buildSha` and `builtAt` are baked at image build time and are null for a local development run, which is how a client tells the two apart. `offlineGeoEnabled` and `geoProviderHost` describe where login-IP geolocation resolves: with the GeoLite2 databases present nothing leaves the host, and without them the named provider sees every login address.", + responses: { + "200": { + description: "Build identity and offline-geolocation state.", + content: { + "application/json": { + schema: dataEnvelope(versionResponse, "VersionEnvelope"), + }, + }, + }, + }, + }, + }, + "/api/feature-flags": { + get: { + tags: ["Meta"], + summary: "The operator's assistant feature matrix", + description: + "Which assistant surfaces this deployment offers. The master flag is applied server-side before the shape leaves the handler, so every sub-flag is already false when the master is off and a client never composes the two.\n\n" + + "An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is acting on somebody else's — the Coach launcher and the assistant chrome are gated on it, and a refusal here would delete a piece of the shell rather than a piece of the data. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted` rather than quietly answered.\n\n" + + "Served with `Cache-Control: private, max-age=60`: an operator's toggle propagates within a minute, and the flag read stays off the hot mount path in the meantime.", + responses: { + "200": { + description: "The resolved assistant matrix.", + content: { + "application/json": { + schema: dataEnvelope( + assistantFlagsResponse, + "AssistantFlagsEnvelope", + ), + }, + }, + }, + "403": { + description: + "A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 4ad9069a5..ee60970f9 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -14,6 +14,17 @@ import { modulePrefsPatchSchema } from "@/lib/validations/modules"; import { MODULE_KEYS } from "@/lib/modules/registry"; import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape"; +import { injectionSiteEnum } from "@/lib/validations/medication"; +import { + thresholdsUpdateSchema, + ALL_METRICS, +} from "@/lib/validations/thresholds"; +import type { ThresholdMetric } from "@/lib/analytics/effective-range"; +import { + documentsAutoAiReadPatchSchema, + injectionSitePrefsPatchSchema, + unitPreferencePatchSchema, +} from "@/lib/validations/user-prefs"; import { dataEnvelope, errorEnvelope, @@ -509,6 +520,127 @@ const aiProviderResponse = z "The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key.", }); +// ── Per-user preference scalars + threshold overrides ──────────────── +// Four surfaces the iOS client has called since they shipped and that the +// registry never carried. The request bodies come from the validation modules +// the handlers themselves parse with, so the published wire cannot drift from +// the runtime one; the responses are shapes the handlers assemble by hand and +// are therefore described here. + +const unitPreferencePatchRequest = unitPreferencePatchSchema.meta({ + id: "UnitPreferencePatchRequest", + description: + "Which display-unit branch to render. Presentation only — the stored measurement rows stay SI either way.", +}); + +const unitPreferenceResponse = z + .object({ unitPreference: z.enum(["metric", "imperial"]) }) + .meta({ id: "UnitPreferenceResponse" }); + +const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({ + id: "DocumentsAutoAiReadPatchRequest", + description: + "Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` consent receipt and schedules a catch-up over the existing vault.", +}); + +const documentsAutoAiReadResponse = z + .object({ documentsAutoAiRead: z.boolean() }) + .meta({ id: "DocumentsAutoAiReadResponse" }); + +const injectionSitePrefsPatchRequest = injectionSitePrefsPatchSchema.meta({ + id: "InjectionSitePrefsPatchRequest", + description: + "Replace the user-level injection-site deny-list. An empty array clears it.", +}); + +const injectionSitePrefsResponse = z + .object({ + globalExcludedInjectionSites: z + .array(injectionSiteEnum) + .describe( + "The stored deny-list, deduplicated and in canonical enum order.", + ), + }) + .meta({ id: "InjectionSitePrefsResponse" }); + +/** + * The `?metric=` query enum, derived from the canonical metric list rather than + * restated — `ALL_METRICS` is what the handler checks the parameter against, so + * a metric added there cannot go missing from the published parameter. + */ +const thresholdMetricEnum = z + .enum(ALL_METRICS as [ThresholdMetric, ...ThresholdMetric[]]) + .meta({ id: "ThresholdMetric" }); + +const trafficRange = z + .object({ + greenMin: z.number(), + greenMax: z.number(), + orangeMin: z.number(), + orangeMax: z.number(), + }) + .meta({ + id: "TrafficRange", + description: + "A traffic-light band: green is the target window, the orange wings stretch either side of it and are clamped to the metric's physiological bounds.", + }); + +const effectiveRange = z + .object({ + range: trafficRange + .nullable() + .describe( + "The band in force — the override when one is set, otherwise the computed default. Null when the default needs profile data the account has not supplied.", + ), + isOverride: z + .boolean() + .describe("Whether `range` came from a stored override."), + default: trafficRange + .nullable() + .describe( + "The unmodified computed default, so a client can render current-versus-default without recomputing it.", + ), + bounds: z + .object({ + min: z.number(), + max: z.number(), + unit: z.string().describe("Canonical unit, e.g. `mmHg`, `kg`, `%`."), + }) + .describe( + "Outer physiological guardrails for this metric — the range an override must sit inside. Not a default.", + ), + }) + .meta({ id: "EffectiveRange" }); + +const thresholdOverrideMap = z + .record(z.string(), z.object({ min: z.number(), max: z.number() })) + .meta({ + id: "ThresholdOverrides", + description: + "Stored overrides, keyed by `ThresholdMetric`. Partial by construction — a metric absent from the map is on its computed default. `{}` means no override anywhere.", + }); + +const thresholdsGetResponse = z + .object({ + effective: z + .record(z.string(), effectiveRange) + .describe( + "Every `ThresholdMetric`, resolved. Exhaustive: a metric with no override and no derivable default is present with a null `range`, not missing.", + ), + overrides: thresholdOverrideMap, + }) + .meta({ id: "ThresholdsResponse" }); + +const thresholdsOverridesResponse = z + .object({ overrides: thresholdOverrideMap }) + .meta({ id: "ThresholdsOverridesResponse" }); + +const thresholdsUpdateRequest = thresholdsUpdateSchema.meta({ + id: "ThresholdsUpdateRequest", + description: + "A partial map of `ThresholdMetric` to `{ min, max }`. Only the metrics named are written; the rest keep their existing override. Strict — an unknown key is a 422, not a silent drop. Each range must sit inside that metric's physiological bounds with `min` strictly below `max`.", +}); + export const profilePaths: NonNullable = { "/api/user/ai-provider": { get: { @@ -1059,4 +1191,251 @@ export const profilePaths: NonNullable = { }, }, }, + // ── Appended: preference surfaces the iOS client calls that the registry + // never carried. All four take a cookie session or a wildcard Bearer token + // (`requireAuth()` with no declared scope), none are delegable — a request + // that attaches an account selector is refused before the handler runs — and + // every PATCH/PUT is a hard-set that answers with the resolved next state, so + // a client replaces its optimistic value rather than re-reading. + "/api/auth/me/unit-preference": { + get: { + tags: ["Auth"], + summary: "Read the metric/imperial display preference", + description: + "Which branch of the display-time transform registry the client should render (km/h vs mph, km vs mi). Canonical storage stays SI on every measurement row — this changes presentation only, and never what was written. Any stored value other than `imperial` resolves to `metric`, including a null on an account that predates the column.", + responses: { + "200": { + description: "The resolved preference.", + content: { + "application/json": { + schema: dataEnvelope( + unitPreferenceResponse, + "GetUnitPreferenceEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Set the metric/imperial display preference", + description: + "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + + "The only endpoint in this group that reads its body with a bare `request.json()` rather than the capped `safeJson` helper, so it applies no body-size limit and answers a malformed body with 422 where its siblings answer 400. Both are the code as it stands.", + requestBody: { + required: true, + content: { + "application/json": { schema: unitPreferencePatchRequest }, + }, + }, + responses: { + "200": { + description: "The resolved next preference.", + content: { + "application/json": { + schema: dataEnvelope( + unitPreferenceResponse, + "PatchUnitPreferenceEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/documents-auto-ai-read": { + get: { + tags: ["Auth"], + summary: "Read the automatic document-reading opt-in", + description: + "Whether the auto-index-on-upload job may read a freshly uploaded document through the account's configured external provider with no per-document tap. OFF by default and off for any account that has never set it: the vault is local-first and every egress otherwise needs an explicit action plus an active consent receipt.", + responses: { + "200": { + description: "The current flag.", + content: { + "application/json": { + schema: dataEnvelope( + documentsAutoAiReadResponse, + "GetDocumentsAutoAiReadEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Set the automatic document-reading opt-in", + description: + "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + + "Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" + + "The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.", + requestBody: { + required: true, + content: { + "application/json": { schema: documentsAutoAiReadPatchRequest }, + }, + }, + responses: { + "200": { + description: "The resolved next flag.", + content: { + "application/json": { + schema: dataEnvelope( + documentsAutoAiReadResponse, + "PatchDocumentsAutoAiReadEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/injection-site-prefs": { + get: { + tags: ["Auth"], + summary: "Read the user-level injection-site deny-list", + description: + "Sites the account never wants offered. The list is a DENY, and deny wins everywhere: a site here is dropped from every medication's injection-site picker and refused at intake with 422, even when that medication names it among its preferred sites.", + responses: { + "200": { + description: "The current deny-list.", + content: { + "application/json": { + schema: dataEnvelope( + injectionSitePrefsResponse, + "GetInjectionSitePrefsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Replace the user-level injection-site deny-list", + description: + "Replace, not merge — the body states the whole list, and an empty array clears the exclusion. Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + + "The stored list is deduplicated and re-ordered into the canonical enum order before it is written, so the response can differ from the body that produced it while describing the same set. Take the response as the state.", + requestBody: { + required: true, + content: { + "application/json": { schema: injectionSitePrefsPatchRequest }, + }, + }, + responses: { + "200": { + description: + "The resolved next deny-list, deduplicated and in canonical order.", + content: { + "application/json": { + schema: dataEnvelope( + injectionSitePrefsResponse, + "PatchInjectionSitePrefsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/user/thresholds": { + get: { + tags: ["Analytics"], + summary: "Read the effective metric ranges and the caller's overrides", + description: + "Every threshold metric with its resolved traffic-light band, whether that band came from an override, the unmodified computed default beside it, and the metric's physiological bounds — so a settings surface can show current-versus-default and validate an edit without a second call.\n\n" + + "`range` and `default` are nullable: a metric whose default is derived from profile data the account has not supplied (height for weight, date of birth and gender for body fat) has no band to resolve until that data exists.", + responses: { + "200": { + description: "Effective ranges plus the stored overrides.", + content: { + "application/json": { + schema: dataEnvelope( + thresholdsGetResponse, + "GetThresholdsEnvelope", + ), + }, + }, + }, + "404": { + description: + "The authenticated user row could not be loaded. In practice unreachable — the session that resolved the caller was validated against that row.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Analytics"], + summary: "Set threshold overrides for one or more metrics", + description: + "MERGE, not replace, and it is the one write on this surface that behaves that way: the body is a partial map and only the metrics it names are touched. A metric omitted keeps whatever override it already had. To remove one, use DELETE with `?metric=` — sending it back as an absent key does nothing.\n\n" + + "Each range must sit inside that metric's physiological bounds with `min` strictly below `max`, and an unknown metric key is refused rather than ignored (the schema is strict). Rate-limited 30 / 5 min per user; audit-logged with the before and after maps. The response carries the merged override map, not the recomputed effective ranges — re-read the GET for those.", + requestBody: { + required: true, + content: { "application/json": { schema: thresholdsUpdateRequest } }, + }, + responses: { + "200": { + description: "The merged override map.", + content: { + "application/json": { + schema: dataEnvelope( + thresholdsOverridesResponse, + "PutThresholdsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Analytics"], + summary: "Reset one threshold override, or all of them", + description: + "With `?metric=` it drops that metric's override; WITHOUT the parameter it drops EVERY override on the account. There is no confirmation step and no dry run, so a client that means to reset one metric must send the parameter.\n\n" + + "Audit-logged with the before and after maps. Unlike the PUT this path runs no rate limit of its own.", + requestParams: { + query: z.object({ + metric: thresholdMetricEnum + .optional() + .describe( + "The single metric to reset. Omit to reset every override on the account.", + ), + }), + }, + responses: { + "200": { + description: "The remaining override map.", + content: { + "application/json": { + schema: dataEnvelope( + thresholdsOverridesResponse, + "DeleteThresholdsEnvelope", + ), + }, + }, + }, + "400": { + description: + "`metric` was present but is not a known threshold metric. Nothing was reset.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index ecb83a289..5576f149c 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -1,5 +1,6 @@ /** - * OpenAPI route table — settings read surfaces. + * OpenAPI route table — settings surfaces: the reminder thresholds, the + * operator's instance-wide service switches, and account deletion. * * Part of the OpenAPI route table; aggregated in `./index.ts`. * Schemas come from `src/lib/validations/*` where shared with the @@ -7,7 +8,12 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; // v1.16.11 — the one threshold read every dose-status consumer makes // (cards, table, take-all-due derivation). `lateMinutes` / @@ -52,6 +58,45 @@ const reminderThresholdsResponse = z "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11).", }); +// ── Instance-level service switches ────────────────────────────────── + +const globalServiceAvailabilityResponse = z + .object({ + telegramGlobal: z.boolean().describe("Telegram notifications are offered."), + ntfyGlobal: z.boolean().describe("ntfy notifications are offered."), + webPushGlobal: z.boolean().describe("Web Push is offered."), + apiGlobal: z + .boolean() + .describe( + "The API-token surface is on. When false, `/api/tokens` and its revoke sibling answer 403 — the read included.", + ), + }) + .meta({ + id: "GlobalServiceAvailability", + description: + "Which optional services the operator has left switched on instance-wide. Every flag defaults to true, and a failed settings read returns all-true rather than an error, so a client must not treat this as proof a channel is configured — only that the operator has not switched it off.", + }); + +// ── Account deletion ───────────────────────────────────────────────── +// The body is hand-parsed rather than Zod-validated (the handler reads the raw +// text, caps it, and compares one field), so the shape is described here rather +// than imported. Documenting it as a schema is still right: it is the wire +// contract, whatever the handler uses to check it. + +const deleteAccountRequest = z + .object({ + confirm: z + .literal("DELETE_ACCOUNT") + .describe( + "The literal string `DELETE_ACCOUNT`. Any other value, and any non-string, is refused with 422.", + ), + }) + .meta({ + id: "DeleteAccountRequest", + description: + "Typed confirmation for permanent account deletion. There is no second step and no undo.", + }); + export const settingsPaths: NonNullable = { "/api/settings/reminder-thresholds": { get: { @@ -76,4 +121,75 @@ export const settingsPaths: NonNullable = { }, }, }, + // ── Appended: two settings surfaces the registry never carried. + "/api/settings/global-services": { + get: { + tags: ["Notifications"], + summary: "Read the operator's instance-wide service switches", + description: + "Which optional services this instance offers at all, so a client can hide a channel the operator has switched off rather than let a user configure something that will never deliver. Auth via cookie or a wildcard Bearer token; the answer is about the DEPLOYMENT and is identical for every account on it.\n\n" + + "Reads fail soft: when the settings row cannot be loaded the handler returns all-true rather than an error, so a true flag means “not switched off”, never “configured and working”.", + responses: { + "200": { + description: "The instance's service switches.", + content: { + "application/json": { + schema: dataEnvelope( + globalServiceAvailabilityResponse, + "GlobalServiceAvailabilityEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/account": { + delete: { + tags: ["Auth"], + summary: "Permanently delete the account and everything in it", + description: + "Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There is no undo and no grace period.\n\n" + + "Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" + + "Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended.", + requestBody: { + required: true, + content: { "application/json": { schema: deleteAccountRequest } }, + }, + responses: { + "200": { + description: "Account and all associated data deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteAccountEnvelope", + ), + }, + }, + }, + "400": { + description: + "This is the last admin account on the instance and cannot be deleted. Promote another admin first.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Add another Guardian, or delete the managed profile, before deleting this account. Nothing was deleted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds 65536 bytes. Nothing was deleted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "401": { + description: + "Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). Re-verify the factor on the web and retry; a Bearer token cannot clear this arm.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, }; diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 1d4021c7f..47b9f1bc7 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr"; +import { webauthnKeyNameSchema } from "@/lib/validations/mfa"; /** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */ const PASSWORD_MIN_LENGTH = 12; @@ -117,6 +118,14 @@ export const changePasswordSchema = z path: ["confirmPassword"], }); +/** + * Rename a registered passkey — a PRIMARY sign-in credential, not the + * second-factor security key `mfaWebauthnRenameSchema` covers. The two carry + * the same label constraint and are served by different routes; they share the + * constraint rather than the schema so neither surface can drift the other. + */ +export const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema }); + export type RegisterInput = z.infer; export type LoginPasswordInput = z.infer; export type ProfileInput = z.infer; diff --git a/src/lib/validations/user-prefs.ts b/src/lib/validations/user-prefs.ts new file mode 100644 index 000000000..533f88636 --- /dev/null +++ b/src/lib/validations/user-prefs.ts @@ -0,0 +1,39 @@ +/** + * Request schemas for the per-user scalar preference endpoints under + * `/api/auth/me/*`. Single-sourced here so the runtime `safeParse` and the + * OpenAPI registry (`src/lib/openapi/routes/profile.ts`) describe the identical + * wire contract — the same reason `mfa.ts` exists next door. + * + * All three follow the same shape: a one-field PATCH that hard-sets the value + * and answers with the resolved next state, so a client can replace its + * optimistic update rather than re-read. + */ +import { z } from "zod/v4"; + +import { + injectionSiteEnum, + INJECTION_SITE_VALUES, +} from "@/lib/validations/medication"; + +/** `PATCH /api/auth/me/unit-preference` — metric/imperial display branch. */ +export const unitPreferencePatchSchema = z.object({ + unitPreference: z.enum(["metric", "imperial"]), +}); + +/** `PATCH /api/auth/me/documents-auto-ai-read` — the auto-read opt-in. */ +export const documentsAutoAiReadPatchSchema = z.object({ + documentsAutoAiRead: z.boolean(), +}); + +/** `PATCH /api/auth/me/injection-site-prefs` — the user-level deny-list. */ +export const injectionSitePrefsPatchSchema = z.object({ + // Dedup at write time; cap at the full enum size. An empty array + // clears the exclusion. + globalExcludedInjectionSites: z + .array(injectionSiteEnum) + .max(INJECTION_SITE_VALUES.length) + .meta({ + description: + "User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.", + }), +}); From 63f791120a08754c9004aeb34069e4b95b8ff2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 12:39:43 +0200 Subject: [PATCH 2/4] Stop the API switch hiding live tokens, and cap the unit-preference body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects the registry work surfaced, each with a check that fails without the fix. `GET /api/tokens` no longer consults the instance-wide API switch. That switch gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a token's ability to authenticate: nothing on the `requireAuth` path reads it, so a token minted before an operator flipped it stays live while it is off, and refusing the list hid exactly the credentials their owner most needed to see, their own signed-in phone among them. The revoke keeps the gate; the asymmetry is stated in both route comments and pinned by the test, because widening it is a decision about what the switch means rather than a fix to the read. `PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a 1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the one endpoint in the family with no body-size limit at all — the new test proves it by sending 2 KB and watching the old handler answer 200. Malformed JSON now answers 400 like every sibling instead of 422; the only caller checks `res.ok` and never the status, so nothing depends on the old code. The existing malformed-JSON assertion was changed deliberately: it pinned the inconsistency, not a contract. `PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a flat "Invalid request", so an empty name and a 200-character name produced byte-identical bodies and the form had nothing to show beside the field. `GET /api/user/thresholds` has a header that describes the handler. It promised `{ defaults, overrides, effective }` and has always returned `{ effective, overrides }`. The guard compares the two, so the comment cannot drift again in either direction. The two published operations whose contracts moved are updated with them. --- .../unit-preference/__tests__/route.test.ts | 41 +++- src/app/api/auth/me/unit-preference/route.ts | 23 ++- .../passkeys/[id]/__tests__/route.test.ts | 157 +++++++++++++++ src/app/api/auth/passkeys/[id]/route.ts | 8 +- src/app/api/tokens/[id]/route.ts | 5 + src/app/api/tokens/__tests__/route.test.ts | 180 ++++++++++++++++++ src/app/api/tokens/route.ts | 22 ++- .../user/thresholds/__tests__/route.test.ts | 74 ++++++- src/app/api/user/thresholds/route.ts | 8 +- src/lib/openapi/routes/auth.ts | 11 +- src/lib/openapi/routes/profile.ts | 6 +- 11 files changed, 507 insertions(+), 28 deletions(-) create mode 100644 src/app/api/auth/passkeys/[id]/__tests__/route.test.ts create mode 100644 src/app/api/tokens/__tests__/route.test.ts diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts index 6e0d57680..17f9e74a3 100644 --- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -148,7 +148,15 @@ describe("PATCH /api/auth/me/unit-preference", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // The three cases below pin the capped `safeJson` read. Before it, the + // handler called `request.json()` bare: malformed JSON came back 422 rather + // than the 400 every sibling scalar answers, a wrong content type was parsed + // rather than refused, and there was NO body-size limit — this was the one + // endpoint in the family that would materialise whatever it was handed. The + // malformed-JSON assertion used to say 422 and was changed deliberately: it + // pinned the inconsistency rather than a contract, and the only caller + // branches on `res.ok` alone. + it("rejects malformed JSON with 400, like its sibling scalars", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/unit-preference", { method: "PATCH", @@ -156,7 +164,36 @@ describe("PATCH /api/auth/me/unit-preference", () => { headers: { "Content-Type": "application/json" }, }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a body over the 1 KB cap with 413", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Valid JSON, valid enum value, and far too big: the cap has to be what + // rejects it, not the schema. + const req = new Request("http://localhost/api/auth/me/unit-preference", { + method: "PATCH", + body: JSON.stringify({ + unitPreference: "imperial", + padding: "x".repeat(2048), + }), + headers: { "Content-Type": "application/json" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(413); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a non-JSON content type with 415", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request("http://localhost/api/auth/me/unit-preference", { + method: "PATCH", + body: "unitPreference=imperial", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(415); expect(prisma.user.update).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index e101378b8..ae7b459d7 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -15,13 +15,22 @@ * Mirrors the `disable-coach` per-user-scalar pattern: 60/min rate * limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). + * + * The body goes through `safeJson` with a 1 KB cap, like the + * `documents-auto-ai-read` scalar beside it. It used to read a bare + * `request.json()`, which applied no size limit at all — the one endpoint in + * this family that would materialise whatever it was sent — and answered a + * malformed body with 422 where every sibling answers 400. Both were + * accidents of the original copy, not a contract: the only caller checks + * `res.ok` and never the status. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp, returnAllZodIssues, + safeJson, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -73,12 +82,12 @@ export const PATCH = apiHandler(async (req: Request) => { return response; } - let body: unknown; - try { - body = await req.json(); - } catch { - throw new HttpError(422, "unit-preference.body.invalid_json"); - } + // The body is a single enum value — bound the parse so a malformed or + // oversized payload is rejected before it is materialised. + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; const parsed = unitPreferencePatchSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..ee81a5d11 --- /dev/null +++ b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts @@ -0,0 +1,157 @@ +/** + * `PATCH /api/auth/passkeys/{id}` — the rename's refusal has to name the field. + * + * The handler used to answer `apiError("Invalid request", 422)` and throw the + * Zod issues away, which is the one thing every sibling body-parsing route in + * this tree does not do. A person renaming a passkey to an empty string and a + * person pasting 200 characters got the same three words, so the form had + * nothing to put beside the field and no way to say which rule was broken. + * + * These cases pin the multi-issue envelope by its CONTENT — the issue list has + * to be present and has to point at `name` — rather than by status alone. A + * status-only assertion would have passed against the flat error it replaced. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + passkey: { + findUnique: vi.fn(), + update: vi.fn(), + count: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +import { PATCH } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; + +const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" }; + +const ROW = { + id: "pk-1", + name: "Phone", + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + createdAt: new Date("2026-01-01T00:00:00Z"), + lastUsedAt: null, +}; + +type IssueEnvelope = { + error: string; + details?: { issues?: Array<{ path?: unknown; message?: string }> }; +}; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/passkeys/pk-1", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +const params = Promise.resolve({ id: "pk-1" }); + +type Handler = ( + r: Request, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); +}); + +describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => { + it("returns the multi-issue envelope for an empty name", async () => { + const res = await (PATCH as unknown as Handler)(mkPatch({ name: "" }), { + params, + }); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + // The flat refusal this replaces said "Invalid request" and carried no + // `details` at all, so both halves matter. + expect(env.error).toBe("Validation failed"); + expect(env.details?.issues?.length).toBeGreaterThan(0); + expect(JSON.stringify(env.details?.issues)).toContain("name"); + expect(prisma.passkey.update).not.toHaveBeenCalled(); + }); + + it("returns the multi-issue envelope for a name over 64 characters", async () => { + const res = await (PATCH as unknown as Handler)( + mkPatch({ name: "x".repeat(65) }), + { params }, + ); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + expect(env.details?.issues?.length).toBeGreaterThan(0); + expect(JSON.stringify(env.details?.issues)).toContain("name"); + expect(prisma.passkey.update).not.toHaveBeenCalled(); + }); + + it("distinguishes the two refusals from each other", async () => { + // The point of the change: a client can tell WHICH rule was broken. Under + // the flat error both bodies were byte-identical. + const tooShort = await (PATCH as unknown as Handler)( + mkPatch({ name: "" }), + { params }, + ); + const tooLong = await (PATCH as unknown as Handler)( + mkPatch({ name: "x".repeat(65) }), + { params }, + ); + + expect(JSON.stringify(await tooShort.json())).not.toBe( + JSON.stringify(await tooLong.json()), + ); + }); + + it("still renames on a valid name, trimming it first", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + userId: USER.id, + } as never); + vi.mocked(prisma.passkey.update).mockResolvedValue({ + ...ROW, + name: "Work laptop", + } as never); + + const res = await (PATCH as unknown as Handler)( + mkPatch({ name: " Work laptop " }), + { params }, + ); + + expect(res.status).toBe(200); + expect(prisma.passkey.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: "Work laptop" } }), + ); + }); + + it("does not leak another account's passkey through the rename", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + userId: "someone-else", + } as never); + + const res = await (PATCH as unknown as Handler)( + mkPatch({ name: "Mine now" }), + { params }, + ); + + expect(res.status).toBe(404); + expect(prisma.passkey.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index eb95c324b..5c7ad7788 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { NextRequest } from "next/server"; @@ -26,7 +27,12 @@ export const PATCH = apiHandler( const parsed = passkeyRenameSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + // Multi-issue, like every sibling that parses a body. The flat + // `apiError("Invalid request", 422)` this replaces discarded the issues, + // so a person renaming a passkey could not tell an empty name from one + // over the 64-character limit — the form had nothing to put beside the + // field. + return returnAllZodIssues(parsed.error, 422); } const passkey = await prisma.passkey.findUnique({ diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts index ef19a4348..c6659d20d 100644 --- a/src/app/api/tokens/[id]/route.ts +++ b/src/app/api/tokens/[id]/route.ts @@ -8,6 +8,11 @@ type RouteParams = { params: Promise<{ id: string }> }; /** * Revoke an API token. + * + * Still gated on the instance-wide API switch, where the sibling LIST is not. + * The reasoning for the split lives in `../route.ts`: the list answers which + * credentials exist on the caller's own account and is never the unsafe half, + * while this one mutates the token surface the operator has switched off. */ export const DELETE = apiHandler( async (_request: Request, { params }: RouteParams) => { diff --git a/src/app/api/tokens/__tests__/route.test.ts b/src/app/api/tokens/__tests__/route.test.ts new file mode 100644 index 000000000..2c757c67d --- /dev/null +++ b/src/app/api/tokens/__tests__/route.test.ts @@ -0,0 +1,180 @@ +/** + * `/api/tokens` — the instance-wide API switch must not hide live credentials. + * + * `AppSettings.apiGlobal` gates the surfaces a token is FOR — external + * medication ingest, the MCP bridge — and not a token's ability to + * authenticate: nothing on the `requireAuth` path consults it, which the last + * case here asserts directly rather than assuming. So a token minted before an + * operator flipped the switch stays a live credential while it is off, and the + * LIST used to answer 403 — hiding exactly the credentials their owner most + * needed to see, including the wildcard access token their own phone signs in + * with. + * + * The read is ungated now. The revoke is not, and that asymmetry is pinned + * below rather than left to drift: it is a decision about what the switch + * means, and a change to it should be a visible diff here. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() })); + +import { GET } from "../route"; +import { DELETE } from "../[id]/route"; +import { requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; + +const USER = { id: "user-1", username: "u" }; + +const ROW = { + id: "tok-1", + name: "iPhone", + permissions: ["*"], + lastUsedAt: new Date("2026-08-01T10:00:00Z"), + expiresAt: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + revoked: false, +}; + +type ListEnvelope = { + data: Array<{ id: string }> | null; + error: string | null; +}; + +const params = Promise.resolve({ id: "tok-1" }); + +type DeleteHandler = ( + r: Request, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); + vi.mocked(prisma.apiToken.findMany).mockResolvedValue([ROW] as never); +}); + +describe("GET /api/tokens", () => { + it("lists the caller's tokens while the operator has the API switched off", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + + const res = await (GET as unknown as () => Promise)(); + + expect(res.status).toBe(200); + const env = (await res.json()) as ListEnvelope; + expect(env.error).toBeNull(); + expect(env.data?.[0]?.id).toBe("tok-1"); + expect(prisma.apiToken.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: USER.id } }), + ); + }); + + it("does not consult the switch at all", async () => { + // Stronger than the status assertion above and the reason it is separate: + // a future re-gating that happened to answer 200 anyway would still be a + // read whose behaviour depends on an operator toggle it has no business + // reading. + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + + await (GET as unknown as () => Promise)(); + + expect(isApiGloballyEnabled).not.toHaveBeenCalled(); + }); + + it("still scopes the list to the caller", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + + await (GET as unknown as () => Promise)(); + + const args = vi.mocked(prisma.apiToken.findMany).mock.calls[0][0]; + expect(args?.where).toEqual({ userId: USER.id }); + // No `revoked: false` filter — revoked rows stay in the list, which is + // what the published contract says and what the UI relies on. + expect(JSON.stringify(args?.where)).not.toContain("revoked"); + }); +}); + +describe("DELETE /api/tokens/{id}", () => { + it("keeps refusing while the operator has the API switched off", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); + + const res = await (DELETE as unknown as DeleteHandler)( + new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), + { params }, + ); + + expect(res.status).toBe(403); + expect(prisma.apiToken.update).not.toHaveBeenCalled(); + }); + + it("revokes when the switch is on", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + ...ROW, + userId: USER.id, + } as never); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + + const res = await (DELETE as unknown as DeleteHandler)( + new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), + { params }, + ); + + expect(res.status).toBe(200); + expect(prisma.apiToken.update).toHaveBeenCalledWith({ + where: { id: "tok-1" }, + data: { revoked: true }, + }); + }); + + it("does not reach another account's token", async () => { + vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + ...ROW, + userId: "someone-else", + } as never); + + const res = await (DELETE as unknown as DeleteHandler)( + new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), + { params }, + ); + + expect(res.status).toBe(404); + expect(prisma.apiToken.update).not.toHaveBeenCalled(); + }); +}); + +describe("the switch and the authentication path", () => { + it("is not consulted by requireAuth, which is why the read must not gate on it", async () => { + // The premise the fix rests on, asserted rather than assumed: if the auth + // kit ever started refusing Bearer callers while `apiGlobal` is off, then + // a token would NOT be live during the outage and hiding the list would + // stop being wrong. This reads the source because the behaviour is an + // absence, and an absence has no call to spy on. + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const source = readFileSync( + join(process.cwd(), "src/lib/api-handler.ts"), + "utf8", + ); + expect(source).not.toContain("isApiGloballyEnabled"); + expect(source).not.toContain("apiGlobal"); + }); +}); diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 541e0bf02..0a653efb0 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -11,21 +11,31 @@ * (`/api/medications/[id]/api-endpoint`), which issues both grants. * * Listing and revoking stay so existing tokens remain visible and revocable. + * + * The list is NOT gated on the instance-wide API switch, and the revoke is. + * The asymmetry is deliberate, and rests on what the switch actually does: + * `AppSettings.apiGlobal` gates the surfaces a token is FOR — external + * medication ingest, the MCP bridge — and not the token's ability to + * authenticate. Nothing on the `requireAuth` path consults it. So a token + * minted before an operator flipped the switch is still a live credential + * while it is off, and refusing the list hid exactly the credentials their + * owner most needed to see. Answering "which credentials exist on your own + * account" is never the unsafe half of this surface: it discloses no token + * material, and it is how a person finds the row to kill. + * + * The revoke keeps the gate as the surface stands. Whether the switch should + * also stop an owner killing a live token is a question about what the switch + * means, not a property of the read, so it is left where it was. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; -import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { apiSuccess } from "@/lib/api-response"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "tokens.list" } }); - if (!(await isApiGloballyEnabled())) { - return apiError("API is globally disabled", 403); - } - const tokens = await prisma.apiToken.findMany({ where: { userId: user.id }, select: { diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts index fd37799d0..dca481f73 100644 --- a/src/app/api/user/thresholds/__tests__/route.test.ts +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -44,9 +44,11 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT } from "../route"; +import { GET, PUT } from "../route"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { getAllEffectiveRanges } from "@/lib/analytics/effective-range"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -108,3 +110,73 @@ describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => { expect(body.details.issues.length).toBeGreaterThanOrEqual(3); }); }); + +/** + * The docblock promised a key the handler has never returned. + * + * For as long as this route has existed its header said GET answers + * `{ defaults, overrides, effective }`. It answers `{ effective, overrides }`; + * the per-metric defaults live inside `effective[metric].default`. Anyone + * reading the file to find out what to decode was told to expect a top-level + * key that is not there. + * + * Asserting the shape alone would not have caught it — the code was always + * right. So this compares the two: the keys the response actually carries + * against the keys the docblock advertises. A comment that drifts from the + * handler fails here, in either direction. + */ +describe("GET /api/user/thresholds — the docblock describes the response", () => { + const RESPONSE_KEYS = ["effective", "overrides"]; + + it("returns exactly the documented top-level keys", async () => { + // `beforeEach` calls `vi.resetAllMocks()`, which clears the module-level + // `mockReturnValue({})` — without re-arming it the resolver returns + // undefined and `effective` vanishes from the JSON entirely. + vi.mocked(getAllEffectiveRanges).mockReturnValue({ + WEIGHT: { + range: { greenMin: 60, greenMax: 80, orangeMin: 57, orangeMax: 83 }, + isOverride: false, + default: { greenMin: 60, greenMax: 80, orangeMin: 57, orangeMax: 83 }, + bounds: { min: 30, max: 300, unit: "kg" }, + }, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: 180, + dateOfBirth: null, + gender: null, + thresholdsJson: null, + } as never); + + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + + const body = (await res.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([...RESPONSE_KEYS].sort()); + expect(body.data).not.toHaveProperty("defaults"); + }); + + it("names those same keys in the route's own header comment", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const source = readFileSync( + join(process.cwd(), "src/app/api/user/thresholds/route.ts"), + "utf8", + ); + const docblock = source.slice(0, source.indexOf("*/")); + + // The promise the header makes, as a brace list: `{ effective, overrides }`. + const promised = docblock.match(/GET returns `\{([^}]+)\}`/); + expect( + promised, + "the header no longer states what GET returns", + ).not.toBeNull(); + + const promisedKeys = (promised?.[1] ?? "") + .split(",") + .map((key) => key.trim()) + .filter(Boolean) + .sort(); + + expect(promisedKeys).toEqual([...RESPONSE_KEYS].sort()); + }); +}); diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index c11ad5410..5034ae027 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -1,8 +1,12 @@ /** * GET / PUT / DELETE per-user threshold overrides. * - * GET returns `{ defaults, overrides, effective }` so the settings UI can - * show a side-by-side comparison. + * GET returns `{ effective, overrides }`. The side-by-side comparison the + * settings UI draws comes out of `effective`, not out of a sibling key: each + * entry carries `range` (the band in force), `isOverride`, `default` (the + * unmodified computed band) and `bounds`. There is no top-level `defaults` + * key and there never was — this comment promised one for long enough that + * it is worth stating the absence rather than just deleting the word. * PUT accepts a partial map — only the provided metrics are updated, others * keep their existing override or remain on the default. * DELETE with ?metric=... resets one metric to default. diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index e804bc6a6..19f313aa1 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1316,7 +1316,7 @@ export const authPaths: NonNullable = { summary: "Rename a registered passkey", description: "Relabels one primary sign-in credential, scoped to the authenticated user. Returns the full passkey row, so the client can replace its list entry rather than re-read.\n\n" + - 'Unlike the rest of this surface the 422 carries a single flat message rather than the multi-issue envelope `returnAllZodIssues` produces — the handler answers `apiError("Invalid request", 422)` and discards the Zod issues, so a client cannot tell an empty name from an over-long one.', + "The name is trimmed before validation and must be 1 to 64 characters once trimmed. A rejected name earns the standard multi-issue 422, so a form can put the reason beside the field rather than showing a bare “invalid request”.", requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -1376,7 +1376,7 @@ export const authPaths: NonNullable = { summary: "List the caller's API tokens", description: "Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" + - 'Two things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. And there is no mint on this path any more — the generic POST that issued `["medication:ingest"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The working credential comes from the per-medication API-endpoint toggle.', + "Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no mint on this path any more — the generic POST that issued `[\"medication:ingest\"]` was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working credential comes from the per-medication API-endpoint toggle. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.", responses: { "200": { description: "The caller's tokens, newest first.", @@ -1389,11 +1389,6 @@ export const authPaths: NonNullable = { }, }, }, - "403": { - description: - "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Checked after authentication, carries no `meta.errorCode`, and applies to the read as well as the revoke — a user cannot inspect their own tokens while the switch is off.", - content: { "application/json": { schema: errorEnvelope } }, - }, ...stdResponses, }, }, @@ -1420,7 +1415,7 @@ export const authPaths: NonNullable = { }, "403": { description: - "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked.", + "The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is off, so its owner can still see it here even though this call will not kill it.", content: { "application/json": { schema: errorEnvelope } }, }, "404": { diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index ee60970f9..628e23592 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -1223,7 +1223,7 @@ export const profilePaths: NonNullable = { summary: "Set the metric/imperial display preference", description: "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" + - "The only endpoint in this group that reads its body with a bare `request.json()` rather than the capped `safeJson` helper, so it applies no body-size limit and answers a malformed body with 422 where its siblings answer 400. Both are the code as it stands.", + "The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group answer.", requestBody: { required: true, content: { @@ -1242,6 +1242,10 @@ export const profilePaths: NonNullable = { }, }, }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, From a283211586e799a3dcdcafbf4c6812d9aaa1af8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 13:03:11 +0200 Subject: [PATCH 3/4] Describe the remaining auth, settings and notification-transport surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-eight more verbs that exist, are called, and said nothing in the contract: registration, password rotation, the profile patch, the SSO entry point, passkey enrolment, the Codex device-auth flow, the device list and its revocation cascade, four per-user preference scalars, the email channel and the four channel self-tests, the record wipe, the privacy facts, the update check, and the Web Push transport. Four of them needed more care than the rest. `GET /api/auth/oidc/login` is documented as the two-armed route it is. The browser arm redirects to the provider; the native arm takes `client=native` plus a mandatory S256 challenge and returns to a compile-time scheme with a one-time code — or an MFA ticket when the account has a second factor. The two PKCE exchanges in play are named apart, because confusing them is the way to get this wrong. `POST /api/auth/register` is the only genuinely anonymous surface in the set, and its refusals are stated exactly: the SSO-only block, the 5-per-15-minutes bucket that collapses rather than falls open, the invite that is consumed whenever it is sent, the zero-user bootstrap that mints the first admin under a lock, and the one 409 that covers a taken email and a taken username so it cannot be used to enumerate accounts. `POST /api/auth/password` is a change behind authentication, not a reset, so it is described as one. `DELETE /api/settings/data` names what it takes and what it leaves: ninety-nine models hard-deleted, the account's own personal columns reset, credentials and interface preferences kept, one transaction, and a confirmation string that is deliberately not the one account deletion wants. Two collisions surfaced while wiring this up and are resolved rather than worked around. `PUT /api/auth/profile` and `PATCH /api/user/profile` are two doors onto one handler, so they now share one published request component instead of two descriptions of one validator. Their RESPONSES differ, though — one carries id and role, the other the display preferences, and neither is a superset — so they are named apart and the older description no longer claims to cover both. The four Web Push and APNs surfaces land in their own route module rather than beside the notification preferences: that module answers what gets sent and whether it arrived, this one the plumbing a client sets up and then verifies. Both self-tests report failure inside a 200, which the contract says plainly, because a client reading only the status will tell someone their notifications work when they do not. The generated YAML is left out; it is regenerated once the remaining groups land. --- src/app/api/auth/me/labs-local-ocr/route.ts | 9 +- .../api/auth/me/use-central-codex/route.ts | 9 +- src/lib/openapi/routes/auth.ts | 482 ++++++++++++++++++ src/lib/openapi/routes/index.ts | 5 + src/lib/openapi/routes/meta.ts | 69 +++ .../openapi/routes/notifications-transport.ts | 249 +++++++++ src/lib/openapi/routes/profile.ts | 384 +++++++++++++- src/lib/openapi/routes/settings.ts | 313 ++++++++++++ src/lib/validations/user-prefs.ts | 12 +- 9 files changed, 1515 insertions(+), 17 deletions(-) create mode 100644 src/lib/openapi/routes/notifications-transport.ts diff --git a/src/app/api/auth/me/labs-local-ocr/route.ts b/src/app/api/auth/me/labs-local-ocr/route.ts index d015b773f..dfa330ca8 100644 --- a/src/app/api/auth/me/labs-local-ocr/route.ts +++ b/src/app/api/auth/me/labs-local-ocr/route.ts @@ -16,8 +16,6 @@ * field-by-field write (no mass assignment). Idempotent — always returns the * resolved next state so the client can hard-set the optimistic update. */ -import { z } from "zod"; - import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -30,10 +28,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; - -const patchBodySchema = z.object({ - labsLocalOcrEnabled: z.boolean(), -}); +import { labsLocalOcrPatchSchema } from "@/lib/validations/user-prefs"; export const dynamic = "force-dynamic"; @@ -81,7 +76,7 @@ export const PATCH = apiHandler(async (req: Request) => { }); if (jsonError) return jsonError; - const parsed = patchBodySchema.safeParse(body); + const parsed = labsLocalOcrPatchSchema.safeParse(body); if (!parsed.success) { annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } }); return returnAllZodIssues(parsed.error, 422); diff --git a/src/app/api/auth/me/use-central-codex/route.ts b/src/app/api/auth/me/use-central-codex/route.ts index e07ab0603..57d1c803e 100644 --- a/src/app/api/auth/me/use-central-codex/route.ts +++ b/src/app/api/auth/me/use-central-codex/route.ts @@ -16,8 +16,6 @@ * `returnAllZodIssues`, audit-log row, field-by-field write (no mass * assignment). Idempotent — always returns the resolved next state. */ -import { z } from "zod"; - import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -30,10 +28,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; - -const patchBodySchema = z.object({ - useCentralCodex: z.boolean(), -}); +import { useCentralCodexPatchSchema } from "@/lib/validations/user-prefs"; export const dynamic = "force-dynamic"; @@ -79,7 +74,7 @@ export const PATCH = apiHandler(async (req: Request) => { }); if (jsonError) return jsonError; - const parsed = patchBodySchema.safeParse(body); + const parsed = useCentralCodexPatchSchema.safeParse(body); if (!parsed.success) { annotate({ action: { name: "auth.me.useCentralCodex.patch.invalid_shape" }, diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 19f313aa1..8d9ebd76f 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -13,9 +13,14 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { + changePasswordSchema, loginPasswordSchema, passkeyRenameSchema, + registerSchema, } from "@/lib/validations/auth"; +// `PUT /api/auth/profile` and `PATCH /api/user/profile` are two doors onto one +// `applyProfileUpdate` call, so they take one body and publish one component. +import { profileUpdateRequest } from "./profile"; import { mfaVerifySchema, totpConfirmSchema, @@ -440,6 +445,153 @@ const apiTokenListResponse = z .array(apiTokenInfo) .meta({ id: "ApiTokenListResponse" }); +// ── Registration, credential rotation, Codex device-auth ───────────── + +const registerRequest = registerSchema.meta({ + id: "RegisterRequest", + description: + "Account creation. `timezone` is a hint, not a requirement — an unknown zone is replaced with the operator default rather than refused. `inviteToken` is consulted whenever it is present, and is the admission requirement only while self-registration is closed.", +}); + +const changePasswordRequest = changePasswordSchema.meta({ + id: "ChangePasswordRequest", + description: + "Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email.", +}); + +const passkeyReauthRequest = z + .object({ + method: z + .enum(["passkey", "webauthn"]) + .describe( + "Which credential store to assert against: the account's primary passkeys, or its second-factor security keys.", + ), + }) + .meta({ + id: "PasskeyReauthRequest", + description: + "The re-proof arm of register-options. Recognised ONLY when `method` is the body's single key — adding any other property routes the request to the enrollment arm, where it will be read as a factor proof and almost certainly refused.", + }); + +const passkeyRegisterVerifyRequest = z + .object({ + challengeId: z + .string() + .describe("The challenge id returned by register-options."), + credential: z + .record(z.string(), z.unknown()) + .describe( + "SimpleWebAuthn attestation response. `response.transports`, when the authenticator reports it, is stored as sent.", + ), + }) + .meta({ + id: "PasskeyRegisterVerifyRequest", + description: + "Finish a passkey enrollment. The handler reads these two fields directly rather than through a Zod schema; a missing one is a 422.", + }); + +const registeredUserResponse = z + .object({ + user: z.object({ + id: z.string(), + username: z.string(), + email: z.string().nullable(), + }), + }) + .meta({ + id: "RegisteredUserResponse", + description: + "The account that was created. No credential rides this response — the session arrives as a cookie, and a native client signs in afterwards through /api/auth/login.", + }); + +const passwordChangedResponse = z + .object({ changed: z.literal(true) }) + .meta({ id: "PasswordChangedResponse" }); + +const authProfileUpdateResponse = z + .object({ + id: z.string(), + username: z.string(), + email: z.string().nullable(), + role: z.string(), + heightCm: z.number().nullable(), + dateOfBirth: z.string().nullable(), + gender: z.string().nullable(), + timezone: z.string().nullable(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z.string().nullable(), + hasInsuranceNumber: z + .boolean() + .describe( + "Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence.", + ), + rejectedFields: z + .array( + z.object({ + path: z.string(), + code: z.string(), + message: z.string(), + }), + ) + .optional() + .describe( + "Present only on a PARTIAL success: the fields that failed validation and were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save.", + ), + }) + .meta({ + id: "AuthProfileUpdateResponse", + description: + "The profile after the patch, as THIS path projects it. Deliberately named apart from `ProfileUpdateResponse`: PATCH /api/user/profile runs the same handler and answers a different field set — this one carries `id` and `role`, that one carries `displayName`, `locale`, `timeFormat`, `dateFormat` and `moodReminderEnabled`. Neither is a superset of the other. Field-by-field either way: a bad field is dropped and reported in `rejectedFields` rather than failing the whole request, unless EVERY supplied field was rejected.", + }); + +const passkeyReauthOptionsResponse = z + .object({ + options: z + .record(z.string(), z.unknown()) + .describe("SimpleWebAuthn assertion options for the re-proof ceremony."), + challengeId: z.string(), + reauth: z + .literal(true) + .describe( + "Marks this as the re-proof arm rather than the enrollment arm. Present only here.", + ), + }) + .meta({ id: "PasskeyReauthOptionsResponse" }); + +const codexDeviceStartResponse = z + .object({ + userCode: z + .string() + .describe("Short code the person types on the verification page."), + verificationUrl: z + .url() + .describe( + "Where to type it — an OpenAI-hosted page, not a HealthLog one.", + ), + intervalSeconds: z + .number() + .int() + .describe( + "How often to call the poll endpoint. Honour it: the poll surface is limited to 60 calls per minute per user.", + ), + }) + .meta({ + id: "CodexDeviceStartResponse", + description: + "The user-facing half of a device-code grant. The device-auth id that pairs with it is NOT here — it is sealed into an encrypted, httpOnly, 15-minute cookie, so the poll can only be completed by the browser that started the flow.", + }); + +const codexDevicePollResponse = z + .object({ + status: z + .enum(["pending", "connected"]) + .describe( + "`pending` while the person has not approved yet — keep polling. `connected` once the credentials are stored; stop polling, the cookie is gone.", + ), + }) + .meta({ id: "CodexDevicePollResponse" }); + export const authPaths: NonNullable = { "/api/auth/check-user": { post: { @@ -1426,4 +1578,334 @@ export const authPaths: NonNullable = { }, }, }, + // ── Appended: registration, credential rotation, the OIDC entry point and + // the Codex device-auth flow. + "/api/auth/register": { + post: { + tags: ["Auth"], + summary: "Create an account", + description: + "Anonymous. Creates the account, issues a browser session cookie, and returns the new account's identity. No token is minted here even for a native caller — an app registers and then signs in through /api/auth/login.\n\n" + + "Admission has three gates and a bootstrap. `OIDC_ONLY=true` refuses outright. Otherwise the surface is rate-limited to 5 attempts per 15 minutes; on a misconfigured proxy trust chain every anonymous caller collapses into ONE bucket rather than falling open. When the operator has closed self-registration a valid invite token still admits the signup, and a token is consumed whenever one is sent — even under open registration, so the issuer's ledger stays complete. The bootstrap: on an instance with zero users registration is always allowed and the first account is minted ADMIN, decided under an advisory lock so two racing signups cannot both become one.\n\n" + + "The password is checked for strength against the chosen username and email, and against a breach corpus over k-anonymity. The breach check fails OPEN — an unreachable corpus never blocks a signup. A supplied `timezone` is validated against the runtime IANA list and silently replaced with the operator default when it is not one; it is never a reason to refuse.\n\n" + + "Existence is not disclosed: a taken email and a taken username return the same 409 with the same prose.", + requestBody: { + required: true, + content: { "application/json": { schema: registerRequest } }, + }, + responses: { + "201": { + description: "Account created; the session cookie is set.", + content: { + "application/json": { + schema: dataEnvelope(registeredUserResponse, "RegisterEnvelope"), + }, + }, + }, + "403": { + description: + "Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired or exhausted. The three are distinguishable by prose but not by code.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The email or the username is taken. Deliberately one message for both, so this is not an account-existence oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index catches the second.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/password": { + post: { + tags: ["Auth"], + summary: "Change the account password", + description: + "A change, not a reset: it is authenticated, and it re-proves the CURRENT password in the body. There is no anonymous forgot-password flow on this surface.\n\n" + + "For an account with a second factor enrolled it additionally requires a fresh factor proof, which is cookie-only by construction — a Bearer transport carries no `mfaVerifiedAt` and cannot satisfy it at any scope. Accounts with no second factor keep the current-password-only contract on either transport.\n\n" + + "On success the blast radius is deliberate and wide: every step-up elevation the account holds is revoked, every session is destroyed, and a fresh session is issued to THIS caller. Other browsers and other devices are signed out. The new password is strength-checked and screened against a breach corpus (fail-open on an unreachable corpus). Rate-limited 5 per 15 minutes per user.", + requestBody: { + required: true, + content: { "application/json": { schema: changePasswordRequest } }, + }, + responses: { + "200": { + description: + "Password rotated. Every other session is gone and this caller holds a new one.", + content: { + "application/json": { + schema: dataEnvelope( + passwordChangedResponse, + "PasswordChangeEnvelope", + ), + }, + }, + }, + "400": { + description: + "The account has no password to change — it was provisioned through SSO. There is no set-a-first-password arm on this route.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "401": { + description: + "Not authenticated, the current password is wrong, or the account has a second factor and the session carries no proof of it fresh enough (`meta.errorCode` = `auth.stepup.required`). A wrong current password is 401 rather than 422 — it is a failed credential check, not a malformed body.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/auth/profile": { + put: { + tags: ["Auth"], + summary: "Patch the account profile", + description: + "Partial and field-by-field: only the keys present in the body are considered, and a key that fails validation is SKIPPED rather than failing its siblings. The response still carries 200 and lists what was dropped in `rejectedFields` — so a client that treats any 200 as a clean save will silently discard the user's correction. Read the key.\n\n" + + "Two cases do fail the whole request: a body that is not an object at all, and a body in which every supplied field was rejected (`meta.errorCode` = `profile.update.nothingSaved`) — because reporting a partial success that saved nothing would be a lie.\n\n" + + 'The insurance number is validated with its check digit, normalised, and stored encrypted; it is never returned. `hasInsuranceNumber` reports presence only. A null or empty string clears a nullable field rather than being refused, so a form that renders “no answer” as `""` does not 422 the whole patch.', + requestBody: { + required: true, + content: { "application/json": { schema: profileUpdateRequest } }, + }, + responses: { + "200": { + description: + "The profile after the patch. A `rejectedFields` key means the save was PARTIAL.", + content: { + "application/json": { + schema: dataEnvelope( + authProfileUpdateResponse, + "AuthProfileUpdateEnvelope", + ), + }, + }, + }, + "409": { + description: + "The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Either the body was not a usable object (`meta.errorCode` = `profile.update.invalidBody`) or every supplied field was rejected (`profile.update.nothingSaved`). Both carry the per-field issue list. A body where SOME fields validate never reaches this response — it succeeds with `rejectedFields`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/auth/oidc/login": { + get: { + tags: ["Auth"], + summary: "Start an SSO sign-in (browser redirect, or the native leg)", + description: + "A full-page navigation, never a fetch: every outcome is a 302, and no branch returns the JSON envelope. Anonymous by definition — it is what authenticates the caller. Rate-limited 10 per 15 minutes on the anonymous auth-surface bucket.\n\n" + + "**Browser arm.** No parameters, or `next=` to return somewhere specific afterwards (sanitised to an in-app path — an absolute URL is discarded, not honoured). Redirects to the provider's authorization endpoint. Every failure redirects to `/auth/login?error=`.\n\n" + + "**Native arm.** The iOS app opens this URL inside an `ASWebAuthenticationSession` with `client=native` and its own PKCE `code_challenge`. The challenge is mandatory on this arm and must be S256, 43–128 characters; `plain` is structurally unsupported because the exchange only ever verifies S256. `next` is ignored and pinned to `/` — a post-login web path means nothing to the app. Every failure redirects to `healthlog://oidc-callback?error=`, and an error redirect carries no code, no ticket and no session, which is why it is safe that `client` is caller-supplied.\n\n" + + "**What comes back.** The provider returns to the server's own callback, not to the app. On success the callback redirects to the compiled-in `healthlog://oidc-callback` with a one-time `code=hlh_…`, which the app exchanges at POST /api/auth/oidc/native/token. When the account has a second factor the callback instead returns `mfa_ticket=…`, which the app completes at /api/auth/mfa/verify to obtain the same bundle. The token pair NEVER rides a URL — only the opaque code or ticket does. There is no `redirect_uri` parameter and no allowlist to configure: the scheme is a compile-time constant, so the open-redirect class is removed by construction.\n\n" + + "Two PKCE exchanges run here and must not be confused: the server↔provider verifier the server generates for itself, and the app↔server challenge supplied above. The native flag and the app's challenge travel only inside an AES-256-GCM state cookie, so the callback branches on tamper-authenticated state rather than on anything the provider or a network attacker can flip.", + requestParams: { + query: z.object({ + client: z + .literal("native") + .optional() + .describe( + "Send `native` to start the app leg. Anything else (including absence) is the browser leg.", + ), + code_challenge: z + .string() + .min(43) + .max(128) + .optional() + .describe( + "The app's PKCE S256 challenge (RFC 7636). REQUIRED when `client=native`; ignored otherwise.", + ), + next: z + .string() + .optional() + .describe( + "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.", + ), + }), + }, + responses: { + "302": { + description: + "Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`.", + }, + }, + }, + }, + "/api/auth/passkey/register-options": { + post: { + tags: ["Auth"], + summary: "Begin registering a passkey (existing-factor proof required)", + description: + "Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" + + '**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' + + "**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" + + "Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" + + "Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller.", + requestBody: { + required: true, + content: { + "application/json": { + schema: z.union([passkeyReauthRequest, stepUpMintSchema]), + }, + }, + }, + responses: { + "200": { + description: + "Options issued. Carries `reauth: true` on the re-proof arm and not on the enrollment arm.", + content: { + "application/json": { + schema: z.union([ + dataEnvelope( + passkeyReauthOptionsResponse, + "PasskeyReauthOptionsEnvelope", + ), + dataEnvelope( + webauthnOptionsResponse, + "PasskeyRegisterOptionsEnvelope", + ), + ]), + }, + }, + }, + "409": { + description: + "The re-proof arm asked for a method the account has no credential for — fall back to another factor.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "401": { + description: + "No cookie session, or no acceptable existing-factor proof. One message covers every proof failure so the response cannot be used to probe which part was wrong.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/auth/passkey/register-verify": { + post: { + tags: ["Auth"], + summary: "Finish registering a passkey", + description: + "Cookie-only. Verifies the attestation against the challenge minted by register-options and stores the credential as a PRIMARY sign-in credential — a different store from the second-factor security keys under /api/auth/me/mfa/webauthn.\n\n" + + "The freshness stamp is re-read from the live session row rather than trusted from the earlier request, so an elevation downgraded or revoked between the two calls cannot be carried through the ceremony. The challenge is bound to the user AND the session that created it: presenting one from another context is refused as a missing proof rather than as a bad challenge.\n\n" + + "The transports the authenticator reports are stored as sent. Nothing about the credential is echoed back — the response is a bare confirmation, and the new row is read through GET /api/auth/passkeys.", + requestBody: { + required: true, + content: { + "application/json": { schema: passkeyRegisterVerifyRequest }, + }, + }, + responses: { + "200": { + description: "Passkey registered.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ verified: z.literal(true) }), + "PasskeyRegisterVerifyEnvelope", + ), + }, + }, + }, + "400": { + description: + "The attestation did not verify, or the registration challenge is unknown or expired. One prose message covers a malformed credential and a failed signature.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "401": { + description: + "No cookie session; or the session's factor proof has aged past five minutes; or the challenge belongs to another session. Restart at register-options.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/auth/codex/device-start": { + post: { + tags: ["Auth"], + summary: "Begin connecting a ChatGPT account by device code", + description: + "Starts an OAuth device-code grant against the provider and returns the short code plus the page to type it on. Takes no body. Rate-limited 5 per minute per user.\n\n" + + "The device-auth id that the poll needs is NOT in the response. It is sealed with the rest of the flow state into an encrypted, httpOnly, 15-minute cookie, which means the poll can only be completed by the browser that started the flow and a native client cannot drive this pair at all.\n\n" + + "Device-code rather than a redirect because the provider only allow-lists localhost callbacks for the public client id, so a hosted deployment has no usable redirect target.", + responses: { + "200": { + description: "Device code issued; the state cookie is set.", + content: { + "application/json": { + schema: dataEnvelope( + codexDeviceStartResponse, + "CodexDeviceStartEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/codex/device-poll": { + post: { + tags: ["Auth"], + summary: "Poll a device-code connection to completion", + description: + "Called every `intervalSeconds` until it answers `connected`. Takes no body — the flow state comes from the cookie device-start set. Rate-limited 60 per minute per user.\n\n" + + "On success the credentials are exchanged server-side and stored encrypted on the account, the state cookie is deleted, and any cached insight text is dropped so the next generation runs on the new provider. Stop polling at that point: a further call has no cookie and answers 400.", + responses: { + "200": { + description: + "Either still waiting for the person to approve, or connected.", + content: { + "application/json": { + schema: dataEnvelope( + codexDevicePollResponse, + "CodexDevicePollEnvelope", + ), + }, + }, + }, + "400": { + description: + "No device-auth attempt is in progress, or the state cookie could not be read. Both mean the same thing to a client: start again at device-start. An unreadable cookie is deleted on the way out.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "502": { + description: + "The provider's device endpoint failed or answered unusably. The underlying message is recorded server-side and deliberately not echoed. Restart the flow.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/codex/disconnect": { + delete: { + tags: ["Auth"], + summary: "Disconnect the ChatGPT account", + description: + "Clears the stored credentials and marks the connection disconnected, and drops the cached insight text with them so nothing generated through that account is served afterwards. Idempotent: disconnecting an account that was never connected succeeds. Rate-limited 5 per minute per user. Takes no body.\n\n" + + "Local only — this revokes HealthLog's copy of the credentials. It does not tell the provider to forget the grant; that is done in the provider's own account settings.", + responses: { + "200": { + description: "Disconnected.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ disconnected: z.boolean() }), + "CodexDisconnectEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 1d053c5ee..4b9119333 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -67,6 +67,7 @@ import { } from "./medications"; import { metaPaths } from "./meta"; import { moodPaths } from "./mood"; +import { notificationTransportPaths } from "./notifications-transport"; import { nutrientPaths } from "./nutrients"; import { onboardingPaths } from "./onboarding"; import { profilePaths } from "./profile"; @@ -128,6 +129,10 @@ export const openApiPaths: NonNullable = { // v1.36.0 — account sharing: the grant lifecycle and the switch endpoint // (appended, spread order is load-bearing). ...accountSharingPaths, + // Notification transport — the VAPID key, the Web Push subscription + // lifecycle and the per-channel self-tests (appended, spread order is + // load-bearing). + ...notificationTransportPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index f04974bd0..5ad026e15 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -250,6 +250,47 @@ const assistantFlagsResponse = z "The operator's assistant visibility matrix, resolved. Gates the server-routed AND the on-device assistant surfaces, so a client hides the surface end-to-end rather than degrading it.", }); +// ── Update check ───────────────────────────────────────────────────── + +const updateCheckResponse = z + .object({ + status: z + .enum(["up_to_date", "newer_available", "unknown"]) + .describe( + "`unknown` is a normal outcome, not an error — it means the release feed could not be reached or read.", + ), + current: z.string().describe("The version this instance is running."), + latest_tag: z + .string() + .optional() + .describe("The newest published release tag. Absent when `unknown`."), + html_url: z + .url() + .nullable() + .optional() + .describe("Release page to deep-link to. Only on `newer_available`."), + published_at: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe("When that release was published. Only on `newer_available`."), + checked_at: z.iso + .datetime({ offset: true }) + .optional() + .describe("When the check ran. Absent when `unknown`."), + reason: z + .string() + .optional() + .describe( + "Only on `unknown`: `network_error`, or `github_status_` when the feed answered but not with a success.", + ), + }) + .meta({ + id: "UpdateCheckResponse", + description: + "Whether a newer release exists. The three states are carried in `status`; which of the optional fields are present follows from it.", + }); + export const metaPaths: NonNullable = { "/api/measurement-categories": { get: { @@ -344,4 +385,32 @@ export const metaPaths: NonNullable = { }, }, }, + + "/api/version/check-updates": { + get: { + tags: ["Meta"], + summary: "Check whether a newer release exists", + description: + "A server-side proxy for the project's public release feed. It exists because the production CSP allows almost nothing on `connect-src`, so a browser calling the feed directly is silently blocked — which is exactly how the first version of the “check for updates” button appeared to do nothing.\n\n" + + "Authenticated (cookie or a wildcard Bearer token): there is no reason an anonymous caller should poll a third-party API through this instance. No credential is forwarded to the feed, so the request runs under the host's anonymous quota — roughly sixty an hour per address, shared by everyone on the instance.\n\n" + + 'It is deliberately hard to fail. A network error, an outage, or a rate-limited feed all return 200 with `status: "unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached server-side; the client is expected to hold the result.', + responses: { + "200": { + description: + "Up to date, an update is available, or the check could not be completed.", + content: { + "application/json": { + schema: dataEnvelope(updateCheckResponse, "UpdateCheckEnvelope"), + }, + }, + }, + "502": { + description: + 'The release feed answered but carried no version tag. Distinct from `status: "unknown"`, which covers not reaching it at all.', + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/notifications-transport.ts b/src/lib/openapi/routes/notifications-transport.ts new file mode 100644 index 000000000..369e75a86 --- /dev/null +++ b/src/lib/openapi/routes/notifications-transport.ts @@ -0,0 +1,249 @@ +/** + * OpenAPI route table — notification transport: the VAPID key a client needs + * to subscribe, the Web Push subscription lifecycle, and the per-channel + * self-tests. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. Kept separate + * from `./notifications.ts` — which owns per-event preferences and per-channel + * delivery health — because the two answer different questions: that module + * describes what gets sent and whether it arrived, this one the plumbing a + * client sets up and then verifies. + * + * Schemas come from `src/lib/validations/notifications.ts` where shared with + * the runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { webPushSubscriptionSchema } from "@/lib/validations/notifications"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +const vapidPublicKeyResponse = z + .object({ + publicKey: z + .string() + .describe( + "The instance's VAPID public key, base64url. Public by definition — it is what a browser passes to `pushManager.subscribe`. The private half never leaves the server.", + ), + }) + .meta({ id: "VapidPublicKeyResponse" }); + +const webPushSubscribeRequest = webPushSubscriptionSchema.meta({ + id: "WebPushSubscribeRequest", + description: + "A browser Push subscription, exactly as `PushSubscription.toJSON()` produces it. `endpoint` must be HTTPS and must resolve to a public host — the push library dials it later with its own fetch that bypasses the shared egress wrapper, so the SSRF floor is enforced here at input time and again at send time. The `p256dh` and `auth` keys are stored encrypted at rest.", +}); + +const webPushUnsubscribeRequest = z + .object({ + endpoint: z.url().describe("The endpoint of the subscription to forget."), + }) + .meta({ + id: "WebPushUnsubscribeRequest", + description: + "Identifies a subscription to remove. Scoped to the caller, so an endpoint belonging to another account matches nothing.", + }); + +/** + * The self-test result shape shared by the APNs and Web Push tests. + * + * Both report failure inside a 200 rather than as an error status, and that is + * the single most important thing about them: the request succeeded, the PUSH + * did not. A client that branches on HTTP status alone will tell the person + * their notifications work when they do not. + */ +const apnsTestResponse = z + .object({ + ok: z.boolean().describe("Whether the push was accepted by APNs."), + reason: z + .string() + .optional() + .describe( + "Why it was not, when `ok` is false. Absent on success. Comes from the dispatcher's own classification — an unregistered token, a configuration gap, no iOS device on the account.", + ), + }) + .meta({ id: "ApnsTestResponse" }); + +const webPushTestResponse = z + .object({ + ok: z.boolean(), + sent: z + .number() + .int() + .describe("1 on success, 0 on failure. Never more than one."), + latencyMs: z + .number() + .int() + .optional() + .describe("Round-trip to the push service. Present on success only."), + perEndpoint: z + .array( + z.object({ + host: z + .string() + .describe( + "Host of the push service the test went to — the endpoint path is a routing secret and is never returned.", + ), + status: z + .number() + .int() + .nullable() + .describe( + "The push service's HTTP status, or null when the attempt threw before one existed.", + ), + }), + ) + .describe("One entry: only the newest subscription is tested."), + }) + .meta({ id: "WebPushTestResponse" }); + +export const notificationTransportPaths: NonNullable< + ZodOpenApiObject["paths"] +> = { + "/api/notifications/vapid": { + get: { + tags: ["Notifications"], + summary: "Read the instance's VAPID public key", + description: + "What a browser needs before it can create a Push subscription. Anonymous — the key is public by construction and discloses nothing about any account. Takes no parameters.", + responses: { + "200": { + description: "The VAPID public key.", + content: { + "application/json": { + schema: dataEnvelope(vapidPublicKeyResponse, "VapidEnvelope"), + }, + }, + }, + "503": { + description: + "The operator has configured no VAPID keys, so Web Push cannot be offered on this instance at all. Hide the subscribe control rather than retrying — this does not resolve on its own.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/notifications/web-push": { + post: { + tags: ["Notifications"], + summary: "Register a Web Push subscription", + description: + "Stores a browser Push subscription for the caller and, on the first one, creates the account's Web Push notification channel enabled. Upsert by endpoint: re-posting the same endpoint refreshes its keys rather than duplicating the row, so a client can send this on every page load without checking first.\n\n" + + "The subscription keys are encrypted at rest. The endpoint is validated as HTTPS and as a public host before anything is written — the push library dials it later through its own fetch, outside the shared egress wrapper, so input time is where that has to be caught.\n\n" + + "Body capped at 64 KB. A malformed body and a rejected endpoint both answer 422 with one flat message; the Zod issues are not surfaced, so a client cannot tell an invalid endpoint from a missing key.", + requestBody: { + required: true, + content: { "application/json": { schema: webPushSubscribeRequest } }, + }, + responses: { + "200": { + description: "Subscription stored.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ subscribed: z.literal(true) }), + "WebPushSubscribeEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 65536 bytes. Nothing was stored.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Notifications"], + summary: "Forget a Web Push subscription", + description: + "Removes the caller's subscription for the given endpoint. Idempotent and silent about the outcome: an endpoint that was never stored, and one belonging to another account, both answer the same success — the response says nothing about how many rows matched, so it cannot be used to probe whether an endpoint exists.\n\n" + + "The Web Push channel row is left in place; this removes a destination, not the channel. Body capped at 64 KB.", + requestBody: { + required: true, + content: { "application/json": { schema: webPushUnsubscribeRequest } }, + }, + responses: { + "200": { + description: + "The subscription is gone, or was never there. Indistinguishable by design.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ unsubscribed: z.literal(true) }), + "WebPushUnsubscribeEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 65536 bytes.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/notifications/web-push/test": { + post: { + tags: ["Notifications"], + summary: "Send a test push to the newest Web Push subscription", + description: + "Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user.\n\n" + + "Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for that subscription, and push services embed it in their own error messages, so it is stripped before anything is logged or returned.", + responses: { + "200": { + description: + "The test ran. `ok` says whether the push was accepted; a false here is a real failure reported inside a success.", + content: { + "application/json": { + schema: dataEnvelope(webPushTestResponse, "WebPushTestEnvelope"), + }, + }, + }, + // Spread FIRST: both overrides below share a status with the generic + // entries, and the generic one would otherwise win and drop the + // errorCode a client branches on. + ...stdResponses, + "422": { + description: + "Nothing to test against: the account has no Push subscription (`meta.errorCode` = `not_configured`), or the operator has configured no VAPID keys (`vapid_not_configured`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 5 tests in a minute (`meta.errorCode` = `rate_limited_self`). The code distinguishes the caller's own test budget from a shared limit.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/notifications/apns/test": { + post: { + tags: ["Notifications"], + summary: "Send a test push to the account's iOS devices", + description: + "Fires one self-test through APNs to the caller's registered iOS devices. Takes no body. Rate-limited 5 per minute per user. Available to any authenticated user for their own account — the admin test endpoint is a different surface that fans out across every channel at once.\n\n" + + "Sent as a medication reminder rather than a generic notification, deliberately: only that event type takes the time-sensitive, high-priority branch, and a test delivered at the default level can be summarised into Notification Center instead of appearing on the lock screen. Testing at the default level would leave a person unable to tell whether their real dose reminders will surface. The visible text still reads as a test so nobody mistakes it for a due dose, and it is rendered in the account's own interface language.\n\n" + + "Read `ok`, not the status: a failed send answers 200 with `ok: false` and a `reason`.", + responses: { + "200": { + description: + "The test ran. `ok` says whether APNs accepted it; `reason` says why not.", + content: { + "application/json": { + schema: dataEnvelope(apnsTestResponse, "ApnsTestEnvelope"), + }, + }, + }, + ...stdResponses, + "429": { + description: + "More than 5 tests in a minute (`meta.errorCode` = `rate_limited_self`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 628e23592..79e4f9d5c 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -15,6 +15,8 @@ import { MODULE_KEYS } from "@/lib/modules/registry"; import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape"; import { injectionSiteEnum } from "@/lib/validations/medication"; +import { deviceDeliverySchema } from "@/lib/validations/notification-prefs"; +import { locales } from "@/lib/i18n/config"; import { thresholdsUpdateSchema, ALL_METRICS, @@ -23,7 +25,9 @@ import type { ThresholdMetric } from "@/lib/analytics/effective-range"; import { documentsAutoAiReadPatchSchema, injectionSitePrefsPatchSchema, + labsLocalOcrPatchSchema, unitPreferencePatchSchema, + useCentralCodexPatchSchema, } from "@/lib/validations/user-prefs"; import { dataEnvelope, @@ -313,7 +317,11 @@ const reportSelectionPutResponse = z // without those transforms so the spec stays a clean nullable-field // document. `insurerIkNumber` is the new v1.8.6 field: optional, 9-digit // German IKNR, surfaced on the FHIR `Coverage` payor. -const profileUpdateRequest = z +// Shared with `PUT /api/auth/profile`, which is served by the same +// `applyProfileUpdate` helper and therefore accepts the identical body. +// Exported rather than restated so the two paths cannot drift into two +// contracts for one validator; `auth.ts` imports it. +export const profileUpdateRequest = z .object({ email: z.email().nullable().optional(), heightCm: z.number().min(50).max(300).nullable().optional(), @@ -440,7 +448,7 @@ const profileUpdateResponse = z .meta({ id: "ProfileUpdateResponse", description: - "Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.", + "Profile echo returned by PATCH /api/user/profile. Reports KVNR presence as a boolean rather than re-emitting the decrypted value. NOT the shape `PUT /api/auth/profile` returns — the two share a handler but project it differently (see `AuthProfileUpdateResponse`), which is why this is no longer described as covering both.", }); // v1.16.16 — AI-provider read surface. iOS gates Coach visibility off @@ -641,6 +649,109 @@ const thresholdsUpdateRequest = thresholdsUpdateSchema.meta({ "A partial map of `ThresholdMetric` to `{ min, max }`. Only the metrics named are written; the rest keep their existing override. Strict — an unknown key is a 422, not a silent drop. Each range must sit inside that metric's physiological bounds with `min` strictly below `max`.", }); +// ── Devices, the remaining scalar prefs, locale and timezone ───────── + +const deviceInfo = z + .object({ + id: z.string(), + label: z + .string() + .describe( + "Best available name: the reported model, falling back to the bundle id, falling back to the bare platform string. Never blank.", + ), + platform: z.string(), + appVersion: z.string().nullable(), + locale: z.string().nullable(), + channels: z + .array(z.enum(["web_push", "apns"])) + .describe( + "Which push transports this device row can currently be reached on. Derived from which token columns are populated, not from a stored list.", + ), + lastSeenAt: z.iso.datetime({ offset: true }), + createdAt: z.iso.datetime({ offset: true }), + isCurrent: z + .boolean() + .describe( + "True only when the caller sent `X-Device-Id` AND that device still holds an unrevoked refresh token for this account. A browser session has no such anchor, so a cookie caller sees false on every row — that is correct, not a bug to work around.", + ), + }) + .meta({ + id: "DeviceInfo", + description: + "A registered device, as the security surface renders it. Identifying metadata only — no push tokens, no APNs identifiers, no IP addresses.", + }); + +const deviceListResponse = z + .object({ devices: z.array(deviceInfo) }) + .meta({ id: "DeviceListResponse" }); + +const devicePatchRequest = z + .object({ medicationDelivery: deviceDeliverySchema }) + .meta({ + id: "DevicePatchRequest", + description: + "Per-device medication-reminder delivery override. `server` forces server-side dispatch to this device, `client` leaves it to the app's own local reminders, and `null` CLEARS the override so the device inherits the account default.", + }); + +const deviceRevokeResponse = z + .object({ + id: z.string(), + revoked: z.literal(true), + refreshTokensRevoked: z.number().int(), + accessTokensRevoked: z.number().int(), + }) + .meta({ + id: "DeviceRevokeResponse", + description: + "What the revocation cascade actually removed. The counts are the point: a client can tell the person how many credentials were killed rather than saying only that something happened.", + }); + +const labsLocalOcrPatchRequest = labsLocalOcrPatchSchema.meta({ + id: "LabsLocalOcrPatchRequest", + description: + "Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a paper report because the image is transcribed on the device and only the TEXT is sent.", +}); + +const labsLocalOcrResponse = z + .object({ labsLocalOcrEnabled: z.boolean() }) + .meta({ id: "LabsLocalOcrResponse" }); + +const useCentralCodexPatchRequest = useCentralCodexPatchSchema.meta({ + id: "UseCentralCodexPatchRequest", + description: + "Opt in or out of the operator's shared ChatGPT subscription as a trailing entry in this account's provider chain.", +}); + +const useCentralCodexResponse = z + .object({ useCentralCodex: z.boolean() }) + .meta({ id: "UseCentralCodexResponse" }); + +const localePutRequest = z + .object({ + locale: z + .enum(locales) + .describe("One of the interface locales this build ships."), + }) + .meta({ + id: "LocalePutRequest", + description: + "The interface language. Validated against the supported set at the surface so the column can never hold a value the resolver would later have to defend against. The handler checks membership directly rather than through a Zod schema; the effect is the same.", + }); + +const timezonePutRequest = z + .object({ + timezone: z + .string() + .describe( + "An IANA zone name. Accepted when the running engine's `Intl.DateTimeFormat` accepts it, which is the whole IANA set the deployment knows — not a fixed list this contract could enumerate.", + ), + }) + .meta({ + id: "TimezonePutRequest", + description: + "The account's timezone. The handler reads and trims the field directly rather than through a Zod schema.", + }); + export const profilePaths: NonNullable = { "/api/user/ai-provider": { get: { @@ -1442,4 +1553,273 @@ export const profilePaths: NonNullable = { }, }, }, + + // ── Appended: registered devices, the last two scalar prefs, and the + // locale / timezone setters. + "/api/auth/me/devices": { + get: { + tags: ["Auth"], + summary: "List the account's registered devices", + description: + "The devices this account has registered for push, newest activity first. Identifying metadata only: no push tokens, no APNs identifiers, no IP addresses — nothing here can be replayed to send a notification.\n\n" + + "`isCurrent` is not taken on trust. A caller may send `X-Device-Id`, but the badge is only set when that device also holds an unrevoked refresh token for this account, so a forged header cannot move it. A cookie caller has no such anchor and correctly sees `false` on every row.", + responses: { + "200": { + description: "Registered devices, most recently seen first.", + content: { + "application/json": { + schema: dataEnvelope(deviceListResponse, "DeviceListEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/devices/{id}": { + patch: { + tags: ["Auth"], + summary: "Set a device's medication-reminder delivery override", + description: + "Chooses where this one device's medication reminders come from: `server` for server-side dispatch, `client` to leave it to the app's own local reminders, or `null` to clear the override and inherit the account default. Scoped to the caller — a foreign id is 404, never another account's row.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: devicePatchRequest } }, + }, + responses: { + "200": { + description: "The device's resolved override.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + medicationDelivery: deviceDeliverySchema, + }), + "DevicePatchEnvelope", + ), + }, + }, + }, + "404": { + description: "No such device for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Revoke a device and every credential bound to it", + description: + "Deletes the device row and, in the same transaction, revokes every unrevoked refresh token bound to it plus the access tokens those refresh rows pair with. The counts come back so a client can say what was actually killed. One transaction on purpose: a partial cascade would leave a live device row whose tokens are all dead.\n\n" + + "This does NOT touch browser sessions. A session lives on its own row, not on a device, so signing a browser out is /api/auth/logout or the session list — the two surfaces cover different blast radii. Re-pairing later creates a fresh device row rather than reviving this one.\n\n" + + "Scoped to the caller: a foreign id answers 404 rather than confirming the id exists.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Device removed and its credentials revoked.", + content: { + "application/json": { + schema: dataEnvelope( + deviceRevokeResponse, + "DeviceRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: "No such device for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/labs-local-ocr": { + get: { + tags: ["Auth"], + summary: "Read the browser-side OCR opt-in", + description: + "Whether a lab-report scan should be transcribed on the device before anything is sent. It exists for the provider chains that cannot read an image at all — a text-only model, or the ChatGPT-OAuth path — so a person on one of those can still scan a paper report. The raw image never leaves the device in this mode; only the extracted text does. Less accurate than native vision, which is why the review screen stays mandatory either way.", + responses: { + "200": { + description: "The current flag.", + content: { + "application/json": { + schema: dataEnvelope( + labsLocalOcrResponse, + "GetLabsLocalOcrEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Set the browser-side OCR opt-in", + description: + "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB.", + requestBody: { + required: true, + content: { "application/json": { schema: labsLocalOcrPatchRequest } }, + }, + responses: { + "200": { + description: "The resolved next flag.", + content: { + "application/json": { + schema: dataEnvelope( + labsLocalOcrResponse, + "PatchLabsLocalOcrEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/use-central-codex": { + get: { + tags: ["Auth"], + summary: "Read the shared-Codex opt-in", + description: + "Whether this account's provider chain gains a trailing entry for the operator's own ChatGPT subscription. OFF by default.", + responses: { + "200": { + description: "The current flag.", + content: { + "application/json": { + schema: dataEnvelope( + useCentralCodexResponse, + "GetUseCentralCodexEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Set the shared-Codex opt-in", + description: + "Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB.\n\n" + + "Worth being plain about what turning it on does and does not do. The shared account is external, shared with every other user who opts in, bound by the operator's rate limits, and trains on its input by default. But the entry is server-managed, so the AI consent gate still applies: flipping this ON egresses nothing by itself, and without an active consent receipt nothing will leave for it.", + requestBody: { + required: true, + content: { + "application/json": { schema: useCentralCodexPatchRequest }, + }, + }, + responses: { + "200": { + description: "The resolved next flag.", + content: { + "application/json": { + schema: dataEnvelope( + useCentralCodexResponse, + "PatchUseCentralCodexEnvelope", + ), + }, + }, + }, + "413": { + description: "Request body exceeds 1024 bytes. Nothing was written.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/locale": { + put: { + tags: ["Auth"], + summary: "Set the interface language", + description: + "Writes the account's UI language and mirrors it into the locale cookie via `Set-Cookie`. Both halves matter: the column is what server-side work reads when there is no request to take a cookie from — a scheduled nudge, a Telegram message — and without it those went out in English however the person reads the app. The server-set cookie outlives the client-written one, which some browsers cap at seven days.\n\n" + + "An ACTOR surface, and the clearest case for that mode existing: the interface language belongs to the person reading the screen, never to the record on it. Under a record scope this would write a delegate's choice onto the owner's row and then mail the owner in a language they may not read. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted`. It keeps answering while a switch is on rather than merely refusing safely, because the language switcher fires a mount-time backfill on every page load.\n\n" + + "Idempotent: when the column already matches, the write is skipped and only the cookie is refreshed. Body capped at 4 KB.", + requestBody: { + required: true, + content: { "application/json": { schema: localePutRequest } }, + }, + responses: { + "200": { + description: "The accepted locale.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ locale: z.enum(locales) }), + "LocalePutEnvelope", + ), + }, + }, + }, + "403": { + description: + "A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/timezone": { + put: { + tags: ["Auth"], + summary: "Set the account timezone", + description: + "Lifted out of the profile patch so the picker can send one field without round-tripping the rest. Accepts any zone the running engine's `Intl` knows; anything else is 422 rather than stored, so the resolver never has to defend against a poisoned column. Audit-logged with the old and new zone.\n\n" + + "A change is not only a display preference. The medication-compliance rollup keys its rows by the LOCAL day a dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The route therefore enqueues a re-fold of the trailing window whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one.", + requestBody: { + required: true, + content: { "application/json": { schema: timezonePutRequest } }, + }, + responses: { + "200": { + description: "The accepted timezone.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ timezone: z.string() }), + "TimezonePutEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/passkey-nudge/dismiss": { + post: { + tags: ["Auth"], + summary: "Dismiss the add-a-passkey nudge", + description: + "Permanently hides the prompt that suggests adding a passkey. Cookie-only, takes no body, idempotent — dismissing twice is a success. The flag is also readable as `passkeyNudgeDismissed` on GET /api/auth/me/mfa.", + responses: { + "200": { + description: "Nudge dismissed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ dismissed: z.literal(true) }), + "PasskeyNudgeDismissEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5576f149c..b27784e89 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -8,6 +8,10 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { + emailSettingsSchema, + notificationChannelEnabledSchema, +} from "@/lib/validations/notifications"; import { dataEnvelope, errorEnvelope, @@ -97,6 +101,113 @@ const deleteAccountRequest = z "Typed confirmation for permanent account deletion. There is no second step and no undo.", }); +// ── Email channel, channel self-tests, the data wipe, privacy facts ── + +const emailChannelResponse = z + .object({ + enabled: z.boolean(), + recipient: z + .string() + .describe("The saved recipient address, or an empty string when none."), + smtpConfigured: z + .boolean() + .describe( + "Whether the OPERATOR has configured an SMTP transport. False means no email can be sent from this instance whatever the user saves, so a client should hide the card rather than offer a control that cannot work.", + ), + }) + .meta({ + id: "EmailChannelResponse", + description: + "The per-user email channel. Only the recipient is per-user — host, port, credentials and sender come from the operator's environment and never enter a user's encrypted channel blob.", + }); + +const emailChannelPutRequest = z + .union([notificationChannelEnabledSchema, emailSettingsSchema]) + .meta({ + id: "EmailChannelPutRequest", + description: + "Two accepted shapes. `{ enabled }` alone toggles the existing channel without restating the address — and is refused with 422 when no recipient has ever been saved, because enabling a channel with nowhere to send is not a state worth storing. `{ recipient, enabled }` upserts both; `recipient` may be an empty string only while `enabled` is false.", + }); + +const channelTestResponse = z.object({ sent: z.literal(true) }).meta({ + id: "ChannelTestResponse", + description: + "The provider accepted the test message. Acceptance is not delivery — a message can still be dropped downstream.", +}); + +const privacySummaryResponse = z + .object({ + retention: z + .object({ + coachMessagesDays: z + .number() + .int() + .describe( + "How long Coach conversation history is kept. Operator-configurable, floor 30, default 365.", + ), + auditLogDays: z + .number() + .int() + .describe( + "How long audit history is kept. Read from the same resolver the purge job uses, so the number a person is shown is the number that is enforced.", + ), + deliveryLogDays: z + .number() + .int() + .describe( + "Fixed 90-day window on the push-attempt and mood-dispatch ledgers.", + ), + }) + .describe( + "Retention windows as the privacy dashboard states them. Off-host backup retention is operator infrastructure rather than an app setting and is deliberately absent — it is disclosed as prose in the UI, not as a number here.", + ), + encryption: z + .object({ + algorithm: z.literal("AES-256-GCM"), + columnCount: z + .number() + .int() + .describe("How many columns are encrypted at rest."), + modelCount: z + .number() + .int() + .describe("How many models carry at least one of them."), + }) + .describe( + "Counted from the canonical encrypted-column registry rather than hand-maintained, so the disclosure cannot drift from what is actually encrypted.", + ), + }) + .meta({ + id: "PrivacySummaryResponse", + description: + "Server-authoritative facts for the Data & Privacy dashboard. No key material, no secrets, no per-row data — the same numbers for every account on the instance.", + }); + +const dataWipeRequest = z + .object({ + confirm: z + .literal("DELETE") + .describe( + "The literal string `DELETE`. Note it is NOT the `DELETE_ACCOUNT` string the account-deletion route wants — the two confirmations are deliberately different so one cannot be pasted into the other.", + ), + }) + .meta({ + id: "DataWipeRequest", + description: "Typed confirmation for erasing the record. No undo.", + }); + +const dataWipeResponse = z + .object({ + cleared: z.literal(true), + deletedRows: z.number().int().describe("Total rows removed."), + models: z + .record(z.string(), z.number().int()) + .describe( + "Per-model row counts, listing only the models that actually had rows. A model absent from this map had nothing to delete — it was not skipped.", + ), + }) + .meta({ id: "DataWipeResponse" }); + export const settingsPaths: NonNullable = { "/api/settings/reminder-thresholds": { get: { @@ -192,4 +303,206 @@ export const settingsPaths: NonNullable = { }, }, }, + + // ── Appended: the email channel, the four channel self-tests, the record + // wipe and the privacy dashboard's facts. + "/api/settings/email": { + get: { + tags: ["Notifications"], + summary: "Read the email channel", + description: + 'The caller\'s recipient address and opt-in, plus whether the operator has configured SMTP at all. An account that has never saved a channel gets `{ enabled: false, recipient: "" }` rather than a 404, so a client can render the empty form without branching.\n\n' + + "Read `smtpConfigured` before offering the control: it is an instance fact, and when it is false nothing this endpoint saves can ever be delivered.", + responses: { + "200": { + description: "The channel, and whether the instance can send at all.", + content: { + "application/json": { + schema: dataEnvelope( + emailChannelResponse, + "GetEmailChannelEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Notifications"], + summary: "Save the email channel", + description: + "Accepts either shape: `{ enabled }` to toggle an existing channel, or `{ recipient, enabled }` to upsert both. The recipient is stored encrypted; SMTP credentials are the operator's and never enter it.\n\n" + + "Enabling without a saved recipient is refused — both when the toggle-only body finds no stored address and when the full body carries `enabled: true` with an empty one. Body capped at 16 KB.", + requestBody: { + required: true, + content: { "application/json": { schema: emailChannelPutRequest } }, + }, + responses: { + "200": { + description: "Channel saved.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ saved: z.literal(true) }), + "PutEmailChannelEnvelope", + ), + }, + }, + }, + "400": { + description: + "The operator has configured no SMTP transport, so there is nothing to save a channel against. An instance-level refusal, not a validation error — which is why it is 400 rather than 422.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/email/test": { + post: { + tags: ["Notifications"], + summary: "Send a test email to the saved recipient", + description: + "Fires one message through the operator's SMTP transport to the address saved on this account. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" + + "Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the channel carries no recipient — three distinguishable prose messages on one status. A transport failure is a 500.", + responses: { + "200": { + description: + "The provider accepted the message. Not proof it was delivered.", + content: { + "application/json": { + schema: dataEnvelope(channelTestResponse, "EmailTestEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/ntfy/test": { + post: { + tags: ["Notifications"], + summary: "Send a test message to the saved ntfy topic", + description: + "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500.", + responses: { + "200": { + description: + "The provider accepted the message. Not proof it was delivered.", + content: { + "application/json": { + schema: dataEnvelope(channelTestResponse, "NtfyTestEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/telegram/test": { + post: { + tags: ["Notifications"], + summary: "Send a test message to the saved Telegram chat", + description: + "Sends one message through the account's saved bot token and chat id, in the account's own interface language. Takes no body. Rate-limited 5 per 5 minutes per user.\n\n" + + "This one differs from its siblings: both the not-configured case and the send failure answer 422 rather than 400 and 500. The failure is genuinely ambiguous — a wrong token and a wrong chat id look the same from here — so one message covers both.", + responses: { + "200": { + description: + "The provider accepted the message. Not proof it was delivered.", + content: { + "application/json": { + schema: dataEnvelope(channelTestResponse, "TelegramTestEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/webhook/test": { + post: { + tags: ["Notifications"], + summary: "Send a test payload to the saved webhook", + description: + "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host.", + responses: { + "200": { + description: + "The provider accepted the message. Not proof it was delivered.", + content: { + "application/json": { + schema: dataEnvelope(channelTestResponse, "WebhookTestEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/settings/data": { + delete: { + tags: ["Auth"], + summary: "Erase the record, keeping the account", + description: + "The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record.\n\n" + + "The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything.\n\n" + + "One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data.\n\n" + + "Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account, which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" + + "One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.", + requestBody: { + required: true, + content: { "application/json": { schema: dataWipeRequest } }, + }, + responses: { + "200": { + description: + "Record erased. The counts say what was actually removed.", + content: { + "application/json": { + schema: dataEnvelope(dataWipeResponse, "DataWipeEnvelope"), + }, + }, + }, + "409": { + description: + "The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Nothing was deleted — the managed records are locked for the whole transaction, so the refusal is atomic rather than partial. Add another Guardian, or delete the managed profile, first.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds 65536 bytes. Nothing was deleted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "401": { + description: + "Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer token cannot clear this arm.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/settings/privacy-summary": { + get: { + tags: ["Meta"], + summary: "Read the retention and encryption facts", + description: + "What the Data & Privacy dashboard states, computed server-side so the page cannot claim a retention window the purge job does not enforce or an encryption count the registry does not back. Identical for every account on the instance; carries no key material and no per-row data.", + responses: { + "200": { + description: "Retention windows and the encryption-at-rest summary.", + content: { + "application/json": { + schema: dataEnvelope( + privacySummaryResponse, + "PrivacySummaryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/validations/user-prefs.ts b/src/lib/validations/user-prefs.ts index 533f88636..76b4811a2 100644 --- a/src/lib/validations/user-prefs.ts +++ b/src/lib/validations/user-prefs.ts @@ -4,7 +4,7 @@ * OpenAPI registry (`src/lib/openapi/routes/profile.ts`) describe the identical * wire contract — the same reason `mfa.ts` exists next door. * - * All three follow the same shape: a one-field PATCH that hard-sets the value + * They all follow the same shape: a one-field PATCH that hard-sets the value * and answers with the resolved next state, so a client can replace its * optimistic update rather than re-read. */ @@ -25,6 +25,16 @@ export const documentsAutoAiReadPatchSchema = z.object({ documentsAutoAiRead: z.boolean(), }); +/** `PATCH /api/auth/me/labs-local-ocr` — browser-side OCR opt-in. */ +export const labsLocalOcrPatchSchema = z.object({ + labsLocalOcrEnabled: z.boolean(), +}); + +/** `PATCH /api/auth/me/use-central-codex` — the operator's shared Codex. */ +export const useCentralCodexPatchSchema = z.object({ + useCentralCodex: z.boolean(), +}); + /** `PATCH /api/auth/me/injection-site-prefs` — the user-level deny-list. */ export const injectionSitePrefsPatchSchema = z.object({ // Dedup at write time; cap at the full enum size. An empty array From aa80cb45c16bb7a858a40f92331617603d103c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 13:17:45 +0200 Subject: [PATCH 4/4] Say which rule a rejected Web Push subscription broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST` and `DELETE /api/notifications/web-push` both answered a flat `apiError("Invalid data", 422)` and discarded the Zod issues. A non-HTTPS endpoint, an endpoint aimed at an internal host, an over-long one and a missing subscription key produced byte-identical bodies, so a browser whose subscription was refused could not tell a fixable client bug from a policy this instance will never satisfy. Both now answer the multi-issue envelope every other body-parsing route in the tree uses. Echoing the issues is safe here and the route says why, because it is the kind of thing that gets re-derived wrongly later: the endpoint is a subscription's routing secret and `keys` is its crypto material, but `sanitiseZodIssues` emits `path`, `code` and `message` only — the rejected value lives in `issue.params` and stays server-side — and every message this schema can produce is a fixed string or a length/format default rather than an interpolation. A test pins that none of the four secrets reaches the wire, so a future schema change that adds an interpolating message fails here instead of leaking quietly. The tests assert on content rather than status: a status-only check passes against the flat error it replaces, which is how this survived. Two of them compare whole bodies between two different refusals and require them to differ. The two `/test` routes were checked in the same pass and need nothing. Neither accepts a body — they take no parameters at all — so neither runs a Zod parse to discard. Their 422s already carry `not_configured`, `vapid_not_configured` and `rate_limited_self` as machine-readable codes. --- .../web-push/__tests__/route.test.ts | 174 ++++++++++++++++++ src/app/api/notifications/web-push/route.ts | 23 ++- .../openapi/routes/notifications-transport.ts | 4 +- 3 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 src/app/api/notifications/web-push/__tests__/route.test.ts diff --git a/src/app/api/notifications/web-push/__tests__/route.test.ts b/src/app/api/notifications/web-push/__tests__/route.test.ts new file mode 100644 index 000000000..516cd7838 --- /dev/null +++ b/src/app/api/notifications/web-push/__tests__/route.test.ts @@ -0,0 +1,174 @@ +/** + * `/api/notifications/web-push` — the refusal has to say which rule was broken. + * + * Both handlers used to answer a flat `apiError("Invalid data", 422)` and throw + * the Zod issues away. A non-HTTPS endpoint, an endpoint aimed at an internal + * host, an over-long one and a missing subscription key all produced the same + * three words, so a browser whose subscription was refused could not tell a + * fixable mistake from a policy it would never satisfy. + * + * The cases below assert on CONTENT rather than status. A status-only test + * passes just as happily against the flat error it replaces, which is the whole + * reason the defect survived this long. + * + * The other half is what must NOT come back. The endpoint is the routing secret + * for a subscription and `keys` is crypto material, so the last test pins that + * neither reaches the wire: `sanitiseZodIssues` emits `path`, `code` and + * `message` only, and every message this schema can produce is a fixed string + * or a length/format default rather than an interpolation of the value. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: unknown) => fn, + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() }, + notificationChannel: { findFirst: vi.fn(), create: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` })); + +import { POST, DELETE } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; + +const USER = { id: "user-1", username: "u" }; + +/** A well-formed subscription, for the cases that should succeed. */ +const GOOD = { + endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret", + keys: { p256dh: "BPkey", auth: "authsecret" }, +}; + +type IssueEnvelope = { + error: string; + details?: { + issues?: Array<{ path?: string; code?: string; message?: string }>; + }; +}; + +function req(body: unknown): Request { + return new Request("http://localhost/api/notifications/web-push", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +type Handler = (r: Request) => Promise; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); +}); + +describe("POST /api/notifications/web-push — the subscribe refusal", () => { + it("names the offending field for a plain-http endpoint", async () => { + const res = await (POST as unknown as Handler)( + req({ ...GOOD, endpoint: "http://fcm.googleapis.com/fcm/send/x" }), + ); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + expect(env.error).toBe("Validation failed"); + expect(env.details?.issues?.length).toBeGreaterThan(0); + expect(env.details?.issues?.some((i) => i.path === "endpoint")).toBe(true); + expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled(); + }); + + it("names the offending field for a missing subscription key", async () => { + const res = await (POST as unknown as Handler)( + req({ endpoint: GOOD.endpoint, keys: { p256dh: "BPkey" } }), + ); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + expect(env.details?.issues?.some((i) => i.path === "keys.auth")).toBe(true); + expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled(); + }); + + it("tells an internal-host endpoint apart from an http one", async () => { + // The two are different problems with different fixes — one is a client + // bug, the other is a subscription this instance will never accept. Under + // the flat error the bodies were byte-identical. + const insecure = await (POST as unknown as Handler)( + req({ ...GOOD, endpoint: "http://fcm.googleapis.com/fcm/send/x" }), + ); + const internal = await (POST as unknown as Handler)( + req({ ...GOOD, endpoint: "https://192.168.1.10/push/x" }), + ); + + expect(insecure.status).toBe(422); + expect(internal.status).toBe(422); + expect(JSON.stringify(await insecure.json())).not.toBe( + JSON.stringify(await internal.json()), + ); + }); + + it("still stores a well-formed subscription", async () => { + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.notificationChannel.create).mockResolvedValue({} as never); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1); + }); +}); + +describe("DELETE /api/notifications/web-push — the unsubscribe refusal", () => { + it("names the offending field rather than answering flat", async () => { + const res = await (DELETE as unknown as Handler)( + req({ endpoint: "not-a-url" }), + ); + + expect(res.status).toBe(422); + const env = (await res.json()) as IssueEnvelope; + expect(env.error).toBe("Validation failed"); + expect(env.details?.issues?.some((i) => i.path === "endpoint")).toBe(true); + expect(prisma.pushSubscription.deleteMany).not.toHaveBeenCalled(); + }); + + it("distinguishes a missing endpoint from a malformed one", async () => { + const missing = await (DELETE as unknown as Handler)(req({})); + const malformed = await (DELETE as unknown as Handler)( + req({ endpoint: "not-a-url" }), + ); + + expect(JSON.stringify(await missing.json())).not.toBe( + JSON.stringify(await malformed.json()), + ); + }); +}); + +describe("what the refusal must never carry", () => { + it("echoes neither the endpoint nor the subscription keys", async () => { + // The endpoint path is the routing secret for a push subscription and the + // keys are its crypto material. Publishing the issue list is only safe + // while this holds; if a future schema change adds a message that + // interpolates the value, this fails rather than leaking quietly. + const res = await (POST as unknown as Handler)( + req({ + endpoint: "http://attacker.example/fcm/send/ROUTING-SECRET-abc123", + keys: { p256dh: "P256-SECRET-xyz", auth: "AUTH-SECRET-789" }, + }), + ); + + expect(res.status).toBe(422); + const raw = await res.text(); + expect(raw).not.toContain("ROUTING-SECRET-abc123"); + expect(raw).not.toContain("P256-SECRET-xyz"); + expect(raw).not.toContain("AUTH-SECRET-789"); + expect(raw).not.toContain("attacker.example"); + }); +}); diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index ccc2d07ef..5e64b11cf 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; import { encrypt } from "@/lib/crypto"; import { webPushSubscriptionSchema } from "@/lib/validations/notifications"; import { z } from "zod/v4"; @@ -18,6 +18,23 @@ const unsubscribeSchema = z.object({ endpoint: z.string().url(), }); +/* + * Both parses below answer with the multi-issue envelope, like every other + * body-parsing route in the tree. They used to answer a flat + * `apiError("Invalid data", 422)`, which made a non-HTTPS endpoint, an + * endpoint pointing at an internal host, an over-long one and a missing + * subscription key byte-identical to each other — a client could see that its + * subscription was refused and had no way to learn which rule it broke. + * + * Echoing the issues is safe here, and it is worth writing down why rather + * than leaving it to be re-derived. The endpoint is a routing secret and the + * keys are subscription crypto material, so the refusal must not carry them + * back. It does not: `sanitiseZodIssues` emits `path`, `code` and `message` + * only — `issue.params`, which is where Zod keeps a rejected value, stays + * server-side — and every message this schema can produce is a fixed string + * or a length/format default. None of them interpolates the value. + */ + /** * POST /api/notifications/web-push * Save a Web Push subscription for the current user. @@ -38,7 +55,7 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = subscribeSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); const { endpoint, keys } = parsed.data; const userAgent = request.headers.get("user-agent") ?? undefined; @@ -104,7 +121,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { } const parsed = unsubscribeSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); await prisma.pushSubscription.deleteMany({ where: { diff --git a/src/lib/openapi/routes/notifications-transport.ts b/src/lib/openapi/routes/notifications-transport.ts index 369e75a86..e364f0754 100644 --- a/src/lib/openapi/routes/notifications-transport.ts +++ b/src/lib/openapi/routes/notifications-transport.ts @@ -130,7 +130,7 @@ export const notificationTransportPaths: NonNullable< description: "Stores a browser Push subscription for the caller and, on the first one, creates the account's Web Push notification channel enabled. Upsert by endpoint: re-posting the same endpoint refreshes its keys rather than duplicating the row, so a client can send this on every page load without checking first.\n\n" + "The subscription keys are encrypted at rest. The endpoint is validated as HTTPS and as a public host before anything is written — the push library dials it later through its own fetch, outside the shared egress wrapper, so input time is where that has to be caught.\n\n" + - "Body capped at 64 KB. A malformed body and a rejected endpoint both answer 422 with one flat message; the Zod issues are not surfaced, so a client cannot tell an invalid endpoint from a missing key.", + "Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and `message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.", requestBody: { required: true, content: { "application/json": { schema: webPushSubscribeRequest } }, @@ -159,7 +159,7 @@ export const notificationTransportPaths: NonNullable< summary: "Forget a Web Push subscription", description: "Removes the caller's subscription for the given endpoint. Idempotent and silent about the outcome: an endpoint that was never stored, and one belonging to another account, both answer the same success — the response says nothing about how many rows matched, so it cannot be used to probe whether an endpoint exists.\n\n" + - "The Web Push channel row is left in place; this removes a destination, not the channel. Body capped at 64 KB.", + "The Web Push channel row is left in place; this removes a destination, not the channel. Body capped at 64 KB; a malformed `endpoint` answers the standard multi-issue 422, which does not echo the value.", requestBody: { required: true, content: { "application/json": { schema: webPushUnsubscribeRequest } },