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
- Contract. In
contracts/procedures/<family>.ts, a plainocchain: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 fromcontracts/callers.ts. TheoperationIdnames the OpenAPI operation, rate-limit key and logoperation. Existing MCP and WebMCP operation tool names match it but are defined independently. An input is one flatz.object; put headers or a raw body in a detailed input only when they are real inputs. A request field must not be calledoperationId: name the fresh intentfreshIntentId. - Router. Add it to
contracts/router.ts(the nesting is the client’s call path,client.cart.items.add) and mirror it insrc/shop-router.ts. The implementer refuses a router that differs. - Handler. In
src/procedures/<family>.ts,export const addCartItem = v1.cart.items.add.handler(({ context, input }) => ...), with the same name as the contract export. Takecontext.ctx(domain),context.actor,context.config,context.resHeaders. ThrowShopError,AccessErrororRequestError; never build a response. SetLocationon a 201 that has a GET URL. Rate limits, caller and scope guards and the status come from middleware and metadata. - Tests. A procedure test beside the handler uses
testApp()fromapps/api/tests/support/app.ts; contract tests (router.test.ts,router.openapi.test.ts) pin the shape, andsrc/openapi/documents.test.tsregenerates both OpenAPI snapshots withWRITE_CONTRACT_FIXTURES=1(review the diff). - Consumers. The webshop calls
callAsCustomer(actor, (api, options) => api.cart.items.add(input, options)). A tool is added explicitly toapps/mcp/src/tools/or the storefront’ssrc/features/webmcp/tools/, with its own schemas, presentation and typed API call. Each surface has a registry intools/index.ts; API metadata does not register tools. Regeneratecontracts/minified.jsonwithWRITE_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.tspins 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:
withRequestwritesX-Request-Idand a validtraceparent, and the boundary writesX-Request-Idagain on every response and finishes errors, so a failure before any middleware still has the header and aninstance.- Conversation writes over 64 KiB are refused by a
Content-Lengthcheck; the plugin’s global limit is 256 KiB, because its option is a single number. - oRPC reads a body without
Content-Typeas text, so clients that write JSON must sendContent-Type: application/json. - A matching
If-None-Matchon 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
shopimplementer; building it fromv1ran every stage twice. - Hub callbacks are
/internal/conversations/{id}/turnand/handoff-timeout. text-deltaevents are not sent (the event iterator writes onlymessage,errorandclose), and a reader that loses access is closed without anevent: error.- A bodiless
DELETEinput usesz.object({}).default({}), because oRPC otherwise validatesundefined.
Error-transport corrections
Content-Type: application/problem+jsonis set on the FetchResponseafterOpenAPIHandler.handle()returns. A routing interceptor is not enough: the Fetch adapter’stoFetchBodysetsapplication/jsonwhile serializing an ordinary object.src/index.test.tskeeps the routing-only regression next to the corrected behavior.- The client forces JSON body resolution for problem responses (
problemBodyAsJson), because the pinned body parser recognizesapplication/jsonbut not the+jsonsuffix, and oRPC resolves the body before its error decoder runs. Without it a problem becomesMALFORMED_ORPC_RESPONSE;client/index.test.tskeeps that unhinted regression. A contract-levelresponseBodyHint: '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.