diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3cb879a6b..c0da54ed3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29501,8 +29501,97 @@ components: requestId: type: string errorCode: - description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be - reworded. + description: "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.` 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. 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.complete.invalid, onboarding.disclaimer.invalid, 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; report-selection — + report-selection.body.invalid_json, report-selection.body.invalid_shape; route — route.retired; + share-link — 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.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, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, medication_layout_conflict, modules_conflict, mood_tag_layout_conflict, + not_configured, notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, + too_many_rows, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: diff --git a/src/__tests__/error-code-catalogue.test.ts b/src/__tests__/error-code-catalogue.test.ts new file mode 100644 index 000000000..a6a7fff08 --- /dev/null +++ b/src/__tests__/error-code-catalogue.test.ts @@ -0,0 +1,189 @@ +/** + * The published `meta.errorCode` list is the list the server actually emits. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * and the envelope tells a client to branch on it — but of the two hundred and + * forty-two codes in the tree, sixteen appeared anywhere in the contract. A + * client had to discover each one by triggering the error, and had no way at + * all to learn a new one had appeared. The catalogue publishes them; this holds + * the catalogue to the code, in both directions: + * + * - A code emitted and not listed is today's defect repeating itself, and it + * is also how a fifth naming convention would get in unnoticed. + * - A code listed and no longer emitted is a promise the server stopped + * keeping, which is worse than an omission: a client can write a branch for + * it and wait forever. + * + * What it reads: every `.ts` under `src/app/api` outside a `__tests__` + * directory, plus the named library modules that build a refusal envelope of + * their own. Comment lines are stripped first, so a code quoted in prose is not + * mistaken for one that ships. + * + * Its limits, stated rather than implied. A new library module that raises a + * coded refusal has to be added to `ENVELOPE_MODULES` below — the route tree is + * swept by rule, that list is not. A code assembled at runtime from a template + * cannot be enumerated at all, and the one family that does so is named in the + * catalogue's own doc comment. And `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 — which is why the sweep is scoped to + * the modules that answer requests rather than to `src/lib` wholesale. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Library modules that build a coded refusal without going through a route + * file. Everything else under `src/lib` is out of scope on purpose — see the + * note on `IntegrationStatus.errorCode` in the file header. + */ +const ENVELOPE_MODULES = [ + "src/lib/ai/consent-guard.ts", + "src/lib/api-errors.ts", + "src/lib/api-handler.ts", + "src/lib/auth/profile-update.ts", + "src/lib/clinician-share/report-download.ts", + "src/lib/cycle/gate.ts", + "src/lib/documents/ai-route-support.ts", + "src/lib/documents/attach-validate.ts", + "src/lib/export/backup-blob.ts", + "src/lib/http/retired-routes.ts", + "src/lib/illness/gate.ts", + "src/lib/modules/gate.ts", + "src/lib/optimistic-lock.ts", + "src/lib/sharing/record-session-fence-contract.ts", +]; + +function apiFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...apiFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Drop comment lines so a code quoted in prose is not read as one that ships. */ +function withoutComments(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("*") || + trimmed.startsWith("//") || + trimmed.startsWith("/*") + ); + }) + .join("\n"); +} + +/** The three shapes a code is written in across the tree. */ +function codesIn(source: string): string[] { + const found: string[] = []; + // `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the + // annotated `errorCode: string = "x"` on the step-up error's constructor. + for (const m of source.matchAll( + /errorCode\s*(?::\s*[A-Za-z.[\]|" ]+)?\s*[:=]\s*"([^"]+)"/g, + )) { + found.push(m[1]); + } + // A single shared constant: `export const MODULE_DISABLED_ERROR_CODE = "…"`. + for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) { + found.push(m[1]); + } + // A family in one object: `export const AUTH_ERROR_CODES = { … }`. + for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODES\s*=\s*\{/g)) { + const start = source.indexOf("{", m.index); + let depth = 0; + let end = start; + for (let j = start; j < source.length; j++) { + if (source[j] === "{") depth++; + else if (source[j] === "}") { + depth--; + if (depth === 0) { + end = j; + break; + } + } + } + for (const value of source.slice(start, end).matchAll(/:\s*"([^"]+)"/g)) { + found.push(value[1]); + } + } + return found; +} + +/** Every code the tree emits, mapped to the files that emit it. */ +function emittedCodes(): Map { + const emitted = new Map(); + const files = [ + ...apiFiles(API_ROOT), + ...ENVELOPE_MODULES.map((path) => join(process.cwd(), path)), + ]; + for (const file of files) { + const source = withoutComments(readFileSync(file, "utf8")); + for (const code of codesIn(source)) { + const where = relative(process.cwd(), file).split(sep).join("/"); + const seen = emitted.get(code); + if (seen) { + if (!seen.includes(where)) seen.push(where); + } else { + emitted.set(code, [where]); + } + } + } + return emitted; +} + +const emitted = emittedCodes(); +const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat()); + +describe("the published errorCode catalogue", () => { + it("finds the codes it is meant to judge", () => { + // A matcher that stopped matching would otherwise agree with an empty + // catalogue. Two hundred and forty-two when this was written. + expect(emitted.size).toBeGreaterThan(200); + }); + + it("lists every code the server emits", () => { + const undocumented = [...emitted.entries()] + .filter(([code]) => !published.has(code)) + .map(([code, files]) => `${code} (${files[0]})`) + .sort(); + expect(undocumented).toEqual([]); + }); + + it("emits every code it lists", () => { + const stale = [...published].filter((code) => !emitted.has(code)).sort(); + expect(stale).toEqual([]); + }); + + it("files every code under the surface its own prefix names", () => { + const misfiled: string[] = []; + for (const [surface, codes] of Object.entries(ERROR_CODE_CATALOGUE)) { + for (const code of codes) { + const expected = code.includes(".") + ? code.slice(0, code.indexOf(".")) + : "(unprefixed)"; + if (expected !== surface) misfiled.push(`${code} under ${surface}`); + } + } + expect(misfiled).toEqual([]); + }); + + it("keeps no duplicate between two surfaces", () => { + const all = Object.values(ERROR_CODE_CATALOGUE).flat(); + expect(all.length).toBe(new Set(all).size); + }); +}); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts new file mode 100644 index 000000000..d30ffabba --- /dev/null +++ b/src/lib/openapi/error-codes.ts @@ -0,0 +1,339 @@ +/** + * 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. + * Sixteen of the 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.` 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> = + { + 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.complete.invalid", + "onboarding.disclaimer.invalid", + "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", + ], + "report-selection": [ + "report-selection.body.invalid_json", + "report-selection.body.invalid_shape", + ], + route: ["route.retired"], + "share-link": [ + "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.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", + "health_score_config_conflict", + "insights_layout_conflict", + "invalid_base_updated_at", + "medication_layout_conflict", + "modules_conflict", + "mood_tag_layout_conflict", + "not_configured", + "notification_prefs_conflict", + "oidc_only", + "private_origin_not_approved", + "rate_limited_self", + "too_many_rows", + "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("; "); +} diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 9c68b30d0..15da53657 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -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.` 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(),