mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Describe the remaining auth, settings and notification-transport surfaces
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.
This commit is contained in:
1 parent
63f791120a
commit
a283211586
9 files changed
+1515
-17
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/check-user": {
|
||||
post: {
|
||||
@@ -1426,4 +1578,334 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── 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=<path>` 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=<reason>`.\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=<reason>`, 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=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
// 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<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -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_<code>` 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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/measurement-categories": {
|
||||
get: {
|
||||
@@ -344,4 +385,32 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/user/ai-provider": {
|
||||
get: {
|
||||
@@ -1442,4 +1553,273 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/settings/reminder-thresholds": {
|
||||
get: {
|
||||
@@ -192,4 +303,206 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user