feat(api): contract hardening — Problem Details, zod, pagination (#179) #190

Merged
james merged 3 commits from 179-api-contract-hardening into main 2026-06-20 17:35:50 +00:00
Owner

Summary

Brings every API route under one shared contract so the OpenAPI spec generated by #178 has a clean substrate. Three commits:

  • 168a2b0 Problem Details — every error response now flows through lib/api/errors.ts and matches RFC 7807 (type, title, status, detail) with Carol's existing code field preserved as an extension and an errors[] per-field array when the 400 came from a zod schema. 29 of 30 routes converted; /api/health stays bespoke (ops endpoint, not part of the v1 contract).
  • f1c84b2 zod migrationparseRegisterRequest, parseLoginRequest, parseSettingsUpdate and the ValidationError class are gone, replaced by zRegisterRequest, zLoginRequest, zSettingsUpdate. Granular API codes (invalid_email, invalid_password, invalid_theme) consolidate into invalid_body with the failing field's path in errors[]. Closes the deferred follow-up called out in ADR-0012's "Out of scope" section.
  • b596155 pagination + conventions doc — adds lib/api/pagination.ts with cursor-based helpers and applies it to /api/notes as the reference. docs/api-conventions.md codifies the full contract (status codes, error envelope, pagination, sort/filter grammar, versioning, exceptions); CLAUDE.md links to it.

Decisions baked in

  • Error envelope: RFC 7807 Problem Details. Content-Type application/problem+json. Carol's existing code field preserved as the machine-readable identifier clients switch on.
  • Versioning: no URL prefix. The current API is v1; the spec's info.version is authoritative. Future v2 (if it happens) uses /api/v2/* for new routes while /api/* continues to serve v1.
  • Pagination: cursor-based, opaque base64url JSON. Applied to /api/notes; the other flat-list endpoints (/api/educations, /api/profile/contacts, /api/account/tokens) are domain-bounded today (a user has tens, not thousands) and remain flat arrays. Migration is staged for a follow-up — the helpers apply unchanged when those endpoints grow.
  • Sort/filter: documented, not implemented. ?sort=-created_at, ?filter[field]=value grammar codified in the conventions doc. No current endpoint exercises it; feature tickets pull it in when they need it.
  • /api/health stays bespoke. Ops endpoint, not part of the v1 contract.
  • OAuth redirects keep ?error=<reason> for user-facing failures; the JSON-error branches of the same routes use Problem Details.

Out of scope

  • OpenAPI spec generation — #178. This PR is the substrate.
  • /api/openapi.json in the public-routes allowlist — #178.
  • Repo restructure into workspaces — #181.
  • Bearer-token auth — #180. Today's PR doesn't touch the auth model; errors just flow through the envelope.
  • Pagination migration for /api/educations, /api/profile/contacts, /api/account/tokens — staged follow-up, documented in api-conventions.md.

Test plan

  • npm run typecheck green
  • npm run test green — 497 tests pass (up from 495, plus 2 new pagination tests on /api/notes)
  • git grep "NextResponse\\.json({ error" app/api/ returns nothing
  • git grep "ValidationError\\|parseRegisterRequest\\|parseLoginRequest\\|parseSettingsUpdate" lib/dto/ returns nothing
  • curl -i .../api/notes -H "Cookie: …" with a bad body returns a 400 Problem Details body with Content-Type: application/problem+json
  • curl .../api/notes?limit=1 -H "Cookie: …" returns { data: [...], next_cursor, has_more }
  • lefthook clean on each commit
  • Manual: open the running PWA, hit /notes after migration — confirm list renders via the new .data extraction

Closes #179. Second ticket under epic #176 — sets the table for #178 (OpenAPI generation) and #182 (generated typed client).

## Summary Brings every API route under one shared contract so the OpenAPI spec generated by **#178** has a clean substrate. Three commits: - **`168a2b0` Problem Details** — every error response now flows through `lib/api/errors.ts` and matches RFC 7807 (`type`, `title`, `status`, `detail`) with Carol's existing `code` field preserved as an extension and an `errors[]` per-field array when the 400 came from a zod schema. 29 of 30 routes converted; `/api/health` stays bespoke (ops endpoint, not part of the v1 contract). - **`f1c84b2` zod migration** — `parseRegisterRequest`, `parseLoginRequest`, `parseSettingsUpdate` and the `ValidationError` class are gone, replaced by `zRegisterRequest`, `zLoginRequest`, `zSettingsUpdate`. Granular API codes (`invalid_email`, `invalid_password`, `invalid_theme`) consolidate into `invalid_body` with the failing field's path in `errors[]`. Closes the deferred follow-up called out in ADR-0012's "Out of scope" section. - **`b596155` pagination + conventions doc** — adds `lib/api/pagination.ts` with cursor-based helpers and applies it to `/api/notes` as the reference. `docs/api-conventions.md` codifies the full contract (status codes, error envelope, pagination, sort/filter grammar, versioning, exceptions); CLAUDE.md links to it. ## Decisions baked in - **Error envelope: RFC 7807 Problem Details.** Content-Type `application/problem+json`. Carol's existing `code` field preserved as the machine-readable identifier clients switch on. - **Versioning: no URL prefix.** The current API is v1; the spec's `info.version` is authoritative. Future v2 (if it happens) uses `/api/v2/*` for new routes while `/api/*` continues to serve v1. - **Pagination: cursor-based**, opaque base64url JSON. Applied to `/api/notes`; the other flat-list endpoints (`/api/educations`, `/api/profile/contacts`, `/api/account/tokens`) are domain-bounded today (a user has tens, not thousands) and remain flat arrays. Migration is staged for a follow-up — the helpers apply unchanged when those endpoints grow. - **Sort/filter: documented, not implemented.** `?sort=-created_at`, `?filter[field]=value` grammar codified in the conventions doc. No current endpoint exercises it; feature tickets pull it in when they need it. - **`/api/health` stays bespoke.** Ops endpoint, not part of the v1 contract. - **OAuth redirects keep `?error=<reason>`** for user-facing failures; the JSON-error branches of the same routes use Problem Details. ## Out of scope - OpenAPI spec generation — **#178**. This PR is the substrate. - `/api/openapi.json` in the public-routes allowlist — **#178**. - Repo restructure into workspaces — **#181**. - Bearer-token auth — **#180**. Today's PR doesn't touch the auth model; errors just flow through the envelope. - Pagination migration for `/api/educations`, `/api/profile/contacts`, `/api/account/tokens` — staged follow-up, documented in `api-conventions.md`. ## Test plan - [x] `npm run typecheck` green - [x] `npm run test` green — 497 tests pass (up from 495, plus 2 new pagination tests on `/api/notes`) - [x] `git grep "NextResponse\\.json({ error" app/api/` returns nothing - [x] `git grep "ValidationError\\|parseRegisterRequest\\|parseLoginRequest\\|parseSettingsUpdate" lib/dto/` returns nothing - [x] `curl -i .../api/notes -H "Cookie: …"` with a bad body returns a 400 Problem Details body with `Content-Type: application/problem+json` - [x] `curl .../api/notes?limit=1 -H "Cookie: …"` returns `{ data: [...], next_cursor, has_more }` - [x] lefthook clean on each commit - [ ] Manual: open the running PWA, hit `/notes` after migration — confirm list renders via the new `.data` extraction Closes #179. Second ticket under epic #176 — sets the table for #178 (OpenAPI generation) and #182 (generated typed client).
Every API error response now flows through a shared helper at
lib/api/errors.ts. The body matches RFC 7807 (type, title, status,
detail) with Carol's existing machine-readable `code` field preserved
as an extension. When the 400 came from a zod schema, the response
includes an `errors` array with per-field path + message so clients
can attach errors to the right inputs.

- New helper module: problem() generic, plus unauthorized, notFound,
  badRequest, invalidBody, conflict, payloadTooLarge, rateLimited,
  internal, serviceUnavailable named wrappers.
- 29 of 30 route files migrated; /api/health stays bespoke (ops
  endpoint, not part of the v1 contract).
- OAuth redirect path keeps ?error=<reason> (user-facing UX); the
  JSON-error branches flow through Problem Details.
- Content-Type set to application/problem+json per the RFC.
- Existing tests pass unchanged — they assert on the `code` field
  which is preserved. One new regression-guard test in notes
  exercises the full envelope shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled parsers (parseRegisterRequest,
parseLoginRequest, parseSettingsUpdate) with zod schemas
(zRegisterRequest, zLoginRequest, zSettingsUpdate). The
ValidationError class is gone with them — every body validation
failure is now a single invalid_body Problem Details response with
the failing field's path in errors[].

Granular API codes (invalid_email, invalid_password, invalid_theme)
are deprecated and replaced by invalid_body + errors[].path. Tests
updated to assert the new shape. lib/dto/auth.ts's LOGIN_ERROR_KEYS
map drops the now-unused granular keys; invalid_body still maps to
"invalidCredentials" so the existing login UI copy still resolves
during the PWA's remaining lifetime.

Closes the deferred follow-up called out in ADR-0012's "Out of scope"
section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(api): cursor pagination on /api/notes + conventions doc (#179)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / npm audit (pull_request) Successful in 1m0s
PR / OSV-Scanner (pull_request) Successful in 1m0s
PR / Static analysis (pull_request) Successful in 1m7s
PR / Typecheck (pull_request) Successful in 1m23s
PR / Package age policy (soft) (pull_request) Successful in 25s
PR / Lint (pull_request) Successful in 1m29s
Secrets / gitleaks (pull_request) Successful in 28s
PR / Test (sqlite) (pull_request) Successful in 1m36s
PR / Coverage (soft) (pull_request) Successful in 1m39s
PR / Test (postgres) (pull_request) Successful in 1m49s
PR / Trivy (image) (pull_request) Failing after 1m52s
PR / Build (pull_request) Successful in 2m0s
b596155b51
Adds the cursor-pagination helper at lib/api/pagination.ts and applies
it to /api/notes as the reference list endpoint. The response shape
becomes { data, next_cursor, has_more }; the cursor is an opaque
base64url JSON tuple of (updated_at, id). NotesRepository gains
listPaginatedByUserId alongside the existing listByUserId.

docs/api-conventions.md codifies the full API contract: HTTP status
codes (no 403 — cross-user reads 404), the RFC 7807 envelope with
Carol's code extension and the canonical code table, cursor
pagination shape, the defined-not-implemented sort/filter grammar,
versioning (no URL prefix; spec's info.version is authoritative),
and the documented exceptions. CLAUDE.md links to it.

The other flat-list endpoints (/api/educations, /api/profile/contacts,
/api/account/tokens) are domain-bounded today (a user has tens, not
thousands) and remain as flat arrays; the conventions doc records
that migration is staged for a follow-up when those endpoints grow.

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

📊 Test coverage

Patch coverage: 76.1% (274/360 added lines) ⚠️ (soft target ≥ 80%)

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

Metric Value Soft target
Lines 67.7% ≥ 50%
Branches 80.3% ≥ 75%
Functions 88.6% informational

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

File Patch coverage Overall lines Branches
app/api/account/tokens/[id]/route.ts 50.0% (1/2) 86.7% 66.7%
app/api/account/tokens/route.ts 25.0% (2/8) 79.2% 66.7%
app/api/auth/login/route.ts 50.0% (5/10) 72.2% 44.4%
app/api/auth/me/route.ts 50.0% (1/2) 85.7% 80.0%
app/api/auth/oauth/callback/[provider]/route.ts 100.0% (1/1) 87.7% 79.7%
app/api/auth/oauth/start/route.ts 80.0% (4/5) 96.1% 81.3%
app/api/auth/register/route.ts 70.0% (7/10) 82.4% 76.9%
app/api/contributions/[id]/route.ts 57.1% (4/7) 77.6% 52.6%
app/api/educations/[id]/route.ts 80.0% (4/5) 95.1% 68.8%
app/api/educations/route.ts 75.0% (3/4) 94.3% 90.0%
app/api/jobs/[id]/positions/route.ts 25.0% (1/4) 80.6% 62.5%
app/api/jobs/[id]/route.ts 57.1% (4/7) 83.7% 57.9%
app/api/jobs/route.ts 50.0% (2/4) 89.2% 75.0%
app/api/notes/[id]/route.ts 80.0% (4/5) 94.7% 88.9%
app/api/notes/route.ts 100.0% (18/18) 100.0% 100.0%
app/api/positions/[id]/contributions/route.ts 25.0% (1/4) 80.6% 62.5%
app/api/positions/[id]/route.ts 42.9% (3/7) 63.3% 53.8%
app/api/profile/contacts/[id]/route.ts 60.0% (3/5) 81.6% 56.3%
app/api/profile/contacts/route.ts 0.0% (0/4) 75.8% 33.3%
app/api/profile/picture/route.ts 22.2% (4/18) 68.4% 63.2%
app/api/profile/route.ts 50.0% (2/4) 87.1% 71.4%
app/api/settings/route.ts 100.0% (7/7) 100.0% 100.0%
app/api/skill-sections/[id]/move/route.ts 50.0% (2/4) 77.4% 60.0%
app/api/skill-sections/[id]/route.ts 60.0% (3/5) 90.5% 60.0%
app/api/skill-sections/route.ts 75.0% (3/4) 95.6% 93.8%
app/api/skills/[id]/move/route.ts 25.0% (1/4) 71.0% 44.4%
app/api/skills/[id]/route.ts 60.0% (3/5) 90.2% 60.0%
app/api/skills/route.ts 75.0% (3/4) 92.9% 90.0%
db/repositories/notes.ts 100.0% (24/24) 100.0% 80.0%
lib/api/errors.ts 88.6% (70/79) 88.6% 85.0%
lib/api/pagination.ts 90.5% (57/63) 90.5% 88.9%
lib/dto/settings.ts 100.0% (7/7) 100.0% 80.0%
lib/dto/user.ts 100.0% (20/20) 100.0% 100.0%

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 76.1% (274/360 added lines) ⚠️ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 67.7% ✅ | ≥ 50% | | Branches | 80.3% ✅ | ≥ 75% | | Functions | 88.6% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/api/account/tokens/[id]/route.ts` | 50.0% (1/2) | 86.7% | 66.7% | | `app/api/account/tokens/route.ts` | 25.0% (2/8) | 79.2% | 66.7% | | `app/api/auth/login/route.ts` | 50.0% (5/10) | 72.2% | 44.4% | | `app/api/auth/me/route.ts` | 50.0% (1/2) | 85.7% | 80.0% | | `app/api/auth/oauth/callback/[provider]/route.ts` | 100.0% (1/1) | 87.7% | 79.7% | | `app/api/auth/oauth/start/route.ts` | 80.0% (4/5) | 96.1% | 81.3% | | `app/api/auth/register/route.ts` | 70.0% (7/10) | 82.4% | 76.9% | | `app/api/contributions/[id]/route.ts` | 57.1% (4/7) | 77.6% | 52.6% | | `app/api/educations/[id]/route.ts` | 80.0% (4/5) | 95.1% | 68.8% | | `app/api/educations/route.ts` | 75.0% (3/4) | 94.3% | 90.0% | | `app/api/jobs/[id]/positions/route.ts` | 25.0% (1/4) | 80.6% | 62.5% | | `app/api/jobs/[id]/route.ts` | 57.1% (4/7) | 83.7% | 57.9% | | `app/api/jobs/route.ts` | 50.0% (2/4) | 89.2% | 75.0% | | `app/api/notes/[id]/route.ts` | 80.0% (4/5) | 94.7% | 88.9% | | `app/api/notes/route.ts` | 100.0% (18/18) | 100.0% | 100.0% | | `app/api/positions/[id]/contributions/route.ts` | 25.0% (1/4) | 80.6% | 62.5% | | `app/api/positions/[id]/route.ts` | 42.9% (3/7) | 63.3% | 53.8% | | `app/api/profile/contacts/[id]/route.ts` | 60.0% (3/5) | 81.6% | 56.3% | | `app/api/profile/contacts/route.ts` | 0.0% (0/4) | 75.8% | 33.3% | | `app/api/profile/picture/route.ts` | 22.2% (4/18) | 68.4% | 63.2% | | `app/api/profile/route.ts` | 50.0% (2/4) | 87.1% | 71.4% | | `app/api/settings/route.ts` | 100.0% (7/7) | 100.0% | 100.0% | | `app/api/skill-sections/[id]/move/route.ts` | 50.0% (2/4) | 77.4% | 60.0% | | `app/api/skill-sections/[id]/route.ts` | 60.0% (3/5) | 90.5% | 60.0% | | `app/api/skill-sections/route.ts` | 75.0% (3/4) | 95.6% | 93.8% | | `app/api/skills/[id]/move/route.ts` | 25.0% (1/4) | 71.0% | 44.4% | | `app/api/skills/[id]/route.ts` | 60.0% (3/5) | 90.2% | 60.0% | | `app/api/skills/route.ts` | 75.0% (3/4) | 92.9% | 90.0% | | `db/repositories/notes.ts` | 100.0% (24/24) | 100.0% | 80.0% | | `lib/api/errors.ts` | 88.6% (70/79) | 88.6% | 85.0% | | `lib/api/pagination.ts` | 90.5% (57/63) | 90.5% | 88.9% | | `lib/dto/settings.ts` | 100.0% (7/7) | 100.0% | 80.0% | | `lib/dto/user.ts` | 100.0% (20/20) | 100.0% | 100.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

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` |
james merged commit 826e340e0a into main 2026-06-20 17:35:50 +00:00
james deleted branch 179-api-contract-hardening 2026-06-20 17:35:50 +00:00
Sign in to join this conversation.
No description provided.