Files
HealthLog/src/lib/openapi/routes/index.ts
T
Marc-André Bombeck 5a875d2840 fix(auth): issue a Secure session cookie under the __Host- prefix
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.

Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
2026-10-25 17:44:31 +01:00

296 lines
13 KiB
TypeScript

/**
* OpenAPI route table — thin aggregation index.
*
* The route table is split into domain modules under this directory;
* this index assembles them into the single `openApiPaths` object the
* registry consumes. The spread order below is load-bearing: the YAML
* emitter runs with `sortMapEntries: false` (see
* `scripts/generate-openapi.ts`), so the key order of this object is
* the path order of the committed `docs/api/openapi.yaml`. Keep the
* spread sequence stable when adding a module — append inside the
* domain module, not by reordering here.
*
* Schemas come from `src/lib/validations/*` so the wire contract stays
* single-source-of-truth. The `.meta()` annotations on each schema land
* the title + description in `components.schemas.*` automatically.
*
* Routes are intentionally registered as inline operation objects
* rather than via a wrapping helper — `zod-openapi`'s `createDocument`
* type-checks the route table directly, which catches schema-shape
* regressions at typecheck time.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
accountSelectorParameter,
accountSharingPaths,
recordEpochParameter,
recordScopeParameter,
} from "./account-sharing";
import { activityPaths } from "./activity";
import { provenancePaths } from "./provenance";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { aiUsagePaths } from "./ai-usage";
import { dawarichPaths } from "./dawarich";
import { lifeCircumstancesPaths } from "./life-circumstances";
import { relationLinkPaths } from "./relation-links";
import { adminBackupPaths } from "./admin-backups";
import { allergyPaths } from "./allergies";
import { analyticsPaths } from "./analytics";
import { authPaths } from "./auth";
import { awardsPaths } from "./awards";
import { environmentPaths } from "./environment";
import { healthPaths } from "./health";
import { ingestPaths } from "./ingest";
import {
coachFeedbackPaths,
coachPaths,
coachReminderSuggestionPaths,
coachReminderPaths,
coachSuggestedActionPaths,
} from "./coach";
import { consentPaths } from "./consent";
import { customMetricPaths } from "./custom-metrics";
import { dailyPaths } from "./daily";
import { cyclePaths } from "./cycle";
import { dashboardWidgetPaths } from "./dashboard";
import { dayPaths } from "./day";
import { searchPaths } from "./search";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
import { documentSourcePaths } from "./document-sources";
import { devicePaths } from "./devices";
import { exportPaths } from "./export";
import { familyHistoryPaths } from "./family-history";
import { healthRecordPaths } from "./health-record";
import { encounterPaths } from "./encounters";
import { vaccinationPaths } from "./vaccinations";
import { illnessPaths } from "./illness";
import { importPaths } from "./import";
import { insightsPaths, sleepScoreValue, wellnessScoreValue } from "./insights";
import { integrationPaths } from "./integrations";
import { insightsSignalPaths } from "./insights-signals";
import { labsPaths } from "./labs";
import { ocrPaths } from "./ocr";
import { aiRunPaths } from "./ai-runs";
import { measurementPaths } from "./measurements";
import { measurementReminderPaths } from "./measurement-reminders";
import { mentalHealthPaths } from "./mental-health";
import {
medicationIngredientPaths,
medicationPaths,
medicationResource,
medicationDoseHistoryImportFatalReasonEnum,
} from "./medications";
import { mcpPaths } from "./mcp";
import { metaPaths } from "./meta";
import { moodPaths, moodTagLayout } from "./mood";
import { notificationTransportPaths } from "./notifications-transport";
import { notificationChannelPaths } from "./notifications";
import { nutrientPaths } from "./nutrients";
import { onboardingPaths } from "./onboarding";
import { profilePaths } from "./profile";
import { recordSettingsPaths } from "./record-settings";
import { retiredPaths } from "./retired";
import { settingsPaths } from "./settings";
import { symptomPaths } from "./symptoms";
import { syncPaths } from "./sync";
import { workoutPaths } from "./workouts";
import { coachPrefsSchema, withScopeRefusal } from "./shared";
const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...cyclePaths,
...metaPaths,
...healthRecordPaths,
...exportPaths,
...authPaths,
...syncPaths,
...measurementPaths,
...workoutPaths,
...activityPaths,
...provenancePaths,
...devicePaths,
...medicationPaths,
...profilePaths,
...coachPaths,
...coachReminderSuggestionPaths,
...coachReminderPaths,
...coachSuggestedActionPaths,
...adminInvitePaths,
...coachFeedbackPaths,
...adminDiagnosticPaths,
...insightsPaths,
...insightsSignalPaths,
...moodPaths,
...settingsPaths,
...consentPaths,
...importPaths,
...measurementReminderPaths,
...labsPaths,
...ocrPaths,
...aiRunPaths,
...biomarkerPaths,
...encounterPaths,
...vaccinationPaths,
...illnessPaths,
...mentalHealthPaths,
...allergyPaths,
...familyHistoryPaths,
...inboundDocumentPaths,
...customMetricPaths,
...onboardingPaths,
// v1.28 — nutrient-intake sync (appended; spread order is load-bearing).
...nutrientPaths,
// v1.28 — HealthKit integration config (appended; spread order is
// load-bearing).
...integrationPaths,
// v1.28 — the unified daily digest (P3 spine; appended, spread order is
// load-bearing).
...dailyPaths,
// v1.32.21 — dashboard widget layout: previously absent from the
// registry entirely, now documented as the reference optimistic-concurrency
// contract (appended, spread order is load-bearing).
...dashboardWidgetPaths,
// v1.36.0 — account sharing: the grant lifecycle and the switch endpoint
// (appended, spread order is load-bearing).
...accountSharingPaths,
// Notification transport — the VAPID key, the Web Push subscription
// lifecycle and the per-channel self-tests (appended, spread order is
// load-bearing).
...notificationTransportPaths,
// Notification channels + MCP connector credentials: both families answered
// the native client for several releases while being absent from the
// registry, because `openapi:check` compares the registry against the YAML
// and never the routes against the registry (appended, spread order is
// load-bearing).
...notificationChannelPaths,
...mcpPaths,
// The managed record's own configuration tree. Kept out of the sharing module
// because it is the one surface fenced by `requireGuardianAuth`, and its 403
// therefore has to be the shared refusal sentence rather than the neighbouring
// module's local wording (appended, spread order is load-bearing).
...recordSettingsPaths,
// Routes that existed for releases without ever reaching the registry, so
// `openapi:check` saw no drift and reported nothing: it compares the
// registry against the committed YAML and has never compared the routes on
// disk against the registry. Appended, spread order is load-bearing.
...healthPaths,
...analyticsPaths,
...awardsPaths,
...environmentPaths,
...ingestPaths,
// The two backups-console routes that open a stored copy. The rest of the
// family stays unpublished — it is a cookie-only console — but these two
// carry a refusal an operator has to be able to look up rather than
// discover: a copy this instance can no longer decrypt (appended, spread
// order is load-bearing).
...adminBackupPaths,
// v1.39.3 — the document picker: a person's connection to Paperless-ngx or
// Papra, searching it and importing picked documents; cookie-only
// (appended, spread order is load-bearing).
...documentSourcePaths,
// v1.40 — person-defined symptoms and their occurrences, behind the illness
// module (appended, spread order is load-bearing).
...symptomPaths,
// v1.42 — the day view, the timeline and life events (appended, spread
// order is load-bearing).
...dayPaths,
...searchPaths,
// v1.43 — the new contract surfaces: AI usage levels, supplement
// composition, life circumstances and AUDIT-C, Dawarich movement context and
// the relation links (appended, spread order is load-bearing).
...aiUsagePaths,
...medicationIngredientPaths,
...lifeCircumstancesPaths,
...dawarichPaths,
...relationLinkPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
// Last, and last on purpose: these are the tombstones, the only published
// paths with no route module behind them. A reader scrolling the emitted
// YAML meets the live surface before the removed one (appended, spread
// order is load-bearing).
...retiredPaths,
};
// The generic auth gate's 403 is declared on the assembled table, not spread
// from `stdResponses`; `scopeRefusalResponse` in `./shared` says why.
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> =
withScopeRefusal(registeredPaths);
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
// Schemas listed here are forced into `components.schemas` even when no
// route references them directly. `Medication` lives in this slot
// because its only consumers are the `MedicationListEntry` /
// `MedicationDetail` variants which extend it — `.extend()` inlines
// the base shape into the derived schema, so without an explicit
// registration the standalone `Medication` component would never
// emit. The iOS codegen reads from `Medication` directly for the
// shared Swift struct backing both variants.
//
// `MedicationDoseHistoryImportFatalReason` lives here for the same reason:
// the dose-history-import 422 description names it as the closed set
// behind `errorCode`, but `ErrorEnvelope.meta.errorCode` is a bare string
// shared by every route, so nothing ever `$ref`s the enum itself. Without
// this forced registration the schema the description points to would be
// silently absent from the published spec.
//
// `MoodTagLayout` joins them for exactly the same reason: its annotation is
// in the assigning form and correct, but both consumers `.extend()` it, so
// the base name is never referenced and the description the mood surface
// relies on would go unpublished.
//
// `CoachPrefs` is the same shape: the PUT `/api/auth/me/coach-prefs`
// description tells the reader "Body is validated against `CoachPrefs`",
// but every actual consumer is a `.extend()` of it (`PutCoachPrefsRequest`,
// `CoachPrefsResponse`) — `.extend()` builds a new object, so the base
// never carries forward to a $ref on its own.
//
// `WellnessScoreValue` is here for a different reason. `GET
// /api/insights/derived` serves eighteen metrics through one envelope, so
// its `value` has to stay an open record — no single `$ref` can describe
// it. That left every metric's payload undocumented, and a client team read
// the DTO and reported two fields as missing that were already on the wire.
// Publishing the shape a `$ref` cannot reach is exactly what this slot is
// for; the `value` description names it as the shape the three score ids
// return. `SleepScoreValue` rides the same slot for `SLEEP_SCORE`.
schemas: {
Medication: medicationResource,
MedicationDoseHistoryImportFatalReason:
medicationDoseHistoryImportFatalReasonEnum,
CoachPrefs: coachPrefsSchema,
MoodTagLayout: moodTagLayout,
WellnessScoreValue: wellnessScoreValue,
SleepScoreValue: sleepScoreValue,
},
// v1.36.0 — the per-request account selector, defined in the sharing route
// module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps
// holding the header's name to two files: the resolver that reads it, and
// the one place that publishes it.
// v1.37.0 — the record-session fence's two request headers, defined in the
// same module for the same reason: the fence guard holds their names to four
// files, and this index references rather than restates them.
parameters: {
AccountSelector: accountSelectorParameter,
RecordEpoch: recordEpochParameter,
RecordScope: recordScopeParameter,
},
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "hlk_<64hex>",
description:
"Native-client API token. Use the `token` field returned by /api/auth/login on a native client.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "healthlog_session",
description:
"Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is `Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps `healthlog_session`.",
},
},
};