ADR 0012: A versioned HTTP API in the Next process

ADR 0012: A versioned HTTP API in the Next process

  • Status: Accepted (the API now runs in Hono’s own Node process, not in Next; ADR 0015)
  • Date: 2026-10-04
  • Issue: #677

Context

The app’s write paths are server actions and about thirty hand-written routes under /api/*. Each reads its own cookies, validates its own body, calls the repositories and shapes its own error, so the same rule (who may RSVP, who may edit a session) is written once per path, and nothing describes the routes to a client: #677 asks for an OpenAPI document, #1006 for an admin API a script can call.

The target architecture puts a REST API under /api/v1 in front of use cases, typed by the zod contracts in packages/contracts (ADR 0010), with RFC 9457 errors and idempotent mutations. Step 3 of the path brings that API into today’s Next process, with server actions and the legacy routes calling the same use cases. Identity stays as it is until step 6.

Decision

1. Hono, mounted by a Next catch-all route

The API is a Hono app built with @hono/zod-openapi, based at /api/v1 and mounted by one route handler, app/api/v1/[[...route]]/route.ts, which passes every method to the app’s fetch. It runs on the Node runtime and is never statically rendered. There is no custom server: the Dockerfile, the standalone output and next dev stay as they are, and proxy.ts gates /api/v1 as section 3 sets out. Every response is sent with cache-control: no-store unless a route sets its own, so a browser never shows a stale read after a reload.

@hono/zod-openapi supports zod 4, checked on 2026-10-04: version 1.6.3 declares the peer dependencies zod ^4.0.0 and hono >=4.10.0 and builds on @asteasolutions/zod-to-openapi 9.1.0, which also peers on zod ^4.0.0. The repo is on zod 4.6.5 and the current hono is 4.13.13. Contracts stay plain zod schemas imported from zod; they gain no dependency on Hono, and anything only the OpenAPI document needs is added where a route is declared.

2. Where the code lives

A top-level server/ directory, next to app/ and db/, follows the target layout as far as today’s app allows:

server/
  kernel/              Result and error kinds, Actor and its resolution, actingGuest
  http/                the Hono app, middleware (actor, idempotency, problem details), OpenAPI document
  composition.ts       builds every use case from the repositories; the one place they are wired
  modules/<module>/
    module.ts          the module's public surface
    ports.ts           the repositories it needs, as a Pick of the container's
    application/       one file per use case or query
    http/              route declarations and handlers: contract in, use case, contract out

Modules are named after what they hold, close to the target’s: sessions (the target’s scheduling and participation: sessions, RSVPs, room unavailability, attendee counts), proposals (with votes), comments, meetings, people (profiles and the admin guest list), notifications, events (with days), venue (locations) and settings (site settings, which the target’s modules have no home for). Repositories stay in db/ as ADR 0010 left them: a module’s ports.ts names the repositories it needs instead of declaring new ones, and composition.ts passes in getRepositories(). dependency-cruiser enforces the target’s rules on this layout: application/ imports no http/, next/*, hono or db/ code beyond the container’s types; a module is imported only through its module.ts; kernel/ imports no module; app/ reaches server/ only through module.ts files, the use cases composition.ts wires, the kernel (server actions resolve the actor there, section 3) and the mount.

A use case is a function of its dependencies taking an actor, validated input and now, and returning a Result, as in the target. now is resolved per request (the dev fake clock, ADR 0004) rather than read from a clock port. The unit of work and tx.record() do not exist yet: repositories keep their own transactions and change logging (ADR 0011), so a use case that writes through two repositories is not atomic until a unit of work replaces them.

3. The actor comes from today’s cookies

Middleware resolves an Actor once per request and hands it to the handler; a use case never reads a cookie or a header. The actor carries the facts the cookies prove: whether the admin cookie is valid, and which guest the guest cookie selects and at what level (open or verified). Server actions resolve the same Actor from cookies() through the same function.

Acting as a guest keeps today’s rule, as one kernel function every use case calls (actingGuest): a request may act as an unprotected guest it names, and as a protected guest only with that guest’s verified cookie (#370). It refuses with guest.unselected or guest.protected. Where the request names the guest in its path or body (RSVPs, votes), actingAsNamedGuest applies the same rule to the named guest. Organizer actions require the admin actor. No tokens, persons or roles: those are step 6.

proxy.ts gates the API as it gates the legacy routes. Endpoints that require the admin actor live under /api/v1/admin/, which gets the /api/admin/* branch: the admin cookie alone, without the site password (the two are independent, as the admin guide documents), the cross-site check, and 404 when ADMIN_PASSWORD is unset. The rest of /api/v1 requires the site password or the admin cookie, so an admin script needs one cookie. Under /api/v1, the proxy refuses with problem details (section 4) instead of the login redirect or { error } body the legacy routes get.

4. Errors are values, sent as problem details

Use cases return Result<T>: a value, or an error with a stable code and a kind (notFound, forbidden, conflict, invalid, gone, and unavailable, 503, when a service the app relies on fails, such as the mail server). One function in server/http maps the kind to a status and the error to an RFC 9457 body (application/problem+json): type: "about:blank", title, status, an optional detail, and the extension member code. A request that fails its contract gets 400 with code request.invalid and an errors list of paths and messages; a use case may attach the same list to its own invalid error to name every refused field at once (location.invalid), and a conflict may too (guest.emailTaken). An edit names the version it was made from (expectedVersion, the version every read of the subject carries); when the subject has changed since, the edit is refused with 409, <subject>.versionConflict, and the extension member current: the subject as it is now, shaped as a read of it, so a client can redo the edit on it without another request. Adding to or removing from a set (assigning rooms to an event) overwrites nothing, so it sends no version, but raises the version of each subject whose read it changes. Codes are dotted, named for what was refused (guest.protected, session.notFound); a code once shipped keeps its meaning, and the client translates it rather than showing detail.

The legacy routes map the same Result to the { error } bodies and statuses they return today, so no existing client changes.

5. Idempotency-Key, stored for 24 hours

Every mutation under /api/v1 accepts an Idempotency-Key header. Middleware stores the key with the actor it was used by, the method and path, a hash of the body, and once handled, the response’s status and body. A retry with the same key and actor:

  • after the first request finished, gets the stored response without running the use case again;
  • while it is still running, gets 409 with idempotency.inProgress;
  • with a different method, path (query included) or body, gets 422 with idempotency.keyReused.

The actor a key is scoped to is the admin flag plus the guest’s id and cookie level, so an open cookie naming a protected guest does not share that guest’s verified keys. Claiming a key is one immediate transaction, so two concurrent requests cannot both run. A claim still unfinished after 60 seconds counts as abandoned (its process died), so a retry runs again rather than getting 409 for a day. The replay carries the stored status, headers (without set-cookie) and body. A key that is empty or over 255 characters is 400 request.invalid. A multipart body is hashed by its parsed fields (files by name, type and content), so a retry with a new boundary still matches.

A 5xx response or a thrown error releases the key, so a retry after a server error runs again. Rows older than 24 hours are deleted by the jobs loop (ADR 0011) as scheduled work. The key is optional; without it a mutation simply runs. A request with neither admin nor guest has no one to scope a key to, so its key is ignored rather than shared by every anonymous client. Server actions do not use it. The changes a keyed request records carry the key as command_key; the feed sends it as ADR 0014 section 10 says.

6. Lists that grow with an event come in pages

Such a list (sessions, proposals, guests, comments, notifications) takes limit (1 to 1000, default 100) and an optional cursor, and answers { items, nextCursor }; nextCursor is null on the last page. Each list has a fixed order, named in its description, ending in the id so it is total. Sort keys ascend and compare by code units (a newest-first list negates its time, and names are not sorted by locale); a list sorts by fields that rarely change (sessions by id, not by start). The cursor is opaque to the client: it holds the list’s name and the last item’s sort key, not a position, so an item deleted between two requests shifts nothing, while one that changes its sort key meanwhile may be skipped or seen twice. A cursor the server cannot read, or one from another list (another event’s sessions, another proposal’s comments), is 400 cursor.invalid. Lists bounded by something else (an event’s days, its locations, a guest’s votes) stay whole. The pages are cut in the HTTP layer from the use case’s full list: the contract allows reading pages from the database later without a change for clients.

7. A committed openapi.json that cannot go stale

A script imports the Hono app and writes its OpenAPI 3.1 document to packages/contracts/openapi.json, which is committed. make openapi rewrites it; make openapi-check regenerates it and fails with “Regenerate and review the API change” when it differs, and runs in make precommit and CI. Every route is declared with createRoute and its request and response contracts, so the document covers the whole API.

packages/api-client (@schellingboard/api-client) is a thin wrapper over openapi-fetch with types that openapi-typescript generates from this file. The generated types are committed too: make openapi writes both from the same document and make openapi-check fails when either is stale. This departs from the target (03: “generated from it in the build”): generating at build time would need a step before every tsc, ESLint, dependency-cruiser, Vitest and next build run, the Dockerfile included, and leave editors without types. One source and no staleness, the target’s intent, hold either way. The app does not import the client, so the standalone build does not carry it.

Consequences

  • One implementation per rule: a route under /api/v1, a server action and a legacy route that do the same thing call the same use case.
  • Two new runtime dependencies, hono and @hono/zod-openapi, carried in the standalone build. They are the target’s HTTP layer, so they stay when Next leaves (step 7).
  • A change to a contract shows up as a diff of openapi.json in review.
  • Admin endpoints sit under their own prefix only because the proxy gates by path; step 6’s roles replace both the prefix and the two passwords.
  • Until a unit of work exists, “one transaction per use case” holds only for use cases that write through one repository call.
  • A new idempotency_keys table and migration; a retried request after a lost response no longer RSVPs twice or creates two proposals (#141), for clients that send the key. The web app’s server actions do not, so #141 stays open until the UI calls the API.
  • Rate limiting from the target is not part of this decision. expectedVersion and pagination came later, with a version column on each editable table.