API pipeline
The oRPC handler, the middleware chain, one problem+json encoding, and authority before validation.
Every endpoint of apps/api is an oRPC procedure behind one OpenAPIHandler, mounted from a plain Worker fetch.
The API does not use Hono; the MCP server does. See How the API is built on oRPC for adding a
procedure.
The Worker fetch (apps/api/src/index.ts, handleRequest) does four things around the handler: it builds the initial
context, answers an unmatched route with the same problem as every other failure, finishes error responses, and logs the
completion once.
The handler
createHandler (apps/api/src/handler.ts) builds the OpenAPIHandler with:
ResponseHeadersHandlerPlugin, which mergescontext.resHeadersinto every response, errors included. This is howWWW-Authenticate,DPoP-Nonce,Retry-After,LocationandCache-Controlleave a procedure or middleware.RequestLimitHandlerPluginwith a 256 KiB cap on every body. Conversation writes are also refused above 64 KiB by aContent-Lengthcheck, and security intents above 8 KiB by a bounded reader.- a client interceptor that turns anything middleware or a handler throws into the single error encoding (below).
customErrorResponseBodyEncoder: encodeProblem, and an event-stream keep-alive comment every 15 seconds.
There is no RPC protocol, no batching and no CORS: no browser calls the API cross-origin.
A request body needs Content-Type: application/json to be read as JSON; oRPC treats a body without a content type as
text. Malformed JSON fails during decoding, before authentication, so a bad body without a token is a 400, not a 401.
Middleware chain
The chain is the router-level builder v1 in apps/api/src/context/builders.ts. Router-level middleware wraps input
validation, so a request with a bad token never reaches validation. oRPC v2 does not
dedupe middleware, and a test pins that each stage runs once per procedure.
| Stage | Adds to the context | What it does |
|---|---|---|
withRequest |
config, requestId, log, now, challenge |
Parses configuration once per isolate, writes X-Request-Id, echoes a valid traceparent, defaults Cache-Control: no-store, picks the challenge scheme from the request alone |
withDomain |
ctx |
The domain context: database, clock and lazy providers |
authenticate |
verified |
Reads the token from Authorization only, verifies it, classifies the caller. A DPoP request gets a fresh DPoP-Nonce on every response |
admitCaller |
caller, callerContext, guest and shopper data |
Resolves a forwarded agent to the caller it speaks for and admits only the headers that belong to the caller kind |
withActor |
actor, authority |
Live domain authority: lifecycle, delegation, and for Poppy the token scopes intersected with the live Session scopes. The conversation variant also forbids browser sessions |
guard |
actor (narrowed) |
Reads the procedure’s api metadata: callers, Poppy agentScopes, API scopes, and delegated scope translation. Names the operation for the log |
rateLimit |
none | Exact D1 counters keyed by operationId for guests, and the Poppy Session and client budgets |
There is no request idempotency. A request that sends Idempotency-Key is accepted and the header is ignored, so a
retried order-placing call places another order; callers check the orders before retrying. Double orders from the
storefront are prevented the ordinary way: the form disables while it submits, and a submission after a paid order finds
the cart empty and is refused.
One error encoding
Everything thrown becomes an ORPCError whose code is the HTTP class and whose data holds the problem members
(toORPCError, apps/api/src/errors). encodeProblem writes the RFC 9457 body with a required error member, the stable
code Poppy needs (spec 7.13):
{
"type": "https://docs.tarnandtimber.com/api/problems/cart_empty",
"title": "Unprocessable Content",
"status": 422,
"error": "cart_empty",
"detail": "The cart has no lines.",
"instance": "<X-Request-Id>"
}
Optional members are issues (validation, at most 20, each with path, code, message), scope (insufficient scope)
and conversation_id (direct_conversation_open). Domain errors may carry only Retry-After, RateLimit and
RateLimit-Policy; challenges and nonces are written by toORPCError alone. Unexpected failures are 500
internal_error with no detail, and are logged as api_error.
| Failure | Status | error |
Challenge |
|---|---|---|---|
No token on /v1 |
401 | invalid_token |
Bearer error="invalid_token", DPoP error="invalid_token", algs="ES256" |
| Malformed, expired or rejected token | 401 | invalid_token |
In the scheme chosen from the request, before verification |
Poppy token sent as Bearer |
401 | invalid_token |
DPoP error="invalid_token", algs="ES256" |
| DPoP proof or nonce failure | 401 | invalid_dpop_proof, use_dpop_nonce |
DPoP error="..." and DPoP-Nonce |
| Ended account access | 401 | account_access_ended |
invalid_token |
| Wrong caller kind | 403 | caller_not_allowed |
none |
| Missing API or account scope | 403 | insufficient_scope with scope |
Poppy callers are told account scopes, never API scopes |
| Not signed in to the account | 403 | sign_in_required |
<scheme> error="sign_in_required" |
| Validation | 400 | invalid_request with issues |
none |
| Rate limit | 429 | rate_limited |
Retry-After |
| Verifier or Auth0 proxy outage | 503 | unavailable |
Retry-After: 5 |
The challenge scheme is chosen from the request alone before any token is verified: a conversation path or an
Authorization: DPoP header means DPoP; a Bearer token whose unverified header typ or act claims say Poppy Session
also means DPoP; everything else is Bearer.
The Worker boundary finishes every response with status 400 or more: it sets Content-Type: application/problem+json
(the Fetch adapter overwrites any earlier value while serializing JSON, so only the boundary can keep it), Cache-Control: no-store, and instance when the error never passed the interceptor (decoding failures and the plugin’s size limit). It
also writes X-Request-Id on every response, and turns a successful GET whose ETag matches If-None-Match into a 304
(withConditionalGet), because oRPC middleware can set headers but not a status.
Unauthenticated and special routes
| Route | Authentication |
|---|---|
GET /health, GET /openapi.json, GET /poppy/openapi.json |
None. The documents are cached for 300 seconds |
POST /webhooks/stripe |
Stripe’s signature over the raw body (octet-stream body hint); never a bearer |
POST /internal/account/security-intents |
The Auth0 Action’s shared secret, an 8 KiB bounded body, not exposed by the webshop proxy |
POST /internal/poppy/sessions/signed-out, /internal/poppy/agents/connected, /internal/agent-authorization |
The Auth0 proxy’s X-Proxy-Auth0-Secret |
POST /internal/conversations/{id}/turn, /handoff-timeout |
The ConversationHub’s CONVERSATION_HUB_SECRET bearer |
POST /internal/account/recover |
The operator’s ACCOUNT_RECOVERY_SECRET; closed when it is not configured |
/poppy/conversations... |
DPoP and live Session admission; see Poppy agents |
Stripe answers with problems whose error is not_configured (404), webhook_secret_missing (503), missing_signature
or invalid_signature (400) and processing_failed (500). A good delivery is 200 { "received": true }. The handler
settles payments or completes an originally admitted card setup before it answers. Logs hold the event type, id and
outcome only.
URL layout
Each operation has one URL, declared with the procedure. Which caller kinds may use it is metadata enforced by guard,
not a path prefix, except the groups that never mix with customer traffic. Every URL is a public contract: there are no
storefront-only URLs, because capabilities only the storefront performs run in the storefront, not here.
| Prefix | Callers |
|---|---|
/v1/catalog/*, /v1/cart/*, /v1/checkouts/*, /v1/checkout-previews, /v1/me/* |
Customer, delegated, Poppy and guest where the procedure allows |
/v1/ops/* |
Operator only |
/poppy/conversations, /poppy/openapi.json |
Poppy agents (DPoP) and discovery |
Account-bound customer and agent requests require an active account. Guests and signed-out Poppy Sessions retain only the base capabilities their caller and scopes allow; operators use separate operator authority. The storefront sign-in that finishes a pending email change is explicitly allowed during that transition. The API has no back-channel logout receiver; the webshop receives it (see back-channel logout).
Creates answer 201 with a Location where a GET URL exists. Catalog GETs carry a weak ETag and Cache-Control: private, no-cache; everything else is no-store.