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

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 merges context.resHeaders into every response, errors included. This is how WWW-Authenticate, DPoP-Nonce, Retry-After, Location and Cache-Control leave a procedure or middleware.
  • RequestLimitHandlerPlugin with a 256 KiB cap on every body. Conversation writes are also refused above 64 KiB by a Content-Length check, 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.

Was this page helpful?