feat(api): OpenAPI 3.1 spec generation + /api/openapi.json + CI drift gate (#178) #193

Merged
james merged 6 commits from 178-openapi-spec-generation into main 2026-06-21 01:06:30 +00:00
Owner

Summary

Carol's API contract is now machine-readable. Two commits:

  • b62db24 feat — the substrate. Pulls in @asteasolutions/zod-to-openapi, wires every route into a central registry at lib/api/openapi-routes.ts, mirrors each DTO interface as a zod schema for drift-detected responses, serves the spec at GET /api/openapi.json, and adds three npm scripts (openapi:generate, openapi:check, openapi:coverage) plus a new CI job that hard-fails on drift or unregistered routes.
  • 0182366 chore — the initial generated openapi.json (4583 lines), split into its own commit so the substantive diff stays reviewable.

52 (path, method) pairs registered across the 30 route files. The OpenAPI coverage script walks app/api/**/route.ts and confirms every route is in the registry; CI fails if a new route ships without a registry entry.

Decisions baked in

  • Generator: @asteasolutions/zod-to-openapi (v8.5.0) — full zod 4 support, mature, handles .transform() / .refine(), registry-based API. The one shape it can't introspect is z.custom<T> (used for the theme preference field); a spec-only string-typed mirror lives in lib/api/openapi-routes.ts while the runtime validation in lib/dto/settings.ts stays unchanged.
  • Central registry, not per-route metadata (lib/api/openapi-routes.ts). One audit surface; the coverage script catches missing registrations.
  • info.version: "1.0.0" hard-coded — the API contract version is independent of the service release version in package.json.
  • No servers[] in the spec — self-hosted instances vary; clients build URLs relative to wherever they fetched the spec.
  • Spec committed at repo root (openapi.json) — drift gate needs a committed baseline.
  • tsx as a devDep for running the TS scripts. Lightweight, zero-config.
  • Theme registry split: app/themes/preferences.ts holds the pure-logic types/predicates; app/themes/registry.ts re-exports them and keeps the CSS side-effect imports. Non-UI modules (DTOs, scripts) import from preferences.ts so script-time loads don't pull CSS through tsx.

Files of note

  • lib/api/openapi.ts — registry, generator, security schemes, zProblemDetails.
  • lib/api/openapi-routes.ts — 52 registerPath(...) calls. The contract.
  • app/api/openapi.json/route.ts — public endpoint.
  • scripts/openapi-{generate,check,coverage}.ts + npm scripts.
  • .forgejo/workflows/pr.yml — new openapi CI job.
  • DTO mirror schemas in lib/dto/{note,profile,skill,education,job,user,settings,pat}.ts.

Out of scope (per the approved plan)

  • Swagger UI / Redoc hosting — spec is machine-readable; UI is a future concern.
  • /api/v2/ URL versioning — per ADR-0027 + docs/api-conventions.md, today's API is v1 with no URL prefix.
  • The generated typed client — lands in #182 against this spec.
  • /api/health in the spec — ops endpoint outside the v1 contract.
  • OAuth callback redirect responses in the spec — user-facing UX, not API contract.

Test plan

  • npm run typecheck green
  • npm run lint green
  • npm run test green — 499 tests pass (up from 497, plus 2 new for the spec endpoint)
  • npm run openapi:generate writes openapi.json
  • npm run openapi:check exits 0 on a clean checkout; exits 1 after editing a schema without regenerating
  • npm run openapi:coverage reports 52 (path, method) pairs registered
  • curl -s .../api/openapi.json | jq .openapi returns "3.1.0" (verified via test endpoint)
  • /api/openapi.json reachable without a session (allowlist update verified)
  • lefthook clean on each commit

Closes #178. Third ticket under epic #176 — sets up the spec that #182 (generated typed client) will consume.

## Summary Carol's API contract is now machine-readable. Two commits: - **`b62db24` feat** — the substrate. Pulls in [`@asteasolutions/zod-to-openapi`](https://github.com/asteasolutions/zod-to-openapi), wires every route into a central registry at `lib/api/openapi-routes.ts`, mirrors each DTO interface as a zod schema for drift-detected responses, serves the spec at `GET /api/openapi.json`, and adds three npm scripts (`openapi:generate`, `openapi:check`, `openapi:coverage`) plus a new CI job that hard-fails on drift or unregistered routes. - **`0182366` chore** — the initial generated `openapi.json` (4583 lines), split into its own commit so the substantive diff stays reviewable. 52 (path, method) pairs registered across the 30 route files. The OpenAPI coverage script walks `app/api/**/route.ts` and confirms every route is in the registry; CI fails if a new route ships without a registry entry. ## Decisions baked in - **Generator: `@asteasolutions/zod-to-openapi`** (v8.5.0) — full zod 4 support, mature, handles `.transform()` / `.refine()`, registry-based API. The one shape it can't introspect is `z.custom<T>` (used for the theme preference field); a spec-only string-typed mirror lives in `lib/api/openapi-routes.ts` while the runtime validation in `lib/dto/settings.ts` stays unchanged. - **Central registry, not per-route metadata** (`lib/api/openapi-routes.ts`). One audit surface; the coverage script catches missing registrations. - **`info.version: "1.0.0"`** hard-coded — the API contract version is independent of the service release version in `package.json`. - **No `servers[]` in the spec** — self-hosted instances vary; clients build URLs relative to wherever they fetched the spec. - **Spec committed at repo root (`openapi.json`)** — drift gate needs a committed baseline. - **`tsx` as a devDep** for running the TS scripts. Lightweight, zero-config. - **Theme registry split: `app/themes/preferences.ts`** holds the pure-logic types/predicates; `app/themes/registry.ts` re-exports them and keeps the CSS side-effect imports. Non-UI modules (DTOs, scripts) import from `preferences.ts` so script-time loads don't pull CSS through tsx. ## Files of note - `lib/api/openapi.ts` — registry, generator, security schemes, `zProblemDetails`. - `lib/api/openapi-routes.ts` — 52 `registerPath(...)` calls. The contract. - `app/api/openapi.json/route.ts` — public endpoint. - `scripts/openapi-{generate,check,coverage}.ts` + npm scripts. - `.forgejo/workflows/pr.yml` — new `openapi` CI job. - DTO mirror schemas in `lib/dto/{note,profile,skill,education,job,user,settings,pat}.ts`. ## Out of scope (per the approved plan) - Swagger UI / Redoc hosting — spec is machine-readable; UI is a future concern. - `/api/v2/` URL versioning — per ADR-0027 + `docs/api-conventions.md`, today's API is v1 with no URL prefix. - The generated typed client — lands in **#182** against this spec. - `/api/health` in the spec — ops endpoint outside the v1 contract. - OAuth callback redirect responses in the spec — user-facing UX, not API contract. ## Test plan - [x] `npm run typecheck` green - [x] `npm run lint` green - [x] `npm run test` green — 499 tests pass (up from 497, plus 2 new for the spec endpoint) - [x] `npm run openapi:generate` writes `openapi.json` - [x] `npm run openapi:check` exits 0 on a clean checkout; exits 1 after editing a schema without regenerating - [x] `npm run openapi:coverage` reports 52 (path, method) pairs registered - [x] `curl -s .../api/openapi.json | jq .openapi` returns `"3.1.0"` (verified via test endpoint) - [x] `/api/openapi.json` reachable without a session (allowlist update verified) - [x] lefthook clean on each commit Closes #178. Third ticket under epic #176 — sets up the spec that **#182** (generated typed client) will consume.
Carol's API contract is now machine-readable. The spec is generated
from the route handlers' zod schemas via @asteasolutions/zod-to-openapi,
served at GET /api/openapi.json (public allowlist), and CI-gated
against drift so a schema change can't merge without the spec
catching up.

- lib/api/openapi.ts: registry, generator, zProblemDetails mirroring
  lib/api/errors.ts, cookieAuth + bearerAuth security schemes,
  hard-coded info.version = 1.0.0 per the v1 contract.
- lib/api/openapi-routes.ts: central registry — every (path, method)
  registered with tags, summary, request schema, response schemas
  keyed by status, security. 52 (path, method) pairs covered.
- 8 DTO files gain mirror response schemas (zNoteDto, zUserDto, ...).
  The existing XxxDto interfaces become `type XxxDto = z.infer<...>`,
  same field shape, drift-detectable both ways.
- app/api/openapi.json/route.ts: public endpoint serving the spec.
  Added to lib/auth/public-routes.ts allowlist.
- scripts/openapi-{generate,check,coverage}.ts + npm scripts.
  openapi:check regenerates and diffs against the committed copy;
  openapi:coverage walks app/api/** and confirms every route is
  registered.
- .forgejo/workflows/pr.yml: new `openapi` job runs both checks
  alongside typecheck/lint. Hard fail on drift.
- app/themes/preferences.ts: pure-logic split-out of registry.ts
  (no CSS imports) so non-UI modules can import the theme types
  without dragging CSS into script-time loads.
- docs/api-conventions.md: new "Spec generation" section.
- CLAUDE.md "API contract is codified" mentions the drift gate.
- README.md gains an "API" section.

The initial committed openapi.json (4583 lines) lands in the next
commit so the substantive diff stays reviewable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chore: commit initial openapi.json (#178)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 13s
PR / Build (pull_request) Failing after 20s
PR / OpenAPI (pull_request) Failing after 21s
PR / Typecheck (pull_request) Failing after 22s
PR / Lint (pull_request) Failing after 23s
PR / Test (postgres) (pull_request) Failing after 23s
PR / Test (sqlite) (pull_request) Failing after 14s
PR / npm audit (pull_request) Failing after 15s
PR / Package age policy (soft) (pull_request) Successful in 17s
PR / Static analysis (pull_request) Successful in 40s
PR / Coverage (soft) (pull_request) Failing after 18s
PR / OSV-Scanner (pull_request) Successful in 21s
Secrets / gitleaks (pull_request) Successful in 19s
PR / Trivy (image) (pull_request) Failing after 31s
0182366fb6
Output of `npm run openapi:generate` against the registry as of the
previous commit. CI's `openapi:check` job diffs against this file on
every PR; regenerate when a schema changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: restore nested next-intl/@swc/helpers@0.5.23 in package-lock.json (#178)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / OSV-Scanner (pull_request) Successful in 43s
PR / Lint (pull_request) Failing after 50s
PR / Typecheck (pull_request) Failing after 55s
PR / OpenAPI (pull_request) Failing after 52s
PR / npm audit (pull_request) Failing after 1m0s
PR / Test (sqlite) (pull_request) Failing after 1m0s
PR / Test (postgres) (pull_request) Failing after 1m0s
PR / Build (pull_request) Failing after 1m6s
PR / Static analysis (pull_request) Successful in 1m9s
PR / Trivy (image) (pull_request) Failing after 1m2s
Secrets / gitleaks (pull_request) Successful in 21s
PR / Package age policy (soft) (pull_request) Successful in 21s
PR / Coverage (soft) (pull_request) Failing after 35s
31ec66b87b
npm 11 (local) deduplicates the nested optional peer dep that npm 10
(CI's Node 22) requires for strict-mode `npm ci` to succeed. My
`npm install --save-dev tsx` + `--save @asteasolutions/zod-to-openapi`
ran under npm 11 and silently dropped the nested entry; CI's lint
job then failed with "Missing: @swc/helpers@0.5.23 from lock file".

Restoring the entry verbatim from main's lockfile. No runtime change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(ci): add tsx>esbuild to lavamoat.allowScripts as disabled (#178)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / OSV-Scanner (pull_request) Successful in 44s
PR / Static analysis (pull_request) Successful in 59s
PR / Package age policy (soft) (pull_request) Successful in 20s
PR / Lint (pull_request) Successful in 1m42s
Secrets / gitleaks (pull_request) Successful in 19s
PR / Build (pull_request) Failing after 1m50s
PR / npm audit (pull_request) Successful in 1m54s
PR / Test (postgres) (pull_request) Successful in 2m0s
PR / Test (sqlite) (pull_request) Successful in 2m2s
PR / Typecheck (pull_request) Successful in 2m4s
PR / OpenAPI (pull_request) Successful in 2m11s
PR / Coverage (soft) (pull_request) Successful in 1m43s
PR / Trivy (image) (pull_request) Failing after 2m25s
500c50a8fb
The new tsx devDep transitively pulls in esbuild, which has a
postinstall script. ADR-0010's allow-scripts gate requires every
install-script-bearing package to be explicitly listed in
package.json's lavamoat.allowScripts. CI failed with
"@lavamoat/allow-scripts has detected dependencies without configuration".

esbuild's postinstall fetches the platform-native binary. tsx bundles
its own esbuild runtime, so the native binary isn't needed for our
use of tsx (running spec-gen / drift / coverage scripts). Setting
tsx>esbuild to `false` skips the postinstall, matching what `allow-scripts auto`
suggested as the safe default for a transitive build tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: 100.0% (896/896 added lines) (soft target ≥ 80%)

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 71.9% ≥ 50%
Branches 80.8% ≥ 75%
Functions 89.1% informational

Changed files in this PR (source only — tests excluded):

File Patch coverage Overall lines Branches
app/api/openapi.json/route.ts 100.0% (7/7) 100.0% 100.0%
app/themes/preferences.ts 100.0% (16/16) 100.0% 100.0%
app/themes/registry.ts 100.0% (1/1) 100.0%
lib/api/openapi-routes.ts 100.0% (703/703) 100.0% 100.0%
lib/api/openapi.ts 100.0% (52/52) 100.0% 100.0%
lib/auth/public-routes.ts 100.0% (1/1) 100.0% 100.0%
lib/dto/education.ts 100.0% (12/12) 100.0% 82.1%
lib/dto/job.ts 100.0% (35/35) 100.0% 96.3%
lib/dto/note.ts 100.0% (8/8) 100.0% 90.9%
lib/dto/pat.ts 100.0% (11/11) 100.0% 80.0%
lib/dto/profile.ts 100.0% (18/18) 99.1% 90.5%
lib/dto/settings.ts 100.0% (8/8) 100.0% 80.0%
lib/dto/skill.ts 100.0% (18/18) 100.0% 100.0%
lib/dto/user.ts 100.0% (6/6) 100.0% 100.0%

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 100.0% (896/896 added lines) ✅ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 71.9% ✅ | ≥ 50% | | Branches | 80.8% ✅ | ≥ 75% | | Functions | 89.1% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/api/openapi.json/route.ts` | 100.0% (7/7) | 100.0% | 100.0% | | `app/themes/preferences.ts` | 100.0% (16/16) | 100.0% | 100.0% | | `app/themes/registry.ts` | 100.0% (1/1) | 100.0% | — | | `lib/api/openapi-routes.ts` | 100.0% (703/703) | 100.0% | 100.0% | | `lib/api/openapi.ts` | 100.0% (52/52) | 100.0% | 100.0% | | `lib/auth/public-routes.ts` | 100.0% (1/1) | 100.0% | 100.0% | | `lib/dto/education.ts` | 100.0% (12/12) | 100.0% | 82.1% | | `lib/dto/job.ts` | 100.0% (35/35) | 100.0% | 96.3% | | `lib/dto/note.ts` | 100.0% (8/8) | 100.0% | 90.9% | | `lib/dto/pat.ts` | 100.0% (11/11) | 100.0% | 80.0% | | `lib/dto/profile.ts` | 100.0% (18/18) | 99.1% | 90.5% | | `lib/dto/settings.ts` | 100.0% (8/8) | 100.0% | 80.0% | | `lib/dto/skill.ts` | 100.0% (18/18) | 100.0% | 100.0% | | `lib/dto/user.ts` | 100.0% (6/6) | 100.0% | 100.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
fix(api): lazy-load openapi registry inside GET handler (#178)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 24s
PR / Static analysis (pull_request) Successful in 38s
PR / Package age policy (soft) (pull_request) Successful in 13s
Secrets / gitleaks (pull_request) Successful in 12s
PR / Trivy (image) (pull_request) Failing after 1m7s
PR / Typecheck (pull_request) Successful in 2m49s
PR / Lint (pull_request) Successful in 3m8s
PR / npm audit (pull_request) Successful in 3m13s
PR / Build (pull_request) Successful in 3m13s
PR / OpenAPI (pull_request) Successful in 3m13s
PR / Test (sqlite) (pull_request) Successful in 3m26s
PR / Test (postgres) (pull_request) Successful in 3m27s
PR / Coverage (soft) (pull_request) Failing after 54s
db0ab48a31
Production build failed in Next's collect-page-data phase:

  TypeError: b3.Ty.openapi is not a function
  Failed to collect page data for /api/openapi.json

The route module imported lib/api/openapi-routes.ts at top level,
which evaluated the .openapi(...) chains on every registered schema
at module load. Those chains require the zod prototype to be extended
by extendZodWithOpenApi; webpack's chunking of route modules can put
the zod import in a chunk that loads before the extension runs.

Force-dynamic + eager imports wasn't enough — Next evaluates the
module to enumerate exports regardless. The fix: dynamic
`await import(...)` of the generator and registry inside the GET
handler so the .openapi() chains never run at build time. Reproduced
the failure and verified the fix by running `npm run build` in a
Node 22.23.0 container matching CI.

For defense in depth, also call extendZodWithOpenApi at the top of
lib/api/openapi-routes.ts (idempotent) so the chain works regardless
of which file loads first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
fix(test): space note creates so cursor pagination is deterministic (#178)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 20s
PR / Static analysis (pull_request) Successful in 35s
PR / Trivy (image) (pull_request) Failing after 28s
PR / Package age policy (soft) (pull_request) Successful in 13s
Secrets / gitleaks (pull_request) Successful in 13s
PR / OpenAPI (pull_request) Successful in 2m13s
PR / npm audit (pull_request) Successful in 2m59s
PR / Typecheck (pull_request) Successful in 3m13s
PR / Lint (pull_request) Successful in 3m15s
PR / Test (sqlite) (pull_request) Successful in 3m26s
PR / Test (postgres) (pull_request) Successful in 3m29s
PR / Coverage (soft) (pull_request) Successful in 3m11s
PR / Build (pull_request) Successful in 3m41s
c3b413c85f
CI's dual-engine matrix flaked the new pagination test:

  expected [ 'First', 'Second' ] to deeply equal [ 'Second', 'First' ]

The three notes were being created back-to-back within the same
millisecond. updated_at uses `new Date().toISOString()` (ms
precision), so all three tied. The cursor's secondary tiebreaker
(id DESC over random UUIDs) then decided page-2 ordering
non-deterministically — local SQLite happened one way, CI the other.

Add a 2ms delay between creates so the timestamps bump cleanly. The
test now asserts what it meant to assert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 54b936f727 into main 2026-06-21 01:06:30 +00:00
james deleted branch 178-openapi-spec-generation 2026-06-21 01:06:31 +00:00
Sign in to join this conversation.
No description provided.