Before submitting
Affected area
Developer experience / tooling
Layer
- Frontend (apps/app)
- API (apps/api)
- Infrastructure / CI
- Docs
Problem
~106 endpoints across apps/api/src/routes/ (and newer per-system folders like media/), but no machine-readable description of the API. Consequences:
apps/app calls the API via raw axios.get("/api/...") — paths and payloads are stringly-typed; renames silently break the client.
apps/app/src/types.ts and apps/api/src/types.ts are kept "intentionally identical" by hand (root AGENTS.md) because we have no shared contract.
- Reviewers can't easily see "this PR changes the API surface" — they have to read every route handler and Zod schema and infer it.
We already write a precise request contract for every wrapped route (the Zod schema passed to queryLoggedIn). The information is there; we're just not extracting it.
Proposed solution
Generate openapi.yaml from existing Zod schemas, commit it, enforce it in CI. Three phases so each merged PR is independently useful:
Phase 1 — Generated request spec + CI enforcement. Adopt @asteasolutions/zod-to-openapi (Zod 4 compatible). Extend queryLoggedIn / queryOptionalLoggedIn in apps/api/src/middleware/queryMiddleware.ts to register each route + schema with a shared OpenAPIRegistry as a side effect of route construction — this is the chokepoint, so registration is automatic for existing and future endpoints. Add npm run openapi:generate that writes apps/api/openapi.yaml (YAML, not JSON — diffs better in review). Commit the file. Add a CI check that regenerates and runs git diff --exit-code on it, the same shape as a Prisma migrate-check — fails with a clear "run npm run openapi:generate" message. Passport auth callbacks, /api/health, and a couple of multipart routes that don't use the wrappers are out of scope for Phase 1; document the exclusion list in apps/api/openapi-excluded.md.
Phase 2 — Response schemas, system-by-system (opt-in). The hard gap: response shapes are inferred from query function returns, not declared in Zod. Don't try to fix everywhere at once. Add an optional responseSchema parameter to the wrapper (no-op when absent). Migrate one system at a time, starting with media/ (smallest, already follows the per-system pattern). Track progress as a checklist on this issue.
Phase 3 — Typed frontend client. Once Phase 2 has coverage for a system, generate a typed client via @hey-api/openapi-ts into packages/shared. Migrate axios call sites incrementally. As each system's call sites land, the duplicated types.ts entries for it can be deleted in favor of the generated types.
Alternatives considered
- Zod-to-TS types in
packages/shared, skip OpenAPI. Solves the duplicated types.ts problem but no reviewable artifact, no docs, no generated client paths/methods. OpenAPI is a superset of what we'd build by hand.
- tRPC. End-to-end type safety, but a rewrite of all 106 endpoints and gives up REST affordances (curl-able, cacheable GETs). Disproportionate.
- Hand-written Swagger/JSDoc annotations. Drifts; doubles the source of truth. Defeats the point of already having Zod.
Additional context
- Single registration chokepoint:
apps/api/src/middleware/queryMiddleware.ts.
- Drift-detection precedent: project already enforces "regenerate and commit" for Prisma — same CI shape.
- Optional later add-on:
oasdiff CI step that PR-comments breaking-vs-non-breaking changes; pairs well with expand-migrate-contract.
Before submitting
Affected area
Developer experience / tooling
Layer
Problem
~106 endpoints across
apps/api/src/routes/(and newer per-system folders likemedia/), but no machine-readable description of the API. Consequences:apps/appcalls the API via rawaxios.get("/api/...")— paths and payloads are stringly-typed; renames silently break the client.apps/app/src/types.tsandapps/api/src/types.tsare kept "intentionally identical" by hand (rootAGENTS.md) because we have no shared contract.We already write a precise request contract for every wrapped route (the Zod schema passed to
queryLoggedIn). The information is there; we're just not extracting it.Proposed solution
Generate
openapi.yamlfrom existing Zod schemas, commit it, enforce it in CI. Three phases so each merged PR is independently useful:Phase 1 — Generated request spec + CI enforcement. Adopt
@asteasolutions/zod-to-openapi(Zod 4 compatible). ExtendqueryLoggedIn/queryOptionalLoggedIninapps/api/src/middleware/queryMiddleware.tsto register each route + schema with a sharedOpenAPIRegistryas a side effect of route construction — this is the chokepoint, so registration is automatic for existing and future endpoints. Addnpm run openapi:generatethat writesapps/api/openapi.yaml(YAML, not JSON — diffs better in review). Commit the file. Add a CI check that regenerates and runsgit diff --exit-codeon it, the same shape as a Prisma migrate-check — fails with a clear "runnpm run openapi:generate" message. Passport auth callbacks,/api/health, and a couple of multipart routes that don't use the wrappers are out of scope for Phase 1; document the exclusion list inapps/api/openapi-excluded.md.Phase 2 — Response schemas, system-by-system (opt-in). The hard gap: response shapes are inferred from query function returns, not declared in Zod. Don't try to fix everywhere at once. Add an optional
responseSchemaparameter to the wrapper (no-op when absent). Migrate one system at a time, starting withmedia/(smallest, already follows the per-system pattern). Track progress as a checklist on this issue.Phase 3 — Typed frontend client. Once Phase 2 has coverage for a system, generate a typed client via
@hey-api/openapi-tsintopackages/shared. Migrate axios call sites incrementally. As each system's call sites land, the duplicatedtypes.tsentries for it can be deleted in favor of the generated types.Alternatives considered
packages/shared, skip OpenAPI. Solves the duplicatedtypes.tsproblem but no reviewable artifact, no docs, no generated client paths/methods. OpenAPI is a superset of what we'd build by hand.Additional context
apps/api/src/middleware/queryMiddleware.ts.oasdiffCI step that PR-comments breaking-vs-non-breaking changes; pairs well with expand-migrate-contract.