Skip to content
Tarn & Timber Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

How the API is built on oRPC

The contract, the implementation, the context builders, the error encoding and the two OpenAPI views.

The whole Shop API runs on oRPC 2.0.0-beta.43 (v2, pinned exactly). The webshop, MCP server and backoffice call it through the @retail-demo/api client. Spikes run before the migration rejected two error-transport assumptions; both corrections are part of the build and have regression tests (see Error-transport corrections).

Layout

apps/api/                    @retail-demo/api (exports only ./contracts* and ./client*)
  contracts/                 public, browser-safe: meta, base, router, procedures/*, schemas, errors, callers, scopes
  contracts/minified.json    minifyRouterContract(contract): routes only, for clients that skip the schemas
  client/                    createShopApi, problemOf, TimeoutError (server-side callers only)
  src/
    index.ts handler.ts      Worker fetch (handleRequest) and the OpenAPIHandler
    router.ts shop-router.ts the implementation: shop-router mirrors contracts/router.ts, router adds server-only procedures
    context/                 initial context, isolate memo, domain context, builders, stages
    middleware/              withRequest ... rate limits, shared secrets, catalog cache, conditional GET
    errors/                  toORPCError, encodeProblem, problem media type
    procedures/              one handler file per contract file
    conversations/ system/   Poppy conversations, health, documents, Stripe, internal callbacks
    openapi/                 generator, decorate, the two views
  workers/ worker.ts         Durable Objects and the scheduled export

Add a procedure

  1. Contract. In contracts/procedures/<family>.ts, a plain oc chain: base.meta(openapi({ method, path, operationId, tags, summary, successStatus })).meta(api({ callers, apiScopes, agentScopes })).errors({...}).input(z.object({...})).output(Schema). Use a named caller list from contracts/callers.ts. The operationId names the OpenAPI operation, rate-limit key and log operation. Existing MCP and WebMCP operation tool names match it but are defined independently. An input is one flat z.object; put headers or a raw body in a detailed input only when they are real inputs. A request field must not be called operationId: name the fresh intent freshIntentId.
  2. Router. Add it to contracts/router.ts (the nesting is the client’s call path, client.cart.items.add) and mirror it in src/shop-router.ts. The implementer refuses a router that differs.
  3. Handler. In src/procedures/<family>.ts, export const addCartItem = v1.cart.items.add.handler(({ context, input }) => ...), with the same name as the contract export. Take context.ctx (domain), context.actor, context.config, context.resHeaders. Throw ShopError, AccessError or RequestError; never build a response. Set Location on a 201 that has a GET URL. Rate limits, caller and scope guards and the status come from middleware and metadata.
  4. Tests. A procedure test beside the handler uses testApp() from apps/api/tests/support/app.ts; contract tests (router.test.ts, router.openapi.test.ts) pin the shape, and src/openapi/documents.test.ts regenerates both OpenAPI snapshots with WRITE_CONTRACT_FIXTURES=1 (review the diff).
  5. Consumers. The webshop calls callAsCustomer(actor, (api, options) => api.cart.items.add(input, options)). A tool is added explicitly to apps/mcp/src/tools/ or the storefront’s src/features/webmcp/tools/, with its own schemas, presentation and typed API call. Each surface has a registry in tools/index.ts; API metadata does not register tools. Regenerate contracts/minified.json with WRITE_CONTRACT_FIXTURES=1 (router.minified.test.ts) when API routing changes. The storefront uses that compact routing contract for API calls and sends only plain WebMCP descriptors to the browser. tools.snapshot.test.ts pins those descriptors independently of the API.

Context without a dependency bag

The initial context is { env, request, waitUntil, record, ports? } plus the resHeaders the response-headers plugin adds. env is Cloudflare’s bindings object, never copied; record carries the request id and what the guard learns for the completion log; ports is test-only (fetch, now, logSink, and providers for stripe, managementApi, hub, opsManagement). Long-lived objects (verifiers, the Auth0 proxy client, the DPoP stores, the Stripe client, the database handle and the generated OpenAPI documents) are memoized once per isolate by once(env, key, create) in context/isolate.ts, keyed by the bindings object and a closed set of keys. Configuration is parsed once per isolate.

Typed stages (context/stages.ts) are added by the middleware: RequestStage, DomainStage, AuthenticatedStage, AdmittedStage, ActorStage. context/builders.ts composes them:

Builder Chain Used for
v1 withRequest, withDomain, authenticate, admitCaller, withActor, guard, rateLimit (on the contract) every /v1 procedure
pub withRequest, withDomain health, the OpenAPI documents and the Stripe webhook
poppyConversation pub, authenticate, admitCaller, conversation actor, direct-Poppy-caller check, rateLimit /poppy/conversations
secret(name) withRequest, shared secret, withDomain internal callbacks (ACTION, PROXY_AUTH0_NOTIFY, HUB, ACCOUNT_RECOVERY)
guarded the v1 chain over a plain builder tests that need procedures of their own

shop is the implementer without middleware; the router is built from it so each stage runs once for a procedure that already went through v1. Domain code, the Durable Objects and the scheduled export build the same domain context with domainContext({ env, ports, fields }).

Tests inject fakes through the initial context: testApp({ env, fetch, logSink, now }) returns { app, env, ports, ctx, raw, settle }, and app.request(path, init) goes through handleRequest, the entry the Worker uses. Set env before the first request, because configuration is parsed once per env object.

Errors and the boundary

The API pipeline describes the encoding. In short: a client interceptor runs toORPCError, encodeProblem writes the body, and the Worker boundary (handleRequest) sets application/problem+json, Cache-Control: no-store, the X-Request-Id and, when the error never passed the interceptor, instance. Domain errors may carry only Retry-After, RateLimit and RateLimit-Policy.

OpenAPI views

src/openapi/documents.ts generates two OpenAPI 3.1.1 documents from the one contract with a private schema registry: apiDocument (all procedures, required bearer security with each procedure’s API scopes) and poppyDocument (procedures whose callers include poppy, dpop security with the account scopes, x-poppy-scopes). decorate then applies, per operation and from the procedure metadata, the security requirement, the problem media type on every failure, WWW-Authenticate on 401 and 403, Retry-After on 429 and 503, and Location on 201. It throws on an operation it can’t map. The Worker serves them at GET /openapi.json and GET /poppy/openapi.json (cached 300 seconds); documents.test.ts checks every operation of each view. Server-only procedures (conversations, webhooks, internal callbacks, health) are not in the contract and so not in the documents.

A guest has no personal user token, but the storefront still authenticates every guest API request with its machine token. Cart authority additionally comes from the signed Guest-Session capability. Guest-capable operations never advertise anonymous API access: tokenless calls receive 401 before the procedure runs.

Implementation notes

These choices differ from what oRPC or the original migration design suggested:

  • withRequest writes X-Request-Id and a valid traceparent, and the boundary writes X-Request-Id again on every response and finishes errors, so a failure before any middleware still has the header and an instance.
  • Conversation writes over 64 KiB are refused by a Content-Length check; the plugin’s global limit is 256 KiB, because its option is a single number.
  • oRPC reads a body without Content-Type as text, so clients that write JSON must send Content-Type: application/json.
  • A matching If-None-Match on a catalog read becomes a 304 at the boundary (withConditionalGet): oRPC middleware can set headers but not a status.
  • The router is built from the middleware-free shop implementer; building it from v1 ran every stage twice.
  • Hub callbacks are /internal/conversations/{id}/turn and /handoff-timeout.
  • text-delta events are not sent (the event iterator writes only message, error and close), and a reader that loses access is closed without an event: error.
  • A bodiless DELETE input uses z.object({}).default({}), because oRPC otherwise validates undefined.

Error-transport corrections

  1. Content-Type: application/problem+json is set on the Fetch Response after OpenAPIHandler.handle() returns. A routing interceptor is not enough: the Fetch adapter’s toFetchBody sets application/json while serializing an ordinary object. src/index.test.ts keeps the routing-only regression next to the corrected behavior.
  2. The client forces JSON body resolution for problem responses (problemBodyAsJson), because the pinned body parser recognizes application/json but not the +json suffix, and oRPC resolves the body before its error decoder runs. Without it a problem becomes MALFORMED_ORPC_RESPONSE; client/index.test.ts keeps that unhinted regression. A contract-level responseBodyHint: 'json' was rejected because it also applies to success bodies, including the JSON-or-event-stream route.

@standard-server/core and @standard-server/fetch are pinned to 0.10.2 by the overrides in pnpm-workspace.yaml; oRPC’s own ranges allow later patches, so rerun both regressions when changing them.

The spikes that found these (15 October 2026) also showed that router-level middleware runs before input validation, that the octet-stream body hint keeps a webhook body byte-exact, that the native event iterator gives conforming SSE framing with a data-less event: close, and that private-registry OpenAPI 3.1.1 generation validates against the official schema. They measured the oRPC client at about 21 KB gzip added to the webshop’s server build; that small sample is not a measurement of the migrated API or its consumers. The spike code and its report are in Git history: git show 062a656 -- scripts/spikes/orpc and git show 58bb1646:docs/research/2026-10-15-orpc-spikes.md. The completed migration plan is at git show 0deb08b6:docs/plans/2026-10-15-orpc.md.

Was this page helpful?