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