Files
HealthLog/src/lib/openapi/error-codes.ts
T
Marc-André Bombeck 107db1bb55 feat(admin): let an admin delete another account through the grace window
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
2026-10-29 09:10:26 +01:00

533 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.already_pending",
"account.deletion.no_guardian",
"account.deletion.not_pending",
],
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
admin: ["admin.users.delete.managed_profile", "admin.users.delete.self"],
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("; ");
}