@enruta/accept
v0.2.0
Published
Accept middleware for merchants: detects agent traffic, verifies Web Bot Auth signatures against the agent's key directory (Ed25519) and UCP signatures against the agent's UCP profile (ES256 with Content-Digest), verifies Enruta mandates (ES256), labels t
Maintainers
Readme
@enruta/accept
The Accept middleware for merchants. It sits in front of a checkout and answers one question for every order an agent places: which agent placed it, on behalf of whom, under which limit and which policy — and it puts that answer on the request as headers and, when the order is placed, into an Enruta record.
Web APIs only (WebCrypto Ed25519 and ES256, fetch, Request/Response), no runtime dependencies. The same code runs on Cloudflare Workers, Vercel Edge, Fastly Compute, Deno and Node 22.
npm install @enruta/acceptWhat it does, per request
| Step | What happens | Where it shows up |
| --- | --- | --- |
| Detect | Reads UCP-Agent: profile="…", MCP JSON-RPC bodies (meta["ucp-agent"]), RFC 9421 Web Bot Auth (Signature-Agent, Signature-Input, Signature) and known agent User-Agent tokens; resolves the platform through a fingerprint table (Google, ChatGPT/OpenAI, Copilot, Meta Muse, RyeBot, Enruta, Anthropic, Perplexity, Amazon). | X-Enruta-Agent-Platform, X-Enruta-Agent-Kind |
| Identify | On a signed request, fetches the Signature-Agent origin's key directory (/.well-known/http-message-signatures-directory), picks the key whose JWK SHA-256 thumbprint is keyid, and verifies the RFC 9421 signature. A UCP signature (the scheme of Shopify's "Signed" traffic tier) verifies with the key the UCP-Agent profile publishes under keyid: ES256 over the components it declares, with Content-Digest checked against the raw body. Visa Trusted Agent Protocol signatures verify against a trusted directory you configure. See Web Bot Auth and UCP signatures. | X-Enruta-Agent-Verified, X-Enruta-Signature-Agent, X-Enruta-Agent-Profile, risk_signals |
| Verify | On POST …/checkout-sessions/{id}/complete and MCP tools/call complete_checkout, reads ap2.checkout_mandate, verifies the ES256 signature with the keys of the Enruta platform profile (cached; /.well-known/enruta-keys.json as fallback), checks exp, payee === domain and checkout.reference === {id}. | X-Enruta-Mandate-Valid: true|false |
| Label | Puts the verified mandate's agent, mandate id, policy, principal on the forwarded request. Any X-Enruta-* header the client sent is removed first. | X-Enruta-Agent, X-Enruta-Mandate, X-Enruta-Policy, X-Enruta-Policy-Version, X-Enruta-Principal, X-Enruta-Principal-Login |
| Report | When the merchant answers 2xx with an order, posts { domain, order, mandate_jws, checkout_id, source: "middleware", risk_signals? } to POST /v0/sellers/orders with the seller key. In the background through waitUntil when the runtime has one, otherwise awaited with a 3 s timeout; failures are events, never errors for the buyer. | X-Enruta-Record: rec_… on the response when the report finished synchronously |
An invalid or missing mandate is passed through with X-Enruta-Mandate-Valid: false: the merchant's own AP2 check decides. Only with enforce: true does the middleware answer itself (402 and the AP2 error envelope: mandate_required, agent_missing_key, mandate_invalid_signature, mandate_expired, mandate_scope_mismatch; an MCP call gets the same envelope in band as a tool error). Likewise a signature that verifies under neither Web Bot Auth nor UCP is only labelled, unless requireVerifiedBotSignature is set (403 with bot_signature_invalid or bot_signature_required; a verified signature of either scheme passes).
What never leaves the merchant: the credential token (payment.instruments[].credential) is not read into labels or events, not logged, and stripped from the order before the report; the request body reaches the merchant untouched (the middleware parses a clone).
Headers
Request headers set on the forwarded request:
| Header | Value | When |
| --- | --- | --- |
| X-Enruta-Agent-Platform | google, openai, copilot, muse, rye, enruta, anthropic, perplexity, amazon, or none | always (a hint: it can come from an unverified header) |
| X-Enruta-Agent-Kind | ucp, mcp, web_bot_auth, user_agent, none | always |
| X-Enruta-Agent-Verified | true when the Web Bot Auth, Visa TAP or UCP signature verified, else false | signed requests |
| X-Enruta-Signature-Agent | the verified agent origin, e.g. https://agent.example | the Web Bot Auth or Visa TAP signature verified |
| X-Enruta-Agent-Profile | the verified UCP profile URL, e.g. https://platform.example/.well-known/ucp | the UCP signature verified |
| X-Enruta-Mandate-Valid | true / false | complete requests |
| X-Enruta-Agent | agt_… | mandate signature verified |
| X-Enruta-Mandate | mnd_… | mandate signature verified |
| X-Enruta-Principal | the buyer organization id (sub) | mandate signature verified |
| X-Enruta-Policy, X-Enruta-Policy-Version | policy id and version | when the mandate carries policy |
| X-Enruta-Principal-Login | principal_login_id | when the mandate carries it |
Response header: X-Enruta-Record: <seller_record_id> when the report finished before the response was sent.
The names are exported as HEADERS (HEADERS.agent, HEADERS.mandate, HEADERS.agentVerified, HEADERS.signatureAgent, HEADERS.agentProfile, …).
Web Bot Auth
Follows draft-ietf-webbotauth-httpsig-protocol-00 (September 2026), which replaced draft-meunier-web-bot-auth-architecture and draft-meunier-http-message-signatures-directory.
A signature counts as verified when all of these hold:
Signature-InputandSignatureparse (RFC 8941) and share the label.tag="web-bot-auth";created,expiresandkeyidare present;@authorityor@target-uriis covered.- The signature covers a
Signature-Agentmember (the dictionary member keyed by its label, else the single member it covers; the legacy string form when it covers the bare field). The member is anhttpsorigin (typedirectory, the default), not an IP literal or a local name, and it is allowed byallow/ not ondeny. created ≤ now + 60 s,expires ≥ now − 60 s,expires − created ≤ 24 h;alg, when present, ised25519(or another configured algorithm) and matches the key.- The key comes from that origin's directory, fetched over HTTPS with a 200 (no redirects, 3 s timeout, 64 KiB, 64 keys), and its RFC 7638 thumbprint equals
keyid. The key has not passed itsexp, and is not an RFC 9421 example key. If the directory response carrieshttp-message-signatures-directorysignatures (Appendix B), the key needs a valid one:@authority;reqpluscontent-digest, the digest matching the body, not dated in the future. - The RFC 9421 signature verifies over the components the request declares (
@method,@path,@query,@query-param, header fields, dictionary members withkey, …). - A
nonce, when present, has not been seen within the signature's lifetime (in-memory per instance, or yournonceStore).
Anything else is not verified, with status and reason:
| status | reason |
| --- | --- |
| invalid | malformed, missing_parameter, missing_component, signature_agent_missing, signature_agent_invalid, unsupported_alg, not_yet_valid, expired, validity_too_long, nonce_replayed, directory_signature_invalid, key_expired, test_key, invalid_signature |
| unverified | unknown_key, directory_unavailable, directory_signature_missing, unsupported_discovery_type, agent_not_allowed, agent_denied, tag_mismatch, not_checked (parse only) |
| unsigned | — |
Directories are cached per origin for Cache-Control: max-age (at least 60 s, 1 h when absent, at most 24 h). A failed fetch is remembered for 30 s and never evicts a cached copy. An unknown keyid refetches a fresh directory at most once a minute, so rotated keys are picked up. Concurrent fetches of one directory are coalesced.
Visa Trusted Agent Protocol (webBotAuth.visaTap): signatures tagged agent-browser-auth or agent-payer-auth cover @authority and @path. They carry created, expires (at most 8 minutes apart), keyid, alg and nonce, and verify with a key from the trusted directory (kid or thumbprint). true uses Visa's published https://mcp.visa.com/.well-known/jwks. As of September 2026 that key store serves one RSA key from a sandbox CA and no agent signing key, so point directoryUrl at the key store your Visa onboarding gives you.
UCP signatures
Follows the UCP specification (2026-08-25): Message Signatures and the Identity Resolution Algorithm of the overview. It is the scheme of Shopify's "Signed" traffic tier: the agent signs its requests with HTTP Message Signatures (RFC 9421, ECDSA P-256) and publishes the public key in its UCP profile. Requests signed the way the UCP reference server signs them verify here, and signatures made by this package's tests verify with the reference server's check; the tests cross-check both directions.
A signature counts as verified when all of these hold:
Signature-InputandSignatureparse (RFC 8941) and share the label. The signature has notag, ortag="web-bot-auth"(the dual-audience shape); signatures with other tags are left to other verifiers.UCP-Agentparses as a dictionary whoseprofileis anhttpsURL of at most 1024 characters on a public host: not an IP literal, not a local name (localhost, single-label,.local,.internal,.home.arpa), no credentials.keyidis present (at most 512 characters).created, when present, is≤ now + 60 s, andexpires, when present, is≥ now − 60 s. Neither is required: UCP leaves replay protection to the signedIdempotency-Key.- The signature covers
@method,@authorityand@path;@querywhen the URL has a query string;content-digestandcontent-typewhen the request has a body; andidempotency-key,ucp-agentandsignature-agentwhen the request carries them (signature-agentas the whole field or as the member under the signature's label). Content-Digesthas asha-256member, and every digest it carries matches the raw body bytes. It is checked whenever the body is not empty or the digest is covered, so a stripped body does not pass. A body overmaxBodyBytesis not verified.- The profile comes back with a 2xx (no redirects, 3 s timeout, 128 KiB, 64 keys) and publishes a key whose
kidiskeyid, inkeys[]orsigning_keys[], at the top level or underucp. Keys with auseother thansig, or withkey_opsthat leave outverify, are skipped. - The key is EC P-256 (
ES256) or Ed25519 (EdDSA), and itsalg, when present, matches its curve. The signature'salgparameter, when present, names that algorithm (ecdsa-p256-sha256,ed25519). Aweb-bot-authtagged signature'skeyidis the key's RFC 7638 thumbprint. - The signature verifies over the components the request declares: ES256 as 64 bytes of raw
r||s(DER is refused), Ed25519 as 64 bytes. When a request carries several signatures, one that verifies is enough.
Anything else is not verified, with status and reason:
| status | reason |
| --- | --- |
| invalid | malformed, ucp_agent_invalid, missing_parameter, missing_component, not_yet_valid, expired, digest_mismatch, unsupported_alg, invalid_signature |
| unverified | ucp_agent_missing, profile_unavailable, unknown_key, body_unavailable, tag_mismatch, not_checked (parse only) |
| unsigned | — |
The result is agent.ucp_signature, next to agent.web_bot_auth:
ucp_signature: {
signed, verified,
status, // "verified" | "invalid" | "unverified" | "unsigned"
profile_url, keyid,
alg, // "ES256" | "EdDSA", from the key
created, expires,
reason?, detail? // why it did not verify
}agent.verified is true when either web_bot_auth or ucp_signature verified. A verified UCP signature sets X-Enruta-Agent-Profile and adds { "type": "ucp_signature_verified", "profile_url", "keyid", "alg" } to risk_signals, which the order report carries.
Profiles are cached per URL for Cache-Control: max-age (at least 60 s, 1 h when absent, at most 24 h). A failed fetch is remembered for 30 s. An expired copy is not used when its refresh fails, so a key removed from the profile stops verifying once the cached copy expires. An unknown keyid refetches the profile at most once a minute per origin, so rotated keys are picked up. Concurrent fetches of one profile are coalesced.
Deploy
Cloudflare Worker (in front of any store)
import { createAccept, toFetchHandler } from "@enruta/accept";
let handler: ReturnType<typeof toFetchHandler> | undefined;
export default {
fetch(request: Request, env: { ENRUTA_SELLER_KEY?: string }, ctx: ExecutionContext) {
handler ??= toFetchHandler(
createAccept({ domain: "store.example.com", sellerKey: env.ENRUTA_SELLER_KEY }),
{ origin: "https://origin.store.example.com" },
);
return handler(request, env, ctx);
},
};The request is proxied to origin with the labels, X-Forwarded-Host and X-Forwarded-Proto; the report runs in ctx.waitUntil. The same handler is a Vercel Edge Function (export default toFetchHandler(accept, { origin }); the edge context with waitUntil is the second argument) and a Fastly Compute or Deno handler.
Next 16 proxy.ts (Vercel)
import { NextResponse, type NextRequest } from "next/server";
import { createAccept, toNextProxy } from "@enruta/accept";
const accept = createAccept({ domain: process.env.STORE_DOMAIN!, sellerKey: process.env.ENRUTA_SELLER_KEY });
const enruta = toNextProxy(accept, { next: (init) => NextResponse.next(init) });
export async function proxy(request: NextRequest) {
return enruta(request);
}
export const config = { matcher: ["/checkout-sessions/:id/complete", "/mcp"] };The route handlers read the labels from the request headers (request.headers.get("x-enruta-agent")). A proxy never sees the route's response, so nothing is reported from proxy.ts; the store's own order webhook to Enruta covers the record, or call accept.report({ order, mandate_jws, checkout_id }) from the route. To combine with an existing proxy, call enruta(request) for the two paths and keep your other rewrites. nextHeadersFor(inspection, request.headers) gives the header set for NextResponse.next({ request: { headers } }) when you want to run accept.inspect yourself.
Hono (Workers, Node, Deno, Bun)
import { Hono } from "hono";
import { createAccept, toHonoMiddleware } from "@enruta/accept";
const accept = createAccept({ domain: "store.example.com", sellerKey: process.env.ENRUTA_SELLER_KEY });
const app = new Hono();
app.use("/checkout-sessions/:id/complete", toHonoMiddleware(accept));
app.use("/mcp", toHonoMiddleware(accept));The handler sees the labels on c.req.raw.headers and the full inspection as c.get("enruta"); the middleware sees the response, so the report runs here too (through c.executionCtx.waitUntil on Workers).
Express (Node)
app.use(express.json());
app.use(toExpress(accept)); // labels in req.headers, inspection in req.enruta; call accept.report(...) once the order is placedA UCP signature binds the raw body bytes (Content-Digest), and express.json() hands the middleware a re-serialized body that no longer matches. When agents sign their requests, mount express.raw({ type: "application/json" }) in front of toExpress instead, and parse the JSON in the route.
API
createAccept(options: AcceptOptions): Accept| Option | Default | Meaning |
| --- | --- | --- |
| domain | required | the merchant domain a mandate names as payee |
| enrutaApiUrl | https://api.enruta.ai | report endpoint and JWKS fallback |
| platformProfileUrl | https://app.enruta.ai/.well-known/agent-profile.json | where the mandate keys come from |
| sellerKey | — | seller API key; without it the middleware labels but does not report |
| report | true when sellerKey is set | report placed orders |
| enforce | false | answer complete requests without a valid mandate with the AP2 error |
| webBotAuth | on | Web Bot Auth / Visa TAP verification options (below); false only parses the signature headers |
| ucpSignature | on | UCP signature verification options (below); false only parses the signature headers |
| requireVerifiedBotSignature | false | true: answer signed requests whose signature verifies under neither Web Bot Auth nor UCP with 403; "agents": also any agent request without a verified signature |
| cacheTtlSeconds | 3600 | mandate key cache lifetime (an unknown kid refetches at most once a minute) |
| fetch | global fetch | for tests and custom transports |
| now | wall clock | seconds since the epoch (tests) |
| onEvent | — | agent, mandate, blocked, keys, directory, profile, report, report_skipped, error events; none carries a credential |
| keys | — | pinned Enruta public JWKs (offline use) |
| platforms | — | extra fingerprints, matched before the built-in table |
| reportTimeoutMs | 3000 | when the report has to be awaited |
webBotAuth options: allow / deny (origins, https://*.example.com wildcards; deny wins), algorithms (["ed25519"]; "rsa-pss-sha512" can be added), clockSkewSeconds (60), maxValiditySeconds (86400), requireNonce (false), requireSignedDirectory (false), directoryTtlSeconds (3600), directoryMaxTtlSeconds (86400), failureTtlSeconds (30), directoryTimeoutMs (3000), maxDirectoryBytes (65536), maxDirectoryKeys (64), allowTestKeys (false), nonceStore ({ add(key, expiresAt) }, shared across instances), visaTap (true or { directoryUrl, maxValiditySeconds }), fetch, now.
ucpSignature options: clockSkewSeconds (60), profileTtlSeconds (3600), profileMaxTtlSeconds (86400), failureTtlSeconds (30), profileTimeoutMs (3000), maxProfileBytes (131072), maxProfileKeys (64), maxBodyBytes (the middleware's, 1 MiB), fetch, now.
Accept:
inspect(request): Promise<Inspection>— detection and verification, no forwarding; the body is cloned.handle(request, next, ctx?): Promise<Response>— inspect, label, callnext(labelledRequest, inspection), report;ctx.waitUntilcarries the report when present.verifyMandate(jws, { checkoutId? }): Promise<MandateInspection>.report({ order, mandate_jws, checkout_id?, risk_signals? }, ctx?): Promise<ReportResult | null>.blockResponse(inspection): Response— the AP2 or bot signature error for a blocked inspection.
Inspection:
{
"request": { "method": "POST", "path": "/checkout-sessions/chk_8f3/complete", "kind": "ucp_complete", "checkout_id": "chk_8f3", "rpc_id": null },
"agent": {
"kind": "ucp", "platform": "openai", "profile_url": "https://chatgpt.com/.well-known/agent-profile.json",
"signed": true, "verified": true, "signature_agent": "https://chatgpt.com", "user_agent": "ChatGPT-User/1.0",
"web_bot_auth": {
"signed": true, "verified": true, "status": "verified", "protocol": "web_bot_auth",
"agent_origin": "https://chatgpt.com", "directory_url": "https://chatgpt.com/.well-known/http-message-signatures-directory",
"keyid": "poqk…", "tag": "web-bot-auth", "alg": "ed25519", "label": "sig1", "created": 1789048800, "expires": 1789048860,
"nonce": "…", "components": ["\"@authority\"", "\"@method\"", "\"@path\"", "\"signature-agent\";key=\"sig1\""], "directory_signed": true
},
"ucp_signature": {
"signed": true, "verified": true, "status": "verified", "profile_url": "https://chatgpt.com/.well-known/agent-profile.json",
"keyid": "poqk…", "alg": "EdDSA", "created": 1789048800, "expires": 1789048860
}
},
"mandate": {
"present": true, "valid": true, "mandate_id": "mnd_…", "agent": "agt_procurement_02", "principal": "org_halvern",
"policy": { "id": "pol_…", "version": 3 }, "amount": "168.40", "currency": "USD", "payee": "store.example.com",
"checkout": { "protocol": "ucp", "reference": "chk_8f3" }, "issued_at": "2026-09-13T10:00:00Z", "expires_at": "2026-09-13T10:15:00Z",
"mode": "specified", "refill_policy": null, "finality_class": null, "principal_login_id": "halvern/[email protected]",
"kid": "key_enruta_mandate_2026-09", "reasons": [], "claims": { "…": "…" }
},
"mandate_jws": "eyJ…",
"headers": { "X-Enruta-Agent": "agt_procurement_02", "X-Enruta-Agent-Verified": "true", "X-Enruta-Signature-Agent": "https://chatgpt.com", "X-Enruta-Agent-Profile": "https://chatgpt.com/.well-known/agent-profile.json", "X-Enruta-Mandate-Valid": "true", "…": "…" },
"risk_signals": [
{ "type": "web_bot_auth_verified", "protocol": "web_bot_auth", "agent_origin": "https://chatgpt.com", "keyid": "poqk…", "tag": "web-bot-auth" },
{ "type": "ucp_signature_verified", "profile_url": "https://chatgpt.com/.well-known/agent-profile.json", "keyid": "poqk…", "alg": "EdDSA" }
],
"block": null
}mandate.reasons on failure: mandate_missing, malformed, unsupported_alg, unknown_key, keys_unavailable, invalid_signature, not_an_object, expired, payee_mismatch, checkout_mismatch. Claims are surfaced only once the signature verified (an expired mandate is authentic, so its labels are still set with X-Enruta-Mandate-Valid: false; a forged one sets no labels).
Also exported: detectAgent (parses only; pass it the verifications to merge), WebBotAuthVerifier and parseWebBotAuth, UcpSignatureVerifier, parseUcpSignature and ucpAgentProfile for use outside the middleware, signatureBase, parseSignatures, jwkThumbprint, checkContentDigest, PLATFORMS, labelsFor, labelRequest, stripCredentials, extractOrder, decodeCompact, verifyEs256, KeyStore, and the types.
What it does not check
The amount against the checkout total and checkout_hash against the checkout state need the checkout itself; they stay with the merchant's AP2 check (the store keeps doing them).
Web Bot Auth limits:
Signature-Agentmembers of typejwks_uriandcimdare ignored (reasonunsupported_discovery_type): only the well-known directory binds keys to a domain.- Delegation and certificate chains (
x5c,x5u) are not evaluated. - Host names that resolve to private addresses cannot be detected with
fetch; useallowin front of anything sensitive. - Without a shared
nonceStore, a nonce is checked per instance only. - The Visa TAP body objects (
agenticConsumer,agenticPaymentContainer) are not verified.
UCP signature limits:
- Replay: UCP leaves it to the business layer. The middleware keeps no
Idempotency-Keyor nonce for UCP signatures; the merchant's idempotency store does that. - The profile host is checked by name only: host names that resolve to private addresses cannot be detected with
fetch. - Keys come from the
UCP-Agentheader's profile only. An MCP body'smeta["ucp-agent"].profilelabels the request but resolves no key.Signature-Agentresolution of typejwks_uriorcimdis not used for UCP. - ES384 keys and response signatures are not verified.
- A registry of pre-approved platforms (UCP's
profile_not_trusted) is the merchant's policy:ucp_signature.profile_urlis the identity to check it against.
Development
npm run typecheck -w @enruta/accept
npm test -w @enruta/accept
npm run build -w @enruta/accept # dist/accept.mjs, one ESM file, no importsApache-2.0.
