chore(deps): add zod-openapi + yaml@2 — OpenAPI generator base

Wires the OpenAPI 3.1 generator scaffolding ahead of the iOS-touched
route registrations. `zod-openapi` (samchungy variant, picked in W1
stream-3) reads Zod v4's native `.meta()` so schemas can stay in their
original validation files; `yaml@2` with `sortMapEntries: true` keeps
the emitted spec stable across re-runs.

`pnpm openapi:generate` overwrites `docs/api/openapi.yaml` from the
registry; `pnpm openapi:check` exits non-zero on drift. The legacy
hand-maintained spec is preserved at `docs/api/openapi-v1422-legacy.yaml`
so the iOS contract reference doesn't disappear during the
incremental migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:56:32 +02:00
1 parent a859661686
commit a8b233bf86
8 files changed
+5697 -5456

No files matched your search

File diff suppressed because it is too large. Load diff
+13 -5455
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -40,7 +40,9 @@
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
"e2e:ui": "playwright test --ui",
"openapi:generate": "npx tsx scripts/generate-openapi.ts",
"openapi:check": "npx tsx scripts/check-openapi.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
@@ -70,7 +72,9 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"web-push": "^3.6.7",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zod-openapi": "^5.4.6",
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
+16
View File
@@ -89,9 +89,15 @@ importers:
web-push:
specifier: ^3.6.7
version: 3.6.7
yaml:
specifier: ^2.8.4
version: 2.8.4
zod:
specifier: ^4.4.3
version: 4.4.3
zod-openapi:
specifier: ^5.4.6
version: 5.4.6(zod@4.4.3)
zxcvbn-typescript:
specifier: ^5.0.1
version: 5.0.1
@@ -6193,6 +6199,12 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod-openapi@5.4.6:
resolution: {integrity: sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==}
engines: {node: '>=20'}
peerDependencies:
zod: ^3.25.74 || ^4.0.0
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
@@ -12970,6 +12982,10 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
zod-openapi@5.4.6(zod@4.4.3):
dependencies:
zod: 4.4.3
zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env tsx
/**
* Compare the freshly-generated OpenAPI spec against the committed
* `docs/api/openapi.yaml`. Exits non-zero on drift.
*
* Used by the CI gate in `.github/workflows/security.yml`. v1.4.23 runs
* the step with `continue-on-error: true` while the registry catches
* up with the hand-maintained spec; v1.4.24+ flips it to hard-fail.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { stringify } from "yaml";
import { buildOpenApiDocument } from "../src/lib/openapi/registry";
const SPEC_PATH = resolve(__dirname, "..", "docs", "api", "openapi.yaml");
function main(): void {
const document = buildOpenApiDocument();
const generated = stringify(document, {
sortMapEntries: true,
lineWidth: 120,
});
const committed = readFileSync(SPEC_PATH, "utf8");
if (generated === committed) {
// eslint-disable-next-line no-console
console.log("OpenAPI spec in sync with source schemas.");
return;
}
// eslint-disable-next-line no-console
console.error(
"OpenAPI spec drift detected. Run `pnpm openapi:generate` and commit the result.",
);
// Surface a unified-diff-ish hint so PR reviewers see what changed
// without having to re-run the generator locally.
const genLines = generated.split("\n");
const onDiskLines = committed.split("\n");
const max = Math.max(genLines.length, onDiskLines.length);
for (let i = 0; i < max; i++) {
if (genLines[i] !== onDiskLines[i]) {
// eslint-disable-next-line no-console
console.error(
`Line ${i + 1}:\n generated: ${JSON.stringify(genLines[i] ?? "")}\n committed: ${JSON.stringify(onDiskLines[i] ?? "")}`,
);
// Limit the noise — first 5 mismatched lines is enough to point
// the maintainer at the regenerate command.
let shown = 1;
for (let j = i + 1; j < max && shown < 5; j++) {
if (genLines[j] !== onDiskLines[j]) {
// eslint-disable-next-line no-console
console.error(
`Line ${j + 1}:\n generated: ${JSON.stringify(genLines[j] ?? "")}\n committed: ${JSON.stringify(onDiskLines[j] ?? "")}`,
);
shown++;
}
}
break;
}
}
process.exit(1);
}
main();
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env tsx
/**
* Emit `docs/api/openapi.yaml` from the registered Zod schemas + route
* table in `src/lib/openapi/registry.ts`.
*
* v1.4.23 baseline: only the iOS-touched routes are registered (see
* `src/lib/openapi/routes.ts`). The CI step that wraps this script runs
* with `continue-on-error: true` until the registry catches up with the
* rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is
* complete, the gate flips to hard-fail in v1.4.24+.
*
* The output is stable across re-runs: `yaml@2` is configured with
* `sortMapEntries: true` so map keys land in lexicographic order
* regardless of Node version or the order in which schemas were
* imported.
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { stringify } from "yaml";
import { buildOpenApiDocument } from "../src/lib/openapi/registry";
const SPEC_PATH = resolve(__dirname, "..", "docs", "api", "openapi.yaml");
function main(): void {
const document = buildOpenApiDocument();
const yaml = stringify(document, {
sortMapEntries: true,
lineWidth: 120,
});
writeFileSync(SPEC_PATH, yaml, "utf8");
// eslint-disable-next-line no-console
console.log(`Wrote ${SPEC_PATH} (${yaml.length} bytes)`);
}
main();
+76
View File
@@ -0,0 +1,76 @@
/**
* 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";
export const openApiBase: Pick<
ZodOpenApiObject,
"openapi" | "info" | "servers" | "tags"
> = {
openapi: "3.1.0",
info: {
title: "HealthLog API",
version: "1.4.23",
description:
"Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.",
license: {
name: "AGPL-3.0",
url: "https://github.com/MBombeck/HealthLog/blob/main/LICENSE",
},
contact: { name: "HealthLog", url: "https://healthlog.dev" },
},
servers: [
{ url: "https://healthlog.bombeck.io", description: "Production" },
{ url: "http://localhost:3000", description: "Local dev" },
],
tags: [
{ name: "Auth" },
{ name: "Measurements" },
{ name: "Medications" },
{ name: "Mood" },
{ name: "Insights" },
{ name: "Notifications" },
{ name: "Devices" },
{ name: "Admin" },
],
};
/**
* Assemble the full OpenAPI 3.1 document. Imports the route table
* lazily so the base scaffolding stays usable from contexts that
* shouldn't pull every Zod schema in the project (e.g. a hypothetical
* future devtools-only consumer).
*/
export function buildOpenApiDocument(): ReturnType<typeof createDocument> {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { openApiPaths, openApiComponents } = require("./routes") as {
openApiPaths: ZodOpenApiObject["paths"];
openApiComponents: ZodOpenApiObject["components"];
};
return createDocument({
...openApiBase,
paths: openApiPaths,
components: openApiComponents,
});
}
+22
View File
@@ -0,0 +1,22 @@
/**
* OpenAPI route table — populated incrementally per W4 F5b and beyond.
*
* Routes are registered as `paths[<url>][<method>] = ZodOpenApiOperationObject`.
* Schemas exported from `src/lib/validations/*` carry their own
* `.meta({ id, title, description })` annotations so they show up in
* `components.schemas` automatically when referenced via `ref:` or
* direct schema inclusion.
*
* The registry intentionally starts at zero coverage. Commit 2 of the
* v1.4.23 W4 batch wires the iOS-touched routes; later waves expand
* coverage organically. The CI gate at
* `.github/workflows/security.yml` runs in `continue-on-error: true`
* mode while coverage grows.
*/
import type { ZodOpenApiObject } from "zod-openapi";
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
schemas: {},
};