Files
HealthLog/src/lib/openapi/registry.ts
T
Marc-André Bombeck f4c0eb0ab9 feat(api): publish retirement in the contract, the capability read, and the coverage guard
Three surfaces, one source. The registry the proxy answers 410 from now
also emits the tombstones into `docs/api/openapi.yaml` and rides on
`GET /api/meta/capabilities`, so a retirement cannot be honoured at
runtime and invisible everywhere a client could have read it first.

The contract publishes each retired path with 410 as its only response,
the removing version, and the replacement. `deprecated: true` is
imprecise — the word means "still works, stop using it", and these do not
work — but it is what makes a generator warn at the call site, and the
description carries the exact truth the flag approximates. `security: []`
because the answer is produced before any handler and reads no
credential; publishing it as authenticated would tell a client to sign in
to be told a path is gone.

The capability read carries the same list. That surface is the one a
client already polls on launch, so a retirement can reach it before the
failing call rather than through it.

The coverage guard gains a third state. Published and exempted both
describe a route that exists, so removing one used to leave exactly one
legal move — erase it from the contract as well — and that move is
invisible to every client already calling it. A registered path is now
required to be absent from `src/app` AND present in the contract, which
is the one combination the phantom-path case rejected outright. Both ways
out are named in the failure message.

Its docblock says what the state cannot do, because the first draft
overclaimed: a check over one commit's state cannot see a deletion, so it
cannot insist that a removal should have been registered. Proving that
needs the previous release tag, and the job running this suite checks out
at depth one with no tags, which would make the check one that silently
cannot fail. What is held instead is the registration's integrity, in
three directions and with the mutation for each written down.

The sharing-denial guard learns the same state: a retired path resolves
no record fence because it reaches no handler.
2026-08-23 14:06:52 +02:00

132 lines
5.8 KiB
TypeScript

/**
* OpenAPI 3.1 base registry (v1.4.23 / Wave 4 F5a).
*
* Per the W1 stream-3 decision: `zod-openapi` (samchungy) reads Zod v4's
* native `.meta()` metadata, so we annotate schemas in their original
* validation files and register the route table here. The committed
* spec at `docs/api/openapi.yaml` is regenerated via
* `pnpm openapi:generate`; the CI gate in `.github/workflows/security.yml`
* runs `pnpm openapi:check` to detect drift.
*
* The v1.4.23 baseline covers the 6-8 routes the v1.5 iOS app touches
* (auth/login, auth/passkey/login-verify, auth/refresh, measurements
* GET + POST + batch, devices POST, insights/comprehensive). Future
* waves expand coverage organically as routes are touched. Once the
* registry catches the rest of the hand-maintained spec, the CI gate
* flips from `continue-on-error: true` to hard-fail.
*
* The registry is intentionally split:
* - `openApiBase` carries the static document scaffolding (info,
* servers, tags).
* - `buildOpenApiDocument` assembles the full spec by importing the
* registered route table from `./routes`. Routes live in their own
* file so additions don't touch the base.
*/
import { createDocument, type ZodOpenApiObject } from "zod-openapi";
import { openApiPaths, openApiComponents } from "./routes";
import { version as packageVersion } from "../../../package.json";
const openApiBase: Pick<
ZodOpenApiObject,
"openapi" | "info" | "servers" | "tags" | "security"
> = {
openapi: "3.1.0",
info: {
title: "HealthLog API",
// v1.5.1 — pulled from package.json so the spec's info.version
// tracks the release line automatically. The CI drift gate
// (`pnpm openapi:check`) catches the case where the registry
// and the committed spec disagree.
version: packageVersion,
description:
"Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.\n\n" +
"## Date-time contract\n\n" +
"Every field marked `format: date-time` is an ISO-8601 instant. The schema pattern accepts both `Z` (UTC) and `±HH:MM` (offset) suffixes; clients must accept either. " +
"Server responses since v1.4.25 emit timestamps with the requesting user's display-timezone offset when a user context exists (exports, doctor-report PDF). Background-job-authored payloads (backups, admin tools) emit `Z`. " +
"Native iOS clients should always parse via a Foundation ISO-8601 formatter with the `withInternetDateTime` + `withTimeZone` options enabled.",
license: {
name: "PolyForm-Noncommercial-1.0.0",
url: "https://github.com/MBombeck/HealthLog/blob/main/LICENSE",
},
contact: { name: "HealthLog", url: "https://healthlog.dev" },
},
servers: [
{
url: "https://healthlog.example.com",
description: "Your self-hosted instance",
},
{ url: "http://localhost:3000", description: "Local dev" },
],
tags: [
{ name: "Auth" },
{ name: "Measurements" },
{ name: "Medications" },
{ name: "Mood" },
{ name: "Analytics" },
{ name: "Insights" },
{ name: "Consent" },
{ name: "Dashboard" },
{ name: "Notifications" },
{ name: "Devices" },
{ name: "Export" },
{ name: "Sync" },
{ name: "Cycle" },
{ name: "MeasurementReminders" },
{ name: "Labs" },
{ name: "Illness" },
{ name: "Documents" },
{ name: "Records" },
{ name: "Admin" },
{ name: "Meta" },
{
name: "Retired",
description:
"Paths that were published and have been removed. They answer 410 Gone with `meta.errorCode` = `route.retired`, the removing version, and the replacement path where there is one. Listed so a client generated from this contract learns the path is gone from the contract rather than from a 404 in production.",
},
],
/**
* How a caller authenticates, for every operation that does not say
* otherwise.
*
* The two schemes were defined under `components.securitySchemes` from the
* start and referenced by nothing — no document-level `security`, none on any
* operation. That is not a small omission: a client generated from the
* contract wires neither the `Authorization` header nor the session cookie,
* and reads the whole surface as an open API. The schemes being present is
* exactly what hid it, because the obvious check passes.
*
* Two entries rather than one object with two keys. In OpenAPI an array is
* OR and the keys inside one object are AND, so `[{bearerAuth: []},
* {cookieAuth: []}]` says "a token or a session", which is what the server
* accepts. One object with both keys would demand both at once.
*
* The order is deliberate: a generator that picks the first scheme it
* understands should reach for the Bearer token, because the native client is
* the reason this contract is published. The browser has its cookie already.
*
* Operations that genuinely take no credential override this with
* `security: []`; `openapi-security-declaration-guard.test.ts` holds the list
* of those and fails on an opt-out that is not on it. Note that the proxy's
* `PUBLIC_PATHS` is a different question and not the list — it says what the
* edge lets through, not what the handler behind it accepts, and
* `/api/ingest/medication` is on it while requiring a Bearer token.
*/
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
};
/**
* Assemble the full OpenAPI 3.1 document. Routes live in their own
* file so additions don't touch the base scaffolding. The static
* import keeps typecheck-narrowing and tree-shaking intact (W6 S-02
* removed the lazy-`require` form which had no real consumer and was
* costing an `eslint-disable` + an `as` cast for no benefit).
*/
export function buildOpenApiDocument(): ReturnType<typeof createDocument> {
return createDocument({
...openApiBase,
paths: openApiPaths,
components: openApiComponents,
});
}