Files
HealthLog/src/lib/openapi/error-codes.ts
T

528 lines
18 KiB
TypeScript

import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
/**
* 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.<switch>` is built from a template at runtime
* (`src/lib/ai/capabilities/refusal.ts`, and the retired
* `src/lib/feature-flags/index.ts` gate), so the last segment is open. It
* is one of `coach`, `briefing`, `insightStatus`, `documentAi` and
* `enabled`; `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[]>> =
{
account: ["account.deletion.no_guardian", "account.deletion.not_pending"],
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
ai: [
"ai.budget.exceeded",
"ai.provider.none",
"ai.record.notPermitted",
"ai.unavailable",
"ai.usage.aboveCeiling",
"ai.usage.defaultAboveCeiling",
"ai.usage.invalid",
"ai.usage.levelTooLow",
"ai.usage.rateLimited",
],
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
aiRuns: [
"aiRuns.failed",
"aiRuns.notFound",
"aiRuns.readerBusy",
"aiRuns.timedOut",
"aiRuns.workerUnavailable",
],
allergy: ["allergy.invalid"],
analytics: ["analytics.invalid_query"],
anamnesis: [
"anamnesis.auditC.invalid",
"anamnesis.auditC.notFound",
"anamnesis.auditC.rateLimited",
"anamnesis.fact.conflict",
"anamnesis.fact.currentExists",
"anamnesis.fact.invalidValue",
"anamnesis.lifeConfirm.invalid",
"anamnesis.lifeSections.invalid",
],
auth: [
"auth.account.deletion_pending",
"auth.admin.required",
"auth.already_authenticated",
"auth.mfa.code_invalid",
"auth.missing",
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
"auth.reproof.failed",
"auth.reproof.required",
"auth.reproof.too_weak",
"auth.scope.insufficient",
"auth.session.insecure_transport",
"auth.session.unavailable",
"auth.stepup.required",
"auth.token.expired",
"auth.token.invalid",
],
backup: [
"backup.foreign_reference",
"backup.key.missing",
"backup.payload.undecryptable",
"backup.restore.active",
"backup.section.missing",
"backup.upload.too_large",
],
biomarkers: ["biomarkers.unit.locked"],
"body-site": ["body-site.invalid"],
coach: [
"coach.budget.exceeded",
"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.confirmRequired", "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"],
data_wipe: ["data_wipe.copy_failed"],
dawarich: [
"dawarich.credentials_rejected",
"dawarich.home_place_unknown",
"dawarich.invalid",
"dawarich.not_connected",
"dawarich.rate_limited",
"dawarich.unreachable",
],
day: ["day.invalid"],
demo: ["demo.readOnly"],
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.notDeleted",
"documents.inbound.notFound",
"documents.inbound.notIndexed",
"documents.inbound.outputLimit",
"documents.inbound.pdfNeedsAnthropic",
"documents.inbound.providerUnsupported",
"documents.inbound.quotaExceeded",
"documents.inbound.rateLimited",
"documents.inbound.restoreDuplicate",
"documents.inbound.restoreGone",
"documents.inbound.sourceAliasLimit",
"documents.inbound.uploadBusy",
"documents.inbound.uploadTimeout",
"documents.inbound.vaccinationNotFound",
"documents.sources.authRefused",
"documents.sources.badResponse",
"documents.sources.browserOnly",
"documents.sources.invalidAddress",
"documents.sources.linkTargetNotFound",
"documents.sources.notConnected",
"documents.sources.notFound",
"documents.sources.organizationRequired",
"documents.sources.originNotAllowed",
"documents.sources.permissionMissing",
"documents.sources.rateLimited",
"documents.sources.redirected",
"documents.sources.tokenRequired",
"documents.sources.unavailable",
"documents.sources.unreachable",
"documents.sources.versionTooOld",
],
encounter: [
"encounter.invalid",
"encounter.link.invalid",
"encounter.practitioner-not-found",
"encounter.reminder-conflict",
"encounter.reminder-not-found",
],
encryption: ["encryption.key_mismatch"],
environment: [
"environment.backfill_pending",
"environment.backfill_rate_limited",
"environment.invalid",
"environment.no_home",
"environment.range_too_large",
"environment.travel.not_found",
"environment.travel_suggestion.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",
],
// The status defaults, derived from the table `apiHandler` applies so the
// two cannot drift apart.
http: [...new Set(Object.values(DEFAULT_ERROR_CODES))].sort(),
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.apple_health.busy",
"import.csv.content_type",
"import.csv.empty",
"import.csv.too_large",
"import.rate_limited",
"import.write_failed",
],
ingest: ["ingest.rate_limited"],
insights: [
"insights.generate.budgetExceeded",
"insights.generate.outboundScreened",
"insights.generate.profileScopeChanged",
"insights.generate.scopeChanged",
],
labs: [
"labs.biomarker.alias.taken",
"labs.biomarker.create.invalid",
"labs.biomarker.duplicate",
"labs.biomarker.merge.invalid",
"labs.biomarker.merge.unitMismatch",
"labs.biomarker.notFound",
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
"labs.create.invalid",
"labs.link.invalid",
"labs.ocr.budgetExceeded",
"labs.ocr.extractFailed",
"labs.ocr.fileTooLarge",
"labs.ocr.fileType",
"labs.ocr.localOcrDisabled",
"labs.ocr.outputLimit",
"labs.ocr.pdfNeedsAnthropic",
"labs.ocr.providerUnsupported",
"labs.ocr.rateLimited",
"labs.ocr.tooLarge",
"labs.restore.invalid",
"labs.result.notFound",
"labs.unit.mismatch",
"labs.update.invalid",
"labs.update.linkedFieldsImmutable",
"labs.update.numericExpected",
"labs.update.qualitativeExpected",
"labs.update.referenceRangeInvalid",
"labs.update.sourceReferenceRangeInvalid",
],
life_event: ["life_event.invalid", "life_event.not_found"],
managed_profile: [
"managed_profile.guardian.duplicate",
"managed_profile.guardian.managed_invitee",
"managed_profile.guardian.required",
"managed_profile.handover.no_pending",
"managed_profile.handover.unknown_guardian",
"managed_profile.not_found",
],
maintenance: [
"maintenance.pulseDayMean.busy",
"maintenance.pulseDayMean.dryRunRequired",
],
measurement: [
"measurement.batch.invalid",
"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",
"medication.intake.notTracked",
"medication.link.invalid",
],
medications: [
"medications.category.invalid",
"medications.category.limitReached",
"medications.category.unknown",
"medications.course.currentOpen",
"medications.course.invalid",
"medications.course.invalidRange",
"medications.course.notFound",
"medications.course.notRunning",
"medications.course.oneShot",
"medications.course.overlap",
"medications.course.windowRequired",
"medications.ingredients.extract.budgetExceeded",
"medications.ingredients.extract.failed",
"medications.ingredients.extract.fileTooLarge",
"medications.ingredients.extract.fileType",
"medications.ingredients.extract.rateLimited",
"medications.ingredients.invalid",
"medications.ingredients.unitMismatch",
"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",
"medications.notFound",
],
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"],
notification: [
"notification.action.already_used",
"notification.action.expired",
"notification.action.invalid",
"notification.action.module_disabled",
],
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.complete.recordNotFound",
"onboarding.complete.recordTargetMismatch",
"onboarding.disclaimer.invalid",
"onboarding.restart.invalid",
"onboarding.restart.rateLimited",
"onboarding.tour.invalid",
],
passkey: [
"passkey.challenge.expired",
"passkey.response.invalid",
"passkey.unknown",
"passkey.verification.failed",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
profile: [
"profile.update.emailInUse",
"profile.update.emailRateLimited",
"profile.update.emailSecondFactorRequired",
"profile.update.invalidBody",
"profile.update.nothingSaved",
],
profile_claim: [
"profile_claim.invalid",
"profile_claim.oidc_only_unsupported",
"profile_claim.sso_unavailable",
"profile_claim.taken",
],
record_write: ["record_write.rate_limited"],
recovery: ["recovery.kit.stale"],
"report-selection": [
"report-selection.body.invalid_json",
"report-selection.body.invalid_shape",
"report-selection.leaves.unknown",
],
request: ["request.cross_site"],
route: ["route.retired"],
search: ["search.invalid"],
security_activity: ["security_activity.invalid"],
"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.no_account",
"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",
],
symptoms: [
"symptoms.definition.limitReached",
"symptoms.definition.notFound",
"symptoms.episode.notOpen",
"symptoms.event.notFound",
"symptoms.invalid",
],
timeline: ["timeline.invalid"],
tokens: [
"tokens.documents.ceiling_reached",
"tokens.measurements.ceiling_reached",
"tokens.mirror.not_measurement_token",
"tokens.workouts.ceiling_reached",
],
vaccination: [
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
"vaccination.booster-out-of-scope",
"vaccination.custom-vaccine-not-found",
"vaccination.custom.invalid",
"vaccination.custom.name-taken",
"vaccination.custom.not-found",
"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.source_not_permitted",
"workout.batch.too_large",
"workout.delete.synced_source",
],
"(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",
"private_origin_not_grantable",
"provider_retired",
"rate_limited_self",
"sync_in_progress",
"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("; ");
}