Merge remote-tracking branch 'origin/main' into fix/private-notification-origins

# Conflicts:
#	src/app/api/settings/ntfy/route.ts
#	src/app/api/settings/webhook/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 18:07:55 +02:00
commit 71175290af
255 files changed
+12654 -1141

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
/**
* Every `meta.errorCode` the API emits, grouped by the surface that emits it.
*
* `meta.errorCode` is the mechanism this API chose for machine-readable errors
* — the envelope's own description tells a client to branch on it rather than
* on the prose — and until this file existed a client could not enumerate it.
* When this file was written, sixteen of its two hundred and forty-two codes
* appeared somewhere in `docs/api/openapi.yaml`; the rest existed only in
* server source, so the only way to learn one was to trigger the error in
* testing and the only way to learn a new one had appeared was to trigger
* that too.
*
* The list is generated from the code and then committed as a literal, so it is
* reviewable in a diff and stable across a release. `error-code-catalogue.test.ts`
* re-derives it from the tree on every run and fails in both directions: a code
* emitted and not listed, and a code listed and no longer emitted.
*
* Naming: four conventions coexist here — dot.case (the majority and the one
* to use), bare snake_case, a camelCase segment, and a hyphen. Nothing is
* renamed: a code is a wire value and a shipped client branches on it. New
* codes are dot.case.
*
* Not in this list, and deliberately:
*
* - `assistant.disabled.<surface>` is built from a template at runtime
* (`src/lib/feature-flags/index.ts`), so the surface segment is open.
* `assistant.disabled.coach` is the one the iOS contract names.
* - The integration-probe classes — `credentials_rejected`, `rate_limited`,
* `upstream_error`, `timeout`, `connection_failed`, and the per-provider
* additions beside them — are chosen by each `/test` route's own
* categoriser and are already enumerated in those operations'
* descriptions, where the per-provider differences can be stated.
* - `IntegrationStatus.errorCode` is a different field with the same name: it
* records an upstream HTTP status in the sync ledger and never reaches a
* response envelope.
*/
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
{
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
allergy: ["allergy.invalid"],
analytics: ["analytics.invalid_query"],
anamnesis: [
"anamnesis.fact.conflict",
"anamnesis.fact.currentExists",
"anamnesis.fact.invalidValue",
],
auth: [
"auth.admin.required",
"auth.mfa.code_invalid",
"auth.missing",
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
"auth.scope.insufficient",
"auth.stepup.required",
"auth.token.expired",
"auth.token.invalid",
],
backup: ["backup.payload.undecryptable", "backup.section.missing"],
coach: [
"coach.conversation.invalidTitle",
"coach.conversation.notFound",
"coach.fenced.attachmentConflict",
"coach.fenced.attachmentLimit",
"coach.fenced.attachmentNotFound",
"coach.fenced.attachmentNotIndexed",
"coach.fenced.attachmentRequired",
"coach.fenced.attachmentUnavailable",
"coach.fenced.invalid",
"coach.request.invalid",
],
"coach-prefs": [
"coach-prefs.body.invalid_json",
"coach-prefs.body.invalid_shape",
],
consent: ["consent.ai.required"],
cycle: [
"cycle.bulk.invalid",
"cycle.bulk.too_large",
"cycle.calendar.invalid",
"cycle.calendar.range",
"cycle.cycles.invalid",
"cycle.day-log.conflict",
"cycle.day-log.invalid",
"cycle.disabled",
"cycle.period.invalid",
"cycle.symptom.custom.invalid",
"cycle.symptom.custom.limit",
],
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
"disable-coach": ["disable-coach.body.invalid_json"],
documents: [
"documents.chat.conversationNotFound",
"documents.chat.invalid",
"documents.inbound.alreadyConfirmed",
"documents.inbound.alreadyPartlyConfirmed",
"documents.inbound.budgetExceeded",
"documents.inbound.decryptFailed",
"documents.inbound.encounterNotFound",
"documents.inbound.episodeNotFound",
"documents.inbound.extractFailed",
"documents.inbound.factNotFound",
"documents.inbound.factNotPending",
"documents.inbound.factTypeMismatch",
"documents.inbound.fileTooLarge",
"documents.inbound.fileType",
"documents.inbound.invalidMetadata",
"documents.inbound.invalidQuery",
"documents.inbound.invalidUpdate",
"documents.inbound.localOcrDisabled",
"documents.inbound.notFound",
"documents.inbound.notIndexed",
"documents.inbound.pdfNeedsAnthropic",
"documents.inbound.providerUnsupported",
"documents.inbound.quotaExceeded",
"documents.inbound.rateLimited",
"documents.inbound.restoreDuplicate",
"documents.inbound.restoreGone",
"documents.inbound.uploadBusy",
"documents.inbound.uploadTimeout",
],
encounter: [
"encounter.invalid",
"encounter.link.invalid",
"encounter.practitioner-not-found",
"encounter.reminder-conflict",
"encounter.reminder-not-found",
],
environment: [
"environment.invalid",
"environment.no_home",
"environment.range_too_large",
"environment.travel.not_found",
],
export: ["export.selection.empty", "export.selection.unknown_leaf"],
familyHistory: ["familyHistory.invalid"],
feedback: ["feedback.body.invalid"],
health_score_config: [
"health_score_config.invalid",
"health_score_config.too_narrow",
],
illness: [
"illness.day-log.invalid",
"illness.disabled",
"illness.episode.chronic-no-resolve",
"illness.episode.invalid",
"illness.episode.invalid-window",
"illness.episode.parent-not-found",
"illness.insights.invalid",
],
import: ["import.write_failed"],
ingest: ["ingest.rate_limited"],
insights: [
"insights.generate.budgetExceeded",
"insights.generate.outboundScreened",
"insights.generate.profileScopeChanged",
"insights.generate.scopeChanged",
],
labs: [
"labs.biomarker.create.invalid",
"labs.biomarker.duplicate",
"labs.biomarker.notFound",
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
"labs.create.invalid",
"labs.ocr.budgetExceeded",
"labs.ocr.extractFailed",
"labs.ocr.fileTooLarge",
"labs.ocr.fileType",
"labs.ocr.localOcrDisabled",
"labs.ocr.pdfNeedsAnthropic",
"labs.ocr.providerUnsupported",
"labs.ocr.rateLimited",
"labs.restore.invalid",
"labs.result.notFound",
"labs.update.invalid",
"labs.update.linkedFieldsImmutable",
"labs.update.numericExpected",
"labs.update.qualitativeExpected",
"labs.update.referenceRangeInvalid",
"labs.update.sourceReferenceRangeInvalid",
],
managed_profile: [
"managed_profile.guardian.duplicate",
"managed_profile.guardian.managed_invitee",
"managed_profile.guardian.required",
"managed_profile.not_found",
],
measurement: [
"measurement.batch.source_not_permitted",
"measurement.batch.too_large",
"measurement.bulk-delete.invalid",
"measurement.create.invalid",
"measurement.create.source_not_permitted",
"measurement.delete.too_large",
"measurement.duplicate_timestamp",
"measurement.restore.invalid",
"measurement.update.server_owned_source",
],
medication: ["medication.intake.import.invalid_format"],
medications: [
"medications.intake.bulk.apple_health_not_mirrored",
"medications.intake.bulk.invalid",
"medications.intake.bulk.too_large",
"medications.intake.force_slot.invalid",
"medications.intake.force_slot.occupied",
"medications.intake.injection_site.disallowed",
"medications.intake.taken_at.before_start",
"medications.mirror.limit_exceeded",
],
mentalHealth: ["mentalHealth.rateLimited"],
module: ["module.disabled"],
modules: ["modules.body.invalid_json", "modules.invalid"],
mood: [
"mood.bulk-delete.invalid",
"mood.bulk.invalid",
"mood.bulk.too_large",
"mood.create.external_id_not_delegable",
"mood.create.invalid",
"mood.duplicate_timestamp",
"mood.linkedContext.invalid",
"mood.list.invalid",
"mood.not_found",
"mood.ratedFactor.out_of_range",
"mood.restore.invalid",
"mood.update.invalid",
],
"notification-prefs": ["notification-prefs.body.invalid_json"],
nutrient: [
"nutrient.batch.invalid",
"nutrient.batch.too_large",
"nutrient.daily.invalid",
"nutrient.read.invalid",
"nutrient.water.invalid",
"nutrient.water.invalid_day",
],
onboarding: [
"onboarding.answers.invalid",
"onboarding.answers.rateLimited",
"onboarding.complete.invalid",
"onboarding.disclaimer.invalid",
"onboarding.restart.invalid",
"onboarding.restart.rateLimited",
"onboarding.step.completed",
"onboarding.step.concurrent",
"onboarding.step.invalid",
"onboarding.step.outOfOrder",
"onboarding.step.rateLimited",
"onboarding.tour.invalid",
"onboarding.user.notFound",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
profile: [
"profile.update.emailInUse",
"profile.update.invalidBody",
"profile.update.nothingSaved",
],
record_write: ["record_write.rate_limited"],
"report-selection": [
"report-selection.body.invalid_json",
"report-selection.body.invalid_shape",
"report-selection.leaves.unknown",
],
route: ["route.retired"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
"share-link.document.rateLimited",
"share-link.documents.invalid",
"share-link.report.notFound",
"share-link.report.rateLimited",
"share-link.selection.forbidden_leaf",
"share-link.selection.unknown_leaf",
],
sharing: [
"sharing.accept.expired",
"sharing.accept.not_found",
"sharing.accept.not_pending",
"sharing.accept.revoked",
"sharing.access.denied",
"sharing.invite.duplicate",
"sharing.invite.invalid",
"sharing.invite.invalid_scope",
"sharing.invite.manage_browser_only",
"sharing.invite.refused",
"sharing.invite.self",
"sharing.not_permitted",
"sharing.renounce.already_ended",
"sharing.renounce.not_found",
"sharing.revoke.already_ended",
"sharing.revoke.not_found",
"sharing.session.changed",
"sharing.switch.invalid",
"sharing.switch.wrong_transport",
],
"source-priority": [
"source-priority.body.invalid_json",
"source-priority.body.invalid_shape",
],
tokens: ["tokens.measurements.ceiling_reached"],
vaccination: [
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
"vaccination.booster-out-of-scope",
"vaccination.encounter-not-found",
"vaccination.identity-required",
"vaccination.invalid",
"vaccination.link.invalid",
"vaccination.not-found",
"vaccination.practitioner-not-found",
],
workout: [
"workout.batch.invalid",
"workout.batch.payload_too_large",
"workout.batch.too_large",
],
"(unprefixed)": [
"about_me_conflict",
"ai_response_truncated",
"coach_prefs_conflict",
"dashboard_layout_conflict",
"empty_file",
"health_score_config_conflict",
"insights_layout_conflict",
"invalid_base_updated_at",
"json_carries_no_intake_time",
"json_not_an_array",
"medication_layout_conflict",
"missing_required_columns",
"modules_conflict",
"mood_tag_layout_conflict",
"not_configured",
"notification_prefs_conflict",
"oidc_only",
"private_origin_not_approved",
"rate_limited_self",
"too_many_rows",
"unreadable_json",
"upstream_invalid_json",
"vapid_not_configured",
],
} as const;
/**
* The catalogue as one sentence for the published `meta.errorCode` description.
*
* Rendered rather than hand-written so the document cannot drift from the list
* the guard checks: there is one source, and it is the object above.
*/
export function renderErrorCodeCatalogue(): string {
return Object.entries(ERROR_CODE_CATALOGUE)
.map(([surface, codes]) => `${surface} — ${codes.join(", ")}`)
.join("; ");
}
+10
View File
@@ -34,6 +34,7 @@ import {
inviteManagedProfileGuardianSchema,
updateManagedProfileSchema,
} from "@/lib/validations/managed-profiles";
import { onboardingStateResource } from "./onboarding";
import { moduleAccessMap } from "./profile";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -462,6 +463,15 @@ const accountPayload = z
notificationPrefs: z.looseObject({
medication: z.looseObject({ clientManaged: z.boolean() }),
}),
// v1.39 (C1) — enumerated for the same reason `notificationPrefs` is: a
// client DECIDES on it. The getting-started checklist orders its rows from
// `needs` and stays on screen while the flow is unfinished, and a future
// native client renders the steps it knows. Always present, and resolved
// for the RECORD the payload describes — the same scoping as `modules`,
// because the checklist reads it beside that record's counts. Under a
// SCOPED grant the answers come back empty, for the reason the module map
// is masked. The three write routes stay actor-only.
onboarding: onboardingStateResource,
})
.meta({
id: "AccountPayload",
+2
View File
@@ -30,6 +30,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -125,6 +126,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
+2
View File
@@ -25,6 +25,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -125,6 +126,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflict,
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
+2
View File
@@ -27,6 +27,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -314,6 +315,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...notFound,
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
+7 -1
View File
@@ -33,7 +33,9 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
malformedJsonResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -515,6 +517,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Cycle"],
summary: "Create a custom cycle symptom (v1.15.1)",
description:
@@ -526,6 +529,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
"201": {
description: "The created custom symptom.",
content: {
@@ -707,6 +711,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...cycleDisabledOnADelegableRoute,
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
@@ -869,7 +874,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Cycle-phase correlation insights (v1.15.0)",
description:
"FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.",
"FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are unchanged.",
responses: {
"200": {
description: "Phase-correlation rows + headline.",
@@ -991,6 +996,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...malformedJsonResponse,
},
},
},
+2
View File
@@ -38,6 +38,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -273,6 +274,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
+11 -1
View File
@@ -20,7 +20,11 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { backupPayloadSchema } from "@/lib/validations/backup";
import { errorEnvelope, stdResponses } from "./shared";
import {
errorEnvelope,
SHARING_NOT_PERMITTED_DESCRIPTION,
stdResponses,
} from "./shared";
const exportQuery = z
.object({
@@ -305,6 +309,12 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
+2
View File
@@ -22,6 +22,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -161,6 +162,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
+2
View File
@@ -21,6 +21,7 @@ import {
measurementSourceEnum,
measurementTypeEnum,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -885,6 +886,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// After the spread: `stdResponses` carries a generic 422 that would
// otherwise overwrite this one.
...stdResponses,
...recordWriteRateLimitResponse,
"422": {
description:
"Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.",
+5 -1
View File
@@ -35,6 +35,7 @@ import {
idempotentWrite,
invalidBaseTokenResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "../shared";
@@ -289,7 +290,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
"401": stdResponses["401"],
"429": stdResponses["429"],
...recordWriteRateLimitResponse,
},
},
},
@@ -321,6 +322,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "Record a titration step or a legacy stock correction",
description:
@@ -331,6 +333,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: glp1PostBodySchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description:
@@ -1151,6 +1154,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
get: {
+2 -1
View File
@@ -135,7 +135,8 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Mental health"],
summary: "Record a completed screener",
description:
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.\n\n" +
"The projection row is the one write this operation makes outside its declared section: the operation is fenced on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a screener whose own total is missing from the record would be an inconsistency inside the section the grant does open. The consent copy for the mood-and-mind section names it.",
parameters: [idempotencyKeyParameter],
requestBody: {
content: {
+2
View File
@@ -56,6 +56,7 @@ import {
invalidBaseTokenResponse,
moduleDisabledResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
updatedAtTokenField,
} from "./shared";
@@ -1636,6 +1637,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...duplicateTimestampResponse,
...recordRefusal(),
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
+205 -4
View File
@@ -23,13 +23,131 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
ONBOARDING_FIRST_RESULT_TASKS,
ONBOARDING_MEDICATION_ANSWERS,
ONBOARDING_RECORD_TARGETS,
ONBOARDING_SOURCE_KEYS,
ONBOARDING_STEP_IDS,
ONBOARDING_STEP_STATUSES,
ONBOARDING_VISIT_ANSWERS,
} from "@/lib/onboarding/needs";
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
import {
onboardingCompleteSchema,
onboardingStepSchema,
} from "@/lib/validations/onboarding";
import {
onboardingAnswerSchema,
onboardingRestartSchema,
} from "@/lib/validations/onboarding-needs";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
/* ── v1.39 (C1): the needs-based flow ─────────────────────────────────────── */
const onboardingNeedsResource = z
.object({
recordTarget: z
.enum(ONBOARDING_RECORD_TARGETS)
.nullable()
.describe("Q1 — whose record this is. Null until answered."),
areas: z
.array(z.enum(ONBOARDING_AREA_KEYS))
.describe(
"Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`; the map is the server's, and a client must not re-derive it.",
),
medication: z
.enum(ONBOARDING_MEDICATION_ANSWERS)
.nullable()
.describe("Q3 — medication on a schedule."),
sources: z
.array(z.enum(ONBOARDING_SOURCE_KEYS))
.describe("Q4 — where the readings come from today."),
visit: z
.enum(ONBOARDING_VISIT_ANSWERS)
.nullable()
.describe("Q5 — a doctor's visit coming up."),
units: z
.object({
glucoseUnit: z.enum(["mg/dL", "mmol/L"]).nullable(),
unitPreference: z.enum(["metric", "imperial"]).nullable(),
})
.describe(
"Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on the account payload are, and the answers step writes them. This records what the flow was told.",
),
})
.meta({
id: "OnboardingNeeds",
description:
"The setup answers as given. Every field is null or empty until its question is answered.",
});
const onboardingStepResource = z
.object({
id: z.enum(ONBOARDING_STEP_IDS),
status: z.enum(ONBOARDING_STEP_STATUSES),
})
.meta({
id: "OnboardingStepState",
description:
"One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather than refusing the list, so the flow may grow a screen without a client release.\n\n`units` carries one resolution the others do not: a record whose account already holds BOTH unit preferences reads as `done` whether or not the question was ever put, which is what stops the flow re-asking a value the account holds.",
});
const onboardingFirstResultResource = z
.object({
task: z.enum(ONBOARDING_FIRST_RESULT_TASKS),
target: z
.string()
.nullable()
.describe(
"What the task is about — a source key for a connection, an area key for a reading. Null for the medication task.",
),
completedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("When the offered task produced its result, or null."),
})
.meta({
id: "OnboardingFirstResult",
description:
"The one task the flow offered at the end, and whether it produced its result.",
});
export const onboardingStateResource = z
.object({
steps: z.array(onboardingStepResource),
needs: onboardingNeedsResource,
completedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one alone.",
),
firstResult: onboardingFirstResultResource.nullable(),
})
.meta({
id: "OnboardingState",
description:
"The needs-based setup flow for one record. Always present on the account payload — a record that never entered the flow reads as empty answers with nine `pending` steps, so a client branches on the step statuses rather than on the field's existence. On the account payload it describes the ACTIVE record, so a client under an account switch reads the same record its counts and module map describe; under a scoped grant the answers come back empty.",
});
const onboardingStateEnvelope = dataEnvelope(
z.object({ onboarding: onboardingStateResource }),
"OnboardingStateEnvelope",
);
const onboardingAnswerRequest = onboardingAnswerSchema.meta({
id: "OnboardingAnswerRequest",
description:
"One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a skip may never carry an answer.",
});
const onboardingRestartRequest = onboardingRestartSchema.meta({
id: "OnboardingRestartRequest",
description: "No input beyond the session. Send `{}`.",
});
const tourProgressResource = tourProgressSchema.meta({
id: "TourProgress",
description:
@@ -121,6 +239,86 @@ const onboardingCompleteRequest = onboardingCompleteSchema.meta({
});
export const onboardingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/onboarding/answers": {
patch: {
tags: ["Onboarding"],
summary: "Save one answer of the needs-based setup flow",
description:
"Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the same step. The body names the step and carries either its answer or `status: \"skipped\"`; the two can never ride together.\n\nIdempotent. The stored state is computed by a pure function of the row and the body, so sending the same step twice writes the same value and the second write changes nothing a reader can see — including the first-result completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract: steps may be answered, re-answered and passed in any order, which is what makes a back button work.\n\nThe `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit` and `unitPreference`, which every display surface already reads, and records the answer beside them.\n\nCookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never write another record's setup. Rate-limited to 120 writes per 10 minutes per account.",
requestBody: {
required: true,
content: { "application/json": { schema: onboardingAnswerRequest } },
},
responses: {
"200": {
description: "The full setup state after the write.",
content: {
"application/json": { schema: onboardingStateEnvelope },
},
},
"413": {
description: "Body exceeds 16 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description: "`Content-Type` is not `application/json`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/onboarding/restart": {
post: {
tags: ["Onboarding"],
summary: "Ask the setup questions again",
description:
"\"Set up again\", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp, and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again.\n\nIt writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module somebody turned on by hand, and the protection against the second derivation lives in the merge rather than here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run redirect.\n\nCookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to 10 restarts per 10 minutes per account.",
requestBody: {
// The route accepts an absent body and treats it as `{}`; the schema
// is strict, so a body that carries anything is a 422.
required: false,
content: { "application/json": { schema: onboardingRestartRequest } },
},
responses: {
"200": {
description: "The reset setup state.",
content: {
"application/json": { schema: onboardingStateEnvelope },
},
},
"413": {
description: "Body exceeds 4 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description: "`Content-Type` is not `application/json`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/onboarding/step": {
post: {
tags: ["Onboarding"],
@@ -178,9 +376,9 @@ export const onboardingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/onboarding/complete": {
post: {
tags: ["Onboarding"],
summary: "Stamp onboarding as complete (legacy single-shot path)",
summary: "Stamp onboarding as complete, and derive the module map",
description:
"Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.",
"Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. A half-answered flow derives nothing and is not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY wizard's completion out of the derivation, since that wizard never puts these questions.\n\nThe response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement it always did.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.",
requestBody: {
required: true,
content: { "application/json": { schema: onboardingCompleteRequest } },
@@ -188,11 +386,14 @@ export const onboardingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
responses: {
"200": {
description:
"Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not echo what was saved.",
"Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries the state after the derivation.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ completed: z.literal(true) }),
z.object({
completed: z.literal(true),
onboarding: onboardingStateResource.optional(),
}),
"OnboardingCompleteEnvelope",
),
},
+14 -7
View File
@@ -38,16 +38,17 @@ import {
healthProfileFactKindSchema,
} from "@/lib/validations/health-profile-facts";
import {
coachPrefsSchema,
dataEnvelope,
errorEnvelope,
stdResponses,
baseUpdatedAtField,
updatedAtTokenField,
coachPrefsSchema,
conflictResponse409,
invalidBaseTokenResponse,
dataEnvelope,
deviceRevokeResponse,
errorEnvelope,
invalidBaseTokenResponse,
malformedJsonResponse,
recordRefusal,
stdResponses,
updatedAtTokenField,
} from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
@@ -1063,6 +1064,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Coach preferences", "coach_prefs_conflict"),
...stdResponses,
...malformedJsonResponse,
...invalidBaseTokenResponse,
},
},
@@ -1108,6 +1110,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...malformedJsonResponse,
},
},
},
@@ -1263,6 +1266,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...conflictResponse409("Module preferences", "modules_conflict"),
...stdResponses,
...malformedJsonResponse,
...invalidBaseTokenResponse,
},
},
@@ -1310,6 +1314,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...malformedJsonResponse,
},
},
},
@@ -1505,6 +1510,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"notification_prefs_conflict",
),
...stdResponses,
...malformedJsonResponse,
...invalidBaseTokenResponse,
},
},
@@ -1534,7 +1540,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Replace the owner's saved doctor-report selection",
description:
"Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.",
"Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: { "application/json": { schema: savedReportProfile } },
@@ -1552,6 +1558,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...malformedJsonResponse,
},
},
},
+5 -2
View File
@@ -19,6 +19,7 @@ import {
dataEnvelope,
errorEnvelope,
recordRefusal,
SHARING_NOT_PERMITTED_DESCRIPTION,
stdResponses,
} from "./shared";
@@ -615,7 +616,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -811,7 +813,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
},
+72 -1
View File
@@ -6,6 +6,8 @@
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import { renderErrorCodeCatalogue } from "../error-codes";
import {
createMeasurementSchema as createMeasurementSchemaBase,
listMeasurementsSchema as listMeasurementsSchemaBase,
@@ -82,7 +84,8 @@ export const errorEnvelope = z
.string()
.optional()
.describe(
"Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded.",
"Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.<surface>` is built from a template so the last segment is open (`assistant.disabled.coach` is the one the native client names); the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " +
renderErrorCodeCatalogue(),
),
})
.optional(),
@@ -225,6 +228,29 @@ export const invalidBaseTokenResponse = {
},
};
/**
* The 400 an unparseable JSON body earns.
*
* `safeJson` has answered 400 for a body that will not parse since it was
* written, and roughly two hundred and twenty routes go through it — but seven
* `/api/auth/me/*` writes hand-rolled the parse and answered 422, so a client's
* "my serializer produced garbage" branch had to accept two statuses on a
* subset of routes it could not predict. They answer 400 now, and this is the
* response that says so. The dotted token each route already carried moved to
* `meta.errorCode`, where a machine code belongs.
*
* Spread only on the operations that hand-rolled the parse. It is deliberately
* NOT folded into `stdResponses`: that set is spread onto reads as well, and a
* GET that accepts no body has no malformed body to refuse.
*/
export const malformedJsonResponse = {
"400": {
description:
"The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`<surface>.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this.",
content: { "application/json": { schema: errorEnvelope } },
},
};
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
export const stdResponses = {
@@ -266,6 +292,32 @@ export const stdResponses = {
},
};
/**
* The 429 the shared single-record write bucket answers with.
*
* The batch endpoints have been capped at 60 calls a minute since they were
* written and the per-record siblings were not capped at all, which is exactly
* backwards: the batch endpoint is the one a well-behaved client uses. The
* eleven single-record creates now share one generous per-account bucket, and
* this is the response that names it — a client that meets a ceiling should be
* able to read which one it met without a bug report.
*
* Spread AFTER `...stdResponses` so it replaces the generic 429 on those
* operations. The headers block is the one the standard 429 already declares.
*
* `record-write-rate-limit-contract.test.ts` holds the numbers in this sentence
* to the constants in `src/lib/rate-limit.ts`, so the paragraph cannot drift
* away from the bucket it describes.
*/
export const recordWriteRateLimitResponse = {
"429": {
description:
"Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
content: { "application/json": { schema: errorEnvelope } },
headers: stdResponses["429"].headers,
},
};
// ── Idempotent writes ────────────────────────────────────────────────
/**
@@ -379,6 +431,25 @@ export function idempotentWrite(): IdempotentConflictResponse {
export const SHARING_ACCESS_DENIED_DESCRIPTION =
"Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it.";
/**
* The OTHER sharing refusal, for a route that names no record at all.
*
* A route resolving through `requireAuth` serves the caller and only the
* caller, and refuses outright while the browser is inside somebody else's
* record rather than quietly answering with the caller's own rows. That
* posture matters most on the routes where being wrong is unrecoverable — the
* record wipe, the account deletion, the encrypted archive — and it was
* documented nowhere on them. A client that switches records has to know which
* of its calls stop working, and finding out by wiping the wrong record is not
* a contract.
*
* One sentence, spliced onto whatever else the operation's 403 already says,
* for the same reason the delegable refusal is one string: a paraphrase per
* path invites a client to tell them apart.
*/
export const SHARING_NOT_PERMITTED_DESCRIPTION =
"Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it.";
/**
* The 403 above, optionally sharing the status with the reasons a route already
* refuses for.
+7 -2
View File
@@ -38,6 +38,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
@@ -263,6 +264,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
@@ -362,14 +364,17 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "Plan a booster reminder from a dose",
description:
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.",
"Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" +
"The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.",
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationBoosterRequest } },
},
responses: {
...recordRefusal(),
...recordRefusal(
"Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.",
),
"200": {
description: "Existing booster reminder re-anchored.",
content: {