Callers and tokens
Caller kinds, how each is recognized, customer authority, and scope translation.
Callers are classified, admitted and guarded by the oRPC middleware chain (authenticate, admitCaller, withActor,
guard; see the API pipeline). Each procedure declares its callers and scopes once, in
its api metadata in apps/api/contracts.
The API never trusts a token on its signature alone. It classifies every token into a caller using
client_id, sub and act, checks those against configured client IDs, and then resolves a live actor, with
lifecycle and delegation checks, before guards, input validation or the handler run. Holding a
user sub alone never makes a caller a human customer.
Consumers and credentials
| Consumer | Credential presented to the API |
|---|---|
| Webshop, guest visitor | Storefront M2M token (client credentials) plus a guest session header |
| Webshop, signed in | The customer’s own access token for the API audience, nothing else |
| MCP server | An On-Behalf-Of token exchanged from the MCP client’s token |
| Backoffice | Backoffice M2M token plus an operator attribution header |
| Webshop, invited-agent session | Dedicated web-agent-forwarder M2M token plus a bearer-bound invited-agent context |
| Webshop, Poppy agent browser | Dedicated web-agent-forwarder M2M token plus the agent’s original browser cookie |
| MCP server, signed-out Poppy | Dedicated MCP-agent-forwarder M2M token plus the original token as Poppy-Subject |
| Poppy personal agent | Calls the API and MCP directly, with a credential issued through the Auth0 proxy |
Caller kinds
| Caller kind | How the API recognizes it | Credential source | Maps to |
|---|---|---|---|
customer |
User sub, no act, client_id is the website client |
Website login with the API audience (refresh via MRRT) | customer actor (channel web), owner is sub; account lifecycle state still applies |
delegated |
User sub, outermost act.sub equals client_id equals the MCP Custom API client |
MCP server On-Behalf-Of exchange | agent actor with origin: 'oauth' (channel mcp), account cart, no human-only operations |
guest |
sub is <client_id>@clients, client_id is the storefront client, a Guest-Session header |
Client credentials from the webshop | guest actor owning one browser cart |
invited-agent |
Web-agent-forwarder M2M token plus an invited-agent context | Webshop, for a delegated website session | The existing delegated agent actor, with its original admission snapshot |
operator |
sub is <client_id>@clients, client_id is the backoffice API client, an Operator header |
Client credentials from the backoffice | OperatorActor |
poppy |
An Auth0 access token for the API or MCP audience, minted through the Auth0 proxy’s Custom Token Exchange profile and presented directly by the agent | Proxy Auth0 (Auth0 extension) to Auth0 | Existing agent actor (origin: 'poppy'), with live session and grant checked through the Auth0 proxy’s introspection |
Any other combination is rejected with 401 or 403, even when the signature is valid. That includes an unknown
client_id, an act chain whose outermost actor isn’t the MCP client, or
an M2M token from a client with no caller kind. A user token from the website client that carries act is an
invited-agent login and is never accepted as customer.
Tokens use the rfc9068_profile dialect, so every access token carries client_id and typ: at+jwt.
Customer authority
A customer is authorized by the access token alone. The API verifies the issuer, JWKS signature, audience,
typ: at+jwt and exp, classifies the caller from client_id, sub and act (website client, no act), checks the
operation’s scopes, and scopes every row by ownership of sub. There is no Shop-Session header (a stale one is
ignored), no sign-in registration, no sid, generation or epoch in the API, and no Web-Verification context. The
customer actor is { kind: 'customer', channel: 'web', account: sub, cartOwner }.
Account lifecycle state still applies. An account that is deleting, deleted or in contact_transition is refused with
401 account_access_ended, except for the email verification sync, which finishes a pending email change. Delegated,
invited-agent and Poppy actors keep their grant checks. The webshop owns the browser session: the Auth0 SDK cookie, the
revocation list and back-channel logout.
Because the API is session-unaware, these properties follow:
| Property | Behaviour |
|---|---|
| Revoking one web session | Ends the web session; an issued API token stays usable until it expires |
| Copied-cookie protection | Not at the API. The webshop validates its cookie against its revocation list |
| Account-wide cutoff | Not enforced by the API until token expiry. Deleted accounts fail where rows are gone |
| Sign out everywhere | The webshop revokes every website Auth0 session and its refresh tokens, plus a local cutoff |
| Sign-in activity | Not recorded for customers |
| Email change | Other browser sessions stay; the account is refused while in contact_transition |
WEB_SESSION_CONTEXT_KEY |
Signs only invited-agent contexts; it cannot forge customer authority |
| Poppy Sessions | Checked live through Auth0 proxy introspection |
The invited-agent context is a separate schema with its own verification branch, signed with
WEB_SESSION_CONTEXT_KEY, which is now used only for invited-agent sessions
(invited-agent+jwt). The storefront records an invited agent’s delegated sign-in and ends it at sign-out itself, through
@retail-demo/identity/delegation, so there is no callback context. It is accepted only with a web-agent-forwarder M2M token, a client distinct from the guest storefront client so guest authority stays narrow.
Guest sessions
The storefront mints guest sessions itself, from server-only code; the API only verifies them on cart routes. A session is
gs1.<lowercase UUID>.<43-character base64url HMAC-SHA256>. Guest reads without a session see an empty cart. Every
guest cart write requires it or returns 400 guest_session_required; a forged or malformed session, or one signed with
a previous key past its deadline, returns 401 invalid_guest_session with WWW-Authenticate: Bearer error="invalid_token". The storefront also verifies
the cookie before it names a cart, so a forged or expired one reads and writes nothing there. Shopper-IP is
guest-only, at most 64 characters, and normalized only for rate limits. Both guest headers are refused on website
tokens.
New sessions always use GUEST_SESSION_KEY, bound on both the API and the storefront Worker. Rotation can configure
GUEST_SESSION_KEY_PREVIOUS together with GUEST_SESSION_KEY_PREVIOUS_UNTIL (ISO 8601); the previous key verifies only
before that deadline. To rotate, the owner adds a new Alchemy Random key, binds the old key for a 30-day window, and
removes it after expiry (see the API README).
The storefront M2M token identifies the storefront, not the visitor. The visitor’s capability is possession of a
random cart id. The Guest-Session value is that id plus an HMAC computed with the shared guest key, which proves the id was minted
by the storefront. Two guests behind the same storefront token can’t see each other’s carts.
Scope translation
Scope translation applies only to delegated (OAuth MCP) callers. Once the route guard has accepted a token’s API
scopes for an operation, the API grants the actor that operation’s domain scopes and nothing else. A cart:write
token therefore never becomes a general poppy:write actor.
invited-agent and poppy callers are never translated. Their actor keeps the immutable permissions it has today,
and their route guard requires that the operation’s scopes fall within the mapped permissions and that the
service credential carrying them is allowed to forward agents. A read-only invited agent can’t check out, whatever the agent-forwarder
token holds.
Procedure caller declarations
CallerKind is customer | delegated | guest | invited-agent | operator | poppy. Every procedure
declares its admitted callers in api({ callers, apiScopes, agentScopes }), using the named lists in
contracts/callers.ts: storefront (customer, delegated, guest, invited-agent, poppy), account (storefront
without guest), checkout (customer, invited-agent, poppy) for the checkout reads and identity, agent checkout
(delegated, invited-agent, poppy) for saved-card checkout, operator, and a diagnostic list for GET /v1/me. agentScopes are the Poppy account scopes a Poppy caller needs; apiScopes are the Auth0 Shop
API scopes. The agent-facing procedures:
| operationId | Route | agentScopes |
apiScopes |
callers | tools |
|---|---|---|---|---|---|
searchProducts |
GET /v1/catalog/products |
none | catalog:read |
storefront | mcp, webmcp |
getProduct |
GET /v1/catalog/products/{handle} |
none | catalog:read |
storefront | mcp, webmcp |
listCollections |
GET /v1/catalog/collections |
none | catalog:read |
storefront | mcp, webmcp |
getCart |
GET /v1/cart |
none | cart:read |
storefront | mcp, webmcp |
addCartItem |
POST /v1/cart/items |
none | cart:write |
storefront | mcp, webmcp |
updateCartItem |
PATCH /v1/cart/items/{variant_id} |
none | cart:write |
storefront | mcp, webmcp |
removeCartItem |
DELETE /v1/cart/items/{variant_id} |
none | cart:write |
storefront | mcp, webmcp |
checkoutSavedCard |
POST /v1/checkouts/saved-card |
checkout |
checkout |
agent checkout | mcp |
getProfile |
GET /v1/me/profile |
poppy:read |
account:read |
account | mcp |
listAddresses |
GET /v1/me/addresses |
poppy:read |
account:read |
account | mcp |
addAddress |
POST /v1/me/addresses |
poppy:write |
account:write |
account | mcp |
deleteAddress |
DELETE /v1/me/addresses/{id} |
poppy:write |
account:write |
account | mcp |
listPaymentMethods |
GET /v1/me/payment-methods |
poppy:read |
payment_methods:read |
account | mcp |
listOrders |
GET /v1/me/orders |
poppy:read |
orders:read |
account | mcp, webmcp |
getOrder |
GET /v1/me/orders/{id} |
poppy:read |
orders:read |
account | mcp, webmcp |
cancelOrder |
POST /v1/me/orders/{id}/cancellation |
poppy:write |
orders:write |
account | mcp |
requestReturn |
POST /v1/me/orders/{id}/returns |
poppy:write |
orders:write |
account | mcp |
Scope helpers and actor shape
translateScopes applies only to delegated OAuth callers. It returns a fresh copy of that operation’s domain
scopes only when every required apiScope is granted, and returns undefined otherwise. It never grants more
than the one operation’s scopes. poppyPermits instead checks immutable Poppy or invited-agent permissions through
this map; unknown permissions unlock nothing beyond the base scopes:
| Permission | API scopes |
|---|---|
| Base, without account permission | catalog:read, cart:read, cart:write |
poppy:read |
account:read, payment_methods:read, orders:read |
poppy:write |
account:write, orders:write |
checkout |
checkout |
CallerKind delegated maps to OAuthAgentActor: kind: 'agent', origin: 'oauth', channel: 'mcp' | 'openapi',
display name and domain, oauth: { clientId, actorChain }, signedIn: true, account subject, captured lifecycle
revision/epoch, translated domain scopes and the shared account cart owner. The actor chain is non-empty and its
outermost subject equals the token client ID. It has no Poppy session, opaque user ID or delegation snapshot.
Existing order/customer-activity attribution uses the innermost client the user consented to. Diagnostic observations
preserve the complete supported current-first actor chain, with intermediate/initiating identities and represented
account separately labelled; richer verified ingress facts are not replaced by a narrower domain projection. A target or
public actor claim is not verified authority. Structural chain coverage is explicit: complete, incomplete or invalid;
historical absence is not proof of completeness. Malformed/oversized snapshots are omitted whole, without upgrading
individual provenance or authorization. See diagnostic investigations.
CallerKind invited-agent maps to the existing DelegatedAgentActor with its immutable admission snapshot.
Existing Poppy and invited-agent actors may omit origin, which means Poppy.
The storefront runs the whole web-session surface itself: it merges the guest cart, serves the profile page and the status receipts, and runs the account-security family (fresh verifications, security methods, embedded verification, contact changes, contact methods and account deletion). None of them is a Shop API endpoint.
Poppy and forwarded-agent admission
A poppy caller is either a single-level personal_agent CTE token from the shared Auth0 proxy client, or a separately
verified signed-out Auth0 proxy token. Dedicated web_agent_forwarder and mcp_agent_forwarder machine credentials are intermediate agent-forwarding
callers only: they require exactly one original artifact. invited-agent uses an independently verified bearer-bound
context and live durable delegation checks. Forwarded Poppy callers and direct Poppy tokens both use /v1: agent forwarders with
their verified web or MCP artifact, direct tokens with Authorization: DPoP and a proof. Live Poppy permissions
intersect with resource scopes and, for M2M agent forwarders, agent:forward. Empty-scope signed-out Auth0 proxy tokens have only the fixed
catalog and cart base authority. MCP verifies the original token and Session before OBO or the narrow signed-out agent forwarder.
Poppy requests act with the permissions the token carries that the Session still grants, the challenge scheme is chosen before verification, and the DPoP replay record is shared by every isolate. See Poppy agents and Poppy compliance.
Poppy authority
| Property | Rule |
|---|---|
| Scheme by token kind | Session Tokens use DPoP and need a proof; they are never accepted as Bearer, in a URL or in a cookie. Everything else is a plain bearer |
| Challenge scheme | Chosen from the request before any verification, so a missing, malformed, expired or rejected token is answered in one scheme with error="invalid_token" |
| Effective scopes | token ∩ Session: the Poppy permissions the token’s API scopes cover, intersected with the Session’s live scopes. Used by REST, MCP and the Company Agent |
| Stale tokens | A signed-out token on a signed-in Session, or a signed-in token on a Session that signed out, acts signed out (sign_in_required for account actions) |
| Proof replay | An accepted jti is a row in D1 (dpop_proofs), so another isolate refuses it; cleaned up by the clean_dpop_proofs job |
sign_in_required |
403 for every caller, with WWW-Authenticate: <scheme> error="sign_in_required". The webshop maps on the error member, not the status |