diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 50f8e0a..2e20a5b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,14 +1,16 @@
# Contributing
-Thanks for looking at Agent Commerce Gateway. This is an early alpha; the
-architecture is deliberate and the scope is deliberately narrow.
+Thanks for looking at Agent Commerce Gateway. The architecture is deliberate and
+the scope is deliberately narrow; both of those are load-bearing, and most of
+the rules below exist to keep them that way.
## Ground rules
-1. **Scope discipline is a release requirement.** This release is MCP + x402 only.
- New protocols and rails land after the adapter model survives real use.
- Classify every proposal as `BLOCKER` / `QUALITY` / `NICE-TO-HAVE` /
- `POST-ALPHA` — the default answer to a new capability is `POST-ALPHA`.
+1. **Scope discipline is a release requirement.** Supported today: MCP, HTTP and
+ x402. Experimental and off by default: A2A, ACP, AP2 and OpenAPI import. New
+ protocols and rails land after the adapter model survives real use. Classify
+ every proposal as `BLOCKER` / `QUALITY` / `NICE-TO-HAVE` / `POST-1.0` - the
+ default answer to a new capability is `POST-1.0`.
2. **Never make the gateway custodial.** No PR may introduce storage of a
merchant or buyer private key, seed phrase or fund custody. See
[`SECURITY.md`](SECURITY.md).
@@ -32,27 +34,30 @@ npm run demo:agent
```
Requirements: Node >= 22, npm 10, Docker, and [Foundry](https://getfoundry.sh)
-(`anvil`, `forge`, `cast`) for the chain work.
+(`anvil`, `forge`, `cast`) for the chain work. On Linux, if your user is not
+UID/GID 1000, see the note in the [README](README.md#quickstart) before
+`docker compose up`.
## The loop
```bash
-npm run verify # lint + typecheck + test — run this before opening a PR
+npm run verify # contract + lint + typecheck + test - run this before opening a PR
npm run test:e2e # deterministic end-to-end (boots its own chain)
npm run lint:fix
```
Scope a run to one area with `npx vitest run tests/unit/`.
-A change is not mergeable if TypeScript fails, lint fails, required tests fail,
-or the deterministic E2E fails.
+A change is not mergeable if the contract surface changed unannounced,
+TypeScript fails, lint fails, required tests fail, or the deterministic E2E
+fails.
## Architecture you need to know before writing code
Read, in order:
-1. [`docs/architecture.md`](docs/architecture.md) — the shape of the system.
-2. [`docs/contracts.md`](docs/contracts.md) — the frozen cross-package contract.
+1. [`docs/architecture.md`](docs/architecture.md) - the shape of the system.
+2. [`docs/contracts.md`](docs/contracts.md) - the frozen cross-package contract.
The one rule that surprises people: **every protocol adapter converges on
`ExecutionPipeline.execute`**. An adapter never calls a merchant backend and
@@ -79,19 +84,40 @@ Implement `PaymentProvider`. Required before review:
- [ ] a `replayKey` derived only from the payment authorisation
- [ ] negative tests: no payment, malformed, wrong amount, wrong recipient,
wrong network, wrong asset, replay, provider unavailable
-- [ ] a deterministic settlement proof — real state change, not a mocked success
+- [ ] a deterministic settlement proof - real state change, not a mocked success
- [ ] no private key held by the gateway
+## Adding an authorization method
+
+Implement `AuthorizationProvider`. Authorization gates settlement; it never
+moves money and never unlocks a resource on its own. Required before review:
+
+- [ ] verification and reservation are one atomic step, before settlement
+- [ ] a replay identity that survives re-presentation of the same proof
+- [ ] binding to the resolved resource, input and price, not just to a signature
+- [ ] `consume` / `release` / `markUncertain` finalizers, with release reserved
+ for failures that provably moved no money
+- [ ] static trust only: no key discovery, no outbound request from a proof
+- [ ] `AUTHORIZATION_*` error codes, never a payment code, and a coarse
+ rejection reason that is not an oracle for trust policy
+- [ ] nothing of the proof itself in a receipt, an event or a log
+
+AP2 is the worked example: [`docs/ap2.md`](docs/ap2.md).
+
## Publishing
The repository *is* the package: one `package.json`, published as
-**`@devlab.group/agent-commerce`**. It ships the `agent-commerce` binary and three
-library paths — `.`, `./mcp`, `./x402` — built from `src/` into `dist/`. The
-MCP SDK, x402 and viem are **optional peer dependencies**: neither the main
-entry nor the CLI may import them, or a default install breaks. Architectural
-boundaries live in directories under `src/`, not in package manifests, so a
-reappearing `pnpm-workspace.yaml` or `packages/` directory means the two models
-are being run at once; the packaging tests fail on either.
+**`@devlab.group/agent-commerce`**. It ships the `agent-commerce` binary and four
+library paths - `.`, `./ap2`, `./mcp`, `./x402` - built from `src/` into
+`dist/`.
+
+Two constraints a PR must not break. **The heavy rails are optional peer
+dependencies** (`@modelcontextprotocol/sdk`, `@x402/core`, `@x402/evm`, `viem`,
+`jose`, `@sd-jwt/core`, `canonicalize`, `@coinbase/x402`), and neither the main
+entry nor the CLI may import one, or a default install breaks. **Architectural
+boundaries live in directories under `src/`**, not in package manifests, so a
+reappearing `pnpm-workspace.yaml` or `packages/` directory means two models are
+being run at once. The packaging tests fail on either.
```bash
npm run build # bundle -> dist/index.js + dist/cli/index.js
@@ -99,8 +125,8 @@ npm run test:cli:dist # run the built binary under plain node
npm run pack:dry # inspect what would be published
```
-Only `dist/`, `README.md` and `LICENSE` are published. Everything else —
-`src/`, `tests/`, `demo/`, `scripts/`, `docs/` — stays in the repository. Note
+Only `dist/`, `README.md` and `LICENSE` are published. Everything else -
+`src/`, `tests/`, `demo/`, `scripts/`, `docs/` - stays in the repository. Note
that `dist/` ships sourcemaps that embed the TypeScript they were built from;
that is intended (public source, real stack traces), not an oversight.
@@ -108,12 +134,15 @@ Never run `npm publish` without explicit maintainer approval.
## Commits and PRs
-Prefix commits by area: `core:` `protocol:` `payment:` `cli:` `test:` `docs:`
-`chore:`. Keep commits small and coherent.
+Conventional commits: `type(scope): subject`, lower case, no trailing full stop.
+Types in use are `feat`, `fix`, `docs`, `test`, `chore` and `refactor`; the
+scope is the area you touched (`core`, `config`, `pipeline`, `gateway`,
+`runtime`, `ap2`, `acp`, `openapi`, `cli`, `payment`), and repo-wide changes
+drop it. Keep commits small and coherent.
```text
-payment: bind x402 verification to a replay key
-protocol: expose configured resources as MCP tools
+feat(pipeline): enforce authorization before payment settlement
+test(acp): add stable checkout conformance coverage
```
A PR should say what changed, why, how you tested it, and what it does **not**
@@ -121,9 +150,9 @@ cover.
## Reporting bugs
-Include the version/commit, your `agent-commerce doctor --json` output (it
+Include the version or commit, your `agent-commerce doctor --json` output (it
contains no secrets), what you expected, and what happened. For security issues,
-follow [`SECURITY.md`](SECURITY.md) instead — do not open a public issue.
+follow [`SECURITY.md`](SECURITY.md) instead - do not open a public issue.
## Code of conduct
diff --git a/README.md b/README.md
index a45b352..192c04e 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
+
## What it is, in ten seconds
@@ -23,13 +24,14 @@ Agent Commerce Gateway sits in front of your existing API, in **your**
infrastructure, and does that for you. You describe an endpoint in a YAML file -
or generate that description from an OpenAPI document you already have - and
agents get an MCP tool and an x402 paywall. Switch on the experimental adapters
-and the same resource is also an A2A skill, or an ACP checkout session. The
-money goes straight to your wallet - the gateway never holds it, and never holds
-your keys.
+and the same resource is also an A2A skill, or an ACP checkout session. Switch
+on AP2 and a paid resource can also demand a signed mandate: proof the human
+behind the agent approved that exact purchase. The money goes straight to your
+wallet - the gateway never holds it, and never holds your keys.
```text
Your existing API → Agent Commerce Gateway → AI Agent
- MCP · A2A · ACP · x402 · receipts · doctor
+ MCP · A2A · ACP · x402 · AP2 · receipts · doctor
```
## Demo
@@ -54,11 +56,9 @@ Your existing API → Agent Commerce Gateway → AI Agent
```
The dashboard at shows the same request as it happens.
-It polls the authenticated events route on a short interval rather than
-streaming: a browser `EventSource` cannot send the admin token, and the operator
-routes are closed without one - so the SSE endpoint is reachable by a
-header-capable client, never by a browser. Polling is the dashboard's intended
-path, not a degraded mode.
+It polls the authenticated events route rather than streaming, because a browser
+cannot send the admin token over `EventSource`; see
+[why the stream is polled](SECURITY.md#the-live-event-stream-is-polled-not-streamed).
## Install
@@ -90,17 +90,18 @@ const { url } = await gateway.listen();
### Optional peers - install only the rails you use
-The MCP adapter and the x402 provider live on their own subpaths, because each
-needs a dependency the rest of the package does not - the x402 rail brings the
-whole EVM signing and RPC stack, which a gateway serving a free HTTP resource
-has no business installing.
+The MCP adapter, the x402 provider and AP2 verification live on their own
+subpaths, because each needs a dependency the rest of the package does not -
+the x402 rail brings the whole EVM signing and RPC stack, which a gateway
+serving a free HTTP resource has no business installing.
-| You want | Install | Import |
-| --------------------------------- | ------------------------------ | ------------------------------------------ |
-| gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` |
-| expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` |
-| accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` |
-| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) |
+| You want | Install | Import |
+| --------------------------------------- | ---------------------------------- | ------------------------------------------ |
+| gateway, config, receipts, CLI | `@devlab.group/agent-commerce` | `from '@devlab.group/agent-commerce'` |
+| expose resources as MCP tools | `+ @modelcontextprotocol/sdk` | `from '@devlab.group/agent-commerce/mcp'` |
+| accept x402 payments | `+ @x402/core @x402/evm viem` | `from '@devlab.group/agent-commerce/x402'` |
+| verify AP2 mandates, sign checkout JWTs | `+ jose @sd-jwt/core canonicalize` | `from '@devlab.group/agent-commerce/ap2'` |
+| authenticate to a CDP facilitator | `+ @coinbase/x402` | (no import - loaded on demand) |
```bash
npm install @devlab.group/agent-commerce @modelcontextprotocol/sdk @x402/core @x402/evm viem
@@ -109,8 +110,14 @@ npm install @devlab.group/agent-commerce @modelcontextprotocol/sdk @x402/core @x
```ts
import { mcp } from '@devlab.group/agent-commerce/mcp';
import { x402 } from '@devlab.group/agent-commerce/x402';
+import { ap2 } from '@devlab.group/agent-commerce/ap2';
```
+The AP2 three are small - about 1.3 MB installed between them, against roughly
+63 MB for the x402 stack - but they stay optional on the same principle: a
+deployment that gates nothing on a mandate should not carry a JOSE stack and an
+SD-JWT parser to serve a resource.
+
Peers are pinned exactly: x402's schemas and EIP-712 domains cross this
boundary, so a version skew is a correctness problem rather than a convenience
one. Import a subpath without its
@@ -157,30 +164,33 @@ To stop and wipe state: `docker compose down -v`.
## How it works
```text
- ┌──────────────────────────────────────────────────────┐
- │ AI Agent │
- └──────────────┬───────────────────────────────────────┘
- │ MCP · HTTP + PAYMENT-SIGNATURE
- ┌──────────────▼───────────────────────────────────────┐
- │ Agent Commerce Gateway (yours) │
- │ │
- │ protocol adapters → ExecutionPipeline → … │
- │ │ │
- │ ┌─────────────────────┼──────────────┐ │
- │ ▼ ▼ ▼ │
- │ PaymentProvider BackendExecutor ReceiptStore │
- │ (x402) (bounded HTTP) (SQLite) │
- └────────┬─────────────────────┬───────────────────────┘
- │ │
- buyer → merchant ┌──────▼───────────────┐
- (never through us) │ Your backend API │
- └───────────────────────┘
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ AI Agent │
+└───────────────────────────────────────┬──────────────────────────────────────┘
+ │ MCP · A2A · ACP · HTTP
+ │ PAYMENT-SIGNATURE
+ │ Agent-Authorization
+┌───────────────────────────────────────▼──────────────────────────────────────┐
+│ Agent Commerce Gateway (yours) │
+│ │
+│ protocol adapters → ExecutionPipeline │
+│ │ │
+│ ┌────────────────────┬──────────────┴──┬────────────────┐ │
+│ ▼ ▼ ▼ ▼ │
+│ AuthorizationProvider PaymentProvider BackendExecutor ReceiptStore │
+│ (ap2) (x402) (bounded HTTP) (SQLite) │
+└───────────────────────────────────────┬──────────────────────────────────────┘
+ │
+ ┌──────────▼─────────┐
+ │ Your backend API │
+ └────────────────────┘
```
Every protocol adapter converges on **one execution pipeline**. That is what
makes payment enforcement a property of the system rather than something each
-adapter has to remember. Full detail in
-[docs/architecture.md](docs/architecture.md).
+adapter has to remember. Money never passes through the box: the buyer pays the
+merchant directly on chain, and the gateway holds neither the funds nor a key.
+Full detail in [docs/architecture.md](docs/architecture.md).
## Configure a resource
@@ -219,33 +229,39 @@ It writes a reviewable `resources:` fragment - path, query and JSON body
mapped, schemas converted to what the gateway actually enforces - and
deliberately leaves `pricing` and `expose` out, because an OpenAPI document has
no opinion on what an operation costs or who may see it. Credentials are never
-imported. See [docs/openapi-import.md](docs/openapi-import.md) for the exact
-supported subset.
+imported. See [OpenAPI import](docs/openapi-import.md) for the exact supported subset.
See [docs/configuration.md](docs/configuration.md).
## Protocol support
-| Protocol | Status | Pinned revision |
-| --------------- | ------------ | -------------------------------------------------------- |
-| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` |
-| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM |
-| **HTTP** | Supported | native routes |
-| **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` |
-| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery |
-| UCP · MPP · AP2 | Planned | - |
+| Protocol | Status | Pinned revision |
+| --------- | ------------ | -------------------------------------------------------- |
+| **MCP** | Supported | `@modelcontextprotocol/sdk@1.30.0` |
+| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`), scheme `exact`, EVM |
+| **HTTP** | Supported | native routes |
+| **A2A** | Experimental | A2A v1.0.0, binding `JSONRPC`, method `SendMessage` |
+| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery |
+| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification |
+| UCP · MPP | Planned | - |
+
+[AP2](docs/ap2.md) is in that table because people look there, but it is an **authorization**
+method rather than a transport: it gates settlement on a resource that still
+takes a real payment, and it is the verifying half only - the gateway holds no
+signing key and issues no Checkout Receipt.
"Planned" means **no code ships for it**. "Experimental" means the code ships,
is tested against the protocol's own official artifacts, and serves a narrow
-named subset - A2A and ACP are both off by default and documented in full at
-[docs/protocols.md](docs/protocols.md#a2a) and
-[docs/protocols.md](docs/protocols.md#acp). ACP serves the five stable checkout
-operations and advertises `services: ["checkout"]` and nothing more; its
-`payment_data` stays with the merchant's own checkout and is never turned into
-an x402 payment. Each adapter reports its own
-`supportedSpec`, `capabilities` and `unsupported` list at runtime via
-`GET /.well-known/agent-commerce` and `agent-commerce doctor` - so the claim is
-checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md).
+named subset: [A2A](docs/protocols.md#a2a) and [ACP](docs/protocols.md#acp) are
+both off by default and documented in full there, as are
+[MCP](docs/protocols.md#mcp) and [x402](docs/protocols.md#x402). ACP serves the
+five stable checkout operations and advertises `services: ["checkout"]` and
+nothing more; its `payment_data` stays with the merchant's own checkout and is
+never turned into an x402 payment.
+
+Each adapter reports its own `supportedSpec`, `capabilities` and `unsupported`
+list at runtime through `GET /.well-known/agent-commerce` and
+`agent-commerce doctor`, so the claim is checkable rather than marketing.
## Payment model
@@ -260,8 +276,10 @@ checkable, not marketing. Detail: [docs/protocols.md](docs/protocols.md).
- **Real settlement in CI.** The end-to-end test asserts the buyer's balance
falls and the merchant's rises by exactly the price, with a real transaction
hash in the receipt. A log line saying "payment successful" would not count.
-
-Detail: [docs/payment-flow.md](docs/payment-flow.md).
+- **Authorization is separate from payment.** A resource can also require an
+ AP2 mandate, verified before settlement and spendable exactly once. It never
+ moves money and never unlocks a resource on its own - the payment still has
+ to be real.
## Public networks
@@ -378,6 +396,7 @@ PASS Backend 2/2 backend host(s) reachable
PASS Protocols http=on mcp=on (/mcp) a2a=off acp=off
INFO A2A disabled
INFO ACP disabled
+INFO AP2 disabled
PASS Payments x402 v2 (scheme=exact) enabled - LOCAL dev chain (eip155:84532, chain id shared with Base Sepolia), destination=0x7099…79C8, facilitator=local
INFO Payments (MPP) planned - not implemented in this release
PASS Storage sqlite schema v1 writable; receipts=2
@@ -398,8 +417,9 @@ Exits non-zero if anything fails. `--json` for machines.
The demo binds everything to `127.0.0.1`. Before putting the gateway anywhere
reachable by anyone else, know the split:
-- **Agent routes** (`/api/resources/:id/invoke`, `/mcp`) are unauthenticated by
- design - paid resources are protected by payment, not by a password.
+- **Agent routes** (`/api/resources/:id/invoke`, `/mcp`, the A2A mount) are
+ unauthenticated by design - paid resources are protected by payment, not by a
+ password. ACP is the exception: its checkout routes require a bearer token.
- **Operator routes** (`/api/receipts`, `/api/events`, `/api/events/stream`) are
the merchant's commerce ledger: payer addresses, amounts, settlement hashes.
They require `server.adminToken`, and return **404** if none is configured.
@@ -425,11 +445,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
**Now** - MCP, x402 v2, settlement on the local chain, Base Sepolia and Base
mainnet, receipts, doctor, deterministic demo, experimental A2A v1.0.0 and ACP
-`2026-04-17` checkout adapters, and experimental OpenAPI import.
+`2026-04-17` checkout adapters, experimental AP2 v0.2.0 mandate verification,
+and experimental OpenAPI import.
-**Next** - a `doctor` GitHub Action · UCP · MPP · AP2 · more of ACP (carts,
-feed, delegated payment) · Shopify and WooCommerce examples · PostgreSQL ·
-richer observability · multi-file and remote OpenAPI sources.
+**Next** - a `doctor` GitHub Action · UCP · MPP · autonomous-mode AP2 (open
+mandates, agent key binding, constraint evaluation) · more of ACP (carts, feed,
+delegated payment) · Shopify and WooCommerce examples · PostgreSQL · richer
+observability · multi-file and remote OpenAPI sources.
New protocols land only after the adapter model survives real use. Scope
discipline is a release requirement, not a mood.
@@ -441,6 +463,7 @@ discipline is a release requirement, not a mood.
| [Architecture](docs/architecture.md) | how the pieces fit |
| [Payment flow](docs/payment-flow.md) | the paid round trip, and every way it fails |
| [Protocols](docs/protocols.md) | exactly what is and is not supported |
+| [AP2](docs/ap2.md) | mandate verification and the trust model |
| [Configuration](docs/configuration.md) | `config.yaml` reference |
| [OpenAPI import](docs/openapi-import.md) | generate resources from an existing API |
| [Security model](docs/security.md) | trust boundaries, and what we do not defend |
diff --git a/SECURITY.md b/SECURITY.md
index 2beb993..2e6b22a 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -5,6 +5,31 @@
> path. There is no third-party audit report to point you at, for any release.
> Weigh that before putting production funds through it.
+## Reporting a vulnerability
+
+Please report security issues **privately** - do not open a public issue.
+
+1. Use GitHub's **Report a vulnerability** (Security → Advisories) on this
+ repository: .
+ That is the only private reporting channel; this project publishes no
+ maintainer email address.
+2. Include the affected version or commit, a description, reproduction steps,
+ and the impact you believe it has.
+3. You will get an acknowledgement within **5 working days**, and a status
+ update at least every **10 working days** until it is resolved.
+4. Please give us **90 days** before public disclosure, or less by agreement if
+ a fix ships sooner.
+
+We credit reporters in the release notes unless you would rather we did not.
+
+### Out of scope for reports
+
+- The deliberately public Anvil development keys and the local demo chain.
+- The demo merchant API's failure-injection routes (`/api/slow`, `/api/fail`),
+ which exist only to test the gateway.
+- Anything in [what the gateway does not protect against](#what-the-gateway-does-not-protect-against),
+ which is documented rather than overlooked.
+
## Non-custodial by design
Agent Commerce Gateway is **not** a payment processor, wallet, exchange or
@@ -13,14 +38,13 @@ custodian.
- The gateway **never** accepts, stores, derives or requires a merchant or buyer
production private key or seed phrase.
- The merchant settlement destination is plain configuration
- (`payments.x402.payTo: ${MERCHANT_WALLET}`) — an address the merchant
+ (`payments.x402.payTo: ${MERCHANT_WALLET}`), an address the merchant
controls. It is never a gateway-owned wallet.
- Funds move **buyer → merchant destination** through the payment protocol
itself. With x402 `exact`/EVM this is an EIP-3009 `transferWithAuthorization`:
the buyer signs an authorisation that names the merchant as recipient, so a
facilitator that broadcasts it cannot redirect the money.
-- Rationale and detail: [`docs/payment-flow.md`](docs/payment-flow.md), which
- walks the money path end to end.
+- The money path end to end: [`docs/payment-flow.md`](docs/payment-flow.md).
## Private-key policy
@@ -32,9 +56,9 @@ custodian.
- The buyer key used by the demo is **Anvil well-known account #2**, defined in
`src/payments/x402/local-chain/accounts.ts` (`LOCAL_BUYER_ACCOUNT`) and
written into `.deploy/local.json` by `npm run chain:deploy`; the demo agent
- reads it from that manifest. It is never read by the gateway and the gateway
- never signs with it. Key locations in this document are stated literally: if
- one moves, this bullet is wrong until it is updated.
+ reads it from that manifest. The gateway never reads it and never signs with
+ it. Key locations in this document are stated literally: if one moves, this
+ bullet is wrong until someone updates it.
- The local facilitator signer pays gas on the dev chain only. Production
deployments point at an external facilitator instead.
@@ -51,8 +75,14 @@ custodian.
provider derives a `replayKey` from the authorisation (payer, nonce, asset,
network) and the pipeline reserves it under a `UNIQUE` constraint *before*
settling. A duplicate is `PAYMENT_REPLAYED`.
+- **Purchase authorisation, where a resource requires it.** With AP2 enabled, a
+ paid resource can demand a signed Checkout Mandate proving the human behind
+ the agent approved that exact purchase. It is verified and reserved before
+ settlement, spendable once, and never a substitute for payment. Trust is
+ static public keys in configuration, with no key discovery of any kind. See
+ [`docs/ap2.md`](docs/ap2.md).
- **Bounded backend calls.** Every merchant backend call has an explicit
- timeout *and* a 1 MB cap on the response body — a timeout bounds a call by
+ timeout *and* a 1 MB cap on the response body: a timeout bounds a call by
time, not by bytes. There is no unbounded outbound HTTP request.
- **Host-header (DNS-rebinding) validation.** Every request, browser or not, is
checked against the configured host allow-list before routing. A rebinding
@@ -61,24 +91,27 @@ custodian.
routes from inside the victim's network. See `src/gateway/access-control.ts`.
- **Configuration validated before startup.** Invalid configuration fails the
process rather than starting a half-configured gateway.
-- **Secret redaction.** The logger redacts `authorization` headers, the
- `x-payment` header, and `privateKey`, `signerPrivateKey`, `signature`,
- `seed`, `mnemonic`, `secret`, `apiKey`, `adminToken` and `token` fields **at
- the top level and one level deep** — pino's redaction wildcards are
- single-level, so a secret nested at depth two or more is not covered by the
- logger and must not be handed to it (every call site funnels caught errors
- through `describeError`, which extracts only `{message, name}`). Receipts
- and events persist
- no secrets and no raw payment proofs.
+- **Secrets kept out of logs and storage.** The request serializer emits only
+ method, sanitised URL, host and remote address, so request headers never
+ reach a log line in the first place. Behind that, the logger redacts every
+ header that carries a credential or a proof - `Authorization`,
+ `PAYMENT-SIGNATURE` and `Agent-Authorization` - and the
+ `privateKey`, `signerPrivateKey`, `signature`, `seed`, `mnemonic`, `secret`,
+ `apiKey`, `adminToken` and `token` fields **at the top level and one level
+ deep**. Pino's wildcards are single-level, so a secret nested two deep is not
+ covered by the logger and must not be handed to it; every call site funnels
+ caught errors through `describeError`, which extracts only `{message, name}`.
+ Receipts and events persist no secrets, no raw payment proofs and no mandates.
- **Input validation** on resource inputs, path parameters, body size, content
type, payment metadata and configuration.
## Which routes are authenticated
**None of the agent-facing routes, by design.** An agent that can pay is a
-customer, not an intruder, so `POST /api/resources/:id/invoke`, `/mcp`,
-`GET /api/resources`, `GET /health` and `GET /.well-known/agent-commerce` are
-open. Paid resources are protected by payment, not by authentication.
+customer, not an intruder, so `POST /api/resources/:id/invoke`,
+`GET /api/resources`, `GET /health`, `GET /ready`,
+`GET /.well-known/agent-commerce` and the `/mcp` and A2A mounts are open. Paid
+resources are protected by payment, not by authentication.
**ACP is the exception among agent routes.** When `protocols.acp` is enabled,
every checkout route under its mount requires
@@ -89,43 +122,43 @@ implemented and a `Signature` header never substitutes for the bearer token, so
ACP must be deployed behind TLS. See [docs/security.md](docs/security.md#acp).
**The operator routes are different.** `GET /api/receipts`, `GET /api/events`
-and `GET /api/events/stream` expose the merchant's commerce ledger — payer and
+and `GET /api/events/stream` expose the merchant's commerce ledger: payer and
payee addresses, amounts, settlement transaction hashes, resource ids and
timings. That is revenue history and customer on-chain identity, not public
data. They require `server.adminToken`, and **if no token is configured they
return 404 rather than serving openly**.
-The dashboard needs this same token to read those routes, via
-`VITE_ADMIN_TOKEN` — and because Vite inlines every `VITE_`-prefixed variable
-into the JavaScript it serves, that token is **not a server-side secret once it
-reaches the dashboard**. It is a public value, readable by anyone who can load
-the dashboard's page, not merely anyone who can reach the gateway. The demo
-stack accepts this because the dashboard is loopback-only and ships a
-non-secret placeholder; a real deployment must not point a real
-`server.adminToken` at this variable. The dashboard's port is a different trust
-boundary from the gateway's, and there is currently no server-side proxy that
-would keep the token off the client (post-alpha).
+The dashboard needs that same token to read those routes, via
+`VITE_ADMIN_TOKEN`. Because Vite inlines every `VITE_`-prefixed variable into
+the JavaScript it serves, that token is **not a server-side secret once it
+reaches the dashboard**: it is readable by anyone who can load the dashboard's
+page, not merely by anyone who can reach the gateway. The demo stack accepts
+this because the dashboard is loopback-only and ships a non-secret placeholder.
+A real deployment must not point a real `server.adminToken` at this variable.
+The dashboard's port is a different trust boundary from the gateway's, and
+there is no server-side proxy yet that would keep the token off the client
+(post-alpha).
Browser access is governed by `server.allowedOrigins`, an explicit allowlist
that defaults to empty. Agent traffic is not browser traffic and receives no
CORS headers at all.
-### The live event stream is polled, not streamed, when a token is configured
+### The live event stream is polled, not streamed
A browser `EventSource` cannot send custom headers, so the dashboard's SSE
-connection to `/api/events/stream` cannot carry the admin token. It therefore
-receives a 401 when a token is configured — and a 404 when one is not, because
-the operator routes are closed by default. **There is no posture in which a
-browser can read the stream.** The route remains usable by a header-capable
-client; the dashboard uses authenticated polling of `GET /api/events`, which is
-its intended path rather than a degraded mode.
+connection to `/api/events/stream` cannot carry the admin token. It receives a
+401 when a token is configured, and a 404 when one is not, because the operator
+routes are closed by default. **There is no posture in which a browser can read
+the stream.** A header-capable client still can; the dashboard polls
+`GET /api/events` instead, which is its intended path rather than a degraded
+mode.
Accepting the token as a `?adminToken=` query parameter on that one route would
-keep the stream working in a browser. **It is deliberately not supported.** The
-cost — credentials leaking through `Referer`, browser history and intermediary
-logs — buys a convenience nothing needs, because the dashboard polls instead.
-Do not add it without a client that genuinely requires it and a reason that
-outweighs putting a credential in a URL.
+make the stream work in a browser. **It is deliberately not supported.** The
+cost - credentials leaking through `Referer`, browser history and intermediary
+logs - buys a convenience nothing needs, because the dashboard polls. Do not
+add it without a client that genuinely requires it and a reason that outweighs
+putting a credential in a URL.
## What the gateway does **not** protect against
@@ -133,17 +166,17 @@ Be clear-eyed about this. Running this gateway does not make your agent, your
backend or your business secure.
- **It does not secure your merchant backend.** Authentication, authorisation,
- rate limiting and data protection in your API remain entirely your
- responsibility.
+ rate limiting and data protection in your API remain entirely yours.
- **It does not vet the buyer.** Any party able to produce a valid payment gets
the resource. There is no KYC, sanctions screening, fraud scoring or dispute
- mechanism.
+ mechanism. An AP2 mandate proves a human approved the purchase; it says
+ nothing about who that human is.
- **It does not make payments reversible.** On-chain settlement is final. There
are no refunds, chargebacks or escrow.
- **It does not protect against SSRF beyond configuration discipline.** The
- gateway calls the backend URLs an administrator configured. Redirects are not
- followed. But if you configure an internal URL, the gateway will call it —
- agent- or user-controlled backend URLs are forbidden, and there is no
+ gateway calls the backend URLs an administrator configured, and does not
+ follow redirects. But if you configure an internal URL, the gateway will call
+ it. Agent- or user-controlled backend URLs are forbidden, and there is no
allowlist enforcement.
- **It does not audit the payment protocol or its SDKs.** x402, the MCP SDK and
their transitive dependencies are third-party code.
@@ -154,11 +187,11 @@ backend or your business secure.
successful payment is possible; it is recorded as an event and a payment
attempt, and reconciliation is the merchant's responsibility.
- **It cannot always tell you whether a payment settled.** If the settlement
- transaction is broadcast but its receipt cannot be confirmed — an RPC timeout
- or a dropped connection — the outcome is genuinely unknown. The gateway
- records the attempt as `settlement-uncertain` with the broadcast transaction
- hash, and does **not** deliver the resource. Resolving it is the merchant's
- responsibility: check the recorded hash with `getTransactionReceipt`. The
+ transaction is broadcast but its receipt cannot be confirmed, through an RPC
+ timeout or a dropped connection, the outcome is genuinely unknown. The
+ gateway records the attempt as `settlement-uncertain` with the broadcast
+ transaction hash, and does **not** deliver the resource. Resolving it is the
+ merchant's job: check the recorded hash with `getTransactionReceipt`. The
gateway will not report this as a failure, because it does not know that it
was one.
- **It prioritises delivery over bookkeeping.** If a resource is delivered but
@@ -167,42 +200,19 @@ backend or your business secure.
delivered.
- **A reserved payment authorisation is never released.** If settlement fails,
that authorisation cannot be reused at this gateway even when nothing moved
- on-chain. This is deliberate — releasing it would reopen a replay window —
- but a buyer hit by a transient error must sign a fresh authorisation.
+ on-chain. This is deliberate, since releasing it would reopen a replay
+ window, but a buyer hit by a transient error must sign a fresh authorisation.
+ An AP2 mandate is handed back in the narrower case where settlement provably
+ moved no money, and kept otherwise.
- **A rejected request can still have been charged for.** A few request-shape
errors are only detectable when the backend call is assembled, which happens
- after settlement. The gateway hoists the checks it can — empty, `.` and `..`
+ after settlement. The gateway hoists the checks it can - empty, `.` and `..`
path parameters, and input keys colliding with an operator-configured query
- parameter, are all rejected **before** any payment is taken. But settlement is
- final and there are no refunds, so if you configure a resource whose inputs can
- fail late, your buyers can pay for a request that is never delivered. The
- attempt is recorded as `settled` with a `backend.failed` event sharing the same
- `requestId`, so reconciliation is possible.
+ parameter are all rejected **before** any payment is taken. But settlement is
+ final and there are no refunds, so if you configure a resource whose inputs
+ can fail late, your buyers can pay for a request that is never delivered. The
+ attempt is recorded as `settled` with a `backend.failed` event sharing the
+ same `requestId`, so reconciliation is possible.
- **It does not rate limit anything.** Free resources are an unauthenticated
proxy to your backend at whatever rate a caller chooses. Rate limiting,
quotas and abuse controls belong in your API or your edge.
-
-## Reporting a vulnerability
-
-Please report security issues **privately** — do not open a public issue.
-
-1. Use GitHub's **Report a vulnerability** (Security → Advisories) on this
- repository: .
- That is the only private reporting channel — this project publishes no
- maintainer email address, and an earlier revision of this page pointed at a
- list in `CONTRIBUTING.md` that does not exist.
-2. Include: affected version/commit, a description, reproduction steps, and the
- impact you believe it has.
-3. You will get an acknowledgement within **5 working days** and a status update
- at least every **10 working days** until resolution.
-4. Please give us **90 days** before public disclosure, or less by agreement if
- a fix ships sooner.
-
-We will credit reporters in the release notes unless you prefer otherwise.
-
-## Out of scope for reports
-
-- The deliberately public Anvil development keys and the local demo chain.
-- The demo merchant API's failure-injection routes (`/api/slow`, `/api/fail`),
- which exist only to test the gateway.
-- Missing hardening we already document as out of scope above.
diff --git a/config.example.yaml b/config.example.yaml
index f76e317..03d8ca0 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -1,5 +1,5 @@
# ---------------------------------------------------------------------------
-# Agent Commerce Gateway — example configuration
+# Agent Commerce Gateway - example configuration
#
# Copy to `config.yaml` and edit, or generate one with:
# npm run agent-commerce -- init
@@ -44,7 +44,7 @@ protocols:
mcp:
enabled: true
mountPath: /mcp
- # A2A (Agent2Agent) v1.0.0 — experimental, off by default.
+ # A2A (Agent2Agent) v1.0.0 - experimental, off by default.
# Enabling it serves JSON-RPC `SendMessage` at mountPath and the
# specification-fixed Agent Card at /.well-known/agent-card.json. Clients must
# send `A2A-Version: 1.0`. Streaming, task persistence and push notifications
@@ -52,7 +52,7 @@ protocols:
a2a:
enabled: false
mountPath: /a2a
- # ACP (Agentic Commerce Protocol), stable snapshot 2026-04-17 — experimental,
+ # ACP (Agentic Commerce Protocol), stable snapshot 2026-04-17 - experimental,
# off by default. Enabling it serves the five checkout routes under mountPath
# and the specification-fixed discovery document at /.well-known/acp.json.
# Clients must send `Authorization: Bearer …`, `API-Version: 2026-04-17`, and
@@ -118,14 +118,14 @@ payments:
assetVersion: ${X402_ASSET_VERSION}
assetDecimals: ${X402_ASSET_DECIMALS}
# Merchant-controlled settlement destination.
- # This is NEVER a gateway-owned wallet — see docs/security.md.
+ # This is NEVER a gateway-owned wallet - see docs/security.md.
payTo: ${MERCHANT_WALLET}
# Seconds a payment challenge stays valid.
maxTimeoutSeconds: 120
# Who verifies the authorisation and broadcasts the transfer.
#
# mode: local the facilitator runs in this process and signs with
- # signerPrivateKey. Deterministic dev chain only — the key
+ # signerPrivateKey. Deterministic dev chain only - the key
# must be an Anvil well-known one, and startup refuses it
# against anything that is not a local/private RPC.
#
@@ -136,7 +136,7 @@ payments:
mode: local
signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY}
#
- # A public testnet instead — no key anywhere in this file:
+ # A public testnet instead - no key anywhere in this file:
#
# network: eip155:84532
# facilitator:
@@ -156,3 +156,55 @@ payments:
# auth:
# type: bearer
# token: ${X402_FACILITATOR_TOKEN}
+
+# ---------------------------------------------------------------------------
+# AP2 mandate verification - experimental, off by default.
+#
+# Makes a paid resource require proof that the human behind the agent approved
+# THIS purchase: a signed AP2 v0.2.0 Direct Checkout Mandate, verified before
+# the payment is allowed to settle. A mandate never unlocks a resource on its
+# own and never moves money; the resource still takes a real x402 payment.
+#
+# Public keys only, written here by you. Nothing is fetched at runtime: no
+# JWKS, no `jku`, no `x5u`, no issuer discovery. Rotate by listing the new key
+# beside the old one, moving the signer to the new `kid`, then removing the old.
+#
+# Needs the optional peers: npm install jose @sd-jwt/core canonicalize
+# Full reference: docs/ap2.md
+# ---------------------------------------------------------------------------
+# authorization:
+# ap2:
+# enabled: true
+# specVersion: "0.2.0" # the only supported value
+# mode: direct # the only supported mode
+# clockSkewSeconds: 60 # default; 300 is the ceiling
+# replay:
+# # Its own file. An authorization replay is not a payment replay, and a
+# # spent mandate must stay spent for as long as you can be asked what
+# # you delivered.
+# path: ./data/ap2-authorizations.sqlite
+# trust:
+# # Who may issue a Checkout Mandate. Usually the buyer's shopping agent
+# # or credential provider.
+# mandateIssuers:
+# - issuer: https://surface.example
+# audience: ${MERCHANT_ID} # required: who the mandate is for
+# keys:
+# - kid: mandate-2026-01
+# jwk: { kty: EC, crv: P-256, x: "...", y: "..." }
+# # Who signs YOUR checkout documents. A separate list on purpose:
+# # signing checkouts must not confer the power to issue mandates.
+# checkoutIssuers:
+# - issuer: https://merchant.example
+# audience: agent-commerce
+# keys:
+# - kid: checkout-2026-01
+# jwk: { kty: EC, crv: P-256, x: "...", y: "..." }
+#
+# Then require it on a paid resource, under `resources:`:
+#
+# market_report:
+# pricing: { type: fixed, amount: "0.01", currency: USDC }
+# payments: [x402]
+# authorization:
+# required: [ap2]
diff --git a/docs/ap2.md b/docs/ap2.md
new file mode 100644
index 0000000..7f2c72f
--- /dev/null
+++ b/docs/ap2.md
@@ -0,0 +1,383 @@
+# AP2 mandate verification
+
+**Experimental.** The gateway verifies an [AP2](https://github.com/google-agentic-commerce/AP2)
+**v0.2.0 Direct Checkout Mandate** before it lets a payment settle, so a paid
+resource can require proof that the human behind an agent approved *this exact
+purchase*.
+
+Off by default: a deployment that configures nothing here behaves exactly as it
+did before AP2 existed.
+
+## Call it what it is
+
+This is **AP2 merchant-side mandate verification**, not a full AP2 Merchant
+implementation. AP2 v0.2's Merchant role also covers Checkout Receipts, and the
+gateway holds no signing key and issues none. It is the verifying half.
+
+| | |
+| --- | --- |
+| Spec | AP2 **v0.2.0**, tagged 2026-04-28, commit `b4587ac` |
+| Mode | Direct (Human-Present) |
+| Mandate type | closed Checkout Mandate, `vct` exactly `mandate.checkout.1` |
+| Signatures | ES256 over P-256, and nothing else |
+| Trust | static public keys in `config.yaml`, no discovery of any kind |
+
+## What a verified mandate proves
+
+1. A configured issuer signed it, with a key that issuer declared.
+2. It has not expired, and was not issued in the future.
+3. It is addressed to this merchant.
+4. It binds a checkout document the merchant signed.
+5. That document authorises the resource, input, price and rail in front of us.
+6. It has not been spent before.
+
+Nothing else. A mandate never unlocks a resource on its own and never moves
+money: a gated resource still needs a real payment proof.
+
+## Where it sits
+
+```text
+CanonicalRequest
+ -> resolve resource, validate input, resolve price
+ -> no payment proof? 402 challenge + the AP2 requirement
+ -> verify payment proof no funds move
+ -> VERIFY AND RESERVE THE MANDATE AUTHORIZATION_*, fail closed
+ -> reserve the payment replay key
+ -> settle funds move here, and only here
+ -> consume | release | mark uncertain the reservation
+ -> merchant backend
+ -> receipt, carrying the mandate's digest
+```
+
+The order is the control. Payment verification runs first because it has no
+side effect, so a bad proof cannot burn a reservation; the mandate is reserved
+before settlement, so two presentations cannot race one payment; and its fate
+is decided afterwards, because until settlement returns nobody knows it.
+
+## The Agent Commerce checkout profile
+
+AP2 leaves the checkout payload outside its scope, so the claims a paid
+invocation needs are specified here instead, under the identifier
+
+```text
+agent-commerce/ap2/checkout/v1
+```
+
+A bare name, like the gateway's other wire identifiers: a profile id is a
+namespace, never dereferenced, so a URL would only tie the format to a domain.
+**Frozen** once released, because merchants sign it into every checkout JWT.
+
+### The mandate
+
+A closed Checkout Mandate, presented as an SD-JWT with its disclosures:
+
+| Claim | Required | Notes |
+| --- | --- | --- |
+| `vct` | yes | exactly `mandate.checkout.1` |
+| `iss` | yes | must be a configured mandate issuer |
+| `aud` | yes | must equal that issuer's configured `audience` |
+| `iat` | yes | rejected if further ahead than the configured skew |
+| `exp` | yes | required, not only checked when present |
+| `checkout_hash` | yes | `base64url(SHA-256(compact checkout JWT))` |
+| `checkout_jwt` | yes | the compact merchant checkout JWT, read after disclosures resolve |
+| `_sd_alg` | when present | `sha-256` only |
+
+A key-bound presentation (`cnf`, a KB-JWT) is refused: Direct mode issues none,
+so one arriving belongs to a flow this release does not verify.
+
+### The merchant checkout JWT
+
+| Claim | Required | Notes |
+| --- | --- | --- |
+| `iss` | yes | must be a configured **checkout** issuer |
+| `aud` | yes | must equal that issuer's configured `audience` |
+| `iat` | yes | rejected if further ahead than the configured skew |
+| `exp` | yes | required |
+| `jti` | yes | an opaque id; recorded in the receipt and used for replay defence |
+| `agent_commerce` | yes | the profile object below |
+
+### The profile object
+
+Every field is a string, and absent is a mismatch rather than a skipped check:
+a mandate that will not say which resource or how much authorises nothing in
+particular.
+
+| Field | Compared against |
+| --- | --- |
+| `profile` | the literal `agent-commerce/ap2/checkout/v1` |
+| `resource_id` | the resolved resource |
+| `input_hash` | the digest of the validated input, below |
+| `amount` | the resolved price, **as a string** |
+| `currency` | the resolved currency |
+| `payment_method` | the payment provider that built the requirement |
+| `destination` | the requirement's settlement destination |
+| `network` | the requirement's CAIP-2 network |
+| `asset` | the requirement's asset |
+
+The last three are checked whenever **either** side names one, so under x402,
+which names all three, all three are required. A mandate silent about the chain
+must not unlock a settlement on one, and a mandate naming a chain the
+requirement lacks was approved for another rail.
+
+Amounts are compared as decimal strings, never numerically: `0.10` and `0.1`
+are different strings, and a mandate says what it says.
+
+Everything is compared against the **already resolved** request. Nothing is
+taken from the mandate and used to shape the purchase, which would invert the
+control.
+
+### The input hash
+
+```text
+input_hash = base64url(SHA-256(RFC 8785 JCS(validated input)))
+```
+
+[RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) (JCS), not a sorted-key
+`JSON.stringify`. The merchant's signer computes this digest too, probably in
+another language, and the two agree only if both follow JCS number formatting
+and UTF-16 key ordering.
+
+What gets hashed is exactly what the backend will receive: validated, reserved
+fields stripped, no request id, no transport metadata. A buyer could not have
+known any of that when they approved.
+
+Without it, one mandate for `translate` would authorise any translation.
+
+## Minting the checkout JWT
+
+The gateway only verifies. Someone has to sign, and for the checkout JWT that
+someone is you, in your own process, with a key whose public half you listed
+under `checkoutIssuers`.
+
+```ts
+import { createCheckoutJwt } from '@devlab.group/agent-commerce/ap2';
+
+const jwt = await createCheckoutJwt({
+ privateKey, // a private JWK, or a PKCS#8 PEM
+ kid: 'checkout-2026-01', // must match a configured key
+ issuer: 'https://merchant.example',
+ audience: 'agent-commerce',
+ resourceId: 'market_report',
+ input: { city: 'Berlin' }, // it computes the RFC 8785 digest
+ amount: '0.01', // a string, from your own catalogue
+ currency: 'USDC',
+ paymentMethod: 'x402',
+ destination, network, asset, // as the 402 published them
+});
+```
+
+It exists mainly to compute [`input_hash`](#the-input-hash) the way the gateway
+does, so nobody has to reimplement JCS and discover the difference on a float.
+
+It also refuses, before signing, what would otherwise surface much later as one
+coarse `AUTHORIZATION_INVALID`: a numeric `amount`, the public half of the key
+pair, a key that is not P-256, and a missing required field.
+
+What it cannot check is agreement with the gateway's own resolved requirement,
+which it never sees. Take `amount` and `currency` from your catalogue and the
+settlement coordinates from the 402, rather than echoing what the agent asked
+for. A lie from the agent fails closed at verification either way, but a
+mismatch you introduce fails just as closed and is yours to debug.
+
+The mandate that wraps this JWT is signed elsewhere, by the buyer's agent or
+credential provider, using a key listed under `mandateIssuers`. Nothing in this
+package mints one: the gateway is the merchant, not the buyer.
+
+## Trust
+
+**Static public keys only.** Every verification key is written into
+`config.yaml` by an operator.
+
+- No JWKS, no issuer metadata, no fetching of any kind at runtime.
+- `jku` and `x5u` are not followed. A JWK carrying either is refused at load,
+ by an allowlist of members (`kty`, `crv`, `x`, `y`, `kid`, `alg`, `use`)
+ rather than a denylist that has to remember them.
+- Private material (`d`) is refused at load, naming the key to rotate.
+
+A mandate's `iss` and `kid` only choose *which* configured key verifies it. An
+unrecognised pair is refused, so a mandate can never nominate its own signer,
+and there is no "try every key" fallback that would make `kid` advisory.
+
+**Two separate lists.** `trust.mandateIssuers` signs mandates;
+`trust.checkoutIssuers` signs the merchant's checkout documents. Being trusted
+for one confers nothing for the other.
+
+Each issuer carries its own `audience`, required and never defaulted. Without
+it a mandate minted for another merchant would verify here, and there is no
+value worth guessing for something that decides that.
+
+### Rotating a key
+
+List the new public key beside the old one under the same issuer and deploy;
+move the signer to the new `kid`; once nothing old is in flight, remove the old
+key and deploy again. Both are live during the overlap, and `kid` picks which
+one verifies a given mandate.
+
+There is no revocation API: removing a key from the config and restarting is
+the revocation.
+
+## Time
+
+`clockSkewSeconds` (default 60, ceiling 300) applies to `exp`, `nbf` and `iat`
+on both the mandate and the checkout JWT. The ceiling exists because a skew
+wide enough to cover a mandate's whole validity window stops `exp` rejecting
+anything; an operator needing more than five minutes has a clock to fix.
+
+`iat` further ahead than the skew is refused. That is a broken signer, or a
+mandate minted to outlive its own expiry window.
+
+## Replay
+
+A mandate is spendable exactly once, recorded in its own SQLite database
+(`authorization.ap2.replay.path`) that no other store shares.
+
+The replay key is a digest of the **issuer-signed token**, not of the
+presentation. Selective disclosure gives one mandate many valid presentation
+strings, so keying on the presentation would let it be spent once per disclosed
+subset. The checkout `jti` is guarded as well, so two mandates binding one
+checkout document cannot both settle.
+
+| State | Meaning |
+| --- | --- |
+| `reserved` | claimed, outcome not yet known. Not reusable |
+| `consumed` | settled. Never reusable |
+| `released` | nothing happened. Presentable again |
+| `uncertain` | settlement broadcast, outcome never learned. Not reusable |
+
+Only a failure that provably moved no money releases a reservation. A
+settlement broadcast but never confirmed is marked `uncertain` instead: the
+buyer's funds may already have moved, and a mandate handed back after that can
+be spent twice.
+
+Nothing is swept: deleting a consumed row makes that mandate spendable again,
+and it must stay consumed for as long as the merchant can be asked what they
+delivered. If the table needs bounding, archive `released` rows only.
+
+Settlement and the local commit are not one transaction. If the process dies
+between them the row stays `reserved` and that mandate is refused from then on:
+a refused retry costs a round trip, the other direction costs a second payment.
+
+## What is recorded, and what is not
+
+A receipt keeps a method and a digest:
+
+```json
+{
+ "authorization": {
+ "method": "ap2",
+ "reference": "sha256:BASE64URL",
+ "metadata": {
+ "mandateIssuer": "https://surface.example",
+ "checkoutIssuer": "https://merchant.example",
+ "checkoutId": "checkout_01K..."
+ }
+ }
+}
+```
+
+The presentation, its disclosures, the checkout JWT and the
+`Agent-Authorization` header are **never** stored and never logged. A receipt
+outlives the request that produced it, and a stored mandate would be a
+spendable secret at rest. Failures are logged as reason codes.
+
+**Evidence retention is not solved here.** A digest proves a mandate with that
+identity was accepted; it does not reconstruct what the buyer saw or agreed to.
+Dispute-grade evidence stays with the merchant or the system that minted the
+mandate, unless an encrypted evidence store is added later.
+
+## Errors
+
+| Code | HTTP | When |
+| --- | --- | --- |
+| `AUTHORIZATION_REQUIRED` | 403 | the resource requires a mandate and none was presented |
+| `AUTHORIZATION_INVALID` | 403 | signature, trust, binding, time or purchase mismatch |
+| `AUTHORIZATION_REPLAYED` | 409 | the mandate is good, and already spent |
+| `AUTHORIZATION_PROVIDER_UNAVAILABLE` | 503 | our verifier or store failed. Retryable |
+
+403 rather than 402: the buyer's money is not the problem. A 402 tells a client
+"pay and retry", which cannot fix a rejected mandate, and a client that auto-pays
+on 402 would be charged for a request that was never going to be delivered.
+
+Rejection reasons are coarse by design (`untrusted_issuer`, `invalid_signature`,
+`expired`, `purchase_mismatch`, and a handful more). A caller learns roughly
+where its mandate was refused, not which field disagreed: finer detail lets
+someone read a mandate's contents out of the gateway by elimination.
+
+An outage is never recorded against the payer. Their mandate may be perfectly
+good.
+
+## Carrying a mandate
+
+One envelope, three transports:
+
+```json
+{ "method": "ap2", "payload": "" }
+```
+
+| Surface | Carrier |
+| --- | --- |
+| HTTP | `Agent-Authorization` header, base64url of that JSON |
+| MCP | the reserved `_authorization` tool argument |
+| A2A | the reserved `_authorization` input field |
+
+HTTP uses a header because the payment proof already travels out of band there,
+and an authorization inside the body would have to survive every backend
+input-binding mode intact. The header is capped at 8192 bytes, checked before
+any decode; see [security.md](security.md#denial-of-service).
+
+The payload is preserved byte for byte from the wire. Reserved fields are
+stripped before validation, so `_authorization` never reaches the merchant
+backend and never enters the input hash.
+
+## Configuration
+
+The YAML block and every rule the loader enforces are in
+[configuration.md](configuration.md#authorizationap2). One rule surprises
+people: `required: [ap2]` on a **free** resource is refused, at load and again
+on the execution path. Authorization gates settlement, so where there is no
+settlement nothing would ever read the mandate.
+
+The provider lives on the `./ap2` subpath and its peers are optional:
+
+```bash
+npm install @devlab.group/agent-commerce jose @sd-jwt/core canonicalize
+```
+
+`agent-commerce doctor` reports the pins, the trusted issuer ids with key
+counts, the replay store's writability, and which resources a mandate gates.
+`GET /.well-known/agent-commerce` lists the provider's descriptor under
+`authorizationProviders`, apart from the payment rails.
+
+## Not implemented
+
+Refused rather than half-served. The adapter descriptor and `agent-commerce
+doctor` print the machine-readable half of this at runtime; this page adds the
+AP2 roles and artefacts the gateway does not play or produce. If the two ever
+disagree about something they both name, the runtime list is the truth and this
+page is a bug.
+
+- autonomous mode, and open Checkout Mandates (`mandate.checkout.open.1`)
+- intent mandates, cart mandates, Payment Mandate verification
+- spending-constraint evaluation (`allowed_merchants`, `line_items`)
+- `cnf`-bound agent keys and delegation chains
+- JWKS, `jku`, `x5u`, issuer metadata fetching, remote revocation
+- key rotation without a config change
+- algorithms other than ES256, digests other than sha-256
+- mandate issuance and signed Checkout Receipts (the checkout JWT you can sign
+ with `createCheckoutJwt`, above; the mandate itself is the buyer's side)
+- an AP2 transport adapter, `/.well-known/ap2`, AP2 as a payment rail
+- AP2 over the ACP checkout adapter
+
+Open mandates are the one worth naming twice: they carry spending constraints
+this release does not evaluate, so accepting one would tell a buyer their
+limits were checked when nothing read them.
+
+## Where to look
+
+| | |
+| --- | --- |
+| `src/authorization/ap2/` | verifier, trust store, purchase binding, replay store |
+| `src/core/domain/authorization.ts` | the generic contract core enforces |
+| `src/core/execution/pipeline.ts` | the ordering above |
+| `tests/integration/ap2-x402-conformance.test.ts` | every refusal, end to end |
+| `tests/e2e/authorization/` | a gated purchase settling on a real chain |
diff --git a/docs/architecture.md b/docs/architecture.md
index cc522e0..09d88b7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -10,28 +10,31 @@ not scale, and handing the money to a proprietary middleman defeats the point.
## The shape of the answer
```text
- ┌──────────────────────────────────────────────────────────┐
- │ AI Agent │
- └───────────────┬──────────────────────────────────────────┘
- │ MCP (tools/list, tools/call) · HTTP + PAYMENT-SIGNATURE
- ┌───────────────▼──────────────────────────────────────────┐
- │ Agent Commerce Gateway │
- │ (runs in MERCHANT infrastructure) │
- │ │
- │ protocol adapters ──┐ │
- │ mcp, http │ │
- │ ▼ │
- │ ExecutionPipeline ── the single path │
- │ │ │
- │ ┌───────────────┼────────────────┐ │
- │ ▼ ▼ ▼ │
- │ PaymentProvider BackendExecutor ReceiptStore │
- │ (x402) (bounded HTTP) (SQLite) │
- └──────┬────────────────┬──────────────────────────────────┘
- │ │
- payment protocol ┌──────▼──────────────┐
- buyer → merchant │ Merchant Backend API │ (unchanged)
- └──────────────────────┘
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ AI Agent │
+└───────────────────────────────────────┬──────────────────────────────────────┘
+ │ MCP (tools/list, tools/call) · A2A · ACP · HTTP
+ │ PAYMENT-SIGNATURE · Agent-Authorization
+┌───────────────────────────────────────▼──────────────────────────────────────┐
+│ Agent Commerce Gateway │
+│ (runs in MERCHANT infrastructure) │
+│ │
+│ protocol adapters: mcp · http · a2a · acp │
+│ │ │
+│ ▼ │
+│ ExecutionPipeline │
+│ │ │
+│ ┌────────────────────┬───┴─────────────┬────────────────┐ │
+│ ▼ ▼ ▼ ▼ │
+│ AuthorizationProvider PaymentProvider BackendExecutor ReceiptStore │
+│ (ap2) (x402) (bounded HTTP) (SQLite) │
+└───────────────────────────────────────┬──────────────────────────────────────┘
+ │
+ ┌────────────▼───────────┐
+ │ Merchant Backend API │
+ └────────────────────────┘
+
+payment protocol: buyer → merchant, directly. Never through the gateway.
```
Three properties are load-bearing:
@@ -39,7 +42,7 @@ Three properties are load-bearing:
- **Self-hosted.** The gateway runs in the merchant's infrastructure. There is
no central service operated by this project, and none is planned.
- **Non-custodial.** The gateway orchestrates a payment protocol; it never holds
- funds or keys. See.
+ funds or keys. See [security.md](security.md).
- **Configuration, not rewriting.** A merchant exposes an existing endpoint by
describing it in `config.yaml`. If they already have an OpenAPI description,
`agent-commerce import openapi` writes that configuration for them - an
@@ -82,7 +85,7 @@ passes them through and never inspects them.
Why this matters: adding ACP, AP2, A2A or a second payment rail becomes one new
adapter rather than a core rewrite - and semantics from one protocol cannot leak
-into another. See.
+into another. See [contributing-adapters.md](contributing-adapters.md).
## The execution pipeline
@@ -103,8 +106,10 @@ CanonicalRequest
├─ no proof ──► PaymentRequiredOutcome (402) ─────┤ fail closed
├─ verify ──► rejected ► PAYMENT_INVALID ──────┤
├─ replayKey missing ────► PAYMENT_INVALID ───────┤
+ ├─ authorize + reserve ──► AUTHORIZATION_* ───────┤
├─ reserve replayKey ────► PAYMENT_REPLAYED ──────┤
- └─ settle ─────────────► PAYMENT_SETTLEMENT_FAILED
+ ├─ settle ─────────────► PAYMENT_SETTLEMENT_FAILED
+ └─ consume | release | mark the authorization
│
┌───────────────────────────────────────────────────────┘
├─ call merchant backend ────────────────► BACKEND_TIMEOUT / BACKEND_ERROR
@@ -116,6 +121,12 @@ CanonicalRequest
deliberately **between** them: a duplicate authorisation is rejected before any
funds move.
+The authorization step is opt-in per resource and absent from almost every
+deployment. Where a resource does require one, it sits between payment
+verification and settlement for the same reason the replay reservation does,
+and only a failure that provably moved no money hands it back. See
+[ap2.md](ap2.md#where-it-sits).
+
## Correlation
Every flow has one `requestId`, generated by the protocol adapter and carried
@@ -129,6 +140,11 @@ resource.requested → payment.required → payment.verified → payment.settled
→ backend.called → resource.delivered
```
+A resource requiring authorization adds `authorization.verified` (or
+`authorization.rejected`) between the request and the payment events. The event
+types are the same whatever the method, so reading the audit trail never
+requires knowing what AP2 is.
+
## Adapter isolation
An optional adapter that fails to start is marked unhealthy and reported by
@@ -151,15 +167,16 @@ demo buyer agent is a deterministic program, and that is the path CI runs.
## Where to look in the code
-| Concern | Path |
-| ------------------------------------------ | ------------------------------ |
-| canonical model, errors, pipeline | `src/core` |
-| config schema, loader, env substitution | `src/config` |
-| Fastify server, routes, adapter mounting | `src/gateway` |
-| MCP adapter | `src/protocols/mcp` |
-| x402 provider + local/remote facilitator | `src/payments/x402` |
-| SQLite receipts/events/attempts | `src/storage/receipts` |
-| OpenAPI import (config ingress only) | `src/openapi` |
-| CLI (`init`, `import`, `validate`, `doctor`, `demo`) | `src/cli` |
-| demo merchant API / buyer / dashboard | `demo/*` |
-| MockUSDC + local chain scripts | `contracts/`, `scripts/chain/` |
+| Concern | Path |
+| ---------------------------------------------------- | ------------------------------ |
+| canonical model, errors, pipeline | `src/core` |
+| config schema, loader, env substitution | `src/config` |
+| Fastify server, routes, adapter mounting | `src/gateway` |
+| MCP adapter | `src/protocols/mcp` |
+| x402 provider + local/remote facilitator | `src/payments/x402` |
+| AP2 mandate verification | `src/authorization/ap2` |
+| SQLite receipts/events/attempts | `src/storage/receipts` |
+| OpenAPI import (config ingress only) | `src/openapi` |
+| CLI (`init`, `import`, `validate`, `doctor`, `demo`) | `src/cli` |
+| demo merchant API / buyer / dashboard | `demo/*` |
+| MockUSDC + local chain scripts | `contracts/`, `scripts/chain/` |
diff --git a/docs/configuration.md b/docs/configuration.md
index 08edb8d..cb82737 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -34,6 +34,7 @@ npm run agent-commerce -- validate
| `protocols` | yes | which surfaces are enabled |
| `resources` | yes | the capabilities you expose |
| `payments` | when a paid resource exists | rail configuration |
+| `authorization` | no | AP2 mandate verification |
## Resources
@@ -209,6 +210,61 @@ the x402 replay defence.
See [protocols.md](protocols.md#acp) for the wire contract.
+## `authorization.ap2`
+
+Experimental, off by default, and absent from a config that predates it. Full
+reference, including the checkout profile and the trust model:
+[ap2.md](ap2.md).
+
+```yaml
+authorization:
+ ap2:
+ enabled: true
+ specVersion: "0.2.0" # the only supported value
+ mode: direct # the only supported mode
+ clockSkewSeconds: 60 # default; 300 is the ceiling
+ replay:
+ path: ./data/ap2-authorizations.sqlite # its own file, never shared
+ trust:
+ mandateIssuers: # who may issue a Checkout Mandate
+ - issuer: https://surface.example
+ audience: merchant.example
+ keys:
+ - kid: mandate-2026-01
+ jwk: { kty: EC, crv: P-256, x: "...", y: "..." }
+ checkoutIssuers: # who may sign the merchant checkout JWT
+ - issuer: https://merchant.example
+ audience: agent-commerce
+ keys:
+ - kid: checkout-2026-01
+ jwk: { kty: EC, crv: P-256, x: "...", y: "..." }
+```
+
+Then require it on a paid resource:
+
+```yaml
+resources:
+ market_report:
+ pricing: { type: fixed, amount: "0.01", currency: USDC }
+ payments: [x402]
+ authorization:
+ required: [ap2]
+```
+
+Public keys only, written here by an operator. Nothing is fetched: no JWKS, no
+`jku`, no `x5u`, no issuer discovery. A JWK carrying private material is
+refused at load and names the key to rotate.
+
+The two issuer lists are separate on purpose - signing the merchant's checkout
+documents must not confer the power to issue mandates - and `audience` is
+required per issuer rather than defaulted, because without it a mandate minted
+for another merchant would verify here.
+
+Refused at load: requiring `ap2` while the block is absent or disabled;
+requiring it on a **free** resource, since authorization gates settlement and
+there would be none; a `replay.path` shared with the receipt store or the ACP
+idempotency store; and a `clockSkewSeconds` above the ceiling.
+
## Payments
```yaml
diff --git a/docs/contract-surface.txt b/docs/contract-surface.txt
index cfd6f6d..1d56cf1 100644
--- a/docs/contract-surface.txt
+++ b/docs/contract-surface.txt
@@ -1,11 +1,11 @@
# Semantic surface of src/core/public-types.ts
# Generated by scripts/contract-surface.mjs — do not edit by hand.
-# 71 exported symbols.
+# 86 exported symbols.
interface AdapterDescriptor {
readonly capabilities: ReadonlyArray;
readonly implementationVersion: string;
- readonly kind: "protocol" | "payment" | "storage";
+ readonly kind: "protocol" | "payment" | "storage" | "authorization";
readonly name: string;
readonly status: "stable" | "experimental" | "planned";
readonly supportedSpec: string;
@@ -25,6 +25,55 @@ interface AdapterHttpRoute {
readonly path: string;
}
+interface AuthorizationFinalizeContext {
+ readonly requestId: string;
+ readonly resourceId: string;
+ }
+
+interface AuthorizationProvider {
+ consume: (reservationId: string, context: AuthorizationFinalizeContext) => Promise;
+ health: () => Promise;
+ markUncertain: (reservationId: string, context: AuthorizationFinalizeContext) => Promise;
+ readonly descriptor: AdapterDescriptor;
+ readonly name: "ap2";
+ readonly requirement: AuthorizationRequirement;
+ release: (reservationId: string, context: AuthorizationFinalizeContext) => Promise;
+ verifyAndReserve: (context: AuthorizationVerificationContext) => Promise;
+ }
+
+interface AuthorizationRecord {
+ readonly metadata?: Readonly>;
+ readonly method: "ap2";
+ readonly reference: string;
+ }
+
+interface AuthorizationRequirement {
+ readonly method: "ap2";
+ readonly profile?: string;
+ readonly version: string;
+ }
+
+interface AuthorizationSubmission {
+ readonly method: "ap2";
+ readonly payload: string;
+ }
+
+interface AuthorizationVerification {
+ readonly metadata?: Readonly>;
+ readonly method: "ap2";
+ readonly reference: string;
+ readonly reservationId: string;
+ readonly status: "verified";
+ }
+
+interface AuthorizationVerificationContext {
+ readonly input: unknown;
+ readonly requestId: string;
+ readonly requirement: PaymentRequirement;
+ readonly resourceId: string;
+ readonly submission: AuthorizationSubmission;
+ }
+
interface BackendExecutor {
call: (handler: BackendHandler, request: BackendRequest) => Promise;
}
@@ -52,6 +101,7 @@ interface BackendResponse {
}
interface CanonicalRequest {
+ readonly authorization?: AuthorizationSubmission;
readonly input: unknown;
readonly metadata?: Readonly>;
readonly payment?: PaymentSubmission;
@@ -68,7 +118,7 @@ interface Clock {
}
interface CommerceErrorInfo {
- readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR";
+ readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR";
readonly details?: Readonly>;
readonly httpStatus: number;
readonly message: string;
@@ -95,10 +145,11 @@ interface CommerceEvent {
readonly requestId: string;
readonly resourceId?: string;
readonly status?: "ok" | "error";
- readonly type: "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "backend.called" | "backend.failed" | "resource.delivered";
+ readonly type: "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "authorization.verified" | "authorization.rejected" | "backend.called" | "backend.failed" | "resource.delivered";
}
interface CommerceReceipt {
+ readonly authorization?: AuthorizationRecord;
readonly backendStatus: number;
readonly deliveredAt: string;
readonly durationMs?: number;
@@ -111,6 +162,7 @@ interface CommerceReceipt {
}
interface CommerceResource {
+ readonly authorization?: { readonly required: readonly AuthorizationMethodName[]; };
readonly description?: string;
readonly exposedVia: ReadonlyArray;
readonly handler: BackendHandler;
@@ -143,7 +195,7 @@ interface DeliverySummary {
}
interface ErrorEnvelope {
- readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR";
+ readonly code: "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR";
readonly details?: Readonly>;
readonly message: string;
readonly requestId?: string;
@@ -249,6 +301,7 @@ interface PaymentProvider {
}
interface PaymentRequiredEnvelope {
+ readonly authorization?: { readonly required: readonly AuthorizationRequirement[]; };
readonly code: "PAYMENT_REQUIRED";
readonly message: string;
readonly payment: { readonly provider: PaymentMethodName; readonly version: string; readonly amount: DecimalAmount; readonly currency: string; readonly destination: string; readonly network?: string; readonly asset?: string; readonly expiresAt?: IsoTimestamp; readonly accepts: readonly Readonly>[]; readonly envelope?: Readonly>; };
@@ -258,6 +311,7 @@ interface PaymentRequiredEnvelope {
}
interface PaymentRequiredOutcome {
+ readonly authorization?: ReadonlyArray;
readonly kind: "payment-required";
readonly requestId: string;
readonly requirement: PaymentRequirement;
@@ -357,11 +411,13 @@ interface ResourceRegistry {
listExposedVia: (protocol: ProtocolName) => readonly CommerceResource[];
}
+type AuthorizationMethodName = "ap2"
+
type BackendMethod = BackendMethod
-type CommerceErrorCode = "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"
+type CommerceErrorCode = "CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR"
-type CommerceEventType = "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "backend.called" | "backend.failed" | "resource.delivered"
+type CommerceEventType = "resource.discovered" | "resource.requested" | "payment.required" | "payment.rejected" | "payment.verified" | "payment.settled" | "authorization.verified" | "authorization.rejected" | "backend.called" | "backend.failed" | "resource.delivered"
type DecimalAmount = string
@@ -377,11 +433,15 @@ type Pricing = Pricing
type ProtocolName = ProtocolName
-value COMMERCE_ERROR_CODES: readonly ["CONFIG_INVALID", "RESOURCE_NOT_FOUND", "INPUT_INVALID", "PAYMENT_REQUIRED", "PAYMENT_INVALID", "PAYMENT_REPLAYED", "PAYMENT_PROVIDER_UNAVAILABLE", "PAYMENT_SETTLEMENT_FAILED", "BACKEND_TIMEOUT", "BACKEND_ERROR", "PROTOCOL_UNSUPPORTED", "GATEWAY_BUSY", "STORAGE_ERROR", "INTERNAL_ERROR"]
+value AUTHORIZATION_HEADER: "agent-authorization"
+
+value AUTHORIZATION_INPUT_FIELD: "_authorization"
-value COMMERCE_ERROR_HTTP_STATUS: Readonly>
+value COMMERCE_ERROR_CODES: readonly ["CONFIG_INVALID", "RESOURCE_NOT_FOUND", "INPUT_INVALID", "PAYMENT_REQUIRED", "PAYMENT_INVALID", "PAYMENT_REPLAYED", "PAYMENT_PROVIDER_UNAVAILABLE", "PAYMENT_SETTLEMENT_FAILED", "AUTHORIZATION_REQUIRED", "AUTHORIZATION_INVALID", "AUTHORIZATION_REPLAYED", "AUTHORIZATION_PROVIDER_UNAVAILABLE", "BACKEND_TIMEOUT", "BACKEND_ERROR", "PROTOCOL_UNSUPPORTED", "GATEWAY_BUSY", "STORAGE_ERROR", "INTERNAL_ERROR"]
-value COMMERCE_EVENT_TYPES: readonly ["resource.discovered", "resource.requested", "payment.required", "payment.rejected", "payment.verified", "payment.settled", "backend.called", "backend.failed", "resource.delivered"]
+value COMMERCE_ERROR_HTTP_STATUS: Readonly>
+
+value COMMERCE_EVENT_TYPES: readonly ["resource.discovered", "resource.requested", "payment.required", "payment.rejected", "payment.verified", "payment.settled", "authorization.verified", "authorization.rejected", "backend.called", "backend.failed", "resource.delivered"]
value CommerceError: typeof CommerceError
@@ -389,6 +449,8 @@ value DEFAULT_BACKEND_TIMEOUT_MS: 10000
value DELIVERY_SUMMARY_META_KEY: "agent-commerce/delivery"
+value MAX_AUTHORIZATION_HEADER_BYTES: 8192
+
value NOOP_LOGGER: Logger
value PAYMENT_HEADER: "payment-signature"
@@ -401,7 +463,11 @@ value PAYMENT_RESPONSE_HEADER: "payment-response"
value PROTOCOL_NAMES: ReadonlyArray
-value RETRYABLE_ERROR_CODES: ReadonlySet<"CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR">
+value RESERVED_INPUT_FIELDS: ReadonlyArray
+
+value RETRYABLE_ERROR_CODES: ReadonlySet<"CONFIG_INVALID" | "RESOURCE_NOT_FOUND" | "INPUT_INVALID" | "PAYMENT_REQUIRED" | "PAYMENT_INVALID" | "PAYMENT_REPLAYED" | "PAYMENT_PROVIDER_UNAVAILABLE" | "PAYMENT_SETTLEMENT_FAILED" | "AUTHORIZATION_REQUIRED" | "AUTHORIZATION_INVALID" | "AUTHORIZATION_REPLAYED" | "AUTHORIZATION_PROVIDER_UNAVAILABLE" | "BACKEND_TIMEOUT" | "BACKEND_ERROR" | "PROTOCOL_UNSUPPORTED" | "GATEWAY_BUSY" | "STORAGE_ERROR" | "INTERNAL_ERROR">
+
+value extractReservedInputFields: (rawInput: Record, resource: CommerceResource | undefined, requestId?: string) => { input: Record; payment?: PaymentSubmission; authorization?: AuthorizationSubmission; }
value isCommerceError: (value: unknown) => value is CommerceError
@@ -409,6 +475,10 @@ value isHttpProtocolAdapter: (adapter: ProtocolAdapter) => adapter is HttpProtoc
value isPaymentRequiredEnvelope: (value: unknown) => value is PaymentRequiredEnvelope
+value parseAuthorizationHeader: (raw: string | readonly string[] | undefined, requestId?: string) => AuthorizationSubmission | undefined
+
+value parseAuthorizationSubmission: (value: unknown, requestId?: string) => AuthorizationSubmission | undefined
+
value systemClock: Clock
value toCommerceError: (value: unknown, fallbackCode?: CommerceErrorCode, fallbackMessage?: string) => CommerceError
diff --git a/docs/contracts.md b/docs/contracts.md
index f33cca2..f987a70 100644
--- a/docs/contracts.md
+++ b/docs/contracts.md
@@ -17,17 +17,18 @@ The cross-package contract is `src/core/public-types.ts`.
| `CommerceReceipt`, `PaymentAttempt` | `domain/receipt.ts` | receipt-store, gateway, cli, dashboard |
| `CommerceEvent`, `CommerceEventType`, `EventSink` | `domain/event.ts` | everything |
| `CanonicalRequest`, `ExecutionOutcome`, `DeliveredOutcome`, `PaymentRequiredOutcome`, `ExecutionPipeline` | `domain/request.ts` | gateway, mcp |
-| `AdapterDescriptor`, `AdapterHealth`, `JsonSchema`, `ProtocolName`, `PaymentMethodName`, `DecimalAmount`, `IsoTimestamp` | `domain/common.ts` | everything |
+| `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider`, `AuthorizationVerificationContext`, `AuthorizationFinalizeContext` | `domain/authorization.ts` | gateway, ap2, mcp, a2a |
+| `AdapterDescriptor`, `AdapterHealth`, `JsonSchema`, `ProtocolName`, `PaymentMethodName`, `AuthorizationMethodName`, `DecimalAmount`, `IsoTimestamp` | `domain/common.ts` | everything |
| `CommerceError`, `CommerceErrorCode`, `COMMERCE_ERROR_HTTP_STATUS`, `toCommerceError`, `isCommerceError` | `errors/**` | everything |
| `ProtocolAdapter`, `HttpProtocolAdapter`, `ProtocolAdapterContext` | `interfaces/protocol-adapter.ts` | gateway, mcp |
| `ReceiptStore`, `PaymentAttemptReservation`, `PaymentAttemptUpdate`, `ListOptions` | `interfaces/store.ts` | receipt-store, gateway, cli |
| `BackendExecutor`, `BackendRequest`, `BackendResponse` | `interfaces/backend.ts` | core, gateway |
| `Logger`, `NOOP_LOGGER`, `Clock`, `IdGenerator`, `systemClock` | `interfaces/logger.ts`, `interfaces/runtime.ts` | everything |
-| `PaymentRequiredEnvelope`, `toPaymentRequiredEnvelope`, `isPaymentRequiredEnvelope`, `DeliverySummary`, `toDeliverySummary`, `DELIVERY_SUMMARY_META_KEY`, `ErrorEnvelope`, `toErrorEnvelope`, `PAYMENT_HEADER`, `PAYMENT_RESPONSE_HEADER`, `PAYMENT_INPUT_FIELD` | `domain/wire.ts` | gateway, mcp, dx, demo |
+| `PaymentRequiredEnvelope`, `toPaymentRequiredEnvelope`, `isPaymentRequiredEnvelope`, `DeliverySummary`, `toDeliverySummary`, `DELIVERY_SUMMARY_META_KEY`, `ErrorEnvelope`, `toErrorEnvelope`, `PAYMENT_HEADER`, `PAYMENT_RESPONSE_HEADER`, `PAYMENT_INPUT_FIELD`, `AUTHORIZATION_INPUT_FIELD`, `AUTHORIZATION_HEADER`, `MAX_AUTHORIZATION_HEADER_BYTES`, `RESERVED_INPUT_FIELDS`, `parseAuthorizationSubmission`, `parseAuthorizationHeader`, `extractReservedInputFields` | `domain/wire.ts` | gateway, mcp, dx, demo |
| `COMMERCE_ERROR_CODES`, `COMMERCE_EVENT_TYPES`, `RETRYABLE_ERROR_CODES`, `DEFAULT_BACKEND_TIMEOUT_MS`, `isHttpProtocolAdapter`, `BackendMethod`, `CommerceErrorInfo`, `CommerceErrorOptions` | `errors/**`, `domain/**`, `interfaces/**` | everything |
**The authoritative enumeration is [`contract-surface.txt`](contract-surface.txt)**
-- 68 symbols, generated by `scripts/contract-surface.mjs` from the barrel
+- 85 symbols, generated by `scripts/contract-surface.mjs` from the barrel
itself and enforced by `npm run check:contract`. The table above groups them
for orientation; it is written by hand and was found under-enumerating in round
6 (the whole `domain/wire.ts` group was missing). If the two ever disagree,
@@ -51,7 +52,14 @@ the generated file is right and this table is stale.
always applies a timeout.
7. Amounts are decimal strings in display units ("0.01"); conversion to base
units belongs to the payment provider.
-8. `exactOptionalPropertyTypes` is on: build optional fields conditionally
+8. `AuthorizationSubmission.payload` is preserved byte-for-byte from the wire.
+ Providers derive a replay identity by hashing it, so decoding and
+ reserialising it would give one proof two identities.
+9. An authorization failure is never reported with a `PAYMENT_*` code. A 402
+ tells a client to pay and retry, which cannot fix a missing or rejected
+ mandate, and an auto-paying client would be charged for a request that was
+ never going to be delivered.
+10. `exactOptionalPropertyTypes` is on: build optional fields conditionally
(`...(x !== undefined ? { x }: {})`), do not assign `undefined`.
## Change log
@@ -79,6 +87,11 @@ the generated file is right and this table is stale.
- **Additive:** `PROTOCOL_NAMES`, the `ProtocolName` values as a runtime array. *Use case:* config validation and the OpenAPI importer's `--expose` both have to check a protocol name at runtime, and config was carrying its own hardcoded `new Set(['http','mcp','a2a'])`. *Alternative considered:* deriving `ProtocolName` from the array instead; rejected because it makes the surface printer expand the type into a literal union at every use site, turning a no-op into a noisy contract diff. *Compatibility:* additive value export, typed `readonly ProtocolName[]` so an unsupported name cannot enter it. No consumer changes.
- **Additive:** `ProtocolName` gains `'acp'` (experimental); config gains `protocols.acp` (disabled by default, mount `/acp`) and accepts `expose: [acp]`. *Use case:* the ACP checkout adapter. *Shape:* unlike `mcp`/`a2a`, the normalised `protocols.acp` is discriminated on `enabled` - an enabled block carries `auth`, `idempotency` and all five `checkout.operations` mappings, so the adapter needs no optional-field assertions and a half-configured checkout lifecycle is refused at load rather than advertised through ACP discovery. *Compatibility:* additive union member; a config with no `protocols.acp` block parses unchanged.
- **Additive (main entry):** `createAcpAdapter`, `AcpAdapterOptions`, `ACP_SPEC_VERSION`, `ACP_API_VERSION`, `ACP_WELL_KNOWN_PATH`. *Use case:* a consumer running `createGateway` needs the adapter to mount. *Why the main entry and not a subpath:* a subpath is a peer-dependency boundary, not a category - the ACP adapter needs no peer, only `ajv`/`ajv-formats` (real dependencies) and its own vendored schema. *Cost:* the pinned schema is inlined into `dist/index.js` (+~124 kB; package 396 kB -> 479 kB). The CLI bundle is unaffected - `doctor` reads only the ACP constants and descriptor, never the validator.
+- **Additive:** the generic authorization contract - `AuthorizationMethodName` (`'ap2'`), `AuthorizationSubmission`, `AuthorizationRequirement`, `AuthorizationVerification`, `AuthorizationProvider` and its two contexts; optional `CanonicalRequest.authorization`, optional `CommerceResource.authorization`, optional `PaymentRequiredOutcome.authorization` and the matching `PaymentRequiredEnvelope.authorization`; `AdapterDescriptor.kind` gains `'authorization'`; four `AUTHORIZATION_*` error codes (403 / 403 / 409 / 503, the last retryable); and the wire carriers `AUTHORIZATION_INPUT_FIELD` (`_authorization`), `AUTHORIZATION_HEADER` (`agent-authorization`), `MAX_AUTHORIZATION_HEADER_BYTES` and `RESERVED_INPUT_FIELDS`. *Use case:* AP2 mandate verification - proving the human behind an agent approved this exact purchase, a separate question from whether the payment verified. *Why generic:* AP2 is the first implementation, not the abstraction. Core states that a resource requires authorization and when the pipeline checks it, and knows nothing about SD-JWTs. An authorization method is deliberately neither a `ProtocolName` nor a `PaymentMethodName`, because it is not a transport and must never be selectable as a payment rail. *Compatibility:* every field is optional and every consumer that sets none behaves exactly as before; a resource with no `authorization` policy is unchanged end to end. `extractReservedInputFields` replaces the two hand-written `_payment` extractors in the MCP and A2A adapters with one path in core, so the reserved-field list cannot drift between surfaces. `_payment` handling is byte-identical, including dropping a proof for a resource with no configured rail.
+- **Additive:** `AuthorizationRecord`; optional `CommerceReceipt.authorization`; `AuthorizationProvider` gains `requirement` and `markUncertain`; `AuthorizationVerification` now extends `AuthorizationRecord`; `CommerceEventType` gains `authorization.verified` and `authorization.rejected`. *Use case:* the execution pipeline enforcing authorization, in the order payment verify -> authorize/reserve -> payment replay reserve -> settle -> consume/release/mark-uncertain. *Why `requirement` on the provider:* the 402 challenge has to name what the retry must also carry, and only the provider knows its own spec version and payload profile. *Why `markUncertain` rather than leaving a reservation alone:* a settlement that was broadcast but never confirmed must not hand the proof back, and "we did nothing" is indistinguishable from a path that forgot to finalize. *Why the receipt stores a record and not the verification:* `reservationId` is a live handle, not an audit fact, and a stored proof would be a spendable secret at rest. *Compatibility:* `CommerceReceipt.authorization` is optional and absent for every resource that requires no authorization; the receipt store adds schema version 2 (`ALTER TABLE receipts ADD COLUMN authorization_json`), so an existing database keeps its rows. `AuthorizationProvider` is not yet implemented by anything shipped, so the two new members break no consumer.
+- **Additive (non-frozen surfaces):** `GatewayOptions.authorizationProviders` (optional) and `ReadinessResult.authorizationProviders`; a new `./ap2` subpath exporting `createAp2AuthorizationProvider` / `ap2`, with `jose`, `@sd-jwt/core` and `canonicalize` as optional peers. *Use case:* running AP2 as a wired subsystem. *Why a subpath:* one entry per distinct peer set, named for the peer - a gateway serving no gated resource should install neither a JOSE stack nor an SD-JWT parser, and the main entry and the CLI import the narrow AP2 modules (`constants.ts`, `types.ts`, `descriptor.ts`) so neither pulls a peer. *Readiness:* an authorization provider reporting `fail` blocks `/ready` on the same threshold as a payment provider - a resource that requires a mandate cannot be served without one, and serving its challenge anyway promises what cannot be honoured. Only the fixed vocabulary token `authorization-provider-unreachable` reaches the client. *Compatibility:* both fields are additive and a deployment configuring no authorization behaves exactly as before.
+- **Additive (`./ap2` subpath):** `createCheckoutJwt` and `CreateCheckoutJwtOptions`. *Use case:* a merchant has to sign the checkout JWT a Checkout Mandate binds, and the gateway only verifies. *Why it ships:* `input_hash` is an RFC 8785 digest, and a hand-rolled signer reaching for a sorted-key `JSON.stringify` agrees on most inputs and disagrees on floats and non-ASCII keys - producing a mandate refused with a deliberately coarse reason. The helper also refuses a numeric `amount`, the public half of a key pair, a non-P-256 key and a missing field before signing, rather than letting each become that same opaque refusal. *Scope:* signing only. It runs in the merchant's process, never calls the gateway and is never called by it - the mirror of `createPaymentProof`. The Checkout Mandate itself is the buyer's side and nothing here mints one.
+- **Additive (gateway wire surface):** `WellKnownDocument.authorizationProviders`, an `AdapterDescriptor[]` that is empty unless a resource requires authorization. *Use case:* the README promises every adapter's `supportedSpec`, `capabilities` and `unsupported` list is checkable at runtime rather than taken on trust, and AP2 was reportable through `doctor` but absent from the document. *Why a separate field and not `paymentProviders`:* an authorization method is not a payment rail and must never be selectable as one - the same reason `AuthorizationMethodName` is neither a `ProtocolName` nor a `PaymentMethodName`. *Compatibility:* additive; the field is always present, and the dashboard's hand-maintained mirror carries only what it renders, as it already does for `protocols.acp`.
---
# Integration contract - exact factory signatures
@@ -153,6 +166,91 @@ export type DeploymentMode = 'local' | 'testnet' | 'mainnet';
export const SUPPORTED_NETWORK_IDS: readonly string[]; // ['eip155:84532', 'eip155:8453']
```
+## `src/authorization/ap2`
+> **Published as** `@devlab.group/agent-commerce/ap2`, gated behind the optional
+> peers `jose`, `@sd-jwt/core` and `canonicalize`. The main entry and the CLI
+> import only the narrow modules (`constants.ts`, `types.ts`, `descriptor.ts`),
+> which pull no peer, so `doctor` can report AP2 without installing a JOSE
+> stack. Trust and config types live here rather than in `src/config`, the way
+> `X402FacilitatorConfig` does: the subsystem owns its own config shape and the
+> loader imports it.
+
+```ts
+export interface Ap2AuthorizationProviderOptions {
+ /** The enabled half of the parsed `authorization.ap2` block. */
+ readonly config: EnabledAp2Config;
+ readonly clock?: Clock;
+ readonly logger?: Logger;
+ /** Injectable so tests need not touch the filesystem. */
+ readonly replayStore?: Ap2ReplayStore;
+}
+
+/** The gateway owns the lifetime: `close()` releases the replay database. */
+export interface Ap2AuthorizationProvider extends AuthorizationProvider {
+ close(): void;
+}
+export function createAp2AuthorizationProvider(
+ options: Ap2AuthorizationProviderOptions,
+): Ap2AuthorizationProvider;
+
+export type Ap2AuthorizationConfig =
+ | { readonly enabled: false }
+ | {
+ readonly enabled: true;
+ readonly specVersion: '0.2.0';
+ readonly mode: 'direct';
+ readonly trust: {
+ /** Signers of the Checkout Mandate itself. */
+ readonly mandateIssuers: readonly Ap2TrustedIssuer[];
+ /** Signers of the merchant checkout JWT the mandate binds. */
+ readonly checkoutIssuers: readonly Ap2TrustedIssuer[];
+ };
+ readonly clockSkewSeconds: number;
+ /** Its own SQLite file. An authorization replay is not a payment replay. */
+ readonly replay: { readonly path: string };
+ };
+
+export interface Ap2TrustedIssuer {
+ readonly issuer: string;
+ /** Per issuer, not gateway-wide: the mandate is addressed to the merchant. */
+ readonly audience: string;
+ readonly keys: readonly Ap2TrustedKey[];
+}
+export interface Ap2TrustedKey {
+ readonly kid: string;
+ /** A public P-256 JWK, validated member by member at config load. */
+ readonly jwk: Readonly>;
+}
+
+/** Merchant-side. Signs the checkout JWT a Checkout Mandate binds. */
+export interface CreateCheckoutJwtOptions {
+ /** A private ES256 JWK, or a PKCS#8 PEM. Never leaves the caller's process. */
+ readonly privateKey: Ap2SigningKey;
+ readonly kid: string;
+ readonly issuer: string;
+ readonly audience: string;
+ readonly resourceId: string;
+ /** Hashed with RFC 8785 (JCS), the same way the gateway hashes it. */
+ readonly input: unknown;
+ /** A decimal string; compared as a string, never numerically. */
+ readonly amount: string;
+ readonly currency: string;
+ readonly paymentMethod: string;
+ readonly destination?: string;
+ readonly network?: string;
+ readonly asset?: string;
+ readonly jwtId?: string;
+ readonly expiresInSeconds?: number;
+ readonly now?: Date;
+}
+export function createCheckoutJwt(options: CreateCheckoutJwtOptions): Promise;
+
+export const AP2_SPEC_VERSION = '0.2.0';
+export const AP2_CHECKOUT_PROFILE = 'agent-commerce/ap2/checkout/v1';
+export const AP2_CAPABILITIES: readonly string[];
+export const AP2_UNSUPPORTED: readonly string[];
+```
+
## `src/protocols/mcp`
> **Published as** `@devlab.group/agent-commerce/mcp`, gated behind the optional peer
> `@modelcontextprotocol/sdk`. In-repo consumers keep importing it by relative
@@ -174,6 +272,8 @@ export interface GatewayOptions {
readonly config: GatewayConfig; // from src/config
readonly store: ReceiptStore;
readonly paymentProviders: readonly PaymentProvider[];
+ // Absent means no resource requires authorization, which is the default
+ readonly authorizationProviders?: readonly AuthorizationProvider[];
readonly protocolAdapters: readonly ProtocolAdapter[];
readonly logger?: Logger;
readonly clock?: Clock;
@@ -243,8 +343,8 @@ export interface GatewayConfig {
| Route | Purpose |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /health` | liveness - always 200 when the process is up |
-| `GET /ready` | readiness - 200 only when config, store, every required adapter **and every configured payment provider** are healthy (`fail` blocks; `warn` is degraded-but-serving) |
-| `GET /.well-known/agent-commerce` | merchant + adapter descriptors, protocol/spec versions |
+| `GET /ready` | readiness - 200 only when config, store, every required adapter, **every configured payment provider and every authorization provider** are healthy (`fail` blocks; `warn` is degraded-but-serving) |
+| `GET /.well-known/agent-commerce` | merchant, adapter, payment-provider and authorization-provider descriptors, protocol/spec versions |
| `GET /api/resources` | canonical resource list (no secrets) |
| `POST /api/resources/:id/invoke` | HTTP protocol surface; `PAYMENT-SIGNATURE` header carries the proof; 402 + `PaymentRequiredEnvelope` body and `PAYMENT-REQUIRED` header when unpaid; `PAYMENT-RESPONSE` header on settlement |
| `GET /api/receipts?limit=` | recent receipts (dashboard/CLI) |
diff --git a/docs/payment-flow.md b/docs/payment-flow.md
index 048ec6a..bd54cf7 100644
--- a/docs/payment-flow.md
+++ b/docs/payment-flow.md
@@ -16,39 +16,40 @@ The gateway is in the middle of the *protocol* and outside the *custody*.
## The round trip
```text
- buyer gateway chain / backend
- │ │ │
- │ 1. tools/call market_report │ │
- ├────────────────────────────►│ │
- │ │ resolve resource, validate input │
- │ │ price: 0.01 USDC → paid │
- │ │ createRequirement │
- │ 2. isError + envelope │ │
- │◄────────────────────────────┤ PaymentRequiredEnvelope │
- │ payment.accepts[0] │ (x402 PaymentRequirements) │
- │ │ │
- │ 3. sign EIP-3009 │ │
- │ authorisation │ │
- │ (to = merchant payTo) │ │
- │ │ │
- │ 4. tools/call + _payment │ │
- ├────────────────────────────►│ │
- │ │ verify ── signature, recipient,│
- │ │ amount, window, │
- │ │ balance, network, │
- │ │ asset ───────────────┤ read
- │ │ replayKey = H(chainId, asset, │
- │ │ payer, nonce) │
- │ │ reservePaymentAttempt(replayKey) │
- │ │ duplicate ⇒ PAYMENT_REPLAYED │
- │ │ settle ─────────────────────────┤ tx
- │ │ transferWithAuthorization│
- │ │◄──────────────────────────────────┤ receipt
- │ │ call merchant backend ────────────┤
- │ │◄──────────────────────────────────┤ 200
- │ │ saveReceipt(txHash) │
- │ 5. result + receipt │ │
- │◄────────────────────────────┤ │
+ buyer gateway chain / backend
+ │ │ │
+ │ 1. tools/call market_report │ │
+ ├───────────────────────────────►│ │
+ │ │ resolve resource, validate input │
+ │ │ price 0.01 USDC → paid │
+ │ │ createRequirement │
+ │◄───────────────────────────────┤ PaymentRequiredEnvelope │
+ │ 2. isError + envelope │ (x402 PaymentRequirements) │
+ │ payment.accepts[0] │ │
+ │ │ │
+ │ 3. sign EIP-3009 authorisation │ │
+ │ (to = merchant payTo) │ │
+ │ │ │
+ │ 4. tools/call + _payment │ │
+ ├───────────────────────────────►│ │
+ │ │ verify: signature, recipient, │
+ │ │ amount, window, │
+ │ │ network, asset │
+ │ ├──────────────────────────────────►│ chain: balance / allowance
+ │ │ replayKey = H(chainId, asset, │
+ │ │ payer, nonce) │
+ │ │ reservePaymentAttempt(replayKey) │
+ │ │ duplicate ⇒ PAYMENT_REPLAYED │
+ │ │ settle │
+ │ ├──────────────────────────────────►│ chain: transferWithAuthorization
+ │ │◄──────────────────────────────────┤ chain: tx receipt
+ │ │ call merchant backend │
+ │ ├──────────────────────────────────►│ backend: GET /api/report
+ │ │◄──────────────────────────────────┤ backend: 200 + body
+ │ │ saveReceipt(txHash) │
+ │ │ │
+ │◄───────────────────────────────┤ │
+ │ 5. result + receipt │ │
```
Steps 1–2 and 4–5 are the same over plain HTTP; the challenge arrives as a
@@ -106,6 +107,11 @@ Deriving the key from the authorisation rather than the request is what makes
it work: the same authorisation replayed against a *different* request still
collides.
+A resource that also requires an AP2 mandate gets a third, independent
+reservation, in its own database and on its own key. It is claimed before the
+payment replay key and released only by a failure that provably moved no money.
+See [ap2.md](ap2.md#replay).
+
## Amounts
Canonical amounts are decimal strings in display units — `"0.01"` — never
diff --git a/docs/protocols.md b/docs/protocols.md
index c2cf49d..ee89b9c 100644
--- a/docs/protocols.md
+++ b/docs/protocols.md
@@ -12,9 +12,9 @@ implemented, exactly what is not, and pins the revisions.
| **HTTP** | Supported | — | native resource routes with `PAYMENT-SIGNATURE` |
| **A2A** | Experimental | A2A **v1.0.0**, negotiation version `1.0`, binding `JSONRPC` | Agent Card discovery, `SendMessage`, terminal tasks, paid flow |
| **ACP** | Experimental | ACP stable snapshot **2026-04-17**, REST binding | discovery, the five checkout operations, bearer auth, idempotency |
+| **AP2** | Experimental | AP2 **v0.2.0**, tagged 2026-04-28, commit `b4587ac`, Direct mode | closed Checkout Mandate verification before settlement |
| UCP | Planned | — | planned, no code ships |
| MPP | Planned | — | planned, no code ships |
-| AP2 | Planned | — | planned, no code ships |
"Planned" means **no code ships for it**. There is no partial adapter, no
endpoint and no diagnostic pretending otherwise.
@@ -24,6 +24,12 @@ ships, it is tested against the protocol's own official artifacts - the A2A SDK,
the ACP schema and examples - and the supported subset is narrow and named
below. Both are off by default.
+AP2 is listed here because this is where people look, but it is not a
+transport and has no adapter, no mount path and no discovery document. It is an
+authorization method: it decides whether a payment is allowed to settle, and a
+resource that requires one still needs a real payment proof. It has its own
+page, [ap2.md](ap2.md), and `doctor` reports it separately from the protocols.
+
Every adapter reports itself at runtime through
`GET /.well-known/agent-commerce` and in `agent-commerce doctor`, with
`supportedSpec`, `capabilities`, `unsupported` and `status`. If this page and
diff --git a/docs/security.md b/docs/security.md
index caac010..274ec75 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -7,15 +7,16 @@ deliberately do not defend.
## Trust boundaries
```text
- UNTRUSTED SEMI-TRUSTED TRUSTED
- ───────── ──────────── ───────
- agent input ────► gateway process ────► merchant backend
- payment proofs (validates all (administrator
- protocol traffic of the left, configured, assumed
- holds no keys) to be yours)
-
- configuration ◄──── administrator (trusted)
- environment ◄──── operator (trusted)
+ UNTRUSTED SEMI-TRUSTED TRUSTED
+ ──────────────────── ────────────────────────── ─────────────────────────
+
+ agent input ────► gateway process ────► merchant backend
+ payment proofs validates all agent input, administrator configured,
+ authorization proofs holds no keys assumed to be yours
+ protocol traffic
+
+ configuration ◄──── administrator
+ environment ◄──── operator
```
Everything from an agent is untrusted and validated. Configuration is trusted
@@ -29,6 +30,8 @@ Never logged, never persisted, never returned:
- private keys, seed phrases, mnemonics
- `Authorization` headers and backend API secrets
- the `PAYMENT-SIGNATURE` header and raw payment authorisation payloads
+- the `Agent-Authorization` header, AP2 presentations, their disclosures, and
+ the merchant checkout JWT they bind
- `signature`, `secret`, `apiKey`, `signerPrivateKey`, `adminToken` and `token`
fields, **at the top level and one level deep** (see below)
@@ -45,6 +48,32 @@ redaction (`src/storage/receipts/redact.ts`) has no such limit: it is a
recursive key-pattern strip at every depth. Both have tests. Resolved `${VAR}` values are never printed, even
in configuration error messages - errors name the *variable*, not the value.
+## Authorization trust (AP2)
+
+A separate trust anchor from payment, and a deliberately small one. The key
+policy, the two issuer lists and the rotation procedure are in
+[ap2.md](ap2.md#trust).
+
+Every AP2 verification key is a **public** key an operator wrote into
+`config.yaml`. The gateway performs no key discovery of any kind: no JWKS
+endpoint, no `jku`, no `x5u`, no issuer metadata fetch, no revocation call.
+A JWK is validated member by member at load against an allowlist, so private
+material and anything naming a URL is refused without the check having to name
+it. That closes an SSRF surface before it exists: no code path lets a presented
+mandate cause an outbound request, and removing a key from the config is the
+revocation.
+
+The algorithm comes from local policy, never from the JWT header: ES256 over
+P-256, one entry, so `alg: none` and the HMAC family are excluded by
+construction rather than by a check that has to remember them. `iss` and `kid`
+select which configured key verifies a mandate, and an unrecognised pair is
+refused - there is no "try every key" fallback that would make `kid` advisory.
+
+Digests are taken over the bytes that arrived - the compact checkout JWT as
+presented, and the issuer-signed token - never over a re-serialised object. A
+normalised payload has a different digest, and hashing it would check a
+document other than the one being verified.
+
## SSRF
The gateway makes outbound HTTP calls to URLs it was configured with:
@@ -215,6 +244,15 @@ list. What exists:
into one upstream RPC call per request
- `X-Request-Id` accepted only as `[A-Za-z0-9._:-]{1,64}`, so a caller cannot
write an unbounded string into every audit row
+- an **8192-byte cap on the `Agent-Authorization` header**
+ (`MAX_AUTHORIZATION_HEADER_BYTES`), checked on the raw header before it is
+ base64url-decoded or parsed as JSON. Over the limit is
+ `AUTHORIZATION_INVALID`, and nothing from the caller's value is echoed back.
+ The cap is on the encoded header rather than on the decoded proof because
+ base64url expands by 4/3, so a decode-first check would have to allocate the
+ oversized string first. A real Direct Checkout Mandate presentation is well
+ inside it; MCP and A2A carry the same proof in the request body instead and
+ are bounded by the body-size cap
What does **not** exist: rate limiting, per-agent quotas, adaptive
backpressure. Put the gateway behind your own edge if you expose it publicly.
@@ -324,6 +362,22 @@ rejection outcomes assert that balances did not move.
| **a merchant leaking a connection string or stack in an error body** | never relayed; the ACP error carries type, code and message only | same |
| **an ACP `Request-Id` carrying a header-injection payload** | dropped, never echoed | `tests/unit/protocols-acp/adapter.test.ts` |
| **an ACP checkout resource configured as paid** | refused at config load; `payment-required` at runtime is a 500 | `tests/unit/config/schema.test.ts` |
+| **an AP2 mandate with a tampered signature** | `AUTHORIZATION_INVALID`; nothing settles | `tests/integration/ap2-x402-conformance.test.ts` |
+| **an expired AP2 mandate** | `AUTHORIZATION_INVALID`; nothing settles | same |
+| **a mandate from an issuer that is not configured** | refused at the trust allowlist, before any signature check | same, `tests/unit/authorization-ap2` |
+| **a mandate claiming a trusted `kid` but signed with another key** | refused at the signature; `kid` selects the key, never labels it | same |
+| **a mandate naming a `kid` the issuer does not have** | refused; no "try every key" fallback | same |
+| **a mandate whose `checkout_hash` does not match its checkout JWT** | `checkout_binding_failed`; nothing settles | same |
+| **a mandate approved for another resource, input, amount, currency, payment method, network or asset** | `purchase_mismatch`, one coarse reason; nothing settles | same |
+| **a mandate silent about the chain the requirement names** | refused - fail closed both ways | same |
+| **a mandate presented twice** | `AUTHORIZATION_REPLAYED`; the second purchase moves no funds | same, `tests/e2e/authorization` |
+| **the same mandate re-presented with a fresh, valid payment proof** | still refused; balances unchanged on a real chain | `tests/e2e/authorization` |
+| **a mandate replayed under selective disclosure** (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | `tests/unit/authorization-ap2` |
+| **the AP2 replay store unreachable** | `AUTHORIZATION_PROVIDER_UNAVAILABLE`, retryable, never the buyer's fault | `tests/integration/ap2-runtime.test.ts` |
+| **a payment rejected after a mandate verified** | the reservation is released; a corrected proof reuses the mandate | `tests/integration/ap2-x402-conformance.test.ts` |
+| **a settlement broadcast but never confirmed** | the mandate is *not* handed back; marked uncertain for an operator | same |
+| **a free resource configured to require a mandate** | refused at config load, and again on the execution path | `tests/unit/config/ap2.test.ts`, `tests/unit/core/execution` |
+| **an oversized `Agent-Authorization` header** | `AUTHORIZATION_INVALID` before any decode; nothing echoed back | `tests/integration/authorization-carrier.test.ts` |
Two of those exist because writing them found a bug. The SDK's `exact`/EVM
scheme reports an unreachable node as `invalid_exact_evm_signature`, and its
diff --git a/package-lock.json b/package-lock.json
index c60c8a8..fcacf3f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@biomejs/biome": "2.5.9",
"@coinbase/x402": "2.1.0",
"@modelcontextprotocol/sdk": "1.30.0",
+ "@sd-jwt/core": "0.20.1",
"@types/better-sqlite3": "9.6.0",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
@@ -37,6 +38,8 @@
"@vitest/coverage-v8": "4.1.10",
"@x402/core": "2.23.0",
"@x402/evm": "2.23.0",
+ "canonicalize": "5.0.0",
+ "jose": "6.2.12",
"pino-pretty": "13.1.2",
"react": "19.0.8",
"react-dom": "19.0.8",
@@ -54,8 +57,11 @@
"peerDependencies": {
"@coinbase/x402": "2.1.0",
"@modelcontextprotocol/sdk": "1.30.0",
+ "@sd-jwt/core": "0.20.1",
"@x402/core": "2.23.0",
"@x402/evm": "2.23.0",
+ "canonicalize": "5.0.0",
+ "jose": "6.2.12",
"viem": "2.55.18"
},
"peerDependenciesMeta": {
@@ -65,12 +71,21 @@
"@modelcontextprotocol/sdk": {
"optional": true
},
+ "@sd-jwt/core": {
+ "optional": true
+ },
"@x402/core": {
"optional": true
},
"@x402/evm": {
"optional": true
},
+ "canonicalize": {
+ "optional": true
+ },
+ "jose": {
+ "optional": true
+ },
"viem": {
"optional": true
}
@@ -1161,6 +1176,13 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@owf/identity-common": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@owf/identity-common/-/identity-common-0.3.2.tgz",
+ "integrity": "sha512-XH5Bg6zuXc9SeimTe2ZD/+54GnhSyenFSy2Qe/z2HY1ul5j+6/lDNIhI83xct5aOkCD0uZ7zHbMhTI4Nq8YJOQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -1947,6 +1969,19 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@sd-jwt/core": {
+ "version": "0.20.1",
+ "resolved": "https://registry.npmjs.org/@sd-jwt/core/-/core-0.20.1.tgz",
+ "integrity": "sha512-RUBZ3WxnjicKgQyUysaFNMuC88xXSQlNLK7V1vxSBKYHAoi/yCXtCGlxxj7JXISAJgFTkmPSV5ZEZp0Dt5bXyw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@owf/identity-common": "^0.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@solana-program/system": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz",
@@ -3535,6 +3570,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/canonicalize": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-5.0.0.tgz",
+ "integrity": "sha512-O/NCg79G0/TWoD3Fo6scOMfP4p7/TsxRXVmRo9mEfD6h/5y5o1wtVbKyBO0E2i7FEcqe5tRijyAH/IWHIHMH4w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "canonicalize": "bin/canonicalize.js"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -4680,9 +4728,9 @@
}
},
"node_modules/jose": {
- "version": "6.2.9",
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
- "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
+ "version": "6.2.12",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
+ "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"dev": true,
"license": "MIT",
"funding": {
diff --git a/package.json b/package.json
index a8a9f56..b9af4f7 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
+ "./ap2": {
+ "types": "./dist/ap2.d.ts",
+ "import": "./dist/ap2.js"
+ },
"./mcp": {
"types": "./dist/mcp.d.ts",
"import": "./dist/mcp.js"
@@ -102,8 +106,11 @@
"peerDependencies": {
"@coinbase/x402": "2.1.0",
"@modelcontextprotocol/sdk": "1.30.0",
+ "@sd-jwt/core": "0.20.1",
"@x402/core": "2.23.0",
"@x402/evm": "2.23.0",
+ "canonicalize": "5.0.0",
+ "jose": "6.2.12",
"viem": "2.55.18"
},
"peerDependenciesMeta": {
@@ -113,12 +120,21 @@
"@modelcontextprotocol/sdk": {
"optional": true
},
+ "@sd-jwt/core": {
+ "optional": true
+ },
"@x402/core": {
"optional": true
},
"@x402/evm": {
"optional": true
},
+ "canonicalize": {
+ "optional": true
+ },
+ "jose": {
+ "optional": true
+ },
"viem": {
"optional": true
}
@@ -128,6 +144,7 @@
"@biomejs/biome": "2.5.9",
"@coinbase/x402": "2.1.0",
"@modelcontextprotocol/sdk": "1.30.0",
+ "@sd-jwt/core": "0.20.1",
"@types/better-sqlite3": "9.6.0",
"@types/node": "24.13.3",
"@types/react": "19.2.18",
@@ -136,6 +153,8 @@
"@vitest/coverage-v8": "4.1.10",
"@x402/core": "2.23.0",
"@x402/evm": "2.23.0",
+ "canonicalize": "5.0.0",
+ "jose": "6.2.12",
"pino-pretty": "13.1.2",
"react": "19.0.8",
"react-dom": "19.0.8",
@@ -147,13 +166,16 @@
"vitest": "4.1.10"
},
"overrides": {
- "@x402/core": {
+ "@coinbase/x402": {
"zod": "3.25.76"
},
- "@x402/evm": {
+ "@sd-jwt/core": {
+ "@owf/identity-common": "0.3.2"
+ },
+ "@x402/core": {
"zod": "3.25.76"
},
- "@coinbase/x402": {
+ "@x402/evm": {
"zod": "3.25.76"
}
}
diff --git a/src/ap2.ts b/src/ap2.ts
new file mode 100644
index 0000000..d5a41e2
--- /dev/null
+++ b/src/ap2.ts
@@ -0,0 +1,41 @@
+/**
+ * `@devlab.group/agent-commerce/ap2` - AP2 Direct Checkout Mandate verification.
+ *
+ * A separate entry point because mandate verification brings a JOSE stack and
+ * an SD-JWT parser, and a gateway serving no authorization-gated resource
+ * should not install either.
+ *
+ * npm install @devlab.group/agent-commerce jose @sd-jwt/core canonicalize
+ * import { ap2 } from '@devlab.group/agent-commerce/ap2';
+ *
+ * Authorization is not a transport and not a payment rail. It gates settlement
+ * on a resource that also takes a real payment proof, and never unlocks one on
+ * its own.
+ */
+
+export {
+ AP2_CAPABILITIES,
+ AP2_CHECKOUT_MANDATE_VCT,
+ AP2_CHECKOUT_PROFILE,
+ AP2_DEFAULT_CLOCK_SKEW_SECONDS,
+ AP2_MAX_CLOCK_SKEW_SECONDS,
+ AP2_REJECTION_REASONS,
+ AP2_SIGNING_ALGORITHM,
+ AP2_SPEC_VERSION,
+ AP2_UNSUPPORTED,
+ type Ap2AuthorizationConfig,
+ type Ap2AuthorizationProvider,
+ type Ap2AuthorizationProviderOptions,
+ type Ap2Mode,
+ type Ap2RejectionReason,
+ type Ap2SigningKey,
+ type Ap2TrustedIssuer,
+ type Ap2TrustedKey,
+ type CreateCheckoutJwtOptions,
+ createAp2AuthorizationProvider,
+ // `ap2` reads well at a call site; the full name reads better in a trace.
+ createAp2AuthorizationProvider as ap2,
+ // Merchant-side, and the only export here that signs rather than verifies.
+ createCheckoutJwt,
+ type EnabledAp2Config,
+} from './authorization/ap2/index.js';
diff --git a/src/authorization/ap2/checkout-jwt.ts b/src/authorization/ap2/checkout-jwt.ts
new file mode 100644
index 0000000..aa39c2c
--- /dev/null
+++ b/src/authorization/ap2/checkout-jwt.ts
@@ -0,0 +1,115 @@
+/**
+ * Stage two: the merchant checkout JWT the mandate binds.
+ *
+ * Two things have to hold. The hash proves the buyer approved *this* document;
+ * the signature proves the merchant issued it. Hash alone accepts a document
+ * the buyer wrote themselves; signature alone accepts a genuine merchant
+ * document this mandate never covered.
+ */
+import { type JWTVerifyOptions, jwtVerify } from 'jose';
+import type { Clock } from '../../core/index.js';
+import { AP2_SIGNING_ALGORITHM } from './constants.js';
+import { type Ap2ErrorContext, ap2Rejected } from './errors.js';
+import type { TrustStore } from './trust.js';
+
+export interface VerifiedCheckoutJwt {
+ readonly issuer: string;
+ readonly jwtId: string;
+ readonly claims: Readonly>;
+}
+
+function decodeSegment(jwt: string, index: 0 | 1): Record | undefined {
+ const segment = jwt.split('.')[index];
+ if (segment === undefined) return undefined;
+ try {
+ const parsed: unknown = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * `mandateClaims` must come from an already-verified mandate: `checkout_hash`
+ * is worth nothing unless the issuer signed it
+ */
+export async function verifyCheckoutJwt(
+ mandateClaims: Readonly>,
+ deps: { readonly trust: TrustStore; readonly clock: Clock; readonly clockSkewSeconds: number },
+ context: Ap2ErrorContext,
+): Promise {
+ const compact = mandateClaims['checkout_jwt'];
+ const expectedHash = mandateClaims['checkout_hash'];
+ if (typeof compact !== 'string' || compact.length === 0) {
+ throw ap2Rejected('invalid_claims', context);
+ }
+ if (typeof expectedHash !== 'string' || expectedHash.length === 0) {
+ throw ap2Rejected('invalid_claims', context);
+ }
+
+ await requireMatchingHash(compact, expectedHash, context);
+
+ const header = decodeSegment(compact, 0);
+ const unverifiedPayload = decodeSegment(compact, 1);
+ if (header === undefined || unverifiedPayload === undefined) {
+ throw ap2Rejected('checkout_binding_failed', context);
+ }
+
+ const { issuer, key } = await deps.trust.resolve(
+ unverifiedPayload['iss'],
+ header['kid'],
+ context,
+ );
+
+ const options: JWTVerifyOptions = {
+ algorithms: [AP2_SIGNING_ALGORITHM],
+ issuer: issuer.issuer,
+ audience: issuer.audience,
+ clockTolerance: deps.clockSkewSeconds,
+ currentDate: deps.clock.now(),
+ };
+
+ let claims: Record;
+ try {
+ const result = await jwtVerify(compact, key, options);
+ claims = result.payload as Record;
+ } catch (cause) {
+ throw ap2Rejected('checkout_binding_failed', { ...context, cause });
+ }
+
+ const exp = claims['exp'];
+ const iat = claims['iat'];
+ const jti = claims['jti'];
+ // jose validates a time claim only when present, so requiring them is ours
+ if (typeof exp !== 'number' || typeof iat !== 'number') {
+ throw ap2Rejected('invalid_claims', context);
+ }
+ // `jti` is what replay defence and the receipt record, so it is required
+ if (typeof jti !== 'string' || jti.length === 0) {
+ throw ap2Rejected('invalid_claims', context);
+ }
+ const nowSeconds = Math.floor(deps.clock.now().getTime() / 1000);
+ if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected('expired', context);
+
+ return { issuer: issuer.issuer, jwtId: jti, claims };
+}
+
+/**
+ * Hashed as the bytes that arrived, never re-encoded from parsed claims: a
+ * re-serialised payload has a different digest, and normalising first would
+ * hash a document other than the one being verified
+ */
+async function requireMatchingHash(
+ compact: string,
+ expected: string,
+ context: Ap2ErrorContext,
+): Promise {
+ // sha-256 only: `_sd_alg` was already pinned to it upstream
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(compact));
+ const actual = Buffer.from(digest).toString('base64url');
+ // Plain comparison: both sides are public to anyone holding the
+ // presentation, so there is no secret for timing to leak
+ if (actual !== expected) throw ap2Rejected('checkout_binding_failed', context);
+}
diff --git a/src/authorization/ap2/checkout-signer.ts b/src/authorization/ap2/checkout-signer.ts
new file mode 100644
index 0000000..ae6adc6
--- /dev/null
+++ b/src/authorization/ap2/checkout-signer.ts
@@ -0,0 +1,165 @@
+/**
+ * Merchant-side helper: mint the checkout JWT a Checkout Mandate binds.
+ *
+ * The gateway only verifies. This is what a merchant runs in their own
+ * process, with their own key, to produce the document the buyer approves. It
+ * never calls the gateway and the gateway never calls it - the mirror of
+ * `createPaymentProof`, which a buyer runs to produce a payment proof.
+ *
+ * It exists for `input_hash`, an RFC 8785 digest. A signer reaching for a
+ * sorted-key `JSON.stringify` agrees on most inputs and disagrees on floats
+ * and non-ASCII keys, and the mandate is then refused with a reason that does
+ * not say which field disagreed.
+ */
+import { importPKCS8, type JWK, SignJWT } from 'jose';
+import {
+ AP2_CHECKOUT_PROFILE,
+ AP2_JWK_CURVE,
+ AP2_KEY_TYPE,
+ AP2_SIGNING_ALGORITHM,
+} from './constants.js';
+import { computeInputHash } from './profile.js';
+
+/**
+ * A private ES256 key: either a private JWK (the pair of the public one in the
+ * gateway's `checkoutIssuers`) or a PKCS#8 PEM, as `openssl` emits it.
+ */
+export type Ap2SigningKey = Readonly> | string;
+
+export interface CreateCheckoutJwtOptions {
+ readonly privateKey: Ap2SigningKey;
+ /** Must match a `kid` configured under the gateway's `checkoutIssuers` */
+ readonly kid: string;
+ /** Must match that issuer's configured `issuer` */
+ readonly issuer: string;
+ /** Must match that issuer's configured `audience` */
+ readonly audience: string;
+
+ readonly resourceId: string;
+ /**
+ * The resource input this purchase is for, exactly as the buyer will send
+ * it: no reserved fields, no request id, no transport metadata.
+ */
+ readonly input: unknown;
+
+ /**
+ * Decimal string, never a number, and compared as a string: `0.10` and `0.1`
+ * are different mandates. Take it from your own catalogue rather than from
+ * whatever the agent asked for.
+ */
+ readonly amount: string;
+ readonly currency: string;
+ /** The rail that will settle, e.g. `x402` */
+ readonly paymentMethod: string;
+
+ /**
+ * Settlement coordinates. Required whenever the gateway's requirement names
+ * them, which under x402 is always. A mandate silent about the chain will
+ * not unlock a settlement on one.
+ */
+ readonly destination?: string;
+ readonly network?: string;
+ readonly asset?: string;
+
+ /** Defaults to a random UUID. Recorded on the receipt and used for replay defence */
+ readonly jwtId?: string;
+ /** Defaults to 900: a human approval sits inside this window */
+ readonly expiresInSeconds?: number;
+ /** Injectable so a test need not move the wall clock */
+ readonly now?: Date;
+}
+
+const DEFAULT_EXPIRES_IN_SECONDS = 900;
+
+function requireText(value: unknown, field: string): string {
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new TypeError(
+ `createCheckoutJwt: ${field} must be a non-empty string, received ${describe(value)}`,
+ );
+ }
+ return value;
+}
+
+function describe(value: unknown): string {
+ return value === null ? 'null' : typeof value;
+}
+
+/**
+ * Every rejection here is a mistake that would otherwise surface as an opaque
+ * verification failure much later: the public half of the pair, the wrong key
+ * type, or a PEM that is not PKCS#8.
+ */
+async function resolveKey(
+ key: Ap2SigningKey,
+): Promise>> {
+ if (typeof key === 'string') {
+ if (!key.includes('BEGIN PRIVATE KEY')) {
+ throw new TypeError(
+ 'createCheckoutJwt: a string privateKey must be a PKCS#8 PEM beginning "-----BEGIN PRIVATE KEY-----"',
+ );
+ }
+ return importPKCS8(key, AP2_SIGNING_ALGORITHM);
+ }
+ if (typeof key !== 'object' || key === null) {
+ throw new TypeError('createCheckoutJwt: privateKey must be a private JWK or a PKCS#8 PEM');
+ }
+ if (key['d'] === undefined) {
+ throw new TypeError(
+ 'createCheckoutJwt: privateKey is a public JWK (no "d"). Use the private half of the pair whose public key is configured under checkoutIssuers',
+ );
+ }
+ if (key['kty'] !== AP2_KEY_TYPE || key['crv'] !== AP2_JWK_CURVE) {
+ throw new TypeError(
+ `createCheckoutJwt: privateKey must be ${AP2_KEY_TYPE}/${AP2_JWK_CURVE}, the pair ${AP2_SIGNING_ALGORITHM} implies`,
+ );
+ }
+ return key as JWK;
+}
+
+/**
+ * Returns the compact JWT to hand to the agent, which wraps it in the Checkout
+ * Mandate the buyer signs.
+ */
+export async function createCheckoutJwt(options: CreateCheckoutJwtOptions): Promise {
+ const kid = requireText(options.kid, 'kid');
+ const issuer = requireText(options.issuer, 'issuer');
+ const audience = requireText(options.audience, 'audience');
+ const resourceId = requireText(options.resourceId, 'resourceId');
+ const currency = requireText(options.currency, 'currency');
+ const paymentMethod = requireText(options.paymentMethod, 'paymentMethod');
+
+ if (typeof options.amount === 'number') {
+ throw new TypeError(
+ 'createCheckoutJwt: amount must be a decimal string, not a number. It is compared as a string, so 0.1 and "0.10" are different mandates',
+ );
+ }
+ const amount = requireText(options.amount, 'amount');
+
+ const key = await resolveKey(options.privateKey);
+ const issuedAt = Math.floor((options.now ?? new Date()).getTime() / 1000);
+ const expiresIn = options.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS;
+ if (!Number.isInteger(expiresIn) || expiresIn <= 0) {
+ throw new TypeError('createCheckoutJwt: expiresInSeconds must be a positive whole number');
+ }
+
+ const agentCommerce: Record = {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: resourceId,
+ input_hash: await computeInputHash(options.input),
+ amount,
+ currency,
+ payment_method: paymentMethod,
+ ...(options.destination !== undefined ? { destination: options.destination } : {}),
+ ...(options.network !== undefined ? { network: options.network } : {}),
+ ...(options.asset !== undefined ? { asset: options.asset } : {}),
+ };
+
+ return new SignJWT({ agent_commerce: agentCommerce })
+ .setProtectedHeader({ alg: AP2_SIGNING_ALGORITHM, kid, typ: 'JWT' })
+ .setIssuer(issuer)
+ .setAudience(audience)
+ .setIssuedAt(issuedAt)
+ .setExpirationTime(issuedAt + expiresIn)
+ .setJti(options.jwtId ?? crypto.randomUUID())
+ .sign(key);
+}
diff --git a/src/authorization/ap2/constants.ts b/src/authorization/ap2/constants.ts
new file mode 100644
index 0000000..e4e8009
--- /dev/null
+++ b/src/authorization/ap2/constants.ts
@@ -0,0 +1,75 @@
+/**
+ * Pinned AP2 identifiers and key policy.
+ *
+ * AP2 v0.2.0 (2026-04-28, commit b4587ac) is the tagged release this gateway
+ * verifies against; unversioned `main` is never implemented against. Config,
+ * the verifier and `doctor` all read these, so a bump lands in one place.
+ */
+
+export const AP2_SPEC_VERSION = '0.2.0';
+
+/**
+ * Autonomous mode needs open mandates, cnf-bound agent keys and constraint
+ * evaluation over `checkout.line_items`. Half of that would be worse than a
+ * declared Direct-only profile, so it is refused rather than partly served.
+ */
+export const AP2_MODES = ['direct'] as const;
+export type Ap2Mode = (typeof AP2_MODES)[number];
+
+/**
+ * One entry, so `alg=none` and the HMAC family are excluded by construction
+ * rather than by a check that has to remember them
+ */
+export const AP2_SIGNING_ALGORITHM = 'ES256';
+
+/** The key type and curve ES256 implies. Any other pair is refused at load */
+export const AP2_KEY_TYPE = 'EC';
+export const AP2_JWK_CURVE = 'P-256';
+
+/** Byte length of a P-256 coordinate, before base64url encoding */
+export const AP2_JWK_COORDINATE_BYTES = 32;
+
+/**
+ * JWK members a verification key may carry.
+ *
+ * An allowlist, so private material (`d`) and anything naming a URL (`x5u`,
+ * or a smuggled `jku`) is refused without this list having to name it. Keys
+ * are configured inline and never fetched; see docs/security.md.
+ */
+export const AP2_JWK_MEMBERS = ['kty', 'crv', 'x', 'y', 'kid', 'alg', 'use'] as const;
+
+/**
+ * Compared against this literal, never as a prefix. `mandate.checkout.open.1`
+ * carries spending constraints this release does not evaluate, so accepting it
+ * would tell a buyer their limits were checked when nothing read them.
+ */
+export const AP2_CHECKOUT_MANDATE_VCT = 'mandate.checkout.1';
+
+/**
+ * The checkout profile every merchant checkout JWT must declare.
+ *
+ * Ours, not AP2's: AP2 leaves the checkout payload outside its scope, so the
+ * claims a paid-resource invocation needs had to be specified somewhere.
+ *
+ * A bare name, matching the gateway's other wire identifiers
+ * (`agent-commerce/delivery`, `agent-commerce/v1.0.0`). A profile id is a
+ * namespace and is never dereferenced, so a URL would only tie the wire format
+ * to a domain. Frozen once released: merchants sign it into every checkout JWT.
+ */
+export const AP2_CHECKOUT_PROFILE = 'agent-commerce/ap2/checkout/v1';
+
+/**
+ * Taken from the SD-JWT `_sd_alg`, which defaults to sha-256 when absent.
+ * Anything else is refused rather than hashed as sha-256 anyway, which would
+ * check a presentation under an algorithm it never claimed.
+ */
+export const AP2_DIGEST_ALGORITHM = 'sha-256';
+
+/** Seconds of clock skew tolerated on mandate and checkout time claims */
+export const AP2_DEFAULT_CLOCK_SKEW_SECONDS = 60;
+
+/**
+ * Skew wide enough to cover a mandate's validity window stops `exp` rejecting
+ * anything. An operator needing more than five minutes has a clock to fix.
+ */
+export const AP2_MAX_CLOCK_SKEW_SECONDS = 300;
diff --git a/src/authorization/ap2/descriptor.ts b/src/authorization/ap2/descriptor.ts
new file mode 100644
index 0000000..f785f32
--- /dev/null
+++ b/src/authorization/ap2/descriptor.ts
@@ -0,0 +1,67 @@
+/**
+ * Adapter self-description.
+ *
+ * Imports nothing but core types and the pins, so `doctor` can report AP2
+ * without pulling `jose` or `@sd-jwt/core` into the CLI bundle.
+ */
+import type { AdapterDescriptor } from '../../core/index.js';
+import {
+ AP2_CHECKOUT_MANDATE_VCT,
+ AP2_CHECKOUT_PROFILE,
+ AP2_DIGEST_ALGORITHM,
+ AP2_SIGNING_ALGORITHM,
+ AP2_SPEC_VERSION,
+} from './constants.js';
+
+/** What this provider actually verifies */
+export const AP2_CAPABILITIES: readonly string[] = [
+ 'direct-mode',
+ AP2_CHECKOUT_MANDATE_VCT,
+ 'sd-jwt-presentation',
+ AP2_SIGNING_ALGORITHM,
+ AP2_DIGEST_ALGORITHM,
+ 'static-inline-trust',
+ 'merchant-checkout-jwt-binding',
+ AP2_CHECKOUT_PROFILE,
+ 'purchase-binding',
+ 'replay-defence',
+];
+
+/**
+ * Everything an AP2 client may reasonably expect and will not get here.
+ * Complete on purpose: a short list reads as "mostly compatible", which is the
+ * blanket claim alpha honesty forbids.
+ */
+export const AP2_UNSUPPORTED: readonly string[] = [
+ // Mandate kinds. Open mandates carry spending constraints nothing here
+ // evaluates, so accepting one would tell a buyer their limits were checked.
+ 'autonomous mode',
+ 'open checkout mandates (mandate.checkout.open.1)',
+ 'intent mandates',
+ 'cart mandates',
+ 'spending constraint evaluation',
+ 'cnf-bound agent keys',
+ // Key handling. Every key is written into config by an operator
+ 'JWKS and any key discovery by URL (jku, x5u)',
+ 'issuer metadata fetching',
+ 'key rotation without a config change',
+ // Algorithms
+ 'signature algorithms other than ES256',
+ 'digest algorithms other than sha-256',
+ // Roles this gateway does not play
+ 'mandate issuance',
+ 'merchant checkout JWT issuance',
+ 'AP2 over the ACP checkout adapter',
+];
+
+export function buildAp2Descriptor(implementationVersion: string): AdapterDescriptor {
+ return {
+ name: 'ap2',
+ kind: 'authorization',
+ implementationVersion,
+ supportedSpec: `ap2/v${AP2_SPEC_VERSION} mode=direct`,
+ capabilities: AP2_CAPABILITIES,
+ status: 'experimental',
+ unsupported: AP2_UNSUPPORTED,
+ };
+}
diff --git a/src/authorization/ap2/errors.ts b/src/authorization/ap2/errors.ts
new file mode 100644
index 0000000..d2ff7f1
--- /dev/null
+++ b/src/authorization/ap2/errors.ts
@@ -0,0 +1,100 @@
+/**
+ * Every failure the AP2 verifier can report.
+ *
+ * `CommerceError.message` reaches the client, and a mandate carries the
+ * buyer's purchase and often personal data, so messages are fixed phrases from
+ * the list below. The machine-readable code goes in `details.reason` and the
+ * original exception on `cause`, which is never serialised.
+ *
+ * Three codes: AUTHORIZATION_INVALID when the buyer's mandate is bad,
+ * AUTHORIZATION_REPLAYED when it is good but spent, and
+ * AUTHORIZATION_PROVIDER_UNAVAILABLE when our verifier never reached a
+ * verdict. Blaming the buyer for our outage refuses a good mandate.
+ */
+import { CommerceError } from '../../core/index.js';
+
+/**
+ * Coarse on purpose: a client learns roughly where its mandate was refused,
+ * not which check failed. Finer detail is an oracle for probing trust policy.
+ */
+export const AP2_REJECTION_REASONS = [
+ 'malformed_presentation',
+ 'untrusted_issuer',
+ 'unknown_key',
+ 'invalid_signature',
+ 'unsupported_algorithm',
+ 'invalid_claims',
+ 'expired',
+ 'wrong_audience',
+ 'unsupported_mandate_type',
+ 'checkout_binding_failed',
+ 'purchase_mismatch',
+] as const;
+
+export type Ap2RejectionReason = (typeof AP2_REJECTION_REASONS)[number];
+
+const MESSAGES: Readonly> = {
+ malformed_presentation: 'The authorization is not a well-formed SD-JWT presentation.',
+ untrusted_issuer: 'The mandate was issued by a party this merchant does not trust.',
+ unknown_key: 'The mandate names a signing key this merchant does not trust.',
+ invalid_signature: 'The mandate signature did not verify.',
+ unsupported_algorithm: 'The mandate uses a signature or digest algorithm that is not accepted.',
+ invalid_claims: 'The mandate is missing required claims or they are malformed.',
+ expired: 'The mandate is expired or not yet valid.',
+ wrong_audience: 'The mandate is addressed to a different audience.',
+ unsupported_mandate_type: 'The mandate is not a closed Direct Checkout Mandate.',
+ checkout_binding_failed: 'The merchant checkout document bound to the mandate did not verify.',
+ purchase_mismatch: 'The mandate does not authorize this purchase.',
+};
+
+export interface Ap2ErrorContext {
+ readonly requestId?: string;
+ readonly resourceId?: string;
+ readonly cause?: unknown;
+}
+
+/** The buyer's mandate is bad. Fail closed */
+export function ap2Rejected(
+ reason: Ap2RejectionReason,
+ context: Ap2ErrorContext = {},
+): CommerceError {
+ return new CommerceError('AUTHORIZATION_INVALID', MESSAGES[reason], {
+ details: { method: 'ap2', reason },
+ ...(context.requestId !== undefined ? { requestId: context.requestId } : {}),
+ ...(context.resourceId !== undefined ? { resourceId: context.resourceId } : {}),
+ ...(context.cause !== undefined ? { cause: context.cause } : {}),
+ });
+}
+
+/**
+ * The mandate verified but has already been presented. A separate code from
+ * a bad mandate: nothing is wrong with this proof except that it is spent
+ */
+export function ap2Replayed(state: string, context: Ap2ErrorContext = {}): CommerceError {
+ return new CommerceError(
+ 'AUTHORIZATION_REPLAYED',
+ 'This mandate has already been presented and cannot authorize another purchase.',
+ {
+ details: { method: 'ap2', reason: 'replayed', state },
+ ...(context.requestId !== undefined ? { requestId: context.requestId } : {}),
+ ...(context.resourceId !== undefined ? { resourceId: context.resourceId } : {}),
+ },
+ );
+}
+
+/**
+ * The check never ran (a configured key that will not import, say). Retryable,
+ * and never recorded against the payer: the mandate may be perfectly good.
+ */
+export function ap2Unavailable(detail: string, context: Ap2ErrorContext = {}): CommerceError {
+ return new CommerceError(
+ 'AUTHORIZATION_PROVIDER_UNAVAILABLE',
+ 'Authorization could not be verified right now. This is a merchant-side fault, not a problem with the presented mandate.',
+ {
+ details: { method: 'ap2', reason: 'verifier_unavailable', detail },
+ ...(context.requestId !== undefined ? { requestId: context.requestId } : {}),
+ ...(context.resourceId !== undefined ? { resourceId: context.resourceId } : {}),
+ ...(context.cause !== undefined ? { cause: context.cause } : {}),
+ },
+ );
+}
diff --git a/src/authorization/ap2/index.ts b/src/authorization/ap2/index.ts
new file mode 100644
index 0000000..6ed9f3e
--- /dev/null
+++ b/src/authorization/ap2/index.ts
@@ -0,0 +1,47 @@
+/**
+ * src/authorization/ap2
+ *
+ * AP2 Direct Checkout Mandate verification. Everything reachable from here
+ * pulls the optional peers (`jose`, `@sd-jwt/core`, `canonicalize`), so the
+ * main entry and the CLI import the narrow modules instead of this barrel.
+ */
+
+export {
+ type Ap2SigningKey,
+ type CreateCheckoutJwtOptions,
+ createCheckoutJwt,
+} from './checkout-signer.js';
+export {
+ AP2_CHECKOUT_MANDATE_VCT,
+ AP2_CHECKOUT_PROFILE,
+ AP2_DEFAULT_CLOCK_SKEW_SECONDS,
+ AP2_DIGEST_ALGORITHM,
+ AP2_MAX_CLOCK_SKEW_SECONDS,
+ AP2_MODES,
+ AP2_SIGNING_ALGORITHM,
+ AP2_SPEC_VERSION,
+ type Ap2Mode,
+} from './constants.js';
+export {
+ AP2_CAPABILITIES,
+ AP2_UNSUPPORTED,
+ buildAp2Descriptor,
+} from './descriptor.js';
+export { AP2_REJECTION_REASONS, type Ap2RejectionReason } from './errors.js';
+export {
+ type Ap2AuthorizationProvider,
+ type Ap2AuthorizationProviderOptions,
+ createAp2AuthorizationProvider,
+} from './provider.js';
+export {
+ type Ap2AuthorizationState,
+ type Ap2ReplayStore,
+ createAp2ReplayStore,
+} from './replay-store.js';
+export type {
+ Ap2AuthorizationConfig,
+ Ap2TrustedIssuer,
+ Ap2TrustedKey,
+ EnabledAp2Config,
+ VerifiedCheckoutMandate,
+} from './types.js';
diff --git a/src/authorization/ap2/profile.ts b/src/authorization/ap2/profile.ts
new file mode 100644
index 0000000..9e79d34
--- /dev/null
+++ b/src/authorization/ap2/profile.ts
@@ -0,0 +1,127 @@
+/**
+ * Binds a verified mandate to the purchase in front of us.
+ *
+ * A perfect mandate authorises exactly one purchase; without this comparison a
+ * mandate approved for a $0.01 report would settle a $500 one.
+ *
+ * Everything is compared against the ALREADY RESOLVED request. Nothing is
+ * taken from the mandate and used to shape the purchase, which would invert
+ * the control.
+ */
+import canonicalize from 'canonicalize';
+import type { AuthorizationVerificationContext } from '../../core/index.js';
+import { AP2_CHECKOUT_PROFILE } from './constants.js';
+import { type Ap2ErrorContext, ap2Rejected } from './errors.js';
+import type { VerifiedCheckoutMandate } from './types.js';
+
+/**
+ * All required. Absent is a mismatch, never a skipped check: a mandate that
+ * will not say which resource or how much authorises nothing in particular.
+ */
+const REQUIRED_PROFILE_CLAIMS = [
+ 'profile',
+ 'resource_id',
+ 'input_hash',
+ 'amount',
+ 'currency',
+ 'payment_method',
+] as const;
+
+/**
+ * RFC 8785 (JCS) digest of the validated resource input.
+ *
+ * Binds a mandate to the exact request, not just to a resource and a price:
+ * without it, one mandate for `translate` would authorise any translation.
+ *
+ * `canonicalize` rather than a sorted-key `JSON.stringify`, which differs
+ * exactly where it matters. The merchant's signer computes this same digest,
+ * probably in another language, and the two agree only if both follow RFC
+ * 8785's number formatting and UTF-16 key ordering.
+ *
+ * The caller passes what the backend will receive: validated, reserved fields
+ * stripped, no request id or transport metadata (the buyer could not have
+ * known those when they approved).
+ */
+export async function computeInputHash(input: unknown): Promise {
+ const canonical = canonicalize(input ?? {});
+ // undefined means JSON cannot represent it. Input has already passed schema
+ // validation, so this is something exotic getting past it, not a buyer error.
+ if (canonical === undefined) {
+ throw new TypeError('resource input is not canonicalizable JSON');
+ }
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
+ return Buffer.from(digest).toString('base64url');
+}
+
+/** What the mandate turned out to authorise, once it matched */
+export interface BoundPurchase {
+ readonly resourceId: string;
+ readonly amount: string;
+ readonly currency: string;
+ readonly paymentMethod: string;
+}
+
+function asRecord(value: unknown): Record | undefined {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+/**
+ * Throws on the first mismatch with one coarse reason. Which field disagreed
+ * is not reported: a caller able to ask about one field at a time can read a
+ * mandate's contents out of the gateway by elimination.
+ */
+export async function bindMandateToPurchase(
+ mandate: VerifiedCheckoutMandate,
+ context: AuthorizationVerificationContext,
+ errorContext: Ap2ErrorContext,
+): Promise {
+ const profile = asRecord(mandate.checkoutClaims['agent_commerce']);
+ if (profile === undefined) throw ap2Rejected('purchase_mismatch', errorContext);
+
+ for (const claim of REQUIRED_PROFILE_CLAIMS) {
+ const value = profile[claim];
+ if (typeof value !== 'string' || value.length === 0) {
+ throw ap2Rejected('purchase_mismatch', errorContext);
+ }
+ }
+
+ const requirement = context.requirement;
+ const expectedInputHash = await computeInputHash(context.input);
+
+ const mustMatch: readonly (readonly [string, unknown])[] = [
+ ['profile', AP2_CHECKOUT_PROFILE],
+ ['resource_id', context.resourceId],
+ ['input_hash', expectedInputHash],
+ // Decimal strings on both sides. Comparing numerically would make "0.10"
+ // and "0.1" equal, and a mandate says what it says.
+ ['amount', requirement.amount],
+ ['currency', requirement.currency],
+ ['payment_method', requirement.provider],
+ ];
+
+ for (const [claim, expected] of mustMatch) {
+ if (profile[claim] !== expected) throw ap2Rejected('purchase_mismatch', errorContext);
+ }
+
+ // Checked whenever EITHER side names one, which is the fail-closed reading.
+ // A mandate silent about the chain must not unlock a mainnet settlement, and
+ // one naming a chain the requirement lacks was approved for another rail.
+ for (const [claim, expected] of [
+ ['destination', requirement.destination],
+ ['network', requirement.network],
+ ['asset', requirement.asset],
+ ] as const) {
+ const declared = profile[claim];
+ if (declared === undefined && expected === undefined) continue;
+ if (declared !== expected) throw ap2Rejected('purchase_mismatch', errorContext);
+ }
+
+ return {
+ resourceId: profile['resource_id'] as string,
+ amount: profile['amount'] as string,
+ currency: profile['currency'] as string,
+ paymentMethod: profile['payment_method'] as string,
+ };
+}
diff --git a/src/authorization/ap2/provider.ts b/src/authorization/ap2/provider.ts
new file mode 100644
index 0000000..86c3ba8
--- /dev/null
+++ b/src/authorization/ap2/provider.ts
@@ -0,0 +1,163 @@
+/**
+ * The AP2 authorization provider: the seam between core's generic contract and
+ * the mandate machinery.
+ *
+ * Owns the replay database, so the gateway owns this object's lifetime and
+ * closes it on shutdown.
+ */
+import type {
+ AdapterHealth,
+ AuthorizationFinalizeContext,
+ AuthorizationProvider,
+ AuthorizationRequirement,
+ AuthorizationVerification,
+ AuthorizationVerificationContext,
+ Clock,
+ Logger,
+} from '../../core/index.js';
+import { NOOP_LOGGER, systemClock } from '../../core/index.js';
+import { PACKAGE_VERSION } from '../../version.js';
+import { AP2_CHECKOUT_PROFILE, AP2_SPEC_VERSION } from './constants.js';
+import { buildAp2Descriptor } from './descriptor.js';
+import { type Ap2ErrorContext, ap2Replayed, ap2Unavailable } from './errors.js';
+import { bindMandateToPurchase } from './profile.js';
+import { type Ap2ReplayStore, createAp2ReplayStore } from './replay-store.js';
+import type { EnabledAp2Config } from './types.js';
+import { createAp2MandateVerifier } from './verifier.js';
+
+export interface Ap2AuthorizationProviderOptions {
+ readonly config: EnabledAp2Config;
+ readonly clock?: Clock;
+ readonly logger?: Logger;
+ /** Injectable so tests need not touch the filesystem */
+ readonly replayStore?: Ap2ReplayStore;
+}
+
+export interface Ap2AuthorizationProvider extends AuthorizationProvider {
+ /** Closes the replay database */
+ close(): void;
+}
+
+/**
+ * A reservation is keyed by the mandate reference, so the handle and the
+ * receipt's identity are the same digest. Kept as one name rather than two
+ * fields that must never disagree.
+ */
+export function createAp2AuthorizationProvider(
+ options: Ap2AuthorizationProviderOptions,
+): Ap2AuthorizationProvider {
+ const clock = options.clock ?? systemClock;
+ const logger = options.logger ?? NOOP_LOGGER;
+
+ const verifier = createAp2MandateVerifier({ config: options.config, clock });
+ const replay =
+ options.replayStore ?? createAp2ReplayStore({ path: options.config.replay.path, logger });
+
+ const requirement: AuthorizationRequirement = {
+ method: 'ap2',
+ version: AP2_SPEC_VERSION,
+ profile: AP2_CHECKOUT_PROFILE,
+ };
+
+ async function verifyAndReserve(
+ context: AuthorizationVerificationContext,
+ ): Promise {
+ const errorContext: Ap2ErrorContext = {
+ requestId: context.requestId,
+ resourceId: context.resourceId,
+ };
+
+ const mandate = await verifier.verify(context.submission.payload, errorContext);
+ // Before reserving, not after: a mandate that does not authorize this
+ // purchase must stay spendable on the purchase it does authorize
+ await bindMandateToPurchase(mandate, context, errorContext);
+
+ let reservation: ReturnType;
+ try {
+ reservation = replay.reserve({
+ reference: mandate.reference,
+ checkoutJti: mandate.checkoutJwtId,
+ mandateIssuer: mandate.mandateIssuer,
+ checkoutIssuer: mandate.checkoutIssuer,
+ resourceId: context.resourceId,
+ requestId: context.requestId,
+ });
+ } catch (cause) {
+ throw ap2Unavailable('replay store could not be written', { ...errorContext, cause });
+ }
+
+ if (reservation.kind === 'replayed') {
+ throw ap2Replayed(reservation.state, errorContext);
+ }
+
+ return {
+ status: 'verified',
+ method: 'ap2',
+ reference: mandate.reference,
+ reservationId: mandate.reference,
+ // Opaque identifiers an operator can reconcile with. Never a claim from
+ // the mandate: those carry the buyer's purchase and their personal data.
+ // `checkoutId`, not `checkoutJwtId` - the receipt store redacts any key
+ // that reads as secret-shaped, and "jwt" is on that list.
+ metadata: {
+ mandateIssuer: mandate.mandateIssuer,
+ checkoutIssuer: mandate.checkoutIssuer,
+ checkoutId: mandate.checkoutJwtId,
+ },
+ };
+ }
+
+ const finalize = (
+ action: 'consume' | 'release' | 'markUncertain',
+ reservationId: string,
+ context: AuthorizationFinalizeContext,
+ ): void => {
+ try {
+ replay[action](reservationId);
+ } catch (cause) {
+ throw ap2Unavailable(`replay store could not record ${action}`, { ...context, cause });
+ }
+ };
+
+ return {
+ name: 'ap2',
+ descriptor: buildAp2Descriptor(PACKAGE_VERSION),
+ requirement,
+ verifyAndReserve,
+
+ async consume(reservationId, context) {
+ finalize('consume', reservationId, context);
+ },
+ async release(reservationId, context) {
+ finalize('release', reservationId, context);
+ },
+ async markUncertain(reservationId, context) {
+ finalize('markUncertain', reservationId, context);
+ },
+
+ async health(): Promise {
+ const startedAt = clock.monotonicMs();
+ const trusted = verifier.trustedIssuers();
+ const checkedAt = clock.nowIso();
+ const durationMs = Math.round(clock.monotonicMs() - startedAt);
+ try {
+ // A read against the real table, so a database that opened but cannot
+ // be queried is caught here rather than on the first purchase
+ replay.stateOf('health-probe');
+ } catch {
+ // A fixed token, never a sentence built from the caught error
+ return { status: 'fail', checkedAt, durationMs, detail: 'replay-store-unavailable' };
+ }
+ return {
+ status: 'pass',
+ checkedAt,
+ durationMs,
+ detail: `mandate-issuers=${trusted.mandate.length} checkout-issuers=${trusted.checkout.length}`,
+ };
+ },
+
+ close() {
+ replay.close();
+ },
+ };
+}
diff --git a/src/authorization/ap2/replay-store.ts b/src/authorization/ap2/replay-store.ts
new file mode 100644
index 0000000..25cffbb
--- /dev/null
+++ b/src/authorization/ap2/replay-store.ts
@@ -0,0 +1,198 @@
+/**
+ * Durable record of which mandates have been spent.
+ *
+ * Its own table in its own file: an x402 payment replay key expires with its
+ * on-chain authorisation, while a consumed mandate must stay consumed for as
+ * long as the merchant can be asked what they delivered. Hence no retention
+ * sweep here, unlike the ACP idempotency store next door - deleting a
+ * `consumed` row makes that mandate spendable again. If the table ever needs
+ * bounding, archive `released` rows and leave the rest.
+ *
+ * Settlement and a local commit are not one transaction. If the process dies
+ * between them the row stays `reserved` and that mandate is refused from then
+ * on: a refused retry costs a round trip, the other direction costs a second
+ * payment.
+ */
+import type { Database } from 'better-sqlite3';
+import { type Logger, NOOP_LOGGER } from '../../core/index.js';
+import { openSqliteDatabase } from '../../storage/sqlite.js';
+
+/**
+ * `released` is "nothing happened" and the only state a mandate can be
+ * presented from again. `uncertain` is a settlement whose outcome we never
+ * learned: not reusable, but findable by an operator reconciling by hand.
+ */
+export type Ap2AuthorizationState = 'reserved' | 'consumed' | 'released' | 'uncertain';
+
+/**
+ * A digest and a few identifiers: everything replay defence needs, and nothing
+ * a leaked database would hand an attacker
+ */
+export interface Ap2ReservationRequest {
+ /**
+ * Digest of the ISSUER-SIGNED TOKEN, not of the presentation. Selective
+ * disclosure gives one mandate many presentation strings, so keying on the
+ * presentation would let it be spent once per disclosed subset.
+ */
+ readonly reference: string;
+ /** `jti` of the merchant checkout JWT the mandate binds */
+ readonly checkoutJti: string;
+ readonly mandateIssuer: string;
+ readonly checkoutIssuer: string;
+ readonly resourceId: string;
+ readonly requestId: string;
+}
+
+export type Ap2ReservationResult =
+ | { readonly kind: 'reserved' }
+ /** Already reserved, consumed, or of uncertain outcome. Never re-spendable */
+ | { readonly kind: 'replayed'; readonly state: Ap2AuthorizationState };
+
+export interface Ap2ReplayStore {
+ /** Atomically claim a mandate, or report that something already holds it */
+ reserve(request: Ap2ReservationRequest): Ap2ReservationResult;
+ /** Settlement succeeded: spend it permanently */
+ consume(reference: string): void;
+ /** Nothing happened: hand it back so a corrected retry can use it */
+ release(reference: string): void;
+ /** Settlement outcome unknown. Not reusable, and flagged for a human */
+ markUncertain(reference: string): void;
+ /** Current state, for tests and diagnostics */
+ stateOf(reference: string): Ap2AuthorizationState | undefined;
+ close(): void;
+}
+
+export interface Ap2ReplayStoreOptions {
+ /** File path, or ':memory:' for tests */
+ readonly path: string;
+ readonly logger?: Logger;
+ /** Injectable so tests need not move the wall clock */
+ readonly now?: () => number;
+}
+
+interface StateRow {
+ state: string;
+}
+
+export function createAp2ReplayStore(options: Ap2ReplayStoreOptions): Ap2ReplayStore {
+ const logger = options.logger ?? NOOP_LOGGER;
+ const now = options.now ?? (() => Date.now());
+
+ const db: Database = openSqliteDatabase({
+ path: options.path,
+ label: 'AP2 authorization database',
+ logger,
+ });
+ migrate(db);
+
+ const selectByReference = db.prepare<[string], StateRow>(
+ 'SELECT state FROM ap2_authorizations WHERE reference = ?',
+ );
+ // Two mandates can bind one checkout document, giving two references, so
+ // the reference alone would not catch the second one
+ const selectLiveByJti = db.prepare<[string, string], StateRow>(
+ `SELECT state FROM ap2_authorizations
+ WHERE checkout_jti = ? AND reference != ? AND state != 'released'`,
+ );
+ const insert = db.prepare(
+ `INSERT INTO ap2_authorizations
+ (reference, checkout_jti, mandate_issuer, checkout_issuer, resource_id, request_id,
+ state, created_at, updated_at)
+ VALUES (@reference, @checkout_jti, @mandate_issuer, @checkout_issuer, @resource_id,
+ @request_id, 'reserved', @at, @at)`,
+ );
+ const reReserve = db.prepare(
+ `UPDATE ap2_authorizations
+ SET state = 'reserved', request_id = @request_id, updated_at = @at
+ WHERE reference = @reference AND state = 'released'`,
+ );
+ // Guarded on `reserved`: releasing a `consumed` row would hand back a
+ // mandate whose money has already moved
+ const transition = db.prepare(
+ `UPDATE ap2_authorizations SET state = @state, updated_at = @at
+ WHERE reference = @reference AND state = 'reserved'`,
+ );
+
+ const reserveTx = db.transaction((request: Ap2ReservationRequest): Ap2ReservationResult => {
+ const at = new Date(now()).toISOString();
+
+ const existing = selectByReference.get(request.reference);
+ if (existing !== undefined) {
+ if (existing.state !== 'released') {
+ return { kind: 'replayed', state: existing.state as Ap2AuthorizationState };
+ }
+ reReserve.run({ reference: request.reference, request_id: request.requestId, at });
+ return { kind: 'reserved' };
+ }
+
+ const sameCheckout = selectLiveByJti.get(request.checkoutJti, request.reference);
+ if (sameCheckout !== undefined) {
+ return { kind: 'replayed', state: sameCheckout.state as Ap2AuthorizationState };
+ }
+
+ insert.run({
+ reference: request.reference,
+ checkout_jti: request.checkoutJti,
+ mandate_issuer: request.mandateIssuer,
+ checkout_issuer: request.checkoutIssuer,
+ resource_id: request.resourceId,
+ request_id: request.requestId,
+ at,
+ });
+ return { kind: 'reserved' };
+ });
+
+ const move = (reference: string, state: Ap2AuthorizationState): void => {
+ transition.run({ reference, state, at: new Date(now()).toISOString() });
+ };
+
+ let closed = false;
+ return {
+ reserve(request) {
+ return reserveTx(request);
+ },
+ consume(reference) {
+ move(reference, 'consumed');
+ },
+ release(reference) {
+ move(reference, 'released');
+ },
+ markUncertain(reference) {
+ move(reference, 'uncertain');
+ },
+ stateOf(reference) {
+ return selectByReference.get(reference)?.state as Ap2AuthorizationState | undefined;
+ },
+ close() {
+ if (closed) return;
+ closed = true;
+ db.close();
+ },
+ };
+}
+
+// `PRAGMA user_version`, so reopening an existing file is a no-op check
+function migrate(db: Database): void {
+ const current = db.pragma('user_version', { simple: true }) as number;
+ if (current >= 1) return;
+ const apply = db.transaction(() => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS ap2_authorizations (
+ reference TEXT PRIMARY KEY,
+ checkout_jti TEXT NOT NULL,
+ mandate_issuer TEXT NOT NULL,
+ checkout_issuer TEXT NOT NULL,
+ resource_id TEXT NOT NULL,
+ request_id TEXT NOT NULL,
+ state TEXT NOT NULL
+ CHECK (state IN ('reserved', 'consumed', 'released', 'uncertain')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_ap2_authorizations_checkout_jti
+ ON ap2_authorizations(checkout_jti);
+ `);
+ db.pragma('user_version = 1');
+ });
+ apply();
+}
diff --git a/src/authorization/ap2/sd-jwt.ts b/src/authorization/ap2/sd-jwt.ts
new file mode 100644
index 0000000..f4b27a3
--- /dev/null
+++ b/src/authorization/ap2/sd-jwt.ts
@@ -0,0 +1,179 @@
+/**
+ * Stage one: parse the presentation, verify the issuer's signature, resolve
+ * the disclosures.
+ *
+ * Disclosure mechanics come from `@sd-jwt/core`, which refuses the three
+ * attacks that live in that algorithm: an appended disclosure nothing
+ * references, the same disclosure twice, and one that will not decode.
+ * Cryptography is `jose`'s. This file supplies the policy: which key, which
+ * algorithm, which audience, what counts as fresh.
+ */
+import { decodeSdJwt, getClaims, splitSdJwt } from '@sd-jwt/core';
+import { type JWTVerifyOptions, jwtVerify } from 'jose';
+import type { Clock } from '../../core/index.js';
+import {
+ AP2_CHECKOUT_MANDATE_VCT,
+ AP2_DIGEST_ALGORITHM,
+ AP2_SIGNING_ALGORITHM,
+} from './constants.js';
+import { type Ap2ErrorContext, ap2Rejected } from './errors.js';
+import type { TrustStore } from './trust.js';
+
+export interface VerifiedMandate {
+ readonly issuer: string;
+ /** Every claim, with the presented disclosures resolved into place */
+ readonly claims: Readonly>;
+ /**
+ * The issuer-signed token on its own, without the disclosures.
+ *
+ * This, not the presentation string, is the stable identity of a mandate:
+ * disclosing or withholding an optional claim rewrites the presentation and
+ * leaves the signed token untouched. Replay defence keys on a digest of it.
+ */
+ readonly signedToken: string;
+}
+
+/**
+ * `@sd-jwt/core` passes the algorithm it read from `_sd_alg`, so this is also
+ * where a presentation declaring anything but sha-256 is refused
+ */
+async function hasher(data: string | ArrayBuffer, algorithm: string): Promise {
+ if (algorithm.toLowerCase() !== AP2_DIGEST_ALGORITHM) {
+ throw new Error(`unsupported digest algorithm ${algorithm}`);
+ }
+ const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
+}
+
+function asRecord(value: unknown): Record | undefined {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
+/**
+ * Order is the security boundary here: nothing is trusted until `jwtVerify`
+ * returns. `kid` and `iss` are read before that only to pick which configured
+ * key to try, and picking wrong just makes the signature fail.
+ */
+export async function verifyMandate(
+ presentation: string,
+ deps: { readonly trust: TrustStore; readonly clock: Clock; readonly clockSkewSeconds: number },
+ context: Ap2ErrorContext,
+): Promise {
+ let decoded: Awaited>;
+ let encodedJws: string;
+ try {
+ // Covers a malformed base JWT, a disclosure that will not decode, a
+ // duplicate digest, and an `_sd_alg` this release does not implement
+ decoded = await decodeSdJwt(presentation, hasher);
+ encodedJws = splitSdJwt(presentation).jwt;
+ } catch (cause) {
+ throw ap2Rejected('malformed_presentation', { ...context, cause });
+ }
+
+ // Direct mode issues no key-bound mandates, so one arriving here belongs to
+ // a flow we do not verify. Ignoring it would mean silently not checking a
+ // proof that was sent.
+ if (decoded.kbJwt !== undefined) {
+ throw ap2Rejected('unsupported_mandate_type', context);
+ }
+
+ const rawPayload = asRecord(decoded.jwt.payload);
+ const header = asRecord(decoded.jwt.header);
+ if (rawPayload === undefined || header === undefined) {
+ throw ap2Rejected('malformed_presentation', context);
+ }
+
+ const { issuer, key } = await deps.trust.resolve(rawPayload['iss'], header['kid'], context);
+
+ const options: JWTVerifyOptions = {
+ // Redundant today (the key is EC, so jose already refuses `alg: none` and
+ // a forged HMAC) and kept for the day this resolves a key set instead of
+ // one key, where the header would get to pick
+ algorithms: [AP2_SIGNING_ALGORITHM],
+ // Also redundant, and for the same reason: the key was resolved *from*
+ // `iss` against the configured allowlist, so nothing reaching here can
+ // carry a different one. It backstops a future resolver that matches on
+ // something else. `audience` below is not redundant - `aud` is the
+ // presenter's claim, checked against what the operator configured.
+ issuer: issuer.issuer,
+ audience: issuer.audience,
+ clockTolerance: deps.clockSkewSeconds,
+ // Injected clock, not jose's `Date.now()`, so expiry tests are tests
+ currentDate: deps.clock.now(),
+ };
+
+ let verifiedPayload: Record;
+ try {
+ const result = await jwtVerify(encodedJws, key, options);
+ verifiedPayload = result.payload as Record;
+ } catch (cause) {
+ throw ap2Rejected(classifyJoseFailure(cause), { ...context, cause });
+ }
+
+ requireFreshness(verifiedPayload, deps, context);
+
+ let claims: Record;
+ try {
+ // Against the VERIFIED payload: the decoded one would match disclosures
+ // against digests nobody signed
+ claims = (await getClaims(verifiedPayload, decoded.disclosures, hasher)) as Record<
+ string,
+ unknown
+ >;
+ } catch (cause) {
+ // Where an appended disclosure that no digest references is refused
+ throw ap2Rejected('malformed_presentation', { ...context, cause });
+ }
+
+ requireClosedCheckoutMandate(claims, context);
+
+ return { issuer: issuer.issuer, claims, signedToken: encodedJws };
+}
+
+/**
+ * Matched on jose's stable error `code`, not its message. A client is owed
+ * "expired" versus "did not verify"; anything finer describes our checks back
+ * to whoever is probing them.
+ */
+function classifyJoseFailure(cause: unknown): 'expired' | 'wrong_audience' | 'invalid_signature' {
+ const code = (cause as { code?: unknown })?.code;
+ if (code === 'ERR_JWT_EXPIRED') return 'expired';
+ if (code === 'ERR_JWT_CLAIM_VALIDATION_FAILED') {
+ const claim = (cause as { claim?: unknown }).claim;
+ if (claim === 'aud') return 'wrong_audience';
+ if (claim === 'nbf' || claim === 'exp') return 'expired';
+ }
+ return 'invalid_signature';
+}
+
+/**
+ * Required, not merely checked when present: jose validates a time claim only
+ * if it is there, so a mandate omitting `exp` would verify and never expire
+ */
+function requireFreshness(
+ payload: Record,
+ deps: { readonly clock: Clock; readonly clockSkewSeconds: number },
+ context: Ap2ErrorContext,
+): void {
+ const exp = payload['exp'];
+ const iat = payload['iat'];
+ if (typeof exp !== 'number' || typeof iat !== 'number') {
+ throw ap2Rejected('invalid_claims', context);
+ }
+ const nowSeconds = Math.floor(deps.clock.now().getTime() / 1000);
+ // A future `iat` is a broken signer, or a mandate minted to outlive its own
+ // expiry window
+ if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected('expired', context);
+}
+
+// Exactly `mandate.checkout.1`; see AP2_CHECKOUT_MANDATE_VCT for why
+function requireClosedCheckoutMandate(
+ claims: Record,
+ context: Ap2ErrorContext,
+): void {
+ if (claims['vct'] !== AP2_CHECKOUT_MANDATE_VCT) {
+ throw ap2Rejected('unsupported_mandate_type', context);
+ }
+}
diff --git a/src/authorization/ap2/trust.ts b/src/authorization/ap2/trust.ts
new file mode 100644
index 0000000..2193c00
--- /dev/null
+++ b/src/authorization/ap2/trust.ts
@@ -0,0 +1,79 @@
+/**
+ * Static key resolution.
+ *
+ * Every key was written into `config.yaml` by an operator. No JWKS fetch, no
+ * `jku`, no `x5u`. A mandate's `iss` and `kid` only choose *which* trusted key
+ * verifies it; an unrecognised pair is refused, so a mandate can never
+ * nominate its own signer.
+ *
+ * Not a "try every key" loop: that would make `kid` advisory and accept a
+ * mandate that named a key it was not signed with.
+ */
+import { importJWK, type JWK } from 'jose';
+import { AP2_SIGNING_ALGORITHM } from './constants.js';
+import { type Ap2ErrorContext, ap2Rejected, ap2Unavailable } from './errors.js';
+import type { Ap2TrustedIssuer } from './types.js';
+
+/** Named off `importJWK` because `CryptoKey` is a DOM type we do not load */
+export type VerificationKey = Awaited>;
+
+export interface ResolvedKey {
+ readonly issuer: Ap2TrustedIssuer;
+ readonly key: VerificationKey;
+}
+
+/**
+ * Resolves `(iss, kid)` against one configured issuer list. Caches the import
+ * promise, not the key, so concurrent requests for a cold key share one import.
+ */
+export function createTrustStore(issuers: readonly Ap2TrustedIssuer[]) {
+ const byIssuer = new Map(issuers.map((entry) => [entry.issuer, entry]));
+ const imported = new Map>();
+
+ return {
+ /** Every trusted issuer id, for diagnostics. Never the keys themselves */
+ issuerIds(): readonly string[] {
+ return [...byIssuer.keys()];
+ },
+
+ async resolve(iss: unknown, kid: unknown, context: Ap2ErrorContext): Promise {
+ if (typeof iss !== 'string' || iss.length === 0) {
+ throw ap2Rejected('invalid_claims', context);
+ }
+ const issuer = byIssuer.get(iss);
+ if (issuer === undefined) throw ap2Rejected('untrusted_issuer', context);
+
+ // Not defaulted to the issuer's only key: mid-rotation there are two,
+ // and the gateway should not guess which one signed this
+ if (typeof kid !== 'string' || kid.length === 0) {
+ throw ap2Rejected('unknown_key', context);
+ }
+ const trusted = issuer.keys.find((candidate) => candidate.kid === kid);
+ if (trusted === undefined) throw ap2Rejected('unknown_key', context);
+
+ // JSON, not a joined string: both halves are operator-chosen and could
+ // contain whatever separator we picked
+ const cacheKey = JSON.stringify([iss, kid]);
+ let pending = imported.get(cacheKey);
+ if (pending === undefined) {
+ pending = importJWK(trusted.jwk as JWK, AP2_SIGNING_ALGORITHM);
+ imported.set(cacheKey, pending);
+ }
+
+ try {
+ return { issuer, key: await pending };
+ } catch (cause) {
+ // Config already checked this JWK member by member, so reaching here
+ // means our config is broken, not the mandate. Drop the cached
+ // rejection so a fixed config is not still failing against it.
+ imported.delete(cacheKey);
+ throw ap2Unavailable('configured verification key could not be imported', {
+ ...context,
+ cause,
+ });
+ }
+ },
+ };
+}
+
+export type TrustStore = ReturnType;
diff --git a/src/authorization/ap2/types.ts b/src/authorization/ap2/types.ts
new file mode 100644
index 0000000..16147bb
--- /dev/null
+++ b/src/authorization/ap2/types.ts
@@ -0,0 +1,78 @@
+/**
+ * AP2 trust configuration and the shape a successful verification produces.
+ *
+ * Trust types live here, not in `src/config`, so the dependency points the way
+ * `X402FacilitatorConfig` does: the subsystem owns its own config shape and
+ * the loader imports it.
+ */
+import type { AP2_SPEC_VERSION, Ap2Mode } from './constants.js';
+
+export type { Ap2Mode };
+
+/** One inline public verification key, trusted because an operator wrote it here */
+export interface Ap2TrustedKey {
+ readonly kid: string;
+ /** A public P-256 JWK. Validated member by member at config load */
+ readonly jwk: Readonly>;
+}
+
+/** One trusted issuer and the keys it signs with */
+export interface Ap2TrustedIssuer {
+ readonly issuer: string;
+ /**
+ * Per issuer, not one gateway-wide value: the mandate is addressed to the
+ * merchant and the checkout JWT it binds to the gateway
+ */
+ readonly audience: string;
+ readonly keys: readonly Ap2TrustedKey[];
+}
+
+/**
+ * Discriminated on `enabled`, like `AcpProtocolConfig`: an enabled config
+ * carries everything the verifier needs, so nothing downstream asserts on an
+ * optional field
+ */
+export type Ap2AuthorizationConfig =
+ | { readonly enabled: false }
+ | {
+ readonly enabled: true;
+ readonly specVersion: typeof AP2_SPEC_VERSION;
+ readonly mode: Ap2Mode;
+ readonly trust: {
+ /** Signers of the Checkout Mandate itself */
+ readonly mandateIssuers: readonly Ap2TrustedIssuer[];
+ /** Signers of the merchant checkout JWT the mandate binds */
+ readonly checkoutIssuers: readonly Ap2TrustedIssuer[];
+ };
+ readonly clockSkewSeconds: number;
+ /** Its own SQLite file. An authorization replay is not a payment replay */
+ readonly replay: { readonly path: string };
+ };
+
+/** The enabled half, which is all the verifier ever runs against */
+export type EnabledAp2Config = Extract;
+
+/**
+ * A Checkout Mandate that passed every cryptographic check.
+ *
+ * Valid is not the same as authorising *this* purchase. Binding it to the
+ * resolved resource, input and price is a separate step (profile.ts).
+ */
+export interface VerifiedCheckoutMandate {
+ /**
+ * `sha256:` over the issuer-signed token. Stable across every
+ * presentation of one mandate, which is what makes it a usable replay key,
+ * and safe to record in a receipt.
+ */
+ readonly reference: string;
+ /** Issuer of the Checkout Mandate, as verified against its signature */
+ readonly mandateIssuer: string;
+ /** Issuer of the merchant checkout JWT the mandate binds */
+ readonly checkoutIssuer: string;
+ /** `jti` of the checkout JWT. Safe to record: it is an opaque identifier */
+ readonly checkoutJwtId: string;
+ /** Signature-verified checkout JWT claims. Carries the checkout profile */
+ readonly checkoutClaims: Readonly>;
+ /** Mandate claims with every presented disclosure resolved into place */
+ readonly mandateClaims: Readonly>;
+}
diff --git a/src/authorization/ap2/verifier.ts b/src/authorization/ap2/verifier.ts
new file mode 100644
index 0000000..6704e9d
--- /dev/null
+++ b/src/authorization/ap2/verifier.ts
@@ -0,0 +1,75 @@
+/**
+ * The Direct Checkout Mandate verifier: mandate signature first, then the
+ * merchant checkout JWT it binds. Either stage failing is a refusal.
+ *
+ * What this proves is narrow. A trusted issuer signed this mandate, it has not
+ * expired, it is addressed to us, and it binds a checkout document the
+ * merchant signed. It does NOT prove the mandate authorises the purchase in
+ * front of us: that is profile.ts, and a caller treating this as permission to
+ * settle has skipped it.
+ */
+import type { Clock } from '../../core/index.js';
+import { verifyCheckoutJwt } from './checkout-jwt.js';
+import { type Ap2ErrorContext, ap2Rejected } from './errors.js';
+import { verifyMandate } from './sd-jwt.js';
+import { createTrustStore } from './trust.js';
+import type { EnabledAp2Config, VerifiedCheckoutMandate } from './types.js';
+
+export interface Ap2VerifierOptions {
+ readonly config: EnabledAp2Config;
+ readonly clock: Clock;
+}
+
+export interface Ap2MandateVerifier {
+ verify(presentation: string, context?: Ap2ErrorContext): Promise;
+ /** Trusted issuer ids, for `doctor`. Counts and names only, never keys */
+ trustedIssuers(): { readonly mandate: readonly string[]; readonly checkout: readonly string[] };
+}
+
+/**
+ * Algorithm-prefixed so a future digest change is visible in stored references
+ * rather than silently producing unequal values for one mandate
+ */
+async function mandateReference(signedToken: string): Promise {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(signedToken));
+ return `sha256:${Buffer.from(digest).toString('base64url')}`;
+}
+
+export function createAp2MandateVerifier(options: Ap2VerifierOptions): Ap2MandateVerifier {
+ // Two stores, not one list: signing the merchant's checkout documents must
+ // not confer the power to issue mandates
+ const mandateTrust = createTrustStore(options.config.trust.mandateIssuers);
+ const checkoutTrust = createTrustStore(options.config.trust.checkoutIssuers);
+ const deps = { clock: options.clock, clockSkewSeconds: options.config.clockSkewSeconds };
+
+ return {
+ async verify(
+ presentation: string,
+ context: Ap2ErrorContext = {},
+ ): Promise {
+ if (typeof presentation !== 'string' || presentation.length === 0) {
+ throw ap2Rejected('malformed_presentation', context);
+ }
+
+ const mandate = await verifyMandate(presentation, { ...deps, trust: mandateTrust }, context);
+ const checkout = await verifyCheckoutJwt(
+ mandate.claims,
+ { ...deps, trust: checkoutTrust },
+ context,
+ );
+
+ return {
+ reference: await mandateReference(mandate.signedToken),
+ mandateIssuer: mandate.issuer,
+ checkoutIssuer: checkout.issuer,
+ checkoutJwtId: checkout.jwtId,
+ checkoutClaims: checkout.claims,
+ mandateClaims: mandate.claims,
+ };
+ },
+
+ trustedIssuers() {
+ return { mandate: mandateTrust.issuerIds(), checkout: checkoutTrust.issuerIds() };
+ },
+ };
+}
diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts
index 5ae9737..6d30b65 100644
--- a/src/cli/commands/doctor.ts
+++ b/src/cli/commands/doctor.ts
@@ -1,6 +1,15 @@
import { accessSync, existsSync, constants as fsConstants } from 'node:fs';
import { dirname } from 'node:path';
import picocolors from 'picocolors';
+// Constants and a descriptor only. Importing the AP2 provider here would pull
+// jose and @sd-jwt/core into the CLI bundle and make two optional peers
+// mandatory for anyone running `agent-commerce`.
+import {
+ AP2_CHECKOUT_MANDATE_VCT,
+ AP2_CHECKOUT_PROFILE,
+ AP2_SPEC_VERSION,
+} from '../../authorization/ap2/constants.js';
+import { AP2_UNSUPPORTED } from '../../authorization/ap2/descriptor.js';
import { extractPathParameterNames } from '../../core/execution/index.js';
import { type CommerceResource, isCommerceError, type ReceiptStore } from '../../core/index.js';
import {
@@ -175,6 +184,73 @@ function findX402Mismatch(configured: LiveX402, live: LiveX402): string | undefi
return `${diffs.join('; ')} — the gateway may be running against an older deployment; restart it or re-run chain:deploy`;
}
+// Issuer ids and how many keys each carries. Never a key
+function describeIssuers(
+ label: string,
+ issuers: readonly { readonly issuer: string; readonly keys: readonly unknown[] }[],
+): string {
+ const described = issuers
+ .map(
+ (entry) => `${entry.issuer} (${entry.keys.length} key${entry.keys.length === 1 ? '' : 's'})`,
+ )
+ .join(', ');
+ return `${label} issuers: ${described}`;
+}
+
+/**
+ * Whether an existing file can be written, without creating one. Shared by the
+ * two store checks below: each loses its guarantee on an unwritable file, and
+ * a second copy of the probe is how one of them reports PASS on a path the
+ * gateway cannot actually use.
+ */
+function isWritableFile(path: string): boolean {
+ try {
+ accessSync(path, fsConstants.W_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The AP2 replay database, diagnosed without creating it. A read-only command
+ * that produced the file would report a healthy empty store and shadow the
+ * real one, exactly as the receipt-store check describes.
+ */
+function ap2ReplayCheck(path: string): DoctorCheck {
+ const name = 'AP2 replay store';
+ if (path === ':memory:') {
+ return {
+ name,
+ status: 'WARN',
+ detail:
+ 'in-memory store - every spent mandate is forgotten on restart, so one could authorize a second purchase; use a file path in production',
+ };
+ }
+ if (existsSync(path)) {
+ return isWritableFile(path)
+ ? { name, status: 'PASS', detail: `writable at "${path}"` }
+ : {
+ name,
+ status: 'FAIL',
+ detail: `"${path}" exists but is not writable by this user - no mandate could be recorded as spent`,
+ };
+ }
+ const directory = dirname(path);
+ if (directory !== '' && directory !== '.' && !existsSync(directory)) {
+ return {
+ name,
+ status: 'WARN',
+ detail: `no store yet at "${path}" and its directory does not exist - it is created when the gateway starts, provided that path is writable`,
+ };
+ }
+ return {
+ name,
+ status: 'WARN',
+ detail: `no store yet at "${path}" - it is created the first time the gateway starts`,
+ };
+}
+
/**
* The idempotency database, diagnosed without creating it: `doctor` is
* read-only, and opening a mistyped path would leave a fresh empty database
@@ -203,16 +279,13 @@ function acpIdempotencyCheck(idempotency: {
}
const directory = dirname(idempotency.path);
if (existsSync(idempotency.path)) {
- try {
- accessSync(idempotency.path, fsConstants.W_OK);
- return { name: 'ACP idempotency', status: 'PASS', detail: `writable, ${retention}` };
- } catch {
- return {
- name: 'ACP idempotency',
- status: 'FAIL',
- detail: `"${idempotency.path}" exists but is not writable by this user — the adapter cannot claim idempotency keys`,
- };
- }
+ return isWritableFile(idempotency.path)
+ ? { name: 'ACP idempotency', status: 'PASS', detail: `writable, ${retention}` }
+ : {
+ name: 'ACP idempotency',
+ status: 'FAIL',
+ detail: `"${idempotency.path}" exists but is not writable by this user — the adapter cannot claim idempotency keys`,
+ };
}
if (directory !== '' && directory !== '.' && !existsSync(directory)) {
return {
@@ -442,6 +515,55 @@ export async function runDoctor(
});
}
+ // 5d. AP2 authorization. Read from config and the pins only. Verification
+ // keys are public, but they are still trust policy an operator did not ask
+ // this report to print, so only issuer ids and key counts appear.
+ const ap2 = config?.authorization?.ap2;
+ if (config === undefined) {
+ checks.push({ name: 'AP2', status: 'WARN', detail: 'skipped — config invalid' });
+ } else if (ap2 === undefined || !ap2.enabled) {
+ checks.push({ name: 'AP2', status: 'INFO', detail: 'disabled' });
+ } else {
+ checks.push({
+ name: 'AP2',
+ status: 'PASS',
+ detail: `experimental · spec ${AP2_SPEC_VERSION} · mode ${ap2.mode} · ${AP2_CHECKOUT_MANDATE_VCT} · profile ${AP2_CHECKOUT_PROFILE} · clock skew ${ap2.clockSkewSeconds}s`,
+ });
+
+ // Two lists, reported separately: signing the merchant's checkout
+ // documents must not read as the power to issue mandates.
+ checks.push({
+ name: 'AP2 trust',
+ status: 'PASS',
+ detail: `${describeIssuers('mandate', ap2.trust.mandateIssuers)} · ${describeIssuers('checkout', ap2.trust.checkoutIssuers)}`,
+ });
+
+ checks.push(ap2ReplayCheck(ap2.replay.path));
+
+ // Resource ids, so an operator can see exactly which purchases now need a
+ // mandate. An empty list means AP2 is configured and gating nothing.
+ const gated = config.resources.filter((resource) =>
+ resource.authorization?.required.includes('ap2'),
+ );
+ checks.push({
+ name: 'AP2 resources',
+ status: gated.length > 0 ? 'PASS' : 'WARN',
+ detail:
+ gated.length > 0
+ ? gated.map((resource) => resource.id).join(', ')
+ : 'AP2 is enabled but no resource requires it — every purchase settles without a mandate',
+ });
+
+ // Listed in full, never summarised as a count: "14 unsupported" tells an
+ // operator nothing about whether the one thing their client sends is
+ // among them.
+ checks.push({
+ name: 'AP2 unsupported',
+ status: 'INFO',
+ detail: AP2_UNSUPPORTED.join(', '),
+ });
+ }
+
// 6. Payments
const x402 = config?.payments.x402;
if (x402 === undefined || !x402.enabled) {
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 330ccc6..205688b 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -25,17 +25,34 @@
*/
import { type ZodError, type ZodIssue, type ZodTypeAny, z } from 'zod';
+import {
+ AP2_DEFAULT_CLOCK_SKEW_SECONDS,
+ AP2_JWK_COORDINATE_BYTES,
+ AP2_JWK_CURVE,
+ AP2_JWK_MEMBERS,
+ AP2_KEY_TYPE,
+ AP2_MAX_CLOCK_SKEW_SECONDS,
+ AP2_MODES,
+ AP2_SIGNING_ALGORITHM,
+ AP2_SPEC_VERSION,
+} from '../authorization/ap2/constants.js';
+import type {
+ Ap2AuthorizationConfig,
+ Ap2Mode,
+ Ap2TrustedIssuer,
+} from '../authorization/ap2/types.js';
import {
extractPathParameterNames,
findUnparsedBraceToken,
isObjectSchemaNode,
} from '../core/execution/index.js';
import {
+ type AuthorizationMethodName,
CommerceError,
type CommerceResource,
- PAYMENT_INPUT_FIELD,
PROTOCOL_NAMES,
type Pricing,
+ RESERVED_INPUT_FIELDS,
} from '../core/index.js';
import { resolveX402Deployment, type X402FacilitatorConfig } from '../payments/x402/guardrails.js';
import {
@@ -301,6 +318,10 @@ const ResourceEntrySchema = z
pricing: PricingSchema,
expose: z.array(z.string().min(1)).min(1),
payments: z.array(z.string().min(1)).optional(),
+ authorization: z
+ .object({ required: z.array(z.string().min(1)).min(1) })
+ .strict()
+ .optional(),
})
.strict();
@@ -371,6 +392,64 @@ const PaymentsSchema = z
})
.strict();
+/**
+ * A public verification key, given inline.
+ *
+ * Inline only: there is no `jwksUri`, no `jku`, no discovery URL. AP2 still
+ * has an open standardisation question around secure key distribution, and
+ * inventing dynamic trust here would mean fetching keys from a location a
+ * mandate can influence. The JWK's own members are checked against
+ * `AP2_JWK_MEMBERS` in the business pass, which stops `x5u` reintroducing the
+ * same fetch one level down.
+ */
+const Ap2KeySchema = z
+ .object({
+ kid: z.string().min(1),
+ jwk: z.record(z.string(), z.unknown()),
+ })
+ .strict();
+
+const Ap2IssuerSchema = z
+ .object({
+ issuer: z.string().min(1),
+ /**
+ * Required, not defaulted. Without it, a mandate minted for another
+ * merchant would verify here, and there is no value worth guessing for
+ * something that decides that.
+ */
+ audience: z.string().min(1),
+ keys: z.array(Ap2KeySchema).min(1),
+ })
+ .strict();
+
+const Ap2Schema = z
+ .object({
+ enabled: BooleanOrString,
+ specVersion: z.string().min(1).optional(),
+ mode: z.string().min(1).optional(),
+ trust: z
+ .object({
+ mandateIssuers: z.array(Ap2IssuerSchema).optional(),
+ checkoutIssuers: z.array(Ap2IssuerSchema).optional(),
+ })
+ .strict()
+ .optional(),
+ clockSkewSeconds: NumberOrString.optional(),
+ replay: z
+ .object({ path: z.string().min(1) })
+ .strict()
+ .optional(),
+ })
+ .strict();
+
+/**
+ * Optional block, off by default. A config predating AP2 stays valid, and the
+ * sub-blocks are optional here so a disabled placeholder is writable; the
+ * business pass requires each of them only once AP2 is enabled, and names the
+ * missing piece.
+ */
+const AuthorizationSchema = z.object({ ap2: Ap2Schema.optional() }).strict();
+
const RawConfigSchema = z
.object({
version: z.literal(SUPPORTED_CONFIG_VERSION),
@@ -380,6 +459,7 @@ const RawConfigSchema = z
protocols: ProtocolsSchema,
resources: ResourcesMapSchema,
payments: PaymentsSchema,
+ authorization: AuthorizationSchema.optional(),
})
.strict();
@@ -450,6 +530,11 @@ export interface GatewayConfig {
readonly allowUnauthenticatedFacilitator?: boolean;
};
};
+ /**
+ * Absent when no `authorization:` block is configured, which is how every
+ * config written before AP2 existed reads.
+ */
+ readonly authorization?: { readonly ap2: Ap2AuthorizationConfig };
}
// ---------------------------------------------------------------------------
@@ -585,6 +670,7 @@ function toBoolean(value: boolean | string, path: string): boolean {
const SUPPORTED_PROTOCOLS: ReadonlySet = new Set(PROTOCOL_NAMES);
const SUPPORTED_PAYMENT_METHODS = new Set(['x402']);
+const SUPPORTED_AUTHORIZATION_METHODS: ReadonlySet = new Set(['ap2']);
function normalise(raw: RawConfig): GatewayConfig {
const protocols = {
@@ -668,8 +754,13 @@ function normalise(raw: RawConfig): GatewayConfig {
});
}
+ const ap2 = normaliseAp2(raw.authorization?.ap2);
+ if (ap2?.enabled) {
+ validateReplayStoreIsolated(ap2.replay.path, raw.storage.receipts.path, protocols.acp);
+ }
+
const resources = Object.entries(raw.resources).map(([id, entry]) =>
- normaliseResource(id, entry, protocols, x402),
+ normaliseResource(id, entry, protocols, x402, ap2),
);
if (protocols.acp.enabled) validateAcpCheckoutMapping(protocols.acp, resources);
@@ -694,6 +785,7 @@ function normalise(raw: RawConfig): GatewayConfig {
payments: {
...(x402 !== undefined ? { x402 } : {}),
},
+ ...(ap2 !== undefined ? { authorization: { ap2 } } : {}),
};
}
@@ -733,6 +825,285 @@ function validateMountPaths(protocols: NormalisedProtocols): void {
}
}
+// ---------------------------------------------------------------------------
+// AP2 authorization (experimental)
+// ---------------------------------------------------------------------------
+
+type RawAp2 = NonNullable['ap2']>;
+
+function ap2Invalid(
+ path: string,
+ message: string,
+ extra: Record = {},
+): CommerceError {
+ return new CommerceError('CONFIG_INVALID', message, { details: { path, ...extra } });
+}
+
+function normaliseAp2(raw: RawAp2 | undefined): Ap2AuthorizationConfig | undefined {
+ if (raw === undefined) return undefined;
+ if (!toBoolean(raw.enabled, 'authorization.ap2.enabled')) return { enabled: false };
+
+ const specVersion = raw.specVersion ?? AP2_SPEC_VERSION;
+ if (specVersion !== AP2_SPEC_VERSION) {
+ throw ap2Invalid(
+ 'authorization.ap2.specVersion',
+ `authorization.ap2.specVersion "${specVersion}" is not supported - this gateway verifies against the tagged AP2 release ${AP2_SPEC_VERSION} only`,
+ );
+ }
+
+ const mode = raw.mode ?? AP2_MODES[0];
+ if (!(AP2_MODES as readonly string[]).includes(mode)) {
+ throw ap2Invalid(
+ 'authorization.ap2.mode',
+ `authorization.ap2.mode "${mode}" is not supported. Supported: ${AP2_MODES.join(', ')}. Autonomous mode needs open mandates, agent key binding and constraint evaluation, none of which this release implements.`,
+ );
+ }
+
+ if (raw.replay === undefined) {
+ throw ap2Invalid(
+ 'authorization.ap2.replay',
+ 'authorization.ap2.replay is required when AP2 is enabled - it names the SQLite file recording which mandates have been spent, and without it a verified mandate could authorise a second settlement',
+ );
+ }
+
+ const mandateIssuers = normaliseIssuers(
+ raw.trust?.mandateIssuers,
+ 'authorization.ap2.trust.mandateIssuers',
+ );
+ const checkoutIssuers = normaliseIssuers(
+ raw.trust?.checkoutIssuers,
+ 'authorization.ap2.trust.checkoutIssuers',
+ );
+
+ return {
+ enabled: true,
+ specVersion: AP2_SPEC_VERSION,
+ mode: mode as Ap2Mode,
+ trust: { mandateIssuers, checkoutIssuers },
+ clockSkewSeconds: toNumber(
+ raw.clockSkewSeconds ?? AP2_DEFAULT_CLOCK_SKEW_SECONDS,
+ 'authorization.ap2.clockSkewSeconds',
+ { min: 0, max: AP2_MAX_CLOCK_SKEW_SECONDS },
+ ),
+ replay: { path: raw.replay.path },
+ };
+}
+
+/**
+ * Both issuer lists are required and non-empty when AP2 is on.
+ *
+ * Two signatures have to be checked: the issuer's over the Checkout Mandate,
+ * and the merchant's over the checkout JWT it binds. An empty list would make
+ * every mandate fail verification, which reads as a broken deployment rather
+ * than as the misconfiguration it is.
+ */
+function normaliseIssuers(
+ raw: readonly z.infer[] | undefined,
+ path: string,
+): readonly Ap2TrustedIssuer[] {
+ if (raw === undefined || raw.length === 0) {
+ throw ap2Invalid(
+ path,
+ `${path} is required when AP2 is enabled and must name at least one issuer - trust is operator-configured and static, so an empty list means no mandate can ever verify`,
+ );
+ }
+
+ const seen = new Set();
+ return raw.map((entry, index) => {
+ const entryPath = `${path}.${index}`;
+ if (seen.has(entry.issuer)) {
+ throw ap2Invalid(
+ `${entryPath}.issuer`,
+ `${entryPath}.issuer "${entry.issuer}" is listed twice - key lookup resolves by issuer, so a second entry for the same one is silently unreachable. Merge the keys into one entry instead (which is also how a rotation overlaps an old and a new key).`,
+ { issuer: entry.issuer },
+ );
+ }
+ seen.add(entry.issuer);
+
+ const kids = new Set();
+ const keys = entry.keys.map((key, keyIndex) => {
+ const keyPath = `${entryPath}.keys.${keyIndex}`;
+ if (kids.has(key.kid)) {
+ throw ap2Invalid(
+ `${keyPath}.kid`,
+ `${keyPath}.kid "${key.kid}" is listed twice for issuer "${entry.issuer}" - a kid selects exactly one key, so which of the two verifies a signature would be undefined`,
+ { issuer: entry.issuer, kid: key.kid },
+ );
+ }
+ kids.add(key.kid);
+ return { kid: key.kid, jwk: validateJwk(key.jwk, key.kid, keyPath) };
+ });
+
+ return { issuer: entry.issuer, audience: entry.audience, keys };
+ });
+}
+
+/**
+ * Checks a configured verification key against the one shape this release
+ * accepts: a public P-256 key, nothing else.
+ *
+ * Every member is checked here rather than passed through to the JOSE library
+ * at first purchase, so a key that is wrong is wrong at deploy time. The
+ * alternative is learning about it from a buyer whose valid mandate was
+ * refused.
+ */
+function validateJwk(
+ jwk: Record,
+ kid: string,
+ path: string,
+): Readonly> {
+ const fail = (detail: string): never => {
+ throw ap2Invalid(`${path}.jwk`, `${path}.jwk (kid "${kid}") ${detail}`, { kid });
+ };
+
+ for (const member of Object.keys(jwk)) {
+ if ((AP2_JWK_MEMBERS as readonly string[]).includes(member)) continue;
+ // `d` is the private scalar; naming it is worth the extra branch, because
+ // an operator who pasted a full key pair into the gateway has put signing
+ // material where only verification material belongs, and needs to know
+ // that rather than read "unsupported member".
+ if (member === 'd' || member === 'k') {
+ fail(
+ `carries private key material ("${member}"). The gateway verifies signatures and never produces them; publish only the public half. Treat the pasted key as compromised and rotate it.`,
+ );
+ }
+ fail(
+ `has unsupported member "${member}". Allowed: ${AP2_JWK_MEMBERS.join(', ')}. Members naming a URL are refused on purpose - keys are configured inline and never fetched.`,
+ );
+ }
+
+ const value = (member: string): string => {
+ const raw = jwk[member];
+ if (typeof raw !== 'string' || raw.length === 0) {
+ fail(`must give "${member}" as a non-empty string`);
+ }
+ return raw as string;
+ };
+
+ if (value('kty') !== AP2_KEY_TYPE) {
+ fail(
+ `must have kty "${AP2_KEY_TYPE}" (got "${value('kty')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`,
+ );
+ }
+ if (value('crv') !== AP2_JWK_CURVE) {
+ fail(
+ `must be on curve "${AP2_JWK_CURVE}" (got "${value('crv')}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`,
+ );
+ }
+ for (const coordinate of ['x', 'y'] as const) {
+ const encoded = value(coordinate);
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
+ fail(`coordinate "${coordinate}" is not base64url (no padding, no "+" or "/")`);
+ }
+ if (Buffer.from(encoded, 'base64url').length !== AP2_JWK_COORDINATE_BYTES) {
+ fail(
+ `coordinate "${coordinate}" decodes to ${Buffer.from(encoded, 'base64url').length} bytes; a ${AP2_JWK_CURVE} coordinate is ${AP2_JWK_COORDINATE_BYTES}`,
+ );
+ }
+ }
+ if (jwk['alg'] !== undefined && value('alg') !== AP2_SIGNING_ALGORITHM) {
+ fail(`declares alg "${value('alg')}"; only ${AP2_SIGNING_ALGORITHM} is accepted`);
+ }
+ if (jwk['use'] !== undefined && value('use') !== 'sig') {
+ fail(`declares use "${value('use')}"; a verification key must be "sig"`);
+ }
+ if (jwk['kid'] !== undefined && value('kid') !== kid) {
+ fail(`declares kid "${value('kid')}", which disagrees with the configured kid "${kid}"`);
+ }
+
+ const normalised: Record = {};
+ for (const member of AP2_JWK_MEMBERS) {
+ if (jwk[member] !== undefined) normalised[member] = value(member);
+ }
+ return normalised;
+}
+
+/**
+ * The AP2 replay store gets its own SQLite file.
+ *
+ * Reserved mandates, payment attempts and ACP idempotency records have
+ * different schemas and different retention rules. Pointing two of them at one
+ * file gives either a migration conflict at startup or a shared write lock on
+ * the settlement path.
+ */
+function validateReplayStoreIsolated(
+ replayPath: string,
+ receiptsPath: string,
+ acp: AcpProtocolConfig,
+): void {
+ // Every `:memory:` handle is its own private database, so two of them are
+ // not the collision the literal comparison would call them.
+ if (replayPath === ':memory:') return;
+
+ const others: [string, string][] = [['storage.receipts.path', receiptsPath]];
+ if (acp.enabled) others.push(['protocols.acp.idempotency.path', acp.idempotency.path]);
+
+ for (const [otherPath, other] of others) {
+ if (replayPath !== other) continue;
+ throw ap2Invalid(
+ 'authorization.ap2.replay.path',
+ `authorization.ap2.replay.path is the same file as ${otherPath} ("${replayPath}") - the AP2 replay store keeps its own schema and must not share a database with another store`,
+ );
+ }
+}
+
+/**
+ * Resolves a resource's `authorization.required` list against the configured
+ * provider.
+ *
+ * A requirement that cannot be enforced is worse than none, because the
+ * resource looks protected in config and settles unprotected in production.
+ * Everything that would produce that gap is refused here.
+ */
+function normaliseResourceAuthorization(
+ id: string,
+ entry: RawResourceEntry,
+ pricing: Pricing,
+ ap2: Ap2AuthorizationConfig | undefined,
+): CommerceResource['authorization'] | undefined {
+ const required = entry.authorization?.required;
+ if (required === undefined) return undefined;
+
+ const path = `resources.${id}.authorization.required`;
+ const methods: AuthorizationMethodName[] = [];
+ for (const method of required) {
+ if (!SUPPORTED_AUTHORIZATION_METHODS.has(method)) {
+ throw ap2Invalid(
+ path,
+ `Resource "${id}" requires unsupported authorization method "${method}". Supported: ${[...SUPPORTED_AUTHORIZATION_METHODS].join(', ')}.`,
+ { resourceId: id, method },
+ );
+ }
+ if (methods.includes(method as AuthorizationMethodName)) {
+ throw ap2Invalid(path, `Resource "${id}" lists authorization method "${method}" twice`, {
+ resourceId: id,
+ method,
+ });
+ }
+ if (method === 'ap2' && (ap2 === undefined || !ap2.enabled)) {
+ throw ap2Invalid(
+ path,
+ `Resource "${id}" requires authorization method "ap2", which is not configured or not enabled under authorization.ap2`,
+ { resourceId: id, method },
+ );
+ }
+ methods.push(method as AuthorizationMethodName);
+ }
+
+ // A mandate binds an exact amount and currency, so there has to be one. It
+ // also never unlocks a resource by itself, which makes requiring one on a
+ // free resource a statement the pipeline has no way to act on.
+ if (pricing.type !== 'fixed') {
+ throw ap2Invalid(
+ path,
+ `Resource "${id}" requires authorization but its pricing is "${pricing.type}" - authorization proves a purchase was approved and never replaces payment, so it applies to fixed-price paid resources only in this release`,
+ { resourceId: id },
+ );
+ }
+
+ return { required: methods };
+}
+
// ---------------------------------------------------------------------------
// ACP (experimental)
// ---------------------------------------------------------------------------
@@ -944,25 +1315,25 @@ function normaliseResource(
entry: RawResourceEntry,
protocols: NormalisedProtocols,
x402: NormalisedX402 | undefined,
+ ap2: Ap2AuthorizationConfig | undefined,
): CommerceResource {
if (entry.input !== undefined) validateResourceSchemaKeywords(id, 'input', entry.input);
const inputProperties = entry.input?.['properties'];
- if (
- inputProperties &&
- typeof inputProperties === 'object' &&
- PAYMENT_INPUT_FIELD in inputProperties
- ) {
- throw new CommerceError(
- 'CONFIG_INVALID',
- `Resource "${id}" declares an input property "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`,
- {
- details: {
- path: `resources.${id}.input.properties.${PAYMENT_INPUT_FIELD}`,
- resourceId: id,
+ if (inputProperties && typeof inputProperties === 'object') {
+ for (const reserved of RESERVED_INPUT_FIELDS) {
+ if (!(reserved in inputProperties)) continue;
+ throw new CommerceError(
+ 'CONFIG_INVALID',
+ `Resource "${id}" declares an input property "${reserved}", which is reserved by the gateway`,
+ {
+ details: {
+ path: `resources.${id}.input.properties.${reserved}`,
+ resourceId: id,
+ },
},
- },
- );
+ );
+ }
}
for (const protocol of entry.expose) {
@@ -1062,6 +1433,8 @@ function normaliseResource(
? { type: 'free' }
: { type: 'fixed', amount: entry.pricing.amount, currency: entry.pricing.currency };
+ const authorization = normaliseResourceAuthorization(id, entry, pricing, ap2);
+
return {
id,
name: entry.name,
@@ -1090,6 +1463,7 @@ function normaliseResource(
pricing,
exposedVia: entry.expose as CommerceResource['exposedVia'],
paymentMethods: paymentMethods as CommerceResource['paymentMethods'],
+ ...(authorization !== undefined ? { authorization } : {}),
};
}
@@ -1412,11 +1786,8 @@ function validateInputBindings(
);
for (const [location, property] of entries) {
- if (property === PAYMENT_INPUT_FIELD) {
- fail(
- `binds "${location}" to "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`,
- { location },
- );
+ if (RESERVED_INPUT_FIELDS.includes(property)) {
+ fail(`binds "${location}" to "${property}", which is reserved by the gateway`, { location });
}
const other = seen.get(property);
if (other !== undefined) {
diff --git a/src/core/domain/authorization.ts b/src/core/domain/authorization.ts
new file mode 100644
index 0000000..4c2d020
--- /dev/null
+++ b/src/core/domain/authorization.ts
@@ -0,0 +1,132 @@
+/**
+ * Canonical authorization model.
+ *
+ * FROZEN CONTRACT.
+ *
+ * Authorization answers a different question from payment. Payment proves
+ * that funds moved; authorization proves that the human behind the agent
+ * approved *this exact purchase*. A valid authorization never unlocks a paid
+ * resource on its own and never moves money. It gates settlement, and a
+ * resource that requires one still needs a real payment proof as well.
+ *
+ * Core decides *that* a resource requires authorization and *when* in the
+ * pipeline it is checked. Authorization providers decide how a submission is
+ * parsed, verified and bound to the purchase. No AP2, SD-JWT or JWT type may
+ * appear in this file.
+ */
+import type { AdapterDescriptor, AdapterHealth, AuthorizationMethodName } from './common.js';
+import type { PaymentRequirement } from './payment.js';
+
+/**
+ * Opaque authorization proof supplied by the buyer's client.
+ *
+ * Over HTTP this arrives base64url-JSON-encoded in the `Agent-Authorization`
+ * header; over MCP and A2A it is the same `{ method, payload }` object carried
+ * in the reserved `_authorization` input field.
+ *
+ * `payload` is preserved byte-for-byte from the wire. Providers derive replay
+ * identities by hashing it, so decoding and reserialising it before it reaches
+ * the provider would change the identity of an otherwise identical proof.
+ */
+export interface AuthorizationSubmission {
+ readonly method: AuthorizationMethodName;
+ readonly payload: string;
+}
+
+/**
+ * What a buyer must present, advertised alongside the payment challenge so a
+ * client learns before it pays that a proof of payment alone will not do.
+ *
+ * The gateway never issues the authorization itself. It states the method,
+ * the spec version it verifies against, and the payload profile it expects.
+ */
+export interface AuthorizationRequirement {
+ readonly method: AuthorizationMethodName;
+ readonly version: string;
+ readonly profile?: string;
+}
+
+/**
+ * Safe audit identity of an authorization, fit for a receipt. `reference` is a
+ * digest: a receipt outlives its request, and a stored proof would be a
+ * spendable secret at rest.
+ */
+export interface AuthorizationRecord {
+ readonly method: AuthorizationMethodName;
+ readonly reference: string;
+ /** Safe audit summary only. Never the proof, its disclosures, or PII */
+ readonly metadata?: Readonly>;
+}
+
+/**
+ * A verified, reserved authorization. `reservationId` is the live handle the
+ * pipeline consumes, releases or marks uncertain once settlement resolves; it
+ * stays out of the record above because a handle is not an audit fact.
+ */
+export interface AuthorizationVerification extends AuthorizationRecord {
+ readonly status: 'verified';
+ readonly reservationId: string;
+}
+
+/** Input to {@link AuthorizationProvider.verifyAndReserve}. */
+export interface AuthorizationVerificationContext {
+ readonly requestId: string;
+ readonly resourceId: string;
+ /**
+ * The validated resource input, reserved fields already stripped: the same
+ * bytes the merchant backend will be called with. A provider that binds a
+ * proof to the request hashes this, so it must not include `_payment`,
+ * `_authorization`, the request id or any transport metadata.
+ */
+ readonly input: unknown;
+ readonly submission: AuthorizationSubmission;
+ /** The resolved price and destination the proof must match. */
+ readonly requirement: PaymentRequirement;
+}
+
+/** Input to {@link AuthorizationProvider.consume} and `release`. */
+export interface AuthorizationFinalizeContext {
+ readonly requestId: string;
+ readonly resourceId: string;
+}
+
+/**
+ * Contract every authorization method implements.
+ *
+ * The lifecycle straddles settlement: a proof must be reserved *before* funds
+ * move so a replay cannot race one, and its fate is only known *after*.
+ *
+ * Release only for a failure that provably moved no money. Anything ambiguous
+ * is marked uncertain: a proof handed back after a settlement that may have
+ * landed can be spent twice.
+ */
+export interface AuthorizationProvider {
+ readonly name: AuthorizationMethodName;
+ readonly descriptor: AdapterDescriptor;
+ /** What a buyer must present, advertised beside the payment challenge */
+ readonly requirement: AuthorizationRequirement;
+
+ /**
+ * Verify a submission against the resolved purchase and atomically reserve
+ * it against reuse.
+ *
+ * Throws a `CommerceError` with an `AUTHORIZATION_*` code, never a payment
+ * code. A verifier or store outage is
+ * `AUTHORIZATION_PROVIDER_UNAVAILABLE`, not the buyer's fault.
+ */
+ verifyAndReserve(context: AuthorizationVerificationContext): Promise;
+
+ /** Mark a reservation permanently spent. Called after settlement succeeds. */
+ consume(reservationId: string, context: AuthorizationFinalizeContext): Promise;
+
+ /** Return a reservation to unused. Only for failures that moved no funds. */
+ release(reservationId: string, context: AuthorizationFinalizeContext): Promise;
+
+ /**
+ * Settlement broadcast but never confirmed: neither spend the reservation
+ * nor hand it back, and flag it for an operator
+ */
+ markUncertain(reservationId: string, context: AuthorizationFinalizeContext): Promise;
+
+ health(): Promise;
+}
diff --git a/src/core/domain/common.ts b/src/core/domain/common.ts
index 14517ec..f2aee68 100644
--- a/src/core/domain/common.ts
+++ b/src/core/domain/common.ts
@@ -35,6 +35,16 @@ export const PROTOCOL_NAMES: readonly ProtocolName[] = ['http', 'mcp', 'a2a', 'a
/** Payment methods a resource can accept in this release. */
export type PaymentMethodName = 'x402';
+/**
+ * Authorization methods a resource can require in this release.
+ *
+ * Deliberately not a `ProtocolName` and not a `PaymentMethodName`: an
+ * authorization method is neither a transport nor a payment rail. It proves
+ * the purchase was approved, and sits beside the payment rather than
+ * replacing it.
+ */
+export type AuthorizationMethodName = 'ap2';
+
/** ISO-8601 timestamp string, always UTC with millisecond precision. */
export type IsoTimestamp = string;
@@ -62,7 +72,7 @@ export interface AdapterHealth {
*/
export interface AdapterDescriptor {
readonly name: string;
- readonly kind: 'protocol' | 'payment' | 'storage';
+ readonly kind: 'protocol' | 'payment' | 'storage' | 'authorization';
/** Version of this adapter implementation (independent of the spec). */
readonly implementationVersion: string;
/** Exact pinned specification revision this adapter targets. */
diff --git a/src/core/domain/event.ts b/src/core/domain/event.ts
index db4c6b2..5632ab8 100644
--- a/src/core/domain/event.ts
+++ b/src/core/domain/event.ts
@@ -13,6 +13,10 @@ export const COMMERCE_EVENT_TYPES = [
'payment.rejected',
'payment.verified',
'payment.settled',
+ // The same two types whatever the method, so an audit-trail reader never
+ // has to know what AP2 is
+ 'authorization.verified',
+ 'authorization.rejected',
'backend.called',
'backend.failed',
'resource.delivered',
diff --git a/src/core/domain/index.ts b/src/core/domain/index.ts
index 2a4fa23..65107a6 100644
--- a/src/core/domain/index.ts
+++ b/src/core/domain/index.ts
@@ -1,3 +1,4 @@
+export * from './authorization.js';
export * from './common.js';
export * from './event.js';
export * from './payment.js';
diff --git a/src/core/domain/receipt.ts b/src/core/domain/receipt.ts
index ca681e5..ae5bccf 100644
--- a/src/core/domain/receipt.ts
+++ b/src/core/domain/receipt.ts
@@ -3,6 +3,7 @@
*
* FROZEN CONTRACT.
*/
+import type { AuthorizationRecord } from './authorization.js';
import type { IsoTimestamp } from './common.js';
import type { PaymentResult } from './payment.js';
@@ -12,6 +13,11 @@ export interface CommerceReceipt {
readonly resourceId: string;
/** Absent for free resources. */
readonly payment?: PaymentResult;
+ /**
+ * Present only when the resource required one. A method and a digest, so the
+ * receipt records that consent existed without storing the proof of it.
+ */
+ readonly authorization?: AuthorizationRecord;
readonly deliveredAt: IsoTimestamp;
readonly backendStatus: number;
readonly durationMs?: number;
diff --git a/src/core/domain/request.ts b/src/core/domain/request.ts
index 1247986..81d22ff 100644
--- a/src/core/domain/request.ts
+++ b/src/core/domain/request.ts
@@ -5,6 +5,7 @@
* `ExecutionOutcome` (or a thrown `CommerceError`) back into their own wire
* format. Adapters must never call merchant backends directly.
*/
+import type { AuthorizationRequirement, AuthorizationSubmission } from './authorization.js';
import type { IsoTimestamp, ProtocolName } from './common.js';
import type { PaymentRequirement, PaymentResult, PaymentSubmission } from './payment.js';
import type { CommerceReceipt } from './receipt.js';
@@ -18,6 +19,12 @@ export interface CanonicalRequest {
readonly protocol: ProtocolName;
/** Payment proof, when the client is retrying after a challenge. */
readonly payment?: PaymentSubmission;
+ /**
+ * Authorization proof, when the resource requires one. Independent of
+ * `payment`: neither substitutes for the other, and a resource requiring
+ * authorization needs both.
+ */
+ readonly authorization?: AuthorizationSubmission;
readonly receivedAt: IsoTimestamp;
/** Non-secret transport metadata (client id, user agent, …). */
readonly metadata?: Readonly>;
@@ -42,6 +49,11 @@ export interface PaymentRequiredOutcome {
readonly requestId: string;
readonly resourceId: string;
readonly requirement: PaymentRequirement;
+ /**
+ * Authorization the buyer must also present on the retry, when the resource
+ * requires one. Absent for the ordinary paid resource.
+ */
+ readonly authorization?: readonly AuthorizationRequirement[];
}
/**
diff --git a/src/core/domain/resource.ts b/src/core/domain/resource.ts
index 9cf744b..6190328 100644
--- a/src/core/domain/resource.ts
+++ b/src/core/domain/resource.ts
@@ -3,7 +3,13 @@
*
* FROZEN CONTRACT.
*/
-import type { DecimalAmount, JsonSchema, PaymentMethodName, ProtocolName } from './common.js';
+import type {
+ AuthorizationMethodName,
+ DecimalAmount,
+ JsonSchema,
+ PaymentMethodName,
+ ProtocolName,
+} from './common.js';
/** HTTP methods a backend handler may use. */
export type BackendMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -79,6 +85,17 @@ export interface CommerceResource {
readonly pricing: Pricing;
readonly exposedVia: readonly ProtocolName[];
readonly paymentMethods: readonly PaymentMethodName[];
+ /**
+ * Authorization the buyer must present in addition to payment.
+ *
+ * Opt-in per resource and absent by default, so every resource configured
+ * before this existed behaves exactly as it did. It sits beside
+ * `paymentMethods` rather than inside it: an authorization method is not a
+ * payment rail and must never be selectable as one.
+ */
+ readonly authorization?: {
+ readonly required: readonly AuthorizationMethodName[];
+ };
}
/** Read-only view of every configured resource. */
diff --git a/src/core/domain/wire.ts b/src/core/domain/wire.ts
index 39c385d..a68ad1c 100644
--- a/src/core/domain/wire.ts
+++ b/src/core/domain/wire.ts
@@ -7,9 +7,12 @@
* three can never drift apart.
*/
-import type { CommerceError, CommerceErrorCode } from '../errors/index.js';
+import { CommerceError, type CommerceErrorCode } from '../errors/index.js';
+import type { AuthorizationRequirement, AuthorizationSubmission } from './authorization.js';
import type { DecimalAmount, IsoTimestamp, PaymentMethodName } from './common.js';
+import type { PaymentSubmission } from './payment.js';
import type { DeliveredOutcome, PaymentRequiredOutcome } from './request.js';
+import type { CommerceResource } from './resource.js';
/**
* Reserved input property carrying a payment proof on protocols that have no
@@ -31,6 +34,54 @@ export const PAYMENT_HEADER = 'payment-signature';
/** HTTP response header carrying the settlement result. */
export const PAYMENT_RESPONSE_HEADER = 'payment-response';
+/**
+ * Reserved input property carrying an authorization proof, the authorization
+ * counterpart of {@link PAYMENT_INPUT_FIELD}.
+ *
+ * Unlike `_payment`, which is a bare string, this one carries an object:
+ * `{ method, payload }`. There is exactly one payment rail per resource, so a
+ * payment proof's method can be inferred from the resource; an authorization
+ * proof cannot lean on that, and guessing the method of a security control is
+ * not a thing to do implicitly.
+ */
+export const AUTHORIZATION_INPUT_FIELD = '_authorization';
+
+/**
+ * Every input property name the gateway claims for itself.
+ *
+ * One list, read by the config loader (which rejects a resource declaring any
+ * of them), by the protocol adapters that lift them out of client input, and
+ * by the pipeline that strips them again as defence in depth. Those three
+ * agree by construction instead of by three copies of two strings.
+ */
+export const RESERVED_INPUT_FIELDS: readonly string[] = [
+ PAYMENT_INPUT_FIELD,
+ AUTHORIZATION_INPUT_FIELD,
+];
+
+/**
+ * HTTP request header carrying the authorization proof, base64url-encoded
+ * JSON of the same `{ method, payload }` envelope the reserved input field
+ * carries.
+ *
+ * This is an Agent Commerce transport carrier, not a header defined by any
+ * authorization specification, so it is namespaced rather than borrowing
+ * `Authorization`, which already means something else on every one of these
+ * routes.
+ */
+export const AUTHORIZATION_HEADER = 'agent-authorization';
+
+/**
+ * Hard cap on the encoded `Agent-Authorization` header.
+ *
+ * Node's own limit is ~16 KiB across *all* request headers, so an
+ * authorization near that size would start evicting everything else and fail
+ * as an unreadable transport error rather than a legible one. Capping well
+ * below it means an oversized proof gets a deterministic
+ * AUTHORIZATION_INVALID naming the limit.
+ */
+export const MAX_AUTHORIZATION_HEADER_BYTES = 8192;
+
/**
* HTTP response header carrying the base64 payment challenge on a 402.
*
@@ -78,6 +129,19 @@ export interface PaymentRequiredEnvelope {
*/
readonly envelope?: Readonly>;
};
+ /**
+ * Present only when the resource requires an authorization proof as well.
+ *
+ * Additive: a client that does not understand the field sees exactly the
+ * envelope it saw before. A client that does learns, before it spends
+ * anything, that paying alone will not get the resource delivered.
+ *
+ * This advertises a requirement; it does not issue anything. The proof is
+ * obtained from the merchant's own approval flow, outside the gateway.
+ */
+ readonly authorization?: {
+ readonly required: readonly AuthorizationRequirement[];
+ };
}
export interface ErrorEnvelope {
@@ -164,6 +228,9 @@ export function toPaymentRequiredEnvelope(
accepts: r.challenge.accepts,
...(r.challenge.envelope !== undefined ? { envelope: r.challenge.envelope } : {}),
},
+ ...(outcome.authorization !== undefined && outcome.authorization.length > 0
+ ? { authorization: { required: outcome.authorization } }
+ : {}),
};
}
@@ -188,3 +255,122 @@ export function isPaymentRequiredEnvelope(value: unknown): value is PaymentRequi
typeof (value as PaymentRequiredEnvelope).payment === 'object'
);
}
+
+/**
+ * Parses the `{ method, payload }` authorization envelope, whichever carrier
+ * brought it.
+ *
+ * Absent is `undefined`. Present but malformed throws, rather than being
+ * dropped the way an unusable `_payment` is. A dropped payment leaves the
+ * buyer holding a 402 they can act on. A silently dropped authorization would
+ * come back as "not authorized" for a proof the client believes it sent, with
+ * no way to tell a rejected mandate from a typo in the envelope around it.
+ */
+export function parseAuthorizationSubmission(
+ value: unknown,
+ requestId?: string,
+): AuthorizationSubmission | undefined {
+ if (value === undefined || value === null) return undefined;
+
+ const fail = (detail: string): never => {
+ throw new CommerceError('AUTHORIZATION_INVALID', `Malformed authorization: ${detail}`, {
+ ...(requestId !== undefined ? { requestId } : {}),
+ });
+ };
+
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ fail(`expected an object with "method" and "payload", got ${typeof value}`);
+ }
+ const record = value as Record;
+ const method = record['method'];
+ const payload = record['payload'];
+
+ // 'ap2' is the only method in this release. Compared against the literal
+ // rather than a registry: a second method means a second verifier, and that
+ // is a deliberate addition, not a string that should start working because
+ // a client sent it.
+ if (method !== 'ap2') {
+ fail(`unsupported method ${typeof method === 'string' ? `"${method}"` : typeof method}`);
+ }
+ if (typeof payload !== 'string' || payload.length === 0) {
+ fail('"payload" must be a non-empty string');
+ }
+
+ return { method: 'ap2', payload: payload as string };
+}
+
+/**
+ * Decodes the base64url-JSON `Agent-Authorization` header.
+ *
+ * The size check runs on the encoded value before any decoding, so an
+ * oversized header costs a length comparison rather than a base64 decode and
+ * a JSON parse of whatever a caller chose to send.
+ */
+export function parseAuthorizationHeader(
+ raw: string | readonly string[] | undefined,
+ requestId?: string,
+): AuthorizationSubmission | undefined {
+ const value = Array.isArray(raw) ? raw[0] : (raw as string | undefined);
+ if (value === undefined || value.length === 0) return undefined;
+
+ const fail = (detail: string): never => {
+ throw new CommerceError('AUTHORIZATION_INVALID', `Malformed authorization: ${detail}`, {
+ ...(requestId !== undefined ? { requestId } : {}),
+ });
+ };
+
+ if (Buffer.byteLength(value, 'utf8') > MAX_AUTHORIZATION_HEADER_BYTES) {
+ fail(`header exceeds the ${MAX_AUTHORIZATION_HEADER_BYTES}-byte limit`);
+ }
+
+ let decoded: unknown;
+ try {
+ decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
+ } catch {
+ // Nothing from the exception is repeated back: a JSON parse error quotes
+ // the input it choked on, which here is whatever the caller sent.
+ fail('header is not base64url-encoded JSON');
+ }
+ return parseAuthorizationSubmission(decoded, requestId);
+}
+
+/**
+ * Splits raw client input into resource input plus the reserved fields the
+ * gateway claims, for every protocol that carries them in the input object
+ * (MCP tool arguments, A2A message data). HTTP carries both in headers and
+ * uses the two parse helpers directly.
+ *
+ * The payment method comes from the resource's own declaration. This is not
+ * the place that decides which rail a resource uses, so a proof for a
+ * resource with no configured rail is dropped rather than forwarded under an
+ * invented method. The pipeline then treats the request as unpaid and decides
+ * on its own terms.
+ */
+export function extractReservedInputFields(
+ rawInput: Record,
+ resource: CommerceResource | undefined,
+ requestId?: string,
+): {
+ input: Record;
+ payment?: PaymentSubmission;
+ authorization?: AuthorizationSubmission;
+} {
+ const {
+ [PAYMENT_INPUT_FIELD]: paymentValue,
+ [AUTHORIZATION_INPUT_FIELD]: authorizationValue,
+ ...input
+ } = rawInput;
+
+ const method = resource?.paymentMethods[0];
+ const payment =
+ typeof paymentValue === 'string' && paymentValue.length > 0 && method !== undefined
+ ? ({ method, payload: paymentValue } satisfies PaymentSubmission)
+ : undefined;
+ const authorization = parseAuthorizationSubmission(authorizationValue, requestId);
+
+ return {
+ input,
+ ...(payment !== undefined ? { payment } : {}),
+ ...(authorization !== undefined ? { authorization } : {}),
+ };
+}
diff --git a/src/core/errors/codes.ts b/src/core/errors/codes.ts
index f1c5d6b..24225ed 100644
--- a/src/core/errors/codes.ts
+++ b/src/core/errors/codes.ts
@@ -13,6 +13,10 @@ export const COMMERCE_ERROR_CODES = [
'PAYMENT_REPLAYED',
'PAYMENT_PROVIDER_UNAVAILABLE',
'PAYMENT_SETTLEMENT_FAILED',
+ 'AUTHORIZATION_REQUIRED',
+ 'AUTHORIZATION_INVALID',
+ 'AUTHORIZATION_REPLAYED',
+ 'AUTHORIZATION_PROVIDER_UNAVAILABLE',
'BACKEND_TIMEOUT',
'BACKEND_ERROR',
'PROTOCOL_UNSUPPORTED',
@@ -38,6 +42,14 @@ export const COMMERCE_ERROR_HTTP_STATUS: Readonly = new Set([
'PAYMENT_PROVIDER_UNAVAILABLE',
+ // Our verifier or our replay store was unreachable, so no verdict on the
+ // mandate was ever reached. That is an outage on our side, and the same
+ // proof will verify once it clears.
+ 'AUTHORIZATION_PROVIDER_UNAVAILABLE',
'BACKEND_TIMEOUT',
// Load shedding is transient by definition: the caller should back off and
// try again. Before this code existed the MCP adapter's queue-full path threw
diff --git a/src/core/execution/pipeline.ts b/src/core/execution/pipeline.ts
index d0b356c..ea18523 100644
--- a/src/core/execution/pipeline.ts
+++ b/src/core/execution/pipeline.ts
@@ -3,22 +3,28 @@
*
* Order is a security boundary — do not reorder:
* 1. resolve resource RESOURCE_NOT_FOUND
- * 2. strip `_payment`, validate input INPUT_INVALID
+ * 2. strip reserved input fields, validate input INPUT_INVALID
* 3. resolve price
* 4. free -> straight to backend
* 5. paid -> pick provider -> createRequirement -> (challenge | verify ->
- * reserve replay key -> settle), fail closed at every step
+ * authorize/reserve -> reserve replay key -> settle -> consume/release the
+ * authorization), fail closed at every step
* 6. call backend BACKEND_TIMEOUT / BACKEND_ERROR
* 7. persist receipt, emit events, return outcome
*/
-import type { PaymentMethodName } from '../domain/common.js';
+import type {
+ AuthorizationProvider,
+ AuthorizationRecord,
+ AuthorizationVerification,
+} from '../domain/authorization.js';
+import type { AuthorizationMethodName, PaymentMethodName } from '../domain/common.js';
import type { CommerceEvent, EventSink } from '../domain/event.js';
import type { PaymentProvider, PaymentRequirement, PaymentResult } from '../domain/payment.js';
import type { CommerceReceipt } from '../domain/receipt.js';
import type { CanonicalRequest, ExecutionOutcome, ExecutionPipeline } from '../domain/request.js';
import type { CommerceResource, ResourceRegistry } from '../domain/resource.js';
-import { PAYMENT_INPUT_FIELD } from '../domain/wire.js';
+import { RESERVED_INPUT_FIELDS } from '../domain/wire.js';
import { CommerceError, isCommerceError, toCommerceError } from '../errors/index.js';
import type { BackendExecutor, BackendResponse } from '../interfaces/backend.js';
import type { Logger } from '../interfaces/logger.js';
@@ -30,6 +36,8 @@ import { compileJsonSchema, type Validator } from './validation.js';
export interface CreateExecutionPipelineOptions {
readonly resources: ResourceRegistry;
readonly paymentProviders: readonly PaymentProvider[];
+ /** Empty by default, so a deployment configuring none is unchanged */
+ readonly authorizationProviders?: readonly AuthorizationProvider[];
readonly store: ReceiptStore;
readonly backend: BackendExecutor;
readonly events: EventSink;
@@ -38,6 +46,15 @@ export interface CreateExecutionPipelineOptions {
readonly ids: IdGenerator;
}
+/**
+ * A reservation held across settlement, plus the summary the receipt keeps.
+ * `finalize` is the only way it ends, so no path can leave one open.
+ */
+interface AuthorizationHold {
+ readonly record: AuthorizationRecord;
+ finalize(action: 'consume' | 'release' | 'markUncertain'): Promise;
+}
+
export function createExecutionPipeline(
options: CreateExecutionPipelineOptions,
): ExecutionPipeline {
@@ -81,6 +98,117 @@ export function createExecutionPipeline(
return { id: options.ids.next('evt'), at: options.clock.nowIso(), ...partial };
}
+ /**
+ * Verify and reserve what the resource requires, or undefined if it requires
+ * none. Runs after payment verification, which has no side effect: a bad
+ * payment proof must not burn a reservation.
+ */
+ async function authorizeAndReserve(
+ request: CanonicalRequest,
+ resource: CommerceResource,
+ providers: readonly AuthorizationProvider[],
+ requirement: PaymentRequirement,
+ input: unknown,
+ ): Promise {
+ if (providers.length === 0) return undefined;
+
+ const submission = request.authorization;
+ const provider = providers.find((candidate) => candidate.name === submission?.method);
+ // A request carries one proof, so a resource requiring two methods is
+ // refused here rather than half-checked
+ const unmet = providers.filter((candidate) => candidate !== provider).map((c) => c.name);
+
+ if (submission === undefined || provider === undefined || unmet.length > 0) {
+ await safeEmit(
+ buildEvent({
+ type: 'authorization.rejected',
+ requestId: request.requestId,
+ resourceId: resource.id,
+ adapter: request.protocol,
+ status: 'error',
+ data: { reason: 'AUTHORIZATION_REQUIRED', methods: unmet },
+ }),
+ );
+ throw new CommerceError(
+ 'AUTHORIZATION_REQUIRED',
+ `Resource "${resource.id}" requires authorization: ${unmet.join(', ')}`,
+ {
+ requestId: request.requestId,
+ resourceId: resource.id,
+ details: { required: providers.map((candidate) => candidate.name), missing: unmet },
+ },
+ );
+ }
+
+ let verification: AuthorizationVerification;
+ try {
+ verification = await provider.verifyAndReserve({
+ requestId: request.requestId,
+ resourceId: resource.id,
+ input,
+ submission,
+ requirement,
+ });
+ } catch (error) {
+ // An untyped throw says nothing about whose fault it was. Unavailable is
+ // honest and retryable; invalid would blame the buyer for our outage.
+ const mapped = isCommerceError(error)
+ ? error
+ : new CommerceError(
+ 'AUTHORIZATION_PROVIDER_UNAVAILABLE',
+ 'Authorization provider is unavailable',
+ {
+ requestId: request.requestId,
+ resourceId: resource.id,
+ cause: error,
+ },
+ );
+ await safeEmit(
+ buildEvent({
+ type: 'authorization.rejected',
+ requestId: request.requestId,
+ resourceId: resource.id,
+ adapter: request.protocol,
+ status: 'error',
+ data: { reason: mapped.code, method: provider.name },
+ }),
+ );
+ throw mapped;
+ }
+
+ await safeEmit(
+ buildEvent({
+ type: 'authorization.verified',
+ requestId: request.requestId,
+ resourceId: resource.id,
+ adapter: request.protocol,
+ status: 'ok',
+ data: { method: verification.method, reference: verification.reference },
+ }),
+ );
+
+ const context = { requestId: request.requestId, resourceId: resource.id };
+ return {
+ record: {
+ method: verification.method,
+ reference: verification.reference,
+ ...(verification.metadata !== undefined ? { metadata: verification.metadata } : {}),
+ },
+ async finalize(action) {
+ try {
+ await provider[action](verification.reservationId, context);
+ } catch (error) {
+ // Any failure leaves the row reserved, which is still unspendable.
+ // Not worth replacing the outcome the caller is about to see.
+ options.logger.error(
+ { err: describeError(error), requestId: request.requestId, action },
+ 'Authorization finalization failed; the reservation stays reserved',
+ );
+ }
+ },
+ };
+ }
+
async function execute(request: CanonicalRequest): Promise {
const pipelineStart = options.clock.monotonicMs();
@@ -110,8 +238,8 @@ export function createExecutionPipeline(
}),
);
- // 2. strip reserved payment field, then validate input
- const strippedInput = stripPaymentField(request.input);
+ // 2. strip reserved gateway fields, then validate input
+ const strippedInput = stripReservedFields(request.input);
const validation = getValidator(resource)(strippedInput);
if (!validation.valid) {
throw new CommerceError(
@@ -152,7 +280,22 @@ export function createExecutionPipeline(
);
}
+ // Authorization gates settlement, so requiring one on a free resource
+ // means nothing would ever read the proof. Config refuses it at load; the
+ // execution path must not be the one that serves it unchecked.
+ if (resource.pricing.type !== 'fixed' && (resource.authorization?.required.length ?? 0) > 0) {
+ throw new CommerceError(
+ 'CONFIG_INVALID',
+ `Resource "${resource.id}" requires authorization but is not a paid resource`,
+ {
+ requestId: request.requestId,
+ resourceId: resource.id,
+ },
+ );
+ }
+
let paymentResult: PaymentResult | undefined;
+ let authorization: AuthorizationRecord | undefined;
if (resource.pricing.type === 'fixed') {
const pricing = resource.pricing;
@@ -169,6 +312,14 @@ export function createExecutionPipeline(
);
}
+ // Resolved before the challenge so the 402 can name what the retry must
+ // also carry, and so an uncheckable method fails before payment starts
+ const authProviders = resolveAuthorizationProviders(
+ options.authorizationProviders ?? [],
+ resource,
+ request.requestId,
+ );
+
let requirement: PaymentRequirement;
try {
requirement = await provider.createRequirement({
@@ -203,6 +354,9 @@ export function createExecutionPipeline(
requestId: request.requestId,
resourceId: resource.id,
requirement,
+ ...(authProviders.length > 0
+ ? { authorization: authProviders.map((p) => p.requirement) }
+ : {}),
};
}
@@ -285,6 +439,15 @@ export function createExecutionPipeline(
}
const replayKey = verification.replayKey;
+ const hold = await authorizeAndReserve(
+ request,
+ resource,
+ authProviders,
+ requirement,
+ validInput,
+ );
+ authorization = hold?.record;
+
try {
await options.store.reservePaymentAttempt({
requestId: request.requestId,
@@ -309,6 +472,7 @@ export function createExecutionPipeline(
'STORAGE_ERROR',
'Payment attempt could not be recorded',
);
+ await hold?.finalize('release');
await safeEmit(
buildEvent({
type: 'payment.rejected',
@@ -354,6 +518,9 @@ export function createExecutionPipeline(
// is still not delivered either way: only what gets *recorded*
// changes, never the fail-closed outcome.
const uncertainTxHash = uncertainSettlementTxHash(error);
+ // An unconfirmed broadcast may still have moved funds, so the proof is
+ // not handed back. Only a settlement that provably failed is releasable.
+ await hold?.finalize(uncertainTxHash !== undefined ? 'markUncertain' : 'release');
await safePersist(
() =>
options.store.updatePaymentAttempt(
@@ -409,6 +576,7 @@ export function createExecutionPipeline(
}
if (settlement.status !== 'settled') {
+ await hold?.finalize('release');
await safePersist(
() =>
options.store.updatePaymentAttempt({
@@ -442,6 +610,7 @@ export function createExecutionPipeline(
);
}
+ await hold?.finalize('consume');
await safePersist(
() =>
options.store.updatePaymentAttempt({
@@ -510,6 +679,7 @@ export function createExecutionPipeline(
backendStatus: backendErrorStatus(commerceError),
protocol: request.protocol,
payment: paymentResult,
+ ...(authorization !== undefined ? { authorization } : {}),
metadata: { delivered: false, backendErrorCode: commerceError.code },
};
await safePersist(
@@ -566,6 +736,7 @@ export function createExecutionPipeline(
durationMs: backendResponse.durationMs,
protocol: request.protocol,
...(paymentResult !== undefined ? { payment: paymentResult } : {}),
+ ...(authorization !== undefined ? { authorization } : {}),
};
await safePersist(() => options.store.saveReceipt(receipt), 'saveReceipt', request.requestId);
@@ -614,6 +785,31 @@ function uncertainSettlementTxHash(error: unknown): string | undefined {
return typeof hash === 'string' ? hash : undefined;
}
+/**
+ * The provider for each method a resource requires, in the resource's order.
+ *
+ * Config refuses an unconfigured method at load. Missing it here would mean
+ * serving the resource with no authorization at all, so it is checked again.
+ */
+function resolveAuthorizationProviders(
+ providers: readonly AuthorizationProvider[],
+ resource: CommerceResource,
+ requestId: string,
+): readonly AuthorizationProvider[] {
+ const required = resource.authorization?.required ?? [];
+ return required.map((method: AuthorizationMethodName) => {
+ const found = providers.find((provider) => provider.name === method);
+ if (!found) {
+ throw new CommerceError(
+ 'CONFIG_INVALID',
+ `Resource "${resource.id}" requires authorization method "${method}", which is not enabled`,
+ { requestId, resourceId: resource.id },
+ );
+ }
+ return found;
+ });
+}
+
function pickProvider(
providers: readonly PaymentProvider[],
methods: readonly PaymentMethodName[],
@@ -625,9 +821,9 @@ function pickProvider(
return undefined;
}
-const RESERVED_INPUT_KEYS = new Set([PAYMENT_INPUT_FIELD, '__proto__']);
+const RESERVED_INPUT_KEYS = new Set([...RESERVED_INPUT_FIELDS, '__proto__']);
-function stripPaymentField(input: unknown): unknown {
+function stripReservedFields(input: unknown): unknown {
if (typeof input !== 'object' || input === null || Array.isArray(input)) return input;
const record = input as Record;
// Object.hasOwn: `in` would also match inherited names and strip things
diff --git a/src/core/public-types.ts b/src/core/public-types.ts
index ff692cd..3a6ae78 100644
--- a/src/core/public-types.ts
+++ b/src/core/public-types.ts
@@ -14,10 +14,20 @@
* See docs/contracts.md for the freeze record.
*/
+export type {
+ AuthorizationFinalizeContext,
+ AuthorizationProvider,
+ AuthorizationRecord,
+ AuthorizationRequirement,
+ AuthorizationSubmission,
+ AuthorizationVerification,
+ AuthorizationVerificationContext,
+} from './domain/authorization.js';
// --- canonical domain ------------------------------------------------------
export type {
AdapterDescriptor,
AdapterHealth,
+ AuthorizationMethodName,
DecimalAmount,
IsoTimestamp,
JsonSchema,
@@ -61,12 +71,19 @@ export type {
PaymentRequiredEnvelope,
} from './domain/wire.js';
export {
+ AUTHORIZATION_HEADER,
+ AUTHORIZATION_INPUT_FIELD,
DELIVERY_SUMMARY_META_KEY,
+ extractReservedInputFields,
isPaymentRequiredEnvelope,
+ MAX_AUTHORIZATION_HEADER_BYTES,
PAYMENT_HEADER,
PAYMENT_INPUT_FIELD,
PAYMENT_REQUIRED_HEADER,
PAYMENT_RESPONSE_HEADER,
+ parseAuthorizationHeader,
+ parseAuthorizationSubmission,
+ RESERVED_INPUT_FIELDS,
toDeliverySummary,
toErrorEnvelope,
toPaymentRequiredEnvelope,
diff --git a/src/gateway/logger.ts b/src/gateway/logger.ts
index 2e9eedc..dd3c111 100644
--- a/src/gateway/logger.ts
+++ b/src/gateway/logger.ts
@@ -1,7 +1,7 @@
/**
- * Pino logger factory with redaction. Never log secrets: Authorization
- * headers, the payment-signature header, private keys, seeds, mnemonics,
- * signatures.
+ * Pino logger factory with redaction. Never log secrets: the credential and
+ * proof headers (`authorization`, `payment-signature`, `agent-authorization`),
+ * private keys, seeds, mnemonics, signatures.
*
* Two independent things are built here, deliberately not the same pino
* instance (Fastify's `loggerInstance` option forces its generic `Logger`
@@ -20,7 +20,7 @@
import { createRequire } from 'node:module';
import type { FastifyReply, FastifyRequest } from 'fastify';
import pino, { type LoggerOptions, type Logger as PinoLogger } from 'pino';
-import type { Logger } from '../core/index.js';
+import { AUTHORIZATION_HEADER, type Logger, PAYMENT_HEADER } from '../core/index.js';
/**
* Absolute path to pino-pretty, or `undefined` when it is not installed.
@@ -75,9 +75,19 @@ const SECRET_FIELD_NAMES = [
* are single-level, so a secret at depth ≥ 2 is still not covered, and the
* documentation says so rather than promising "any field".
*/
+/**
+ * Request headers carrying a credential or a proof, read from the wire
+ * constants rather than written out again here.
+ *
+ * A hardcoded copy is how this drifted before: `x-payment` became
+ * `payment-signature` for x402 v2, and a literal list would still be redacting
+ * a header no client sends. `agent-authorization` carries an AP2 mandate and
+ * belongs here for the same reason a payment proof does.
+ */
+const SECRET_HEADERS = ['authorization', PAYMENT_HEADER, AUTHORIZATION_HEADER] as const;
+
export const REDACT_PATHS: readonly string[] = [
- 'req.headers.authorization',
- 'req.headers["payment-signature"]',
+ ...SECRET_HEADERS.map((name) => `req.headers[${JSON.stringify(name)}]`),
...SECRET_FIELD_NAMES,
...SECRET_FIELD_NAMES.map((name) => `*.${name}`),
];
diff --git a/src/gateway/main.ts b/src/gateway/main.ts
index 0290ddb..c552278 100644
--- a/src/gateway/main.ts
+++ b/src/gateway/main.ts
@@ -12,11 +12,15 @@
* store that will not open is fatal, not degraded;
* 3. build payment providers — a paid resource with no working provider must
* fail closed at request time, not be quietly downgraded to free;
+ * 3b. build authorization providers — same reasoning, and the replay database
+ * opens here, so a mandate store that will not open stops startup;
* 4. build protocol adapters — these are isolated: one failing to start is
* reported unhealthy and does not stop the others;
* 5. listen, then print the effective settlement destination so an operator
* or presenter can see where money actually goes.
*/
+import type { Ap2AuthorizationProvider } from '../authorization/ap2/index.js';
+import { createAp2AuthorizationProvider } from '../authorization/ap2/index.js';
import { loadConfig } from '../config/index.js';
import type { PaymentProvider, ProtocolAdapter, ReceiptStore } from '../core/index.js';
import { CommerceError, isCommerceError } from '../core/index.js';
@@ -72,6 +76,14 @@ async function main(): Promise {
);
}
+ // Built only when enabled: the replay database is opened by the constructor,
+ // so a disabled AP2 block creates no file and holds no handle.
+ const authorizationProviders: Ap2AuthorizationProvider[] = [];
+ const ap2 = config.authorization?.ap2;
+ if (ap2?.enabled) {
+ authorizationProviders.push(createAp2AuthorizationProvider({ config: ap2, logger }));
+ }
+
// A paid resource with no provider is a configuration error we can catch now
// rather than discovering it on the first purchase attempt. Config validation
// already rejects this, so reaching here means the two drifted apart.
@@ -123,6 +135,7 @@ async function main(): Promise {
config,
store,
paymentProviders,
+ authorizationProviders,
protocolAdapters,
logger,
});
@@ -136,6 +149,7 @@ async function main(): Promise {
resources: config.resources.length,
protocols: protocolAdapters.map((adapter) => adapter.name),
payments: paymentProviders.map((provider) => provider.name),
+ authorization: authorizationProviders.map((provider) => provider.name),
},
'gateway listening',
);
@@ -167,6 +181,7 @@ async function main(): Promise {
void (async () => {
try {
await gateway.close();
+ for (const provider of authorizationProviders) provider.close();
await store.close();
process.exit(0);
} catch (error) {
diff --git a/src/gateway/readiness.ts b/src/gateway/readiness.ts
index 9fddf2d..bd2815b 100644
--- a/src/gateway/readiness.ts
+++ b/src/gateway/readiness.ts
@@ -1,8 +1,9 @@
/**
* `GET /ready` readiness computation: 503 unless the store, every configured
- * protocol adapter, AND every payment provider are healthy. `status: 'warn'`
- * is treated as still-serving (degraded); only `status: 'fail'` blocks
- * readiness — applied uniformly to all three kinds of dependency.
+ * protocol adapter, every payment provider AND every authorization provider
+ * are healthy. `status: 'warn'` is treated as still-serving (degraded); only
+ * `status: 'fail'` blocks readiness — applied uniformly to all four kinds of
+ * dependency.
*
* Payment providers are consulted here alongside the store and protocol
* adapters: without that, a gateway whose x402 RPC is unreachable reports
@@ -26,7 +27,14 @@
* READINESS_TTL_MS and collapses concurrent callers onto one in-flight
* evaluation, so a burst of N requests produces at most one real check.
*/
-import type { AdapterHealth, Clock, Logger, PaymentProvider, ReceiptStore } from '../core/index.js';
+import type {
+ AdapterHealth,
+ AuthorizationProvider,
+ Clock,
+ Logger,
+ PaymentProvider,
+ ReceiptStore,
+} from '../core/index.js';
import { type AdapterRuntime, getAdapterHealth } from './adapters.js';
export interface ReadinessCheck {
@@ -40,12 +48,14 @@ export interface ReadinessResult {
readonly store: ReadinessCheck;
readonly adapters: readonly ReadinessCheck[];
readonly paymentProviders: readonly ReadinessCheck[];
+ readonly authorizationProviders: readonly ReadinessCheck[];
}
export interface CheckReadinessOptions {
readonly store: ReceiptStore;
readonly adapterRuntimes: readonly AdapterRuntime[];
readonly paymentProviders: readonly PaymentProvider[];
+ readonly authorizationProviders: readonly AuthorizationProvider[];
readonly clock: Clock;
readonly logger: Logger;
}
@@ -68,6 +78,46 @@ const PAYMENT_PROVIDER_DETAIL: Readonly> =
+ {
+ pass: undefined,
+ warn: 'authorization-provider-degraded',
+ fail: 'authorization-provider-unreachable',
+ };
+
+/**
+ * One probe for both provider kinds. A provider whose `health()` throws told us
+ * nothing, so it counts as failing rather than as absent, and only the fixed
+ * vocabulary above reaches the client.
+ */
+async function probeProvider(
+ provider: { readonly name: string; health(): Promise },
+ kind: string,
+ details: Readonly>,
+ options: Pick,
+): Promise {
+ let health: AdapterHealth;
+ try {
+ health = await provider.health();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ options.logger.error({ err: message, provider: provider.name }, `${kind} health() threw`);
+ health = { status: 'fail', checkedAt: options.clock.nowIso() };
+ }
+ if (health.detail !== undefined) {
+ options.logger.debug(
+ { provider: provider.name, detail: health.detail },
+ `${kind} health detail (not sent to the client)`,
+ );
+ }
+ const detail = details[health.status];
+ return {
+ name: provider.name,
+ status: health.status,
+ ...(detail !== undefined ? { detail } : {}),
+ };
+}
+
export async function checkReadiness(options: CheckReadinessOptions): Promise {
let storeHealth: AdapterHealth;
let storeThrew = false;
@@ -105,40 +155,30 @@ export async function checkReadiness(options: CheckReadinessOptions): Promise => {
- let health: AdapterHealth;
- try {
- health = await provider.health();
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- options.logger.error(
- { err: message, provider: provider.name },
- 'payment provider health() threw',
- );
- health = { status: 'fail', checkedAt: options.clock.nowIso() };
- }
- if (health.detail !== undefined) {
- options.logger.debug(
- { provider: provider.name, detail: health.detail },
- 'payment provider health detail (not sent to the client)',
- );
- }
- const detail = PAYMENT_PROVIDER_DETAIL[health.status];
- return {
- name: provider.name,
- status: health.status,
- ...(detail !== undefined ? { detail } : {}),
- };
- }),
+ options.paymentProviders.map((provider) =>
+ probeProvider(provider, 'payment provider', PAYMENT_PROVIDER_DETAIL, options),
+ ),
+ );
+
+ // An unusable authorization provider blocks readiness on the same threshold
+ // as a payment one: a resource that requires a mandate cannot be served
+ // without it, and serving the challenge anyway promises what we cannot honour
+ const authorizationProviderChecks = await Promise.all(
+ options.authorizationProviders.map((provider) =>
+ probeProvider(provider, 'authorization provider', AUTHORIZATION_PROVIDER_DETAIL, options),
+ ),
);
const storeReady = storeHealth.status !== 'fail';
const adaptersReady = adapterChecks.every((check) => check.status !== 'fail');
const paymentProvidersReady = paymentProviderChecks.every((check) => check.status !== 'fail');
+ const authorizationProvidersReady = authorizationProviderChecks.every(
+ (check) => check.status !== 'fail',
+ );
const storeDetail = storeThrew ? 'store-unreachable' : STORE_DETAIL[storeHealth.status];
return {
- ready: storeReady && adaptersReady && paymentProvidersReady,
+ ready: storeReady && adaptersReady && paymentProvidersReady && authorizationProvidersReady,
store: {
name: 'store',
status: storeHealth.status,
@@ -146,6 +186,7 @@ export async function checkReadiness(options: CheckReadinessOptions): Promise;
readonly paymentProviders: readonly AdapterDescriptor[];
+ /**
+ * Empty unless a resource requires authorization. Listed separately from
+ * `paymentProviders` because an authorization method is not a payment rail
+ * and must never be selectable as one.
+ */
+ readonly authorizationProviders: readonly AdapterDescriptor[];
readonly store: AdapterDescriptor;
readonly payments: {
readonly x402?: {
@@ -97,6 +104,7 @@ export interface WellKnownDocument {
export interface BuildWellKnownOptions {
readonly config: GatewayConfig;
readonly paymentProviders: readonly PaymentProvider[];
+ readonly authorizationProviders: readonly AuthorizationProvider[];
readonly store: ReceiptStore;
readonly adapterRuntimes: readonly AdapterRuntime[];
readonly clock: Clock;
@@ -149,6 +157,7 @@ export async function buildWellKnownDocument(
protocols: publicProtocols(options.config.protocols),
adapters,
paymentProviders: options.paymentProviders.map((provider) => provider.descriptor),
+ authorizationProviders: options.authorizationProviders.map((provider) => provider.descriptor),
store: options.store.descriptor,
payments: {
...(x402 !== undefined
diff --git a/src/protocols/a2a/adapter.ts b/src/protocols/a2a/adapter.ts
index 33b85a1..5edf546 100644
--- a/src/protocols/a2a/adapter.ts
+++ b/src/protocols/a2a/adapter.ts
@@ -18,6 +18,7 @@ import {
CommerceError,
type CommerceResource,
type ExecutionOutcome,
+ extractReservedInputFields,
type HttpProtocolAdapter,
type ProtocolAdapterContext,
toCommerceError,
@@ -47,11 +48,7 @@ import {
jsonRpcResult,
parseJsonRpcRequest,
} from './jsonrpc.js';
-import {
- type A2aInvocation,
- extractPaymentSubmission,
- parseInvocation,
-} from './message-mapping.js';
+import { type A2aInvocation, parseInvocation } from './message-mapping.js';
import {
completedTask,
failedTask,
@@ -314,18 +311,27 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter {
);
}
- const { input, payment } = extractPaymentSubmission(invocation.input, resource);
- const request: CanonicalRequest = {
- requestId: context.ids.next('a2a'),
- resourceId: invocation.resourceId,
- input,
- protocol: 'a2a',
- receivedAt: context.clock.nowIso(),
- ...(payment !== undefined ? { payment } : {}),
- };
- const identity = this.taskIdentity(context, request.requestId);
+ const requestId = context.ids.next('a2a');
+ const identity = this.taskIdentity(context, requestId);
+ // Reserved-field extraction sits inside the try: a malformed
+ // `_authorization` envelope is rejected there, and that rejection is a
+ // commerce outcome for the caller like any other, not an escaped throw.
try {
+ const { input, payment, authorization } = extractReservedInputFields(
+ invocation.input,
+ resource,
+ requestId,
+ );
+ const request: CanonicalRequest = {
+ requestId,
+ resourceId: invocation.resourceId,
+ input,
+ protocol: 'a2a',
+ receivedAt: context.clock.nowIso(),
+ ...(payment !== undefined ? { payment } : {}),
+ ...(authorization !== undefined ? { authorization } : {}),
+ };
const outcome: ExecutionOutcome = await context.pipeline.execute(request);
return this.taskResult(
id,
@@ -339,7 +345,7 @@ export class A2aProtocolAdapter implements HttpProtocolAdapter {
// nothing internal reaches the artifact.
const error = toCommerceError(err);
context.logger.warn(
- { resourceId: invocation.resourceId, requestId: request.requestId, err: error.toInfo() },
+ { resourceId: invocation.resourceId, requestId, err: error.toInfo() },
'a2a adapter: execution failed',
);
return this.taskResult(id, failedTask(error, identity));
diff --git a/src/protocols/a2a/message-mapping.ts b/src/protocols/a2a/message-mapping.ts
index 9cb619f..efafec0 100644
--- a/src/protocols/a2a/message-mapping.ts
+++ b/src/protocols/a2a/message-mapping.ts
@@ -25,12 +25,7 @@
* successful, possibly *paid*, call for something they did not ask for.
*/
import { z } from 'zod';
-import {
- CommerceError,
- type CommerceResource,
- PAYMENT_INPUT_FIELD,
- type PaymentSubmission,
-} from '../../core/index.js';
+import { CommerceError } from '../../core/index.js';
import { A2A_JSON_MEDIA_TYPE } from './constants.js';
/** The only role a request message may carry. A2A v1 spells roles this way. */
@@ -185,25 +180,3 @@ export function parseInvocation(rawParams: unknown): A2aInvocation {
...(message.messageId !== undefined ? { messageId: message.messageId } : {}),
};
}
-
-/**
- * Lifts a payment proof out of the reserved input field into the canonical
- * `PaymentSubmission` the pipeline reads, leaving the rest of the input alone.
- *
- * The *convention* is shared with MCP — one reserved field named once in
- * `core` — but the code is not: a cross-adapter import would make an A2A
- * deployment's payment retry depend on the MCP SDK being installed. The
- * adapter decides nothing about the payment here; it only moves it to where
- * the pipeline looks, and the rail comes from the resource's own declaration.
- */
-export function extractPaymentSubmission(
- rawInput: Record,
- resource: CommerceResource | undefined,
-): { input: Record; payment?: PaymentSubmission } {
- const { [PAYMENT_INPUT_FIELD]: proof, ...input } = rawInput;
- const method = resource?.paymentMethods[0];
- if (typeof proof === 'string' && proof.length > 0 && method !== undefined) {
- return { input, payment: { method, payload: proof } };
- }
- return { input };
-}
diff --git a/src/protocols/mcp/adapter.ts b/src/protocols/mcp/adapter.ts
index 64e8b2a..2840f42 100644
--- a/src/protocols/mcp/adapter.ts
+++ b/src/protocols/mcp/adapter.ts
@@ -66,12 +66,13 @@ import {
type CanonicalRequest,
CommerceError,
type CommerceResource,
+ extractReservedInputFields,
type HttpProtocolAdapter,
type ProtocolAdapterContext,
toCommerceError,
} from '../../core/index.js';
import { buildDescriptor, PACKAGE_VERSION } from './descriptor.js';
-import { errorResult, extractPaymentSubmission, mapOutcome } from './result-mapping.js';
+import { errorResult, mapOutcome } from './result-mapping.js';
import {
buildInputSchema,
buildToolDescription,
@@ -379,14 +380,20 @@ export class McpProtocolAdapter implements HttpProtocolAdapter {
new CommerceError('RESOURCE_NOT_FOUND', `Unknown tool "${resourceId}".`, { resourceId }),
);
}
- const { input, payment } = extractPaymentSubmission(rawArgs, resource);
+ const requestId = context.ids.next('mcp');
+ const { input, payment, authorization } = extractReservedInputFields(
+ rawArgs,
+ resource,
+ requestId,
+ );
const request: CanonicalRequest = {
- requestId: context.ids.next('mcp'),
+ requestId,
resourceId,
input,
protocol: 'mcp',
receivedAt: context.clock.nowIso(),
...(payment !== undefined ? { payment } : {}),
+ ...(authorization !== undefined ? { authorization } : {}),
};
await this.acquireToolCallSlot(signal);
try {
diff --git a/src/protocols/mcp/result-mapping.ts b/src/protocols/mcp/result-mapping.ts
index 459390c..428ef30 100644
--- a/src/protocols/mcp/result-mapping.ts
+++ b/src/protocols/mcp/result-mapping.ts
@@ -8,13 +8,11 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import {
type CommerceError,
- type CommerceResource,
DELIVERY_SUMMARY_META_KEY,
type DeliveredOutcome,
type ExecutionOutcome,
PAYMENT_INPUT_FIELD,
type PaymentRequiredOutcome,
- type PaymentSubmission,
toDeliverySummary,
toErrorEnvelope,
toPaymentRequiredEnvelope,
@@ -24,33 +22,6 @@ function toRecord(value: object): Record {
return value as Record;
}
-/**
- * Splits raw MCP tool arguments into resource input and an optional payment
- * submission. `_payment` never leaks into the input handed to the pipeline.
- *
- * The payment method is derived from the resource's own `paymentMethods`
- * (never hard-coded) — this adapter does not decide which rail a resource
- * uses, that is core/config's job. If a `_payment` proof is supplied for a
- * resource with no configured payment method (or an unrecognised resource),
- * it is dropped rather than forwarded under an invented method: the pipeline
- * then treats the request as unpaid and decides for itself (free delivery,
- * or a rejection) — never a payment-method guess made in this adapter.
- */
-export function extractPaymentSubmission(
- rawArgs: Record,
- resource: CommerceResource | undefined,
-): {
- input: Record;
- payment?: PaymentSubmission;
-} {
- const { [PAYMENT_INPUT_FIELD]: paymentValue, ...input } = rawArgs;
- const method = resource?.paymentMethods[0];
- if (typeof paymentValue === 'string' && paymentValue.length > 0 && method !== undefined) {
- return { input, payment: { method, payload: paymentValue } };
- }
- return { input };
-}
-
export function deliveredResult(outcome: DeliveredOutcome): CallToolResult {
const body = outcome.body;
const text = typeof body === 'string' ? body : JSON.stringify(body ?? null);
diff --git a/src/storage/receipts/rows.ts b/src/storage/receipts/rows.ts
index 5c669f5..84f2651 100644
--- a/src/storage/receipts/rows.ts
+++ b/src/storage/receipts/rows.ts
@@ -5,6 +5,7 @@
* present (see docs/contracts.md, assumption 8).
*/
import type {
+ AuthorizationRecord,
CommerceEvent,
CommerceEventType,
CommerceReceipt,
@@ -23,6 +24,7 @@ export interface ReceiptRow {
readonly duration_ms: number | null;
readonly protocol: string | null;
readonly metadata_json: string | null;
+ readonly authorization_json: string | null;
}
export interface EventRow {
@@ -65,6 +67,7 @@ export function receiptToRow(receipt: CommerceReceipt): {
duration_ms: number | null;
protocol: string | null;
metadata_json: string | null;
+ authorization_json: string | null;
} {
return {
id: receipt.id,
@@ -76,6 +79,8 @@ export function receiptToRow(receipt: CommerceReceipt): {
duration_ms: receipt.durationMs !== undefined ? receipt.durationMs : null,
protocol: receipt.protocol !== undefined ? receipt.protocol : null,
metadata_json: receipt.metadata !== undefined ? JSON.stringify(redact(receipt.metadata)) : null,
+ authorization_json:
+ receipt.authorization !== undefined ? JSON.stringify(redact(receipt.authorization)) : null,
};
}
@@ -86,6 +91,12 @@ export function rowToReceipt(row: ReceiptRow): CommerceReceipt {
row.metadata_json !== null
? (JSON.parse(row.metadata_json) as Record)
: undefined;
+ // Null for every receipt written before this column existed, and for every
+ // resource that requires no authorization
+ const authorization =
+ row.authorization_json !== null
+ ? (JSON.parse(row.authorization_json) as AuthorizationRecord)
+ : undefined;
return {
id: row.id,
requestId: row.request_id,
@@ -96,6 +107,7 @@ export function rowToReceipt(row: ReceiptRow): CommerceReceipt {
...(row.duration_ms !== null ? { durationMs: row.duration_ms } : {}),
...(row.protocol !== null ? { protocol: row.protocol } : {}),
...(metadata !== undefined ? { metadata } : {}),
+ ...(authorization !== undefined ? { authorization } : {}),
};
}
diff --git a/src/storage/receipts/schema.ts b/src/storage/receipts/schema.ts
index dd86dc2..1d204c9 100644
--- a/src/storage/receipts/schema.ts
+++ b/src/storage/receipts/schema.ts
@@ -71,6 +71,14 @@ const MIGRATIONS: readonly Migration[] = [
`);
},
},
+ {
+ version: 2,
+ up(db) {
+ // Not folded into v1: an existing database must keep its rows, and a
+ // receipt written before authorization truthfully has none
+ db.exec(`ALTER TABLE receipts ADD COLUMN authorization_json TEXT;`);
+ },
+ },
];
/** Current target schema version — the version of the last migration. */
diff --git a/src/storage/receipts/store.ts b/src/storage/receipts/store.ts
index 25d22be..4a1c5d7 100644
--- a/src/storage/receipts/store.ts
+++ b/src/storage/receipts/store.ts
@@ -92,8 +92,8 @@ export function createSqliteReceiptStore(options: SqliteReceiptStoreOptions): Re
migrate(db);
const insertReceiptStmt = db.prepare(
- `INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json)
- VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json)`,
+ `INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json, authorization_json)
+ VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json, @authorization_json)`,
);
const getReceiptStmt = db.prepare<[string], ReceiptRow>('SELECT * FROM receipts WHERE id = ?');
const listReceiptsStmt = db.prepare<[number], ReceiptRow>(
diff --git a/tests/e2e/authorization/ap2-x402.e2e.test.ts b/tests/e2e/authorization/ap2-x402.e2e.test.ts
new file mode 100644
index 0000000..48292e4
--- /dev/null
+++ b/tests/e2e/authorization/ap2-x402.e2e.test.ts
@@ -0,0 +1,334 @@
+/**
+ * An AP2-gated purchase settling on chain, through the whole gateway.
+ *
+ * The refusal matrix and the call counts are in
+ * `tests/integration/ap2-x402-conformance.test.ts`. Here is what only a chain
+ * shows: the coordinates a mandate commits to - destination, network, asset -
+ * are the ones the x402 provider builds its challenge from, and a replayed
+ * mandate moves no money. Balance deltas read back off the chain, never a log
+ * line claiming success.
+ *
+ * The chain is an ephemeral Anvil this file spawns; the merchant backend is
+ * stubbed, because what is under test is everything in front of it.
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { AP2_CHECKOUT_PROFILE } from '../../../src/authorization/ap2/constants.js';
+import {
+ type Ap2AuthorizationProvider,
+ createAp2AuthorizationProvider,
+} from '../../../src/authorization/ap2/index.js';
+import { computeInputHash } from '../../../src/authorization/ap2/profile.js';
+import { type GatewayConfig, parseConfig } from '../../../src/config/index.js';
+import type { BackendExecutor, ReceiptStore } from '../../../src/core/index.js';
+import { AUTHORIZATION_HEADER, PAYMENT_HEADER } from '../../../src/core/index.js';
+import { createGateway, type GatewayInstance } from '../../../src/gateway/index.js';
+import { createPaymentProof, createX402PaymentProvider } from '../../../src/payments/x402/index.js';
+import {
+ type AnvilHandle,
+ deployLocalChain,
+ startAnvil,
+} from '../../../src/payments/x402/testing.js';
+import { createSqliteReceiptStore } from '../../../src/storage/receipts/index.js';
+import { expectRealSettlement, readBalances } from '../../fixtures/x402/settlement.js';
+import {
+ checkoutPayload,
+ createParties,
+ fixedClock,
+ mintMandate,
+ type Party,
+ signCheckoutJwt,
+} from '../../unit/authorization-ap2/fixtures.js';
+
+const PORT = 18791;
+const RESOURCE_ID = 'market_report';
+const INPUT = { city: 'Berlin' };
+const AMOUNT = '1.00';
+const CURRENCY = 'USD';
+const NETWORK = 'eip155:84532';
+
+let anvil: AnvilHandle;
+let deployment: Awaited>;
+let parties: Party;
+let gateway: GatewayInstance;
+let store: ReceiptStore;
+let authorization: Ap2AuthorizationProvider;
+let backendCalls = 0;
+
+const backend: BackendExecutor = {
+ async call() {
+ backendCalls += 1;
+ return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 };
+ },
+};
+
+function rawConfig(): Record {
+ const issuers = (entries: Party['mandateIssuers']) =>
+ entries.map((entry) => ({
+ issuer: entry.issuer,
+ audience: entry.audience,
+ keys: entry.keys.map((key) => ({ kid: key.kid, jwk: key.jwk })),
+ }));
+ return {
+ version: 1,
+ merchant: { id: 'ap2-e2e', name: 'AP2 E2E', publicBaseUrl: 'http://127.0.0.1:8080' },
+ server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] },
+ storage: { receipts: { driver: 'sqlite', path: ':memory:' } },
+ protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } },
+ resources: {
+ [RESOURCE_ID]: {
+ name: 'Market report',
+ input: {
+ type: 'object',
+ properties: { city: { type: 'string' } },
+ required: ['city'],
+ additionalProperties: false,
+ },
+ backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' },
+ pricing: { type: 'fixed', amount: AMOUNT, currency: CURRENCY },
+ expose: ['http'],
+ payments: ['x402'],
+ authorization: { required: ['ap2'] },
+ },
+ },
+ payments: {
+ x402: {
+ enabled: true,
+ network: NETWORK,
+ rpcUrl: anvil.rpcUrl,
+ asset: deployment.asset,
+ assetName: deployment.assetName,
+ assetVersion: deployment.assetVersion,
+ assetDecimals: deployment.assetDecimals,
+ payTo: deployment.merchant.address,
+ maxTimeoutSeconds: 120,
+ facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey },
+ },
+ },
+ authorization: {
+ ap2: {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: {
+ mandateIssuers: issuers(parties.mandateIssuers),
+ checkoutIssuers: issuers(parties.checkoutIssuers),
+ },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ },
+ },
+ };
+}
+
+async function balances() {
+ return readBalances({
+ rpcUrl: anvil.rpcUrl,
+ asset: deployment.asset,
+ buyer: deployment.buyer.address,
+ merchant: deployment.merchant.address,
+ });
+}
+
+// A mandate approving exactly what the gateway's own x402 challenge asks for
+async function mandateForChallenge(): Promise {
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({
+ agent_commerce: {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: RESOURCE_ID,
+ input_hash: await computeInputHash(INPUT),
+ amount: AMOUNT,
+ currency: CURRENCY,
+ payment_method: 'x402',
+ destination: deployment.merchant.address,
+ network: NETWORK,
+ asset: deployment.asset,
+ },
+ }),
+ );
+ return mintMandate(parties.mandateSigner, jwt);
+}
+
+function carrier(presentation: string): string {
+ return Buffer.from(JSON.stringify({ method: 'ap2', payload: presentation }), 'utf8').toString(
+ 'base64url',
+ );
+}
+
+interface Invocation {
+ readonly statusCode: number;
+ readonly body: Record;
+}
+
+async function invoke(headers: Record = {}): Promise {
+ const res = await gateway.server.inject({
+ method: 'POST',
+ url: `/api/resources/${RESOURCE_ID}/invoke`,
+ headers: { 'content-type': 'application/json', ...headers },
+ payload: INPUT,
+ });
+ return { statusCode: res.statusCode, body: res.json>() };
+}
+
+// Asks for the resource unpaid, then signs a proof against the challenge it returns
+async function freshProof(): Promise {
+ const challenge = await invoke();
+ expect(challenge.statusCode).toBe(402);
+ const payment = challenge.body['payment'] as { accepts: Record[] };
+ return createPaymentProof({
+ buyerPrivateKey: deployment.buyer.privateKey,
+ rpcUrl: anvil.rpcUrl,
+ accepts: payment.accepts[0] as Record,
+ });
+}
+
+beforeAll(async () => {
+ anvil = await startAnvil({ port: PORT, silent: true });
+ deployment = await deployLocalChain({ rpcUrl: anvil.rpcUrl, buyerInitialBalance: '100.00' });
+ parties = await createParties();
+
+ const config: GatewayConfig = parseConfig(rawConfig(), process.env);
+ const ap2 = config.authorization?.ap2;
+ if (ap2 === undefined || !ap2.enabled) throw new Error('the fixture config must enable AP2');
+
+ store = createSqliteReceiptStore({ path: ':memory:' });
+ await store.init();
+ authorization = createAp2AuthorizationProvider({ config: ap2, clock: fixedClock() });
+ gateway = await createGateway({
+ config,
+ store,
+ paymentProviders: [
+ createX402PaymentProvider({
+ network: NETWORK,
+ rpcUrl: anvil.rpcUrl,
+ asset: deployment.asset,
+ assetName: deployment.assetName,
+ assetVersion: deployment.assetVersion,
+ assetDecimals: deployment.assetDecimals,
+ payTo: deployment.merchant.address,
+ facilitator: { mode: 'local', signerPrivateKey: deployment.facilitator.privateKey },
+ }),
+ ],
+ authorizationProviders: [authorization],
+ protocolAdapters: [],
+ backend,
+ });
+}, 180_000);
+
+afterAll(async () => {
+ await gateway?.close().catch(() => {});
+ authorization?.close();
+ await store?.close().catch(() => {});
+ await anvil?.stop();
+});
+
+describe('AP2-gated purchase over x402 - real local chain', () => {
+ it('1. the 402 carries both the payment challenge and the mandate requirement', async () => {
+ const challenge = await invoke();
+
+ expect(challenge.statusCode).toBe(402);
+ expect(challenge.body['authorization']).toEqual({
+ required: [{ method: 'ap2', version: '0.2.0', profile: AP2_CHECKOUT_PROFILE }],
+ });
+ const payment = challenge.body['payment'] as Record;
+ // The three coordinates the mandate has to commit to are the ones the
+ // challenge publishes, not values this test invented
+ expect(payment['destination']).toBe(deployment.merchant.address);
+ expect(payment['network']).toBe(NETWORK);
+ expect(payment['asset']).toBe(deployment.asset);
+ });
+
+ it('2. a mandate matching that challenge settles on chain and delivers once', async () => {
+ const proof = await freshProof();
+ const presentation = await mandateForChallenge();
+ const before = await balances();
+ const callsBefore = backendCalls;
+
+ const delivered = await invoke({
+ [PAYMENT_HEADER]: proof,
+ [AUTHORIZATION_HEADER]: carrier(presentation),
+ });
+
+ expect(delivered.statusCode).toBe(200);
+ expect(backendCalls).toBe(callsBefore + 1);
+
+ const receipts = await store.listReceipts({ limit: 5 });
+ const receipt = receipts[0];
+ expect(receipt?.authorization?.reference).toMatch(/^sha256:[\w-]+$/);
+ const txHash = receipt?.payment?.externalReference;
+ expect(txHash).toBeDefined();
+
+ await expectRealSettlement({
+ rpcUrl: anvil.rpcUrl,
+ asset: deployment.asset,
+ buyer: deployment.buyer.address,
+ merchant: deployment.merchant.address,
+ before,
+ after: await balances(),
+ amountBaseUnits: 1_000_000n, // 1.00 at 6 decimals
+ txHash: txHash as string,
+ });
+ });
+
+ it('3. the same mandate with a fresh payment proof moves no second payment', async () => {
+ const proof = await freshProof();
+ const presentation = await mandateForChallenge();
+ await invoke({
+ [PAYMENT_HEADER]: proof,
+ [AUTHORIZATION_HEADER]: carrier(presentation),
+ });
+
+ // A brand-new, perfectly good payment authorisation. Only the mandate is
+ // reused, so nothing but the mandate can be what refuses this.
+ const replayProof = await freshProof();
+ const before = await balances();
+ const callsBefore = backendCalls;
+
+ const replayed = await invoke({
+ [PAYMENT_HEADER]: replayProof,
+ [AUTHORIZATION_HEADER]: carrier(presentation),
+ });
+
+ expect(replayed.statusCode).toBe(409);
+ expect(replayed.body['code']).toBe('AUTHORIZATION_REPLAYED');
+ expect(backendCalls).toBe(callsBefore);
+ const after = await balances();
+ expect(after.buyer).toBe(before.buyer);
+ expect(after.merchant).toBe(before.merchant);
+ });
+
+ it('4. a mandate approved for a different amount settles nothing', async () => {
+ const proof = await freshProof();
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({
+ agent_commerce: {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: RESOURCE_ID,
+ input_hash: await computeInputHash(INPUT),
+ amount: '0.01',
+ currency: CURRENCY,
+ payment_method: 'x402',
+ destination: deployment.merchant.address,
+ network: NETWORK,
+ asset: deployment.asset,
+ },
+ }),
+ );
+ const before = await balances();
+ const callsBefore = backendCalls;
+
+ const refused = await invoke({
+ [PAYMENT_HEADER]: proof,
+ [AUTHORIZATION_HEADER]: carrier(await mintMandate(parties.mandateSigner, jwt)),
+ });
+
+ expect(refused.statusCode).toBe(403);
+ expect(refused.body['code']).toBe('AUTHORIZATION_INVALID');
+ expect(backendCalls).toBe(callsBefore);
+ const after = await balances();
+ expect(after.buyer).toBe(before.buyer);
+ expect(after.merchant).toBe(before.merchant);
+ });
+});
diff --git a/tests/integration/ap2-runtime.test.ts b/tests/integration/ap2-runtime.test.ts
new file mode 100644
index 0000000..e33cf75
--- /dev/null
+++ b/tests/integration/ap2-runtime.test.ts
@@ -0,0 +1,365 @@
+/**
+ * AP2 as a wired subsystem, through the real gateway.
+ *
+ * What a mandate must contain is settled elsewhere. These own the wiring: that
+ * enabling AP2 changes only the resources that ask for it, that a gated
+ * purchase advertises what it needs, and that a broken verifier degrades those
+ * resources without taking the rest of the gateway down with them.
+ */
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { AP2_CHECKOUT_PROFILE } from '../../src/authorization/ap2/constants.js';
+import { createAp2AuthorizationProvider } from '../../src/authorization/ap2/index.js';
+import { computeInputHash } from '../../src/authorization/ap2/profile.js';
+import type { Ap2ReplayStore } from '../../src/authorization/ap2/replay-store.js';
+import type { EnabledAp2Config } from '../../src/authorization/ap2/types.js';
+import type { GatewayConfig } from '../../src/config/index.js';
+import type { AuthorizationProvider, BackendExecutor } from '../../src/core/index.js';
+import { AUTHORIZATION_HEADER } from '../../src/core/index.js';
+import { createGateway, type GatewayInstance } from '../../src/gateway/index.js';
+import {
+ checkoutPayload,
+ createParties,
+ fixedClock,
+ mintMandate,
+ type Party,
+ signCheckoutJwt,
+} from '../unit/authorization-ap2/fixtures.js';
+import { createFakePaymentProvider, createFakeStore } from '../unit/gateway/helpers.js';
+
+process.env['NODE_ENV'] = 'test';
+
+let parties: Party;
+let gateway: GatewayInstance | undefined;
+let provider: { close(): void } | undefined;
+
+beforeAll(async () => {
+ parties = await createParties();
+});
+
+afterEach(async () => {
+ await gateway?.close().catch(() => {});
+ provider?.close();
+ gateway = undefined;
+ provider = undefined;
+});
+
+const backend: BackendExecutor = {
+ async call() {
+ return { status: 200, body: { forecast: 'sunny' }, headers: {}, durationMs: 1 };
+ },
+};
+
+function ap2Config(): EnabledAp2Config {
+ return {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ };
+}
+
+/**
+ * Three resources: one gated by AP2, one paid but ungated, one free. The
+ * second and third are what proves enabling AP2 is not a gateway-wide switch.
+ */
+function config(): GatewayConfig {
+ return {
+ version: 1,
+ merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' },
+ server: { port: 0, host: '127.0.0.1', allowedOrigins: [] },
+ storage: { receipts: { driver: 'sqlite', path: ':memory:' } },
+ protocols: {
+ http: { enabled: true },
+ mcp: { enabled: false, mountPath: '/mcp' },
+ a2a: { enabled: false, mountPath: '/a2a' },
+ acp: { enabled: false, mountPath: '/acp' },
+ },
+ resources: [
+ {
+ id: 'gated_report',
+ name: 'Gated Report',
+ handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' },
+ pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' },
+ exposedVia: ['http'],
+ paymentMethods: ['x402'],
+ authorization: { required: ['ap2'] },
+ },
+ {
+ id: 'paid_report',
+ name: 'Paid Report',
+ handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' },
+ pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' },
+ exposedVia: ['http'],
+ paymentMethods: ['x402'],
+ },
+ {
+ id: 'free_report',
+ name: 'Free Report',
+ handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' },
+ pricing: { type: 'free' },
+ exposedVia: ['http'],
+ paymentMethods: [],
+ },
+ ],
+ payments: {},
+ };
+}
+
+async function startGateway(
+ authorizationProviders: readonly AuthorizationProvider[],
+): Promise {
+ gateway = await createGateway({
+ config: config(),
+ store: createFakeStore(),
+ paymentProviders: [createFakePaymentProvider()],
+ authorizationProviders,
+ protocolAdapters: [],
+ backend,
+ });
+ return gateway;
+}
+
+function startAp2(replayStore?: Ap2ReplayStore): AuthorizationProvider {
+ const created = createAp2AuthorizationProvider({
+ config: ap2Config(),
+ clock: fixedClock(),
+ ...(replayStore !== undefined ? { replayStore } : {}),
+ });
+ provider = created;
+ return created;
+}
+
+async function invoke(
+ gw: GatewayInstance,
+ resourceId: string,
+ headers: Record = {},
+): Promise<{ statusCode: number; body: Record }> {
+ const res = await gw.server.inject({
+ method: 'POST',
+ url: `/api/resources/${resourceId}/invoke`,
+ headers: { 'content-type': 'application/json', ...headers },
+ payload: {},
+ });
+ return { statusCode: res.statusCode, body: res.json>() };
+}
+
+function encodeCarrier(payload: string): string {
+ return Buffer.from(JSON.stringify({ method: 'ap2', payload }), 'utf8').toString('base64url');
+}
+
+/**
+ * A mandate that authorizes exactly what `createFakePaymentProvider` requires
+ * of `gated_report`, so a refusal can only come from the wiring under test.
+ */
+async function validCarrier(): Promise {
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({
+ agent_commerce: {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: 'gated_report',
+ input_hash: await computeInputHash({}),
+ amount: '0.01',
+ currency: 'USDC',
+ payment_method: 'x402',
+ destination: '0xMERCHANT',
+ },
+ }),
+ );
+ return encodeCarrier(await mintMandate(parties.mandateSigner, jwt));
+}
+
+describe('AP2 wired into the gateway', () => {
+ it('advertises the mandate a gated resource needs alongside its 402 challenge', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const { statusCode, body } = await invoke(gw, 'gated_report');
+
+ expect(statusCode).toBe(402);
+ expect(body['authorization']).toEqual({
+ required: [{ method: 'ap2', version: '0.2.0', profile: 'agent-commerce/ap2/checkout/v1' }],
+ });
+ });
+
+ it('leaves the challenge for an ungated paid resource untouched', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const { statusCode, body } = await invoke(gw, 'paid_report');
+
+ expect(statusCode).toBe(402);
+ expect(body['authorization']).toBeUndefined();
+ });
+
+ it('serves a free resource with AP2 enabled exactly as before', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const { statusCode } = await invoke(gw, 'free_report');
+
+ expect(statusCode).toBe(200);
+ });
+
+ it('refuses a gated purchase whose proof is missing, with a payment proof present', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const { statusCode, body } = await invoke(gw, 'gated_report', {
+ 'payment-signature': 'x402-proof',
+ });
+
+ expect(statusCode).toBe(403);
+ expect(body['code']).toBe('AUTHORIZATION_REQUIRED');
+ });
+
+ it('delivers a gated purchase and records the mandate digest on the receipt', async () => {
+ const store = createFakeStore();
+ gateway = await createGateway({
+ config: config(),
+ store,
+ paymentProviders: [createFakePaymentProvider()],
+ authorizationProviders: [startAp2()],
+ protocolAdapters: [],
+ backend,
+ });
+
+ const { statusCode } = await invoke(gateway, 'gated_report', {
+ 'payment-signature': 'x402-proof',
+ [AUTHORIZATION_HEADER]: await validCarrier(),
+ });
+
+ expect(statusCode).toBe(200);
+ const receipt = store.receipts[0];
+ expect(receipt?.authorization?.method).toBe('ap2');
+ expect(receipt?.authorization?.reference).toMatch(/^sha256:[\w-]+$/);
+ // The proof itself never reaches the record
+ expect(JSON.stringify(receipt)).not.toContain('eyJ');
+ });
+
+ it('refuses a proof that is not a mandate as invalid, never as a payment failure', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const { statusCode, body } = await invoke(gw, 'gated_report', {
+ 'payment-signature': 'x402-proof',
+ [AUTHORIZATION_HEADER]: encodeCarrier('not-a-mandate'),
+ });
+
+ expect(statusCode).toBe(403);
+ expect(body['code']).toBe('AUTHORIZATION_INVALID');
+ });
+
+ describe('a verifier whose store is broken', () => {
+ const brokenStore = (): Ap2ReplayStore => {
+ const boom = (): never => {
+ throw new Error('sqlite: disk I/O error');
+ };
+ return {
+ reserve: boom,
+ consume: boom,
+ release: boom,
+ markUncertain: boom,
+ stateOf: boom,
+ close: () => {},
+ };
+ };
+
+ it('reports a good mandate it cannot record as unavailable, not as a bad mandate', async () => {
+ const gw = await startGateway([startAp2(brokenStore())]);
+
+ const { statusCode, body } = await invoke(gw, 'gated_report', {
+ 'payment-signature': 'x402-proof',
+ [AUTHORIZATION_HEADER]: await validCarrier(),
+ });
+
+ // 503 and retryable: the mandate verified, and our store is what failed
+ expect(statusCode).toBe(503);
+ expect(body['code']).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE');
+ expect(body['retryable']).toBe(true);
+ });
+
+ it('keeps serving every resource that does not require a mandate', async () => {
+ const gw = await startGateway([startAp2(brokenStore())]);
+
+ expect((await invoke(gw, 'free_report')).statusCode).toBe(200);
+ expect((await invoke(gw, 'paid_report')).statusCode).toBe(402);
+ });
+
+ it('blocks readiness, since a gated purchase cannot be honoured', async () => {
+ const gw = await startGateway([startAp2(brokenStore())]);
+
+ const res = await gw.server.inject({ method: 'GET', url: '/ready' });
+ const body = res.json<{
+ ready: boolean;
+ authorizationProviders: { name: string; status: string; detail?: string }[];
+ }>();
+
+ expect(res.statusCode).toBe(503);
+ expect(body.ready).toBe(false);
+ expect(body.authorizationProviders).toEqual([
+ { name: 'ap2', status: 'fail', detail: 'authorization-provider-unreachable' },
+ ]);
+ // A fixed vocabulary token: `/ready` is unauthenticated
+ expect(JSON.stringify(body)).not.toContain('disk I/O');
+ });
+ });
+
+ it('reports a healthy provider on /ready without naming a key', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const res = await gw.server.inject({ method: 'GET', url: '/ready' });
+ const body = res.json<{ ready: boolean; authorizationProviders: { status: string }[] }>();
+
+ expect(res.statusCode).toBe(200);
+ expect(body.ready).toBe(true);
+ expect(body.authorizationProviders).toEqual([{ name: 'ap2', status: 'pass' }]);
+ expect(JSON.stringify(body)).not.toContain(parties.mandateSigner.publicJwk['x']);
+ });
+
+ it('advertises itself in the well-known document, so the claim is checkable', async () => {
+ const gw = await startGateway([startAp2()]);
+
+ const res = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' });
+ const body = res.json<{
+ authorizationProviders: {
+ name: string;
+ kind: string;
+ status: string;
+ supportedSpec: string;
+ }[];
+ paymentProviders: { name: string }[];
+ }>();
+
+ const [ap2] = body.authorizationProviders;
+ expect(ap2?.name).toBe('ap2');
+ expect(ap2?.kind).toBe('authorization');
+ expect(ap2?.status).toBe('experimental');
+ expect(ap2?.supportedSpec).toContain('0.2.0');
+ // Listed apart from the rails: an authorization method is not a payment
+ // method and must never be selectable as one
+ expect(body.paymentProviders.map((p) => p.name)).not.toContain('ap2');
+ // Public keys are public, but the document still does not carry trust policy
+ expect(JSON.stringify(body)).not.toContain(parties.mandateSigner.publicJwk['x']);
+ });
+
+ it('reports an empty list when no authorization is configured', async () => {
+ const gw = await startGateway([]);
+
+ const res = await gw.server.inject({ method: 'GET', url: '/.well-known/agent-commerce' });
+ expect(res.json<{ authorizationProviders: unknown[] }>().authorizationProviders).toEqual([]);
+ });
+
+ it('runs with no authorization provider at all, which is the default', async () => {
+ const gw = await startGateway([]);
+
+ const res = await gw.server.inject({ method: 'GET', url: '/ready' });
+ const body = res.json<{ ready: boolean; authorizationProviders: unknown[] }>();
+
+ expect(body.ready).toBe(true);
+ expect(body.authorizationProviders).toEqual([]);
+ // The resource still declares `authorization.required`, and with no
+ // provider to check it the pipeline refuses rather than serving it
+ // A misconfigured gateway is our fault, not the caller's, so 500
+ const { statusCode, body: invoked } = await invoke(gw, 'gated_report');
+ expect(statusCode).toBe(500);
+ expect(invoked['code']).toBe('CONFIG_INVALID');
+ });
+});
diff --git a/tests/integration/ap2-x402-conformance.test.ts b/tests/integration/ap2-x402-conformance.test.ts
new file mode 100644
index 0000000..746207b
--- /dev/null
+++ b/tests/integration/ap2-x402-conformance.test.ts
@@ -0,0 +1,626 @@
+/**
+ * Every AP2 refusal the gateway has to make, driven end to end.
+ *
+ * Only the payment rail is doubled, and it counts calls: what every case has
+ * to prove is that settlement was never reached. The config, the provider and
+ * the signing are the shipped ones. Settlement behind a mandate is
+ * `tests/e2e/authorization`.
+ *
+ * FIXTURE PROVENANCE: mandates are minted to the AP2 v0.2.0 shape (tagged
+ * 2026-04-28, commit b4587ac), not upstream golden vectors. See fixtures.ts.
+ */
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { AP2_CHECKOUT_PROFILE } from '../../src/authorization/ap2/constants.js';
+import { createAp2AuthorizationProvider } from '../../src/authorization/ap2/index.js';
+import { computeInputHash } from '../../src/authorization/ap2/profile.js';
+import { type GatewayConfig, parseConfig } from '../../src/config/index.js';
+import type {
+ AdapterDescriptor,
+ AuthorizationProvider,
+ BackendExecutor,
+ PaymentProvider,
+ PaymentRequirement,
+ PaymentResult,
+ ReceiptStore,
+} from '../../src/core/index.js';
+import { AUTHORIZATION_HEADER, CommerceError, PAYMENT_HEADER } from '../../src/core/index.js';
+import { createGateway, type GatewayInstance } from '../../src/gateway/index.js';
+import { createSqliteReceiptStore } from '../../src/storage/receipts/index.js';
+import {
+ checkoutPayload,
+ createParties,
+ fixedClock,
+ type MandateOptions,
+ mintMandate,
+ NOW_SECONDS,
+ type Party,
+ sha256Base64url,
+ signCheckoutJwt,
+} from '../unit/authorization-ap2/fixtures.js';
+
+process.env['NODE_ENV'] = 'test';
+
+const RESOURCE_ID = 'market_report';
+const INPUT = { city: 'Berlin' };
+const MERCHANT = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
+const ASSET = '0x1111111111111111111111111111111111111111';
+const NETWORK = 'eip155:84532';
+const PROOF = 'x402-proof-1';
+
+let parties: Party;
+let config: GatewayConfig;
+let inputHash: string;
+
+let gateway: GatewayInstance | undefined;
+let store: ReceiptStore | undefined;
+let authorization: (AuthorizationProvider & { close(): void }) | undefined;
+
+interface Counters {
+ verify: number;
+ settle: number;
+ backend: number;
+}
+let counts: Counters;
+
+// --- the rail ---------------------------------------------------------------
+
+const descriptor: AdapterDescriptor = {
+ name: 'x402',
+ kind: 'payment',
+ implementationVersion: '0.0.0-test',
+ supportedSpec: 'x402/v2 scheme=exact family=eip155',
+ capabilities: [],
+ status: 'stable',
+};
+
+interface RailOptions {
+ readonly verify?: () => Promise;
+ readonly settle?: () => Promise;
+}
+
+// Counts what it was asked to do. Its requirement carries the chain
+// coordinates a real x402 challenge does, which the mandate must agree with
+function countingRail(options: RailOptions = {}): PaymentProvider {
+ const settled: PaymentResult = {
+ status: 'settled',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ payer: '0xBUYER',
+ payee: MERCHANT,
+ network: NETWORK,
+ externalReference: '0xtx',
+ };
+ return {
+ name: 'x402',
+ descriptor,
+ async createRequirement(ctx): Promise {
+ return {
+ id: 'requirement-1',
+ requestId: ctx.requestId,
+ resourceId: ctx.resource.id,
+ provider: 'x402',
+ amount: ctx.amount,
+ currency: ctx.currency,
+ destination: MERCHANT,
+ network: NETWORK,
+ asset: ASSET,
+ challenge: { provider: 'x402', version: '2', accepts: [{ scheme: 'exact' }] },
+ };
+ },
+ async verify() {
+ counts.verify += 1;
+ if (options.verify) return options.verify();
+ return {
+ status: 'verified',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ payer: '0xBUYER',
+ payee: MERCHANT,
+ replayKey: `replay-${counts.verify}`,
+ };
+ },
+ async settle() {
+ counts.settle += 1;
+ if (options.settle) return options.settle();
+ return settled;
+ },
+ async health() {
+ return { status: 'pass', checkedAt: '2026-01-01T00:00:00.000Z' };
+ },
+ };
+}
+
+const backend: BackendExecutor = {
+ async call() {
+ counts.backend += 1;
+ return { status: 200, body: { report: 'ok' }, headers: {}, durationMs: 1 };
+ },
+};
+
+// --- config -----------------------------------------------------------------
+
+function rawConfig(): Record {
+ const issuer = (entry: { issuer: string; audience: string; kid: string; jwk: unknown }) => ({
+ issuer: entry.issuer,
+ audience: entry.audience,
+ keys: [{ kid: entry.kid, jwk: entry.jwk }],
+ });
+ return {
+ version: 1,
+ merchant: { id: 'conformance', name: 'Conformance', publicBaseUrl: 'http://127.0.0.1:8080' },
+ server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] },
+ storage: { receipts: { driver: 'sqlite', path: ':memory:' } },
+ protocols: {
+ http: { enabled: true },
+ mcp: { enabled: false, mountPath: '/mcp' },
+ },
+ resources: {
+ [RESOURCE_ID]: {
+ name: 'Market report',
+ input: {
+ type: 'object',
+ properties: { city: { type: 'string' } },
+ required: ['city'],
+ // Closed: a reserved field that survived extraction would fail here
+ additionalProperties: false,
+ },
+ backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' },
+ pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' },
+ expose: ['http'],
+ payments: ['x402'],
+ authorization: { required: ['ap2'] },
+ },
+ },
+ payments: {
+ x402: {
+ enabled: true,
+ network: NETWORK,
+ rpcUrl: 'http://127.0.0.1:8545',
+ asset: ASSET,
+ assetName: 'MockUSDC',
+ assetVersion: '2',
+ assetDecimals: 6,
+ payTo: MERCHANT,
+ maxTimeoutSeconds: 120,
+ // Never used: the rail below is a counting double, and no chain is
+ // reached. It is here so the config is the one a real deployment writes
+ facilitator: { mode: 'local', signerPrivateKey: '0xKEY' },
+ },
+ },
+ authorization: {
+ ap2: {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: {
+ mandateIssuers: parties.mandateIssuers.map((entry) =>
+ issuer({
+ issuer: entry.issuer,
+ audience: entry.audience,
+ kid: entry.keys[0]?.kid ?? '',
+ jwk: entry.keys[0]?.jwk,
+ }),
+ ),
+ checkoutIssuers: parties.checkoutIssuers.map((entry) =>
+ issuer({
+ issuer: entry.issuer,
+ audience: entry.audience,
+ kid: entry.keys[0]?.kid ?? '',
+ jwk: entry.keys[0]?.jwk,
+ }),
+ ),
+ },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ },
+ },
+ };
+}
+
+// --- mandates ---------------------------------------------------------------
+
+// The profile a correctly minted mandate carries for this exact purchase
+function profile(overrides: Record = {}): Record {
+ return {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: RESOURCE_ID,
+ input_hash: inputHash,
+ amount: '0.01',
+ currency: 'USDC',
+ payment_method: 'x402',
+ destination: MERCHANT,
+ network: NETWORK,
+ asset: ASSET,
+ ...overrides,
+ };
+}
+
+interface MandateSpec {
+ readonly profile?: Record;
+ readonly checkout?: Record;
+ readonly mandate?: MandateOptions;
+ // Signs the mandate with a key nobody trusts
+ readonly stranger?: boolean;
+}
+
+async function mandate(spec: MandateSpec = {}): Promise {
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ agent_commerce: profile(spec.profile), ...spec.checkout }),
+ );
+ return mintMandate(
+ spec.stranger === true ? parties.stranger : parties.mandateSigner,
+ jwt,
+ spec.mandate ?? {},
+ );
+}
+
+function carrier(presentation: string): string {
+ return Buffer.from(JSON.stringify({ method: 'ap2', payload: presentation }), 'utf8').toString(
+ 'base64url',
+ );
+}
+
+// --- harness ----------------------------------------------------------------
+
+type Ap2Provider = AuthorizationProvider & { close(): void };
+
+async function startGateway(
+ rail: PaymentProvider = countingRail(),
+ // Wraps the real provider, for the cases that need one of its calls to fail
+ wrap: (real: Ap2Provider) => AuthorizationProvider = (real) => real,
+): Promise {
+ counts = { verify: 0, settle: 0, backend: 0 };
+ store = createSqliteReceiptStore({ path: ':memory:' });
+ await store.init();
+ const ap2Config = config.authorization?.ap2;
+ if (ap2Config === undefined || !ap2Config.enabled) {
+ throw new Error('the fixture config must enable AP2');
+ }
+ authorization = createAp2AuthorizationProvider({ config: ap2Config, clock: fixedClock() });
+ gateway = await createGateway({
+ config,
+ store,
+ paymentProviders: [rail],
+ authorizationProviders: [wrap(authorization)],
+ protocolAdapters: [],
+ backend,
+ });
+ return gateway;
+}
+
+interface Invocation {
+ readonly statusCode: number;
+ readonly body: Record;
+}
+
+async function invoke(
+ gw: GatewayInstance,
+ options: { proof?: string; presentation?: string; input?: Record } = {},
+): Promise {
+ const res = await gw.server.inject({
+ method: 'POST',
+ url: `/api/resources/${RESOURCE_ID}/invoke`,
+ headers: {
+ 'content-type': 'application/json',
+ ...(options.proof !== undefined ? { [PAYMENT_HEADER]: options.proof } : {}),
+ ...(options.presentation !== undefined
+ ? { [AUTHORIZATION_HEADER]: carrier(options.presentation) }
+ : {}),
+ },
+ payload: options.input ?? INPUT,
+ });
+ return { statusCode: res.statusCode, body: res.json>() };
+}
+
+// A purchase with a real payment proof and whatever mandate the case supplies
+async function purchase(presentation: string, gw = gateway): Promise {
+ return invoke(gw as GatewayInstance, { proof: PROOF, presentation });
+}
+
+beforeAll(async () => {
+ parties = await createParties();
+ config = parseConfig(rawConfig(), process.env);
+ inputHash = await computeInputHash(INPUT);
+});
+
+afterEach(async () => {
+ await gateway?.close().catch(() => {});
+ authorization?.close();
+ await store?.close().catch(() => {});
+ gateway = undefined;
+ authorization = undefined;
+ store = undefined;
+});
+
+describe('AP2 over x402: the purchase that works', () => {
+ it('challenges, verifies, settles once, consumes, delivers once', async () => {
+ const gw = await startGateway();
+
+ const challenge = await invoke(gw);
+ expect(challenge.statusCode).toBe(402);
+ expect(challenge.body['authorization']).toEqual({
+ required: [{ method: 'ap2', version: '0.2.0', profile: AP2_CHECKOUT_PROFILE }],
+ });
+ expect(counts.settle).toBe(0);
+
+ const delivered = await purchase(await mandate());
+
+ expect(delivered.statusCode).toBe(200);
+ expect(counts).toEqual({ verify: 1, settle: 1, backend: 1 });
+ });
+
+ it('records the mandate as a digest and stores no part of the presentation', async () => {
+ await startGateway();
+ const presentation = await mandate();
+
+ await purchase(presentation);
+
+ const receipts = await (store as ReceiptStore).listReceipts({ limit: 10 });
+ const receipt = receipts[0];
+ expect(receipt?.authorization?.method).toBe('ap2');
+ expect(receipt?.authorization?.reference).toMatch(/^sha256:[\w-]+$/);
+
+ // Every segment of the presentation, not just the whole string: a stored
+ // disclosure alone would still leak the buyer's purchase
+ const persisted = JSON.stringify(receipts);
+ for (const segment of presentation.split('~').filter((part) => part.length > 0)) {
+ expect(persisted).not.toContain(segment);
+ }
+ });
+
+ it('emits the authorization in the audit trail alongside the payment', async () => {
+ await startGateway();
+
+ await purchase(await mandate());
+
+ const events = await (store as ReceiptStore).listEvents({ limit: 20 });
+ const types = events.map((event) => event.type);
+ expect(types).toContain('authorization.verified');
+ expect(types).toContain('payment.settled');
+ expect(types).toContain('resource.delivered');
+ });
+});
+
+describe('AP2 over x402: mandates that must not settle', () => {
+ // Every case here asserts the same thing: no money moved, nothing delivered
+ async function refuse(
+ presentation: string,
+ expected: { status: number; code: string },
+ ): Promise {
+ const gw = await startGateway();
+
+ const result = await purchase(presentation, gw);
+
+ expect(result.statusCode).toBe(expected.status);
+ expect(result.body['code']).toBe(expected.code);
+ expect(counts.settle).toBe(0);
+ expect(counts.backend).toBe(0);
+ }
+
+ const invalid = { status: 403, code: 'AUTHORIZATION_INVALID' } as const;
+
+ it('refuses an altered mandate', async () => {
+ const original = await mandate();
+ const [token, ...rest] = original.split('~');
+ const [header, payload, signature] = (token as string).split('.');
+ // A flipped bit in the signature's first byte, not the last base64url
+ // character: that one has four meaningful bits in an 86-character ES256
+ // signature, so A/B/C/D all decode alike and nothing would change.
+ const bytes = Buffer.from(signature as string, 'base64url');
+ bytes[0] = (bytes[0] as number) ^ 0x01;
+ const forged = `${header}.${payload}.${bytes.toString('base64url')}`;
+ await refuse([forged, ...rest].join('~'), invalid);
+ });
+
+ it('refuses an expired mandate', async () => {
+ await refuse(
+ await mandate({
+ mandate: { payloadOverrides: { iat: NOW_SECONDS - 7200, exp: NOW_SECONDS - 3600 } },
+ }),
+ invalid,
+ );
+ });
+
+ it('refuses a mandate from an untrusted issuer', async () => {
+ await refuse(
+ await mandate({ mandate: { payloadOverrides: { iss: 'https://evil.example' } } }),
+ invalid,
+ );
+ });
+
+ it('refuses a mandate that claims a trusted kid but was signed with another key', async () => {
+ // The attack `kid` exists to stop: a trusted issuer, a trusted key id, and
+ // a real signature from a key nobody trusts. Refused at the signature, so
+ // `kid` selects the verifying key rather than labelling it.
+ await refuse(
+ await mandate({ stranger: true, mandate: { header: { kid: parties.mandateSigner.kid } } }),
+ invalid,
+ );
+ });
+
+ it('refuses a mandate naming a kid the issuer does not have', async () => {
+ await refuse(await mandate({ mandate: { header: { kid: 'rotated-out-2025' } } }), invalid);
+ });
+
+ it('refuses a mandate whose checkout_hash does not match the disclosed checkout', async () => {
+ await refuse(
+ await mandate({
+ mandate: { payloadOverrides: { checkout_hash: await sha256Base64url('another-document') } },
+ }),
+ invalid,
+ );
+ });
+
+ it('refuses a mandate approved for a different resource', async () => {
+ await refuse(await mandate({ profile: { resource_id: 'other_report' } }), invalid);
+ });
+
+ it('refuses a mandate approved for different input', async () => {
+ await refuse(
+ await mandate({ profile: { input_hash: await computeInputHash({ city: 'Paris' }) } }),
+ invalid,
+ );
+ });
+
+ it('refuses a mandate approved for a different amount', async () => {
+ await refuse(await mandate({ profile: { amount: '500.00' } }), invalid);
+ });
+
+ it('refuses a mandate approved in a different currency', async () => {
+ await refuse(await mandate({ profile: { currency: 'EURC' } }), invalid);
+ });
+
+ it('refuses a mandate approved for a different payment method', async () => {
+ await refuse(await mandate({ profile: { payment_method: 'acp' } }), invalid);
+ });
+
+ it('refuses a mandate approved for a different network', async () => {
+ await refuse(await mandate({ profile: { network: 'eip155:8453' } }), invalid);
+ });
+
+ it('refuses a mandate approved for a different asset', async () => {
+ await refuse(
+ await mandate({ profile: { asset: '0x2222222222222222222222222222222222222222' } }),
+ invalid,
+ );
+ });
+
+ it('refuses a mandate silent about the chain the requirement names', async () => {
+ // Fail closed both ways: a mandate that never mentioned a chain must not
+ // unlock a settlement on one. `undefined` is dropped when the JWT is
+ // serialised, so these two claims are genuinely absent.
+ await refuse(await mandate({ profile: { network: undefined, asset: undefined } }), invalid);
+ });
+
+ it('refuses a replayed mandate as replayed, not as invalid', async () => {
+ const gw = await startGateway();
+ const presentation = await mandate();
+
+ const first = await purchase(presentation, gw);
+ const second = await purchase(presentation, gw);
+
+ expect(first.statusCode).toBe(200);
+ expect(second.statusCode).toBe(409);
+ expect(second.body['code']).toBe('AUTHORIZATION_REPLAYED');
+ // The first purchase settled; the replay did not
+ expect(counts.settle).toBe(1);
+ expect(counts.backend).toBe(1);
+ });
+});
+
+describe('AP2 over x402: when settlement goes wrong', () => {
+ it('reports a verifier outage as unavailable and retryable, never as a payment failure', async () => {
+ const gw = await startGateway(countingRail(), (real) => ({
+ ...real,
+ // Our store is what broke, not the buyer's mandate
+ async verifyAndReserve() {
+ throw new CommerceError('AUTHORIZATION_PROVIDER_UNAVAILABLE', 'store down');
+ },
+ }));
+
+ const result = await purchase(await mandate(), gw);
+
+ expect(result.statusCode).toBe(503);
+ expect(result.body['code']).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE');
+ expect(result.body['retryable']).toBe(true);
+ expect(counts.settle).toBe(0);
+ });
+
+ it('lets a corrected payment proof retry with the same still-valid mandate', async () => {
+ let attempt = 0;
+ const gw = await startGateway(
+ countingRail({
+ // First proof is rejected, the second verifies
+ verify: async () => {
+ attempt += 1;
+ return attempt === 1
+ ? {
+ status: 'rejected',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ rejectionReason: 'signature does not match payer',
+ }
+ : {
+ status: 'verified',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ replayKey: 'replay-2',
+ };
+ },
+ }),
+ );
+ const presentation = await mandate();
+
+ const rejected = await purchase(presentation, gw);
+ expect(rejected.statusCode).toBe(402);
+ expect(rejected.body['code']).toBe('PAYMENT_INVALID');
+
+ const delivered = await purchase(presentation, gw);
+ expect(delivered.statusCode).toBe(200);
+ expect(counts.settle).toBe(1);
+ });
+
+ it('hands the mandate back when settlement is definitively refused', async () => {
+ let attempt = 0;
+ const gw = await startGateway(
+ countingRail({
+ settle: async () => {
+ attempt += 1;
+ return attempt === 1
+ ? {
+ status: 'rejected',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ rejectionReason: 'insufficient balance',
+ }
+ : {
+ status: 'settled',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ externalReference: '0xtx',
+ };
+ },
+ }),
+ );
+ const presentation = await mandate();
+
+ const failed = await purchase(presentation, gw);
+ expect(failed.statusCode).toBe(502);
+ expect(failed.body['code']).toBe('PAYMENT_SETTLEMENT_FAILED');
+
+ // The reservation was released, so the buyer's own mandate is still theirs
+ const delivered = await purchase(presentation, gw);
+ expect(delivered.statusCode).toBe(200);
+ expect(counts.backend).toBe(1);
+ });
+
+ it('does not hand the mandate back when a broadcast settlement was never confirmed', async () => {
+ const gw = await startGateway(
+ countingRail({
+ settle: async () => {
+ throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'confirmation timed out', {
+ details: { transactionHash: '0xabc' },
+ });
+ },
+ }),
+ );
+ const presentation = await mandate();
+
+ const uncertain = await purchase(presentation, gw);
+ expect(uncertain.statusCode).toBe(502);
+ expect(uncertain.body['code']).toBe('PAYMENT_SETTLEMENT_FAILED');
+
+ // The buyer's funds may already have moved, so the mandate is not reusable
+ const retry = await purchase(presentation, gw);
+ expect(retry.statusCode).toBe(409);
+ expect(retry.body['code']).toBe('AUTHORIZATION_REPLAYED');
+ expect(counts.backend).toBe(0);
+ });
+});
diff --git a/tests/integration/authorization-carrier.test.ts b/tests/integration/authorization-carrier.test.ts
new file mode 100644
index 0000000..2a2ee08
--- /dev/null
+++ b/tests/integration/authorization-carrier.test.ts
@@ -0,0 +1,287 @@
+/**
+ * The same authorization proof over all three transports.
+ *
+ * The carrier differs per surface: a header over HTTP, a reserved input field
+ * over MCP and A2A. All three have to reach the pipeline as the same
+ * `AuthorizationSubmission` with the proof untouched, and a per-adapter copy
+ * of the extraction is the drift this catches. So every assertion runs
+ * against the real gateway with the real adapters mounted, not against the
+ * extraction helper on its own.
+ *
+ * Nothing verifies the proof yet. What is asserted here is transport
+ * behaviour: it reaches the pipeline intact, it never reaches the resource
+ * input, and a malformed envelope is refused before the pipeline runs.
+ */
+import { afterEach, describe, expect, it } from 'vitest';
+import type { GatewayConfig } from '../../src/config/index.js';
+import type { BackendExecutor, CanonicalRequest, ExecutionPipeline } from '../../src/core/index.js';
+import {
+ AUTHORIZATION_HEADER,
+ AUTHORIZATION_INPUT_FIELD,
+ MAX_AUTHORIZATION_HEADER_BYTES,
+} from '../../src/core/index.js';
+import { createGateway, type GatewayInstance } from '../../src/gateway/index.js';
+import { createA2aAdapter } from '../../src/protocols/a2a/index.js';
+import { createMcpAdapter } from '../../src/protocols/mcp/index.js';
+import { createFakeStore } from '../unit/gateway/helpers.js';
+
+process.env['NODE_ENV'] = 'test';
+
+const PROOF = 'eyJhbGciOiJFUzI1NiJ9.checkout-mandate~disclosure-0~';
+const ENVELOPE = { method: 'ap2', payload: PROOF };
+
+let gateway: GatewayInstance | undefined;
+
+afterEach(async () => {
+ await gateway?.close().catch(() => {});
+ gateway = undefined;
+});
+
+function config(): GatewayConfig {
+ return {
+ version: 1,
+ merchant: { id: 'demo-store', name: 'Demo Store', publicBaseUrl: 'http://localhost:8080' },
+ server: { port: 0, host: '127.0.0.1', allowedOrigins: [] },
+ storage: { receipts: { driver: 'sqlite', path: ':memory:' } },
+ protocols: {
+ http: { enabled: true },
+ mcp: { enabled: true, mountPath: '/mcp' },
+ a2a: { enabled: true, mountPath: '/a2a' },
+ acp: { enabled: false, mountPath: '/acp' },
+ },
+ resources: [
+ {
+ id: 'weather_basic',
+ name: 'Basic Weather',
+ description: 'Current weather for a city.',
+ inputSchema: {
+ type: 'object',
+ properties: { city: { type: 'string' } },
+ required: ['city'],
+ // Closed schema: if a reserved field survived extraction it would
+ // fail here as INPUT_INVALID rather than reaching the backend.
+ additionalProperties: false,
+ },
+ handler: { type: 'http', method: 'GET', url: 'http://backend.local/weather/{city}' },
+ pricing: { type: 'free' },
+ exposedVia: ['http', 'mcp', 'a2a'],
+ paymentMethods: [],
+ },
+ ],
+ payments: {},
+ };
+}
+
+const backendInputs: unknown[] = [];
+const backend: BackendExecutor = {
+ async call(_handler, request) {
+ backendInputs.push(request.input);
+ return { status: 200, body: { forecast: 'sunny' }, headers: {}, durationMs: 1 };
+ },
+};
+
+/** Captures the exact CanonicalRequest each surface built, without re-routing. */
+function spyOnPipeline(gw: GatewayInstance): CanonicalRequest[] {
+ const captured: CanonicalRequest[] = [];
+ const original = gw.pipeline.execute.bind(gw.pipeline);
+ (gw.pipeline as { execute: ExecutionPipeline['execute'] }).execute = async (request) => {
+ captured.push(request);
+ return original(request);
+ };
+ return captured;
+}
+
+async function startGateway(): Promise {
+ backendInputs.length = 0;
+ gateway = await createGateway({
+ config: config(),
+ store: createFakeStore(),
+ paymentProviders: [],
+ protocolAdapters: [createMcpAdapter(), createA2aAdapter()],
+ backend,
+ });
+ return gateway;
+}
+
+function encodeHeader(value: unknown): string {
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
+}
+
+// --- one call per surface --------------------------------------------------
+
+async function callHttp(
+ gw: GatewayInstance,
+ authorization?: string,
+): Promise<{ statusCode: number; body: unknown }> {
+ const res = await gw.server.inject({
+ method: 'POST',
+ url: '/api/resources/weather_basic/invoke',
+ headers: {
+ 'content-type': 'application/json',
+ ...(authorization !== undefined ? { [AUTHORIZATION_HEADER]: authorization } : {}),
+ },
+ payload: { city: 'Berlin' },
+ });
+ return { statusCode: res.statusCode, body: res.json() };
+}
+
+interface McpToolResult {
+ isError?: boolean;
+ content: { type: string; text?: string }[];
+ structuredContent?: { code?: string };
+}
+
+async function callMcp(gw: GatewayInstance, args: Record): Promise {
+ const res = await gw.server.inject({
+ method: 'POST',
+ url: '/mcp',
+ headers: {
+ 'content-type': 'application/json',
+ accept: 'application/json, text/event-stream',
+ },
+ payload: {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/call',
+ params: { name: 'weather_basic', arguments: { city: 'Berlin', ...args } },
+ },
+ });
+ // The adapter answers over Streamable HTTP, which can frame the reply as a
+ // single SSE event rather than a bare JSON body.
+ const raw = res.body.startsWith('event:')
+ ? (res.body.split('\n').find((line) => line.startsWith('data:')) ?? '').slice(5)
+ : res.body;
+ return (JSON.parse(raw) as { result: McpToolResult }).result;
+}
+
+interface A2aTaskResult {
+ result?: { task?: { artifacts: { parts: { data: Record }[] }[] } };
+}
+
+async function callA2a(
+ gw: GatewayInstance,
+ input: Record,
+): Promise | undefined> {
+ const res = await gw.server.inject({
+ method: 'POST',
+ url: '/a2a',
+ headers: { 'content-type': 'application/json', 'a2a-version': '1.0' },
+ payload: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 'req-1',
+ method: 'SendMessage',
+ params: {
+ message: {
+ role: 'ROLE_USER',
+ messageId: 'msg-1',
+ parts: [
+ {
+ data: { resource: 'weather_basic', input: { city: 'Berlin', ...input } },
+ mediaType: 'application/json',
+ },
+ ],
+ },
+ },
+ }),
+ });
+ const body = res.json();
+ return body.result?.task?.artifacts[0]?.parts[0]?.data;
+}
+
+// --- tests -----------------------------------------------------------------
+
+describe('generic authorization carrier across every surface', () => {
+ it('normalises an HTTP header, an MCP argument and an A2A input field to the same submission', async () => {
+ const gw = await startGateway();
+ const captured = spyOnPipeline(gw);
+
+ await callHttp(gw, encodeHeader(ENVELOPE));
+ await callMcp(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE });
+ await callA2a(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE });
+
+ expect(captured.map((r) => r.protocol)).toEqual(['http', 'mcp', 'a2a']);
+ for (const request of captured) {
+ expect(request.authorization).toEqual({ method: 'ap2', payload: PROOF });
+ }
+ });
+
+ it('keeps the reserved field out of the resource input on every surface', async () => {
+ const gw = await startGateway();
+ const captured = spyOnPipeline(gw);
+
+ const http = await callHttp(gw, encodeHeader(ENVELOPE));
+ const mcp = await callMcp(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE });
+ const a2a = await callA2a(gw, { [AUTHORIZATION_INPUT_FIELD]: ENVELOPE });
+
+ // The resource schema is closed, so a leaked field would surface as
+ // INPUT_INVALID. All three deliver instead.
+ expect(http.statusCode).toBe(200);
+ expect(mcp.isError).not.toBe(true);
+ expect(a2a).toEqual({ forecast: 'sunny' });
+
+ for (const request of captured) {
+ expect(request.input).toEqual({ city: 'Berlin' });
+ }
+ // And the merchant backend never sees a gateway-reserved field.
+ expect(backendInputs).toEqual([{ city: 'Berlin' }, { city: 'Berlin' }, { city: 'Berlin' }]);
+ });
+
+ it('leaves a request carrying no authorization exactly as it was', async () => {
+ const gw = await startGateway();
+ const captured = spyOnPipeline(gw);
+
+ const http = await callHttp(gw);
+ const mcp = await callMcp(gw, {});
+ const a2a = await callA2a(gw, {});
+
+ expect(http.statusCode).toBe(200);
+ expect(mcp.isError).not.toBe(true);
+ expect(a2a).toEqual({ forecast: 'sunny' });
+ for (const request of captured) {
+ expect(request.authorization).toBeUndefined();
+ }
+ });
+
+ it('refuses a malformed envelope before the pipeline runs, on every surface', async () => {
+ const gw = await startGateway();
+ const captured = spyOnPipeline(gw);
+
+ const http = await callHttp(gw, encodeHeader({ method: 'ap2' }));
+ expect(http.statusCode).toBe(403);
+ expect(http.body).toMatchObject({ code: 'AUTHORIZATION_INVALID', retryable: false });
+
+ const mcp = await callMcp(gw, { [AUTHORIZATION_INPUT_FIELD]: 'a-bare-string' });
+ expect(mcp.isError).toBe(true);
+ expect(mcp.structuredContent?.code).toBe('AUTHORIZATION_INVALID');
+
+ const a2a = await callA2a(gw, {
+ [AUTHORIZATION_INPUT_FIELD]: { method: 'ap3', payload: PROOF },
+ });
+ expect(a2a?.['code']).toBe('AUTHORIZATION_INVALID');
+
+ expect(captured).toHaveLength(0);
+ expect(backendInputs).toEqual([]);
+ });
+
+ it('refuses an oversized HTTP carrier deterministically', async () => {
+ const gw = await startGateway();
+ const captured = spyOnPipeline(gw);
+
+ const { statusCode, body } = await callHttp(gw, 'a'.repeat(MAX_AUTHORIZATION_HEADER_BYTES + 1));
+
+ expect(statusCode).toBe(403);
+ expect(body).toMatchObject({ code: 'AUTHORIZATION_INVALID' });
+ expect(captured).toHaveLength(0);
+ });
+
+ it('never reports an authorization failure as a payment failure', async () => {
+ // A 402 would tell an auto-paying client to spend money on a request that
+ // was never going to be delivered.
+ const gw = await startGateway();
+
+ const { statusCode, body } = await callHttp(gw, encodeHeader({ method: 'ap2', payload: 42 }));
+
+ expect(statusCode).not.toBe(402);
+ expect((body as { code: string }).code).not.toMatch(/^PAYMENT_/);
+ });
+});
diff --git a/tests/unit/authorization-ap2/checkout-signer.test.ts b/tests/unit/authorization-ap2/checkout-signer.test.ts
new file mode 100644
index 0000000..f40ea53
--- /dev/null
+++ b/tests/unit/authorization-ap2/checkout-signer.test.ts
@@ -0,0 +1,232 @@
+/**
+ * The merchant-side signer, checked against the verifier that will judge it.
+ *
+ * The round trip is the test that matters: a JWT this helper produced has to
+ * pass `verifyCheckoutJwt` and then bind to the purchase. Asserting the claim
+ * names on their own would pass while the digest silently disagreed, which is
+ * the failure the helper exists to prevent.
+ */
+import { exportJWK, exportPKCS8, generateKeyPair } from 'jose';
+import { beforeAll, describe, expect, it } from 'vitest';
+import { AP2_CHECKOUT_PROFILE } from '../../../src/authorization/ap2/constants.js';
+import { createCheckoutJwt } from '../../../src/authorization/ap2/index.js';
+import { bindMandateToPurchase, computeInputHash } from '../../../src/authorization/ap2/profile.js';
+import {
+ type Ap2MandateVerifier,
+ createAp2MandateVerifier,
+} from '../../../src/authorization/ap2/verifier.js';
+import type {
+ AuthorizationVerificationContext,
+ PaymentRequirement,
+} from '../../../src/core/index.js';
+import { isCommerceError } from '../../../src/core/index.js';
+import { createParties, fixedClock, mintMandate, NOW, type Party } from './fixtures.js';
+
+const RESOURCE_ID = 'market_report';
+// Floats and a non-ASCII key: the inputs where a sorted-key JSON.stringify and
+// RFC 8785 part company, and a hand-rolled signer starts producing mandates
+// this gateway refuses.
+const INPUT = { city: 'Zürich', precision: 1.5e30, tags: ['b', 'a'] };
+
+let parties: Party;
+let verifier: Ap2MandateVerifier;
+let privateJwk: Record;
+
+beforeAll(async () => {
+ parties = await createParties();
+ verifier = createAp2MandateVerifier({
+ config: {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ },
+ clock: fixedClock(),
+ });
+ privateJwk = (await exportJWK(parties.checkoutSigner.privateKey)) as Record;
+});
+
+function signOptions(overrides: Record = {}) {
+ return {
+ privateKey: privateJwk,
+ kid: parties.checkoutSigner.kid,
+ issuer: 'https://merchant.example',
+ audience: 'agent-commerce',
+ resourceId: RESOURCE_ID,
+ input: INPUT,
+ amount: '0.01',
+ currency: 'USDC',
+ paymentMethod: 'x402',
+ destination: '0xMERCHANT',
+ network: 'eip155:84532',
+ asset: '0xASSET',
+ now: NOW,
+ ...overrides,
+ } as Parameters[0];
+}
+
+function requirement(): PaymentRequirement {
+ return {
+ id: 'pr-1',
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ destination: '0xMERCHANT',
+ network: 'eip155:84532',
+ asset: '0xASSET',
+ challenge: { provider: 'x402', version: '2', accepts: [] },
+ };
+}
+
+function context(): AuthorizationVerificationContext {
+ return {
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ input: INPUT,
+ submission: { method: 'ap2', payload: 'unused-here' },
+ requirement: requirement(),
+ };
+}
+
+async function failureOf(run: () => Promise): Promise {
+ try {
+ await run();
+ } catch (error) {
+ return error instanceof Error ? error.message : String(error);
+ }
+ return 'no-error';
+}
+
+describe('createCheckoutJwt', () => {
+ it('produces a checkout JWT that verifies and authorises the purchase', async () => {
+ const jwt = await createCheckoutJwt(signOptions());
+ const presentation = await mintMandate(parties.mandateSigner, jwt);
+
+ const mandate = await verifier.verify(presentation);
+ const bound = await bindMandateToPurchase(mandate, context(), {});
+
+ expect(bound).toEqual({
+ resourceId: RESOURCE_ID,
+ amount: '0.01',
+ currency: 'USDC',
+ paymentMethod: 'x402',
+ });
+ });
+
+ it('computes the same input hash the gateway computes', async () => {
+ const jwt = await createCheckoutJwt(signOptions());
+ const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt));
+
+ const profile = mandate.checkoutClaims['agent_commerce'] as Record;
+ expect(profile['input_hash']).toBe(await computeInputHash(INPUT));
+ expect(profile['profile']).toBe(AP2_CHECKOUT_PROFILE);
+ });
+
+ it('accepts a PKCS#8 PEM as well as a private JWK', async () => {
+ const pem = await exportPKCS8(parties.checkoutSigner.privateKey);
+ const jwt = await createCheckoutJwt(signOptions({ privateKey: pem }));
+
+ const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt));
+ expect(mandate.checkoutIssuer).toBe('https://merchant.example');
+ });
+
+ it('omits the chain claims when the purchase has no chain coordinates', async () => {
+ const jwt = await createCheckoutJwt(
+ signOptions({ destination: undefined, network: undefined, asset: undefined }),
+ );
+ const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt));
+
+ const profile = mandate.checkoutClaims['agent_commerce'] as Record;
+ // Absent, not present-and-undefined: the gateway checks a claim whenever
+ // either side names one
+ expect('destination' in profile).toBe(false);
+ expect('network' in profile).toBe(false);
+ });
+
+ it('mints a jti when none is supplied, and honours one that is', async () => {
+ const generated = await createCheckoutJwt(signOptions());
+ const supplied = await createCheckoutJwt(signOptions({ jwtId: 'checkout_01KNOWN' }));
+
+ const first = await verifier.verify(await mintMandate(parties.mandateSigner, generated));
+ const second = await verifier.verify(await mintMandate(parties.mandateSigner, supplied));
+
+ expect(first.checkoutJwtId).toMatch(/^[0-9a-f-]{36}$/);
+ expect(second.checkoutJwtId).toBe('checkout_01KNOWN');
+ });
+
+ it('expires 15 minutes out by default, and honours an explicit window', async () => {
+ const now = Math.floor(NOW.getTime() / 1000);
+ const claimsOf = async (jwt: string): Promise> =>
+ JSON.parse(Buffer.from(jwt.split('.')[1] as string, 'base64url').toString('utf8')) as Record<
+ string,
+ number
+ >;
+
+ expect((await claimsOf(await createCheckoutJwt(signOptions())))['exp']).toBe(now + 900);
+ expect(
+ (await claimsOf(await createCheckoutJwt(signOptions({ expiresInSeconds: 60 }))))['exp'],
+ ).toBe(now + 60);
+ });
+
+ describe('refuses what would fail verification with no useful reason', () => {
+ it('refuses a numeric amount', async () => {
+ const message = await failureOf(() => createCheckoutJwt(signOptions({ amount: 0.01 })));
+ expect(message).toContain('decimal string');
+ });
+
+ it('refuses the public half of the key pair', async () => {
+ const { d: _d, ...publicHalf } = privateJwk;
+ const message = await failureOf(() =>
+ createCheckoutJwt(signOptions({ privateKey: publicHalf })),
+ );
+ expect(message).toContain('public JWK');
+ });
+
+ it('refuses a key that is not P-256', async () => {
+ const { privateKey } = await generateKeyPair('RS256', { extractable: true });
+ const rsa = (await exportJWK(privateKey)) as Record;
+ const message = await failureOf(() => createCheckoutJwt(signOptions({ privateKey: rsa })));
+ expect(message).toContain('EC/P-256');
+ });
+
+ it('refuses a string key that is not a PKCS#8 PEM', async () => {
+ const message = await failureOf(() =>
+ createCheckoutJwt(signOptions({ privateKey: '-----BEGIN EC PRIVATE KEY-----' })),
+ );
+ expect(message).toContain('PKCS#8');
+ });
+
+ it('names the missing field rather than signing an unverifiable JWT', async () => {
+ expect(await failureOf(() => createCheckoutJwt(signOptions({ kid: '' })))).toContain('kid');
+ expect(
+ await failureOf(() => createCheckoutJwt(signOptions({ resourceId: undefined }))),
+ ).toContain('resourceId');
+ });
+
+ it('refuses a non-canonicalizable input rather than hashing something else', async () => {
+ // A BigInt throws inside canonicalize; a function serialises to nothing
+ // and is caught by computeInputHash. Both must fail, neither may sign.
+ expect(await failureOf(() => createCheckoutJwt(signOptions({ input: { n: 1n } })))).toContain(
+ 'BigInt',
+ );
+ expect(await failureOf(() => createCheckoutJwt(signOptions({ input: () => 1 })))).toContain(
+ 'canonicalizable',
+ );
+ });
+ });
+
+ it('produces a mandate the gateway refuses when the price disagrees', async () => {
+ // The helper cannot know the gateway's requirement, so a wrong price is
+ // still caught at verification - fail closed, just without a useful reason
+ const jwt = await createCheckoutJwt(signOptions({ amount: '500.00' }));
+ const mandate = await verifier.verify(await mintMandate(parties.mandateSigner, jwt));
+
+ await expect(bindMandateToPurchase(mandate, context(), {})).rejects.toSatisfy(
+ (error: unknown) => isCommerceError(error) && error.code === 'AUTHORIZATION_INVALID',
+ );
+ });
+});
diff --git a/tests/unit/authorization-ap2/fixtures.ts b/tests/unit/authorization-ap2/fixtures.ts
new file mode 100644
index 0000000..c5bf966
--- /dev/null
+++ b/tests/unit/authorization-ap2/fixtures.ts
@@ -0,0 +1,234 @@
+/**
+ * Builds AP2 Direct Checkout Mandate presentations for the verifier tests.
+ *
+ * PROVENANCE: these are NOT golden vectors from the AP2 repository. They are
+ * built here to the closed Checkout Mandate shape of AP2 v0.2.0, the release
+ * tagged 2026-04-28 at commit b4587ac, with real ES256 keys and real
+ * signatures from `jose`. So they show the verifier enforces the
+ * rules as this repository reads them; they do not show interoperability with
+ * a mandate the reference implementation minted. Upstream vectors, with the
+ * commit recorded, belong here before anyone calls this stable.
+ *
+ * Keys are generated per run and never written down.
+ */
+import { exportJWK, generateKeyPair, SignJWT } from 'jose';
+import {
+ AP2_CHECKOUT_MANDATE_VCT,
+ AP2_CHECKOUT_PROFILE,
+} from '../../../src/authorization/ap2/constants.js';
+import type { Ap2TrustedIssuer } from '../../../src/authorization/ap2/types.js';
+
+export const MANDATE_ISSUER = 'https://trusted-surface.example';
+export const MANDATE_AUDIENCE = 'merchant.example';
+export const CHECKOUT_ISSUER = 'https://merchant.example';
+export const CHECKOUT_AUDIENCE = 'agent-commerce';
+
+/** Fixed instant every fixture is minted against, so nothing races a real clock */
+export const NOW = new Date('2026-09-14T12:00:00.000Z');
+/** {@link NOW} as the epoch seconds every `iat`/`exp` here is built from */
+export const NOW_SECONDS = Math.floor(NOW.getTime() / 1000);
+
+// `CryptoKey` is a DOM type and server code here does not load the DOM lib
+type PrivateKey = Awaited>['privateKey'];
+
+export interface SigningIdentity {
+ readonly kid: string;
+ readonly privateKey: PrivateKey;
+ readonly publicJwk: Readonly>;
+}
+
+async function identity(kid: string): Promise {
+ const { privateKey, publicKey } = await generateKeyPair('ES256', { extractable: true });
+ const jwk = (await exportJWK(publicKey)) as Record;
+ return {
+ kid,
+ privateKey,
+ // Only the four members the config loader accepts. `exportJWK` also emits
+ // `key_ops`/`ext` on some runtimes, which config rightly refuses.
+ publicJwk: {
+ kty: jwk['kty'] as string,
+ crv: jwk['crv'] as string,
+ x: jwk['x'] as string,
+ y: jwk['y'] as string,
+ },
+ };
+}
+
+export function trustedIssuer(
+ issuer: string,
+ audience: string,
+ signer: SigningIdentity,
+): Ap2TrustedIssuer {
+ return { issuer, audience, keys: [{ kid: signer.kid, jwk: signer.publicJwk }] };
+}
+
+function base64url(bytes: ArrayBuffer | Uint8Array): string {
+ return Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)).toString(
+ 'base64url',
+ );
+}
+
+async function sha256(input: string): Promise {
+ return base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)));
+}
+
+/** base64url(SHA-256(utf8)), the digest AP2 uses everywhere */
+export const sha256Base64url = sha256;
+
+/** One SD-JWT disclosure for an object property: `[salt, name, value]` */
+export function disclosure(salt: string, name: string, value: unknown): string {
+ return Buffer.from(JSON.stringify([salt, name, value]), 'utf8').toString('base64url');
+}
+
+/** The Agent Commerce checkout profile payload, as the plan specifies it */
+export function checkoutPayload(overrides: Record = {}): Record {
+ return {
+ iss: CHECKOUT_ISSUER,
+ aud: CHECKOUT_AUDIENCE,
+ iat: NOW_SECONDS - 30,
+ exp: NOW_SECONDS + 300,
+ jti: 'checkout_01KTEST',
+ agent_commerce: {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: 'market_report',
+ input_hash: 'PLACEHOLDER_UNTIL_PURCHASE_BINDING',
+ amount: '0.01',
+ currency: 'USDC',
+ payment_method: 'x402',
+ },
+ ...overrides,
+ };
+}
+
+export async function signCheckoutJwt(
+ signer: SigningIdentity,
+ payload: Record = checkoutPayload(),
+ header: Record = {},
+): Promise {
+ return new SignJWT(payload)
+ .setProtectedHeader({ alg: 'ES256', kid: signer.kid, ...header })
+ .sign(signer.privateKey);
+}
+
+export interface MandateOptions {
+ /** Replaces the compact checkout JWT after `checkout_hash` has been computed */
+ readonly checkoutJwtOverride?: string;
+ readonly payloadOverrides?: Record;
+ readonly header?: Record;
+ /** Extra disclosure strings appended to the presentation */
+ readonly extraDisclosures?: readonly string[];
+ /** Extra claims committed to in `_sd`, disclosable independently */
+ readonly disclosable?: Readonly>;
+ /** Which of {@link MandateOptions.disclosable} to actually present */
+ readonly present?: readonly string[];
+ /** Omit the disclosure that carries the checkout JWT */
+ readonly withholdCheckoutDisclosure?: boolean;
+}
+
+/**
+ * A signed mandate and its disclosures, kept apart. ES256 uses a fresh nonce
+ * per signature, so a test needing ONE mandate presented two ways must mint
+ * once and vary the disclosures afterwards.
+ */
+export interface MandateParts {
+ readonly signedToken: string;
+ readonly checkoutDisclosure: string;
+ /** Encoded disclosure per optional claim name */
+ readonly optional: Readonly>;
+}
+
+/** Joins a signed token and a chosen set of disclosures into a presentation */
+export function assemblePresentation(signedToken: string, disclosures: readonly string[]): string {
+ return `${signedToken}~${disclosures.map((d) => `${d}~`).join('')}`;
+}
+
+/**
+ * Mints a closed Checkout Mandate presentation carrying `checkout_jwt` as a
+ * selectively disclosed claim, which is the shape a Direct presentation takes
+ */
+export async function mintMandate(
+ mandateSigner: SigningIdentity,
+ checkoutJwt: string,
+ options: MandateOptions = {},
+): Promise {
+ const parts = await mintMandateParts(mandateSigner, checkoutJwt, options);
+ const presented = [
+ ...(options.withholdCheckoutDisclosure ? [] : [parts.checkoutDisclosure]),
+ ...Object.entries(parts.optional)
+ .filter(([name]) => options.present === undefined || options.present.includes(name))
+ .map(([, encoded]) => encoded),
+ ...(options.extraDisclosures ?? []),
+ ];
+ return assemblePresentation(parts.signedToken, presented);
+}
+
+/** The same mandate, handed back unassembled */
+export async function mintMandateParts(
+ mandateSigner: SigningIdentity,
+ checkoutJwt: string,
+ options: MandateOptions = {},
+): Promise {
+ const checkoutDisclosure = disclosure('salt-checkout', 'checkout_jwt', checkoutJwt);
+ const optional = Object.entries(options.disclosable ?? {}).map(([name, value]) => ({
+ name,
+ encoded: disclosure(`salt-${name}`, name, value),
+ }));
+ const optionalDigests = await Promise.all(optional.map((entry) => sha256(entry.encoded)));
+ const payload: Record = {
+ vct: AP2_CHECKOUT_MANDATE_VCT,
+ iss: MANDATE_ISSUER,
+ aud: MANDATE_AUDIENCE,
+ iat: NOW_SECONDS - 10,
+ exp: NOW_SECONDS + 300,
+ checkout_hash: await sha256(checkoutJwt),
+ _sd_alg: 'sha-256',
+ _sd: [await sha256(checkoutDisclosure), ...optionalDigests],
+ ...options.payloadOverrides,
+ };
+
+ const jws = await new SignJWT(payload)
+ .setProtectedHeader({ alg: 'ES256', kid: mandateSigner.kid, ...options.header })
+ .sign(mandateSigner.privateKey);
+
+ return {
+ signedToken: jws,
+ checkoutDisclosure:
+ options.checkoutJwtOverride !== undefined
+ ? disclosure('salt-checkout', 'checkout_jwt', options.checkoutJwtOverride)
+ : checkoutDisclosure,
+ optional: Object.fromEntries(optional.map((entry) => [entry.name, entry.encoded])),
+ };
+}
+
+export interface Party {
+ readonly mandateSigner: SigningIdentity;
+ readonly checkoutSigner: SigningIdentity;
+ readonly stranger: SigningIdentity;
+ readonly mandateIssuers: readonly Ap2TrustedIssuer[];
+ readonly checkoutIssuers: readonly Ap2TrustedIssuer[];
+}
+
+/** Key generation is the slow part, so a suite builds this once */
+export async function createParties(): Promise {
+ const [mandateSigner, checkoutSigner, stranger] = await Promise.all([
+ identity('mandate-key-2026-01'),
+ identity('checkout-key-2026-01'),
+ identity('stranger-key'),
+ ]);
+ return {
+ mandateSigner,
+ checkoutSigner,
+ stranger,
+ mandateIssuers: [trustedIssuer(MANDATE_ISSUER, MANDATE_AUDIENCE, mandateSigner)],
+ checkoutIssuers: [trustedIssuer(CHECKOUT_ISSUER, CHECKOUT_AUDIENCE, checkoutSigner)],
+ };
+}
+
+/** A `Clock` pinned to {@link NOW}, or to an offset from it */
+export function fixedClock(at: Date = NOW) {
+ return {
+ now: () => at,
+ nowIso: () => at.toISOString(),
+ monotonicMs: () => 0,
+ };
+}
diff --git a/tests/unit/authorization-ap2/provider.test.ts b/tests/unit/authorization-ap2/provider.test.ts
new file mode 100644
index 0000000..d649e44
--- /dev/null
+++ b/tests/unit/authorization-ap2/provider.test.ts
@@ -0,0 +1,335 @@
+/**
+ * The provider is the seam between core's generic contract and the mandate
+ * machinery. These own what that seam is responsible for: the right error code
+ * for each kind of failure, the reservation lifecycle, and a health probe that
+ * tells the truth about a store it cannot read.
+ */
+import { beforeAll, describe, expect, it } from 'vitest';
+import {
+ AP2_CHECKOUT_PROFILE,
+ AP2_SPEC_VERSION,
+} from '../../../src/authorization/ap2/constants.js';
+import { AP2_UNSUPPORTED } from '../../../src/authorization/ap2/descriptor.js';
+import { computeInputHash } from '../../../src/authorization/ap2/profile.js';
+import {
+ type Ap2AuthorizationProvider,
+ createAp2AuthorizationProvider,
+} from '../../../src/authorization/ap2/provider.js';
+import {
+ type Ap2ReplayStore,
+ createAp2ReplayStore,
+} from '../../../src/authorization/ap2/replay-store.js';
+import type { EnabledAp2Config } from '../../../src/authorization/ap2/types.js';
+import type {
+ AuthorizationVerificationContext,
+ PaymentRequirement,
+} from '../../../src/core/index.js';
+import { isCommerceError } from '../../../src/core/index.js';
+import {
+ checkoutPayload,
+ createParties,
+ fixedClock,
+ mintMandate,
+ type Party,
+ signCheckoutJwt,
+} from './fixtures.js';
+
+const RESOURCE_ID = 'market_report';
+const INPUT = { city: 'Berlin' };
+
+let parties: Party;
+let inputHash: string;
+
+function config(overrides: Partial = {}): EnabledAp2Config {
+ return {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ ...overrides,
+ };
+}
+
+function requirement(): PaymentRequirement {
+ return {
+ id: 'pr-1',
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ destination: '0xMERCHANT',
+ challenge: { provider: 'x402', version: '2', accepts: [] },
+ };
+}
+
+function context(payload: string, requestId = 'req-1'): AuthorizationVerificationContext {
+ return {
+ requestId,
+ resourceId: RESOURCE_ID,
+ input: INPUT,
+ submission: { method: 'ap2', payload },
+ requirement: requirement(),
+ };
+}
+
+// A mandate that authorizes exactly the purchase `requirement()` describes
+async function validMandate(overrides: Record = {}): Promise {
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({
+ agent_commerce: {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: RESOURCE_ID,
+ input_hash: inputHash,
+ amount: '0.01',
+ currency: 'USDC',
+ payment_method: 'x402',
+ // Required whenever the requirement names one, which x402 always does
+ destination: '0xMERCHANT',
+ ...overrides,
+ },
+ }),
+ );
+ return mintMandate(parties.mandateSigner, jwt);
+}
+
+function makeProvider(replayStore?: Ap2ReplayStore): Ap2AuthorizationProvider {
+ return createAp2AuthorizationProvider({
+ config: config(),
+ clock: fixedClock(),
+ ...(replayStore !== undefined ? { replayStore } : {}),
+ });
+}
+
+async function codeOf(run: () => Promise): Promise {
+ try {
+ await run();
+ } catch (error) {
+ return isCommerceError(error) ? error.code : `untyped:${String(error)}`;
+ }
+ return 'no-error';
+}
+
+beforeAll(async () => {
+ parties = await createParties();
+ inputHash = await computeInputHash(INPUT);
+});
+
+describe('createAp2AuthorizationProvider', () => {
+ it('describes itself as an experimental authorization adapter', () => {
+ const provider = makeProvider();
+ expect(provider.name).toBe('ap2');
+ expect(provider.descriptor.kind).toBe('authorization');
+ expect(provider.descriptor.status).toBe('experimental');
+ expect(provider.descriptor.supportedSpec).toContain(AP2_SPEC_VERSION);
+ expect(provider.descriptor.capabilities).toContain('direct-mode');
+ provider.close();
+ });
+
+ it('names what it does not do, rather than summarising it as a count', () => {
+ const provider = makeProvider();
+ expect(provider.descriptor.unsupported).toEqual(AP2_UNSUPPORTED);
+ // The two an operator is most likely to assume they have
+ expect(AP2_UNSUPPORTED).toContain('autonomous mode');
+ expect(AP2_UNSUPPORTED).toContain('open checkout mandates (mandate.checkout.open.1)');
+ provider.close();
+ });
+
+ it('advertises the spec version and the profile a retry must carry', () => {
+ const provider = makeProvider();
+ expect(provider.requirement).toEqual({
+ method: 'ap2',
+ version: AP2_SPEC_VERSION,
+ profile: AP2_CHECKOUT_PROFILE,
+ });
+ provider.close();
+ });
+
+ it('verifies a mandate, reserves it, and returns a digest rather than the proof', async () => {
+ const provider = makeProvider();
+ const presentation = await validMandate();
+
+ const verification = await provider.verifyAndReserve(context(presentation));
+
+ expect(verification.status).toBe('verified');
+ expect(verification.method).toBe('ap2');
+ expect(verification.reference).toMatch(/^sha256:[\w-]+$/);
+ // One digest, used as both the audit identity and the reservation handle,
+ // so the two can never name different mandates
+ expect(verification.reservationId).toBe(verification.reference);
+ expect(JSON.stringify(verification)).not.toContain(presentation);
+ provider.close();
+ });
+
+ it('records only opaque identifiers on the verification, never mandate claims', async () => {
+ const provider = makeProvider();
+
+ const verification = await provider.verifyAndReserve(context(await validMandate()));
+
+ expect(verification.metadata).toEqual({
+ mandateIssuer: 'https://trusted-surface.example',
+ checkoutIssuer: 'https://merchant.example',
+ checkoutId: 'checkout_01KTEST',
+ });
+ // The receipt store redacts any key that reads as secret-shaped, so a
+ // field named for the JWT would persist as [REDACTED]
+ expect(Object.keys(verification.metadata ?? {})).not.toContain('checkoutJwtId');
+ provider.close();
+ });
+
+ it('refuses a second presentation of one mandate as replayed, not as invalid', async () => {
+ const provider = makeProvider();
+ const presentation = await validMandate();
+
+ await provider.verifyAndReserve(context(presentation));
+ const code = await codeOf(() => provider.verifyAndReserve(context(presentation, 'req-2')));
+
+ expect(code).toBe('AUTHORIZATION_REPLAYED');
+ provider.close();
+ });
+
+ it('refuses a mandate for a different purchase as invalid', async () => {
+ const provider = makeProvider();
+ const presentation = await validMandate({ amount: '500.00' });
+
+ const code = await codeOf(() => provider.verifyAndReserve(context(presentation)));
+
+ expect(code).toBe('AUTHORIZATION_INVALID');
+ provider.close();
+ });
+
+ it('refuses a mandate from an untrusted issuer as invalid', async () => {
+ const provider = makeProvider();
+ const jwt = await signCheckoutJwt(parties.checkoutSigner, checkoutPayload());
+ const presentation = await mintMandate(parties.stranger, jwt);
+
+ const code = await codeOf(() => provider.verifyAndReserve(context(presentation)));
+
+ expect(code).toBe('AUTHORIZATION_INVALID');
+ provider.close();
+ });
+
+ it('does not reserve a mandate that fails to bind to the purchase', async () => {
+ const reserved: string[] = [];
+ const provider = makeProvider(recordingStore(reserved));
+ const presentation = await validMandate({ amount: '9.99' });
+
+ await codeOf(() => provider.verifyAndReserve(context(presentation)));
+
+ // Otherwise the buyer's own mandate is spent by the purchase it does not
+ // authorize, and unusable for the one it does
+ expect(reserved).toEqual([]);
+ provider.close();
+ });
+
+ it('reports a replay-store failure as unavailable, not as a bad mandate', async () => {
+ const provider = makeProvider(throwingStore());
+ const presentation = await validMandate();
+
+ const code = await codeOf(() => provider.verifyAndReserve(context(presentation)));
+
+ expect(code).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE');
+ provider.close();
+ });
+
+ describe('finalization', () => {
+ it('moves a reservation to consumed, released or uncertain', async () => {
+ for (const [action, expected] of [
+ ['consume', 'consumed'],
+ ['release', 'released'],
+ ['markUncertain', 'uncertain'],
+ ] as const) {
+ const store = createAp2ReplayStore({ path: ':memory:' });
+ const provider = makeProvider(store);
+ const verification = await provider.verifyAndReserve(context(await validMandate()));
+
+ await provider[action](verification.reservationId, {
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ });
+
+ expect(store.stateOf(verification.reference)).toBe(expected);
+ provider.close();
+ }
+ });
+
+ it('lets a released mandate authorize a corrected retry', async () => {
+ const store = createAp2ReplayStore({ path: ':memory:' });
+ const provider = makeProvider(store);
+ const presentation = await validMandate();
+ const first = await provider.verifyAndReserve(context(presentation));
+ await provider.release(first.reservationId, {
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ });
+
+ const second = await provider.verifyAndReserve(context(presentation, 'req-2'));
+
+ expect(second.reference).toBe(first.reference);
+ provider.close();
+ });
+
+ it('reports a store failure during finalization as unavailable', async () => {
+ const provider = makeProvider(throwingStore());
+
+ const code = await codeOf(() =>
+ provider.consume('sha256:whatever', { requestId: 'req-1', resourceId: RESOURCE_ID }),
+ );
+
+ expect(code).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE');
+ provider.close();
+ });
+ });
+
+ describe('health', () => {
+ it('passes and reports how many issuers are trusted, never a key', async () => {
+ const provider = makeProvider();
+
+ const health = await provider.health();
+
+ expect(health.status).toBe('pass');
+ expect(health.detail).toBe('mandate-issuers=1 checkout-issuers=1');
+ expect(JSON.stringify(health)).not.toContain(parties.mandateSigner.publicJwk['x']);
+ provider.close();
+ });
+
+ it('fails with a fixed token when the replay store cannot be read', async () => {
+ const provider = makeProvider(throwingStore());
+
+ const health = await provider.health();
+
+ expect(health.status).toBe('fail');
+ // A fixed vocabulary token, never a sentence built from the caught error
+ expect(health.detail).toBe('replay-store-unavailable');
+ provider.close();
+ });
+ });
+});
+
+function throwingStore(): Ap2ReplayStore {
+ const boom = (): never => {
+ throw new Error('sqlite: disk I/O error');
+ };
+ return {
+ reserve: boom,
+ consume: boom,
+ release: boom,
+ markUncertain: boom,
+ stateOf: boom,
+ close: () => {},
+ };
+}
+
+function recordingStore(reserved: string[]): Ap2ReplayStore {
+ const inner = createAp2ReplayStore({ path: ':memory:' });
+ return {
+ ...inner,
+ reserve(request) {
+ reserved.push(request.reference);
+ return inner.reserve(request);
+ },
+ };
+}
diff --git a/tests/unit/authorization-ap2/purchase-binding.test.ts b/tests/unit/authorization-ap2/purchase-binding.test.ts
new file mode 100644
index 0000000..da1ef3c
--- /dev/null
+++ b/tests/unit/authorization-ap2/purchase-binding.test.ts
@@ -0,0 +1,369 @@
+/**
+ * Binding a verified mandate to the purchase in front of us, and spending it
+ * exactly once.
+ *
+ * The verifier proves a mandate is genuine, which on its own authorises
+ * nothing: a genuine mandate for a $0.01 report would unlock a $500 one. These
+ * own the comparison that stops that, and the reservation that stops one
+ * approval paying twice.
+ */
+import { beforeAll, describe, expect, it } from 'vitest';
+import { AP2_CHECKOUT_PROFILE } from '../../../src/authorization/ap2/constants.js';
+import { bindMandateToPurchase, computeInputHash } from '../../../src/authorization/ap2/profile.js';
+import { createAp2ReplayStore } from '../../../src/authorization/ap2/replay-store.js';
+import {
+ type Ap2MandateVerifier,
+ createAp2MandateVerifier,
+} from '../../../src/authorization/ap2/verifier.js';
+import type {
+ AuthorizationVerificationContext,
+ CommerceError,
+ PaymentRequirement,
+} from '../../../src/core/index.js';
+import { isCommerceError } from '../../../src/core/index.js';
+import {
+ assemblePresentation,
+ checkoutPayload,
+ createParties,
+ fixedClock,
+ mintMandate,
+ mintMandateParts,
+ type Party,
+ signCheckoutJwt,
+} from './fixtures.js';
+
+const RESOURCE_ID = 'market_report';
+const INPUT = { city: 'Berlin', detail: { depth: 2, tags: ['a', 'b'] } };
+
+let parties: Party;
+let verifier: Ap2MandateVerifier;
+let inputHash: string;
+
+function requirement(overrides: Partial = {}): PaymentRequirement {
+ return {
+ id: 'pr-1',
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
+ network: 'eip155:84532',
+ asset: '0x1111111111111111111111111111111111111111',
+ challenge: { provider: 'x402', version: '2', accepts: [] },
+ ...overrides,
+ };
+}
+
+// A requirement for a rail with no chain coordinates at all
+function requirementWithoutCoordinates(): PaymentRequirement {
+ const { network: _n, asset: _a, ...rest } = requirement();
+ return rest;
+}
+
+function context(
+ overrides: Partial = {},
+): AuthorizationVerificationContext {
+ return {
+ requestId: 'req-1',
+ resourceId: RESOURCE_ID,
+ input: INPUT,
+ submission: { method: 'ap2', payload: 'unused-here' },
+ requirement: requirement(),
+ ...overrides,
+ };
+}
+
+// The checkout profile a correctly minted mandate carries for this purchase
+function profileClaims(overrides: Record = {}): Record {
+ return {
+ profile: AP2_CHECKOUT_PROFILE,
+ resource_id: RESOURCE_ID,
+ input_hash: inputHash,
+ amount: '0.01',
+ currency: 'USDC',
+ payment_method: 'x402',
+ destination: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
+ network: 'eip155:84532',
+ asset: '0x1111111111111111111111111111111111111111',
+ ...overrides,
+ };
+}
+
+// Mints a mandate whose checkout JWT carries `agent_commerce`
+async function mandateFor(profile: Record): Promise {
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ agent_commerce: profile }),
+ );
+ return mintMandate(parties.mandateSigner, jwt);
+}
+
+async function bindRejection(
+ presentation: string,
+ ctx: AuthorizationVerificationContext = context(),
+): Promise {
+ const verified = await verifier.verify(presentation);
+ try {
+ await bindMandateToPurchase(verified, ctx, {});
+ } catch (error) {
+ expect(isCommerceError(error)).toBe(true);
+ return error as CommerceError;
+ }
+ return expect.unreachable('expected the mandate to be refused') as never;
+}
+
+beforeAll(async () => {
+ parties = await createParties();
+ inputHash = await computeInputHash(INPUT);
+ verifier = createAp2MandateVerifier({
+ config: {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: { mandateIssuers: parties.mandateIssuers, checkoutIssuers: parties.checkoutIssuers },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ },
+ clock: fixedClock(),
+ });
+});
+
+describe('the RFC 8785 input hash', () => {
+ it('does not depend on the order keys were written in', async () => {
+ // Their signer hashed the buyer's request, we hash what arrived. Same
+ // content in a different key order is the same request.
+ const a = await computeInputHash({ city: 'Berlin', depth: 2 });
+ const b = await computeInputHash({ depth: 2, city: 'Berlin' });
+ expect(a).toBe(b);
+ });
+
+ it('is stable through nesting', async () => {
+ const a = await computeInputHash({ outer: { x: 1, y: { p: 'a', q: 'b' } } });
+ const b = await computeInputHash({ outer: { y: { q: 'b', p: 'a' }, x: 1 } });
+ expect(a).toBe(b);
+ });
+
+ it('does depend on array order, because a reordered list is a different request', async () => {
+ const a = await computeInputHash({ tags: ['a', 'b'] });
+ const b = await computeInputHash({ tags: ['b', 'a'] });
+ expect(a).not.toBe(b);
+ });
+
+ it.each([
+ ['a changed value', { city: 'Paris' }],
+ ['an added field', { city: 'Berlin', extra: 1 }],
+ ['a number where a string was', { city: 1 }],
+ ['an empty object', {}],
+ ])('changes for %s', async (_label, input) => {
+ expect(await computeInputHash(input)).not.toBe(await computeInputHash({ city: 'Berlin' }));
+ });
+
+ it('treats absent input as the empty object rather than failing', async () => {
+ expect(await computeInputHash(undefined)).toBe(await computeInputHash({}));
+ });
+});
+
+describe('binding a mandate to the resolved purchase', () => {
+ it('accepts a mandate that authorises exactly this purchase', async () => {
+ const verified = await verifier.verify(await mandateFor(profileClaims()));
+ await expect(bindMandateToPurchase(verified, context(), {})).resolves.toEqual({
+ resourceId: RESOURCE_ID,
+ amount: '0.01',
+ currency: 'USDC',
+ paymentMethod: 'x402',
+ });
+ });
+
+ it.each([
+ ['a different profile', { profile: 'agent-commerce/ap2/checkout/v2' }],
+ ['a different resource', { resource_id: 'weather_basic' }],
+ ['a different input', { input_hash: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }],
+ ['a different amount', { amount: '500.00' }],
+ ['the same amount written differently', { amount: '0.010' }],
+ ['a different currency', { currency: 'EUR' }],
+ ['a different payment method', { payment_method: 'card' }],
+ ['a different destination', { destination: '0x000000000000000000000000000000000000dEaD' }],
+ ['a different network', { network: 'eip155:8453' }],
+ ['a different asset', { asset: '0x2222222222222222222222222222222222222222' }],
+ ])('refuses one carrying %s', async (_label, override) => {
+ const error = await bindRejection(await mandateFor(profileClaims(override)));
+ expect(error.code).toBe('AUTHORIZATION_INVALID');
+ expect(error.details?.['reason']).toBe('purchase_mismatch');
+ });
+
+ it.each(['profile', 'resource_id', 'input_hash', 'amount', 'currency', 'payment_method'])(
+ 'refuses one that omits %s rather than skipping the check',
+ async (claim) => {
+ const claims = profileClaims();
+ delete claims[claim];
+ await bindRejection(await mandateFor(claims));
+ },
+ );
+
+ it('refuses a mandate with no checkout profile at all', async () => {
+ const jwt = await signCheckoutJwt(parties.checkoutSigner, checkoutPayload());
+ const presentation = await mintMandate(parties.mandateSigner, jwt);
+ // The default fixture profile has a placeholder input hash, so this is
+ // also the "mandate for some other request" case
+ await bindRejection(presentation);
+ });
+
+ it('refuses a profile that is not an object', async () => {
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ agent_commerce: 'agent-commerce/ap2/checkout/v1' }),
+ );
+ await bindRejection(await mintMandate(parties.mandateSigner, jwt));
+ });
+
+ it('refuses a mandate silent about the chain when the requirement names one', async () => {
+ // A mandate that does not say which chain it authorises must not unlock a
+ // mainnet settlement
+ const claims = profileClaims();
+ delete claims['network'];
+ await bindRejection(await mandateFor(claims));
+ });
+
+ it('refuses a mandate naming a chain when the requirement has none', async () => {
+ const ctx = context({ requirement: requirementWithoutCoordinates() });
+ await bindRejection(await mandateFor(profileClaims()), ctx);
+ });
+
+ it('accepts when neither side names settlement coordinates', async () => {
+ const claims = profileClaims();
+ for (const key of ['network', 'asset']) delete claims[key];
+ const ctx = context({ requirement: requirementWithoutCoordinates() });
+ const verified = await verifier.verify(await mandateFor(claims));
+ await expect(bindMandateToPurchase(verified, ctx, {})).resolves.toBeDefined();
+ });
+
+ it('does not report which field disagreed', async () => {
+ // Asking one field at a time reads a mandate out by elimination
+ const error = await bindRejection(await mandateFor(profileClaims({ amount: '500.00' })));
+ const onTheWire = JSON.stringify(error.toInfo());
+ expect(onTheWire).not.toContain('500.00');
+ expect(onTheWire).not.toContain('amount');
+ });
+});
+
+describe('the replay identity of a mandate', () => {
+ it('is the same however the mandate is presented', async () => {
+ // The defect this design exists to avoid: keying replay on the
+ // presentation string gives each disclosed subset its own identity, so one
+ // approval could be spent once per subset
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ agent_commerce: profileClaims() }),
+ );
+ // Minted ONCE, presented two ways. Signing twice gives two different
+ // tokens (fresh ES256 nonce) and would prove nothing.
+ const parts = await mintMandateParts(parties.mandateSigner, jwt, {
+ disclosable: { buyer_note: 'hello' },
+ });
+ const withNote = assemblePresentation(parts.signedToken, [
+ parts.checkoutDisclosure,
+ parts.optional['buyer_note'] as string,
+ ]);
+ const withoutNote = assemblePresentation(parts.signedToken, [parts.checkoutDisclosure]);
+
+ expect(withNote).not.toBe(withoutNote);
+ const a = await verifier.verify(withNote);
+ const b = await verifier.verify(withoutNote);
+ expect(a.reference).toBe(b.reference);
+ // And the presentations really did differ in what they disclosed
+ expect(a.mandateClaims['buyer_note']).toBe('hello');
+ expect(b.mandateClaims['buyer_note']).toBeUndefined();
+ });
+
+ it('is a digest, carrying nothing readable from the mandate', async () => {
+ const verified = await verifier.verify(await mandateFor(profileClaims()));
+ expect(verified.reference).toMatch(/^sha256:[A-Za-z0-9_-]{43}$/);
+ });
+
+ it('differs between two mandates', async () => {
+ const first = await verifier.verify(await mandateFor(profileClaims()));
+ const second = await verifier.verify(
+ await mandateFor(profileClaims({ input_hash: await computeInputHash({ city: 'Paris' }) })),
+ );
+ expect(first.reference).not.toBe(second.reference);
+ });
+});
+
+describe('verify, bind and reserve together', () => {
+ it('spends a mandate once and refuses every later presentation of it', async () => {
+ const store = createAp2ReplayStore({ path: ':memory:' });
+ const presentation = await mandateFor(profileClaims());
+
+ const verified = await verifier.verify(presentation);
+ await bindMandateToPurchase(verified, context(), {});
+ const first = store.reserve({
+ reference: verified.reference,
+ checkoutJti: verified.checkoutJwtId,
+ mandateIssuer: verified.mandateIssuer,
+ checkoutIssuer: verified.checkoutIssuer,
+ resourceId: RESOURCE_ID,
+ requestId: 'req-1',
+ });
+ expect(first).toEqual({ kind: 'reserved' });
+ store.consume(verified.reference);
+
+ // Same mandate, second request: still valid, still bound, still refused
+ const again = await verifier.verify(presentation);
+ expect(again.reference).toBe(verified.reference);
+ await expect(bindMandateToPurchase(again, context(), {})).resolves.toBeDefined();
+ expect(
+ store.reserve({
+ reference: again.reference,
+ checkoutJti: again.checkoutJwtId,
+ mandateIssuer: again.mandateIssuer,
+ checkoutIssuer: again.checkoutIssuer,
+ resourceId: RESOURCE_ID,
+ requestId: 'req-2',
+ }),
+ ).toEqual({ kind: 'replayed', state: 'consumed' });
+
+ store.close();
+ });
+
+ it('refuses a re-presented mandate even when disclosed differently', async () => {
+ const store = createAp2ReplayStore({ path: ':memory:' });
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ agent_commerce: profileClaims() }),
+ );
+ const parts = await mintMandateParts(parties.mandateSigner, jwt, {
+ disclosable: { buyer_note: 'hello' },
+ });
+ const full = assemblePresentation(parts.signedToken, [
+ parts.checkoutDisclosure,
+ parts.optional['buyer_note'] as string,
+ ]);
+ const trimmed = assemblePresentation(parts.signedToken, [parts.checkoutDisclosure]);
+
+ const a = await verifier.verify(full);
+ store.reserve({
+ reference: a.reference,
+ checkoutJti: a.checkoutJwtId,
+ mandateIssuer: a.mandateIssuer,
+ checkoutIssuer: a.checkoutIssuer,
+ resourceId: RESOURCE_ID,
+ requestId: 'req-1',
+ });
+ store.consume(a.reference);
+
+ const b = await verifier.verify(trimmed);
+ expect(
+ store.reserve({
+ reference: b.reference,
+ checkoutJti: b.checkoutJwtId,
+ mandateIssuer: b.mandateIssuer,
+ checkoutIssuer: b.checkoutIssuer,
+ resourceId: RESOURCE_ID,
+ requestId: 'req-2',
+ }),
+ ).toEqual({ kind: 'replayed', state: 'consumed' });
+
+ store.close();
+ });
+});
diff --git a/tests/unit/authorization-ap2/replay-store.test.ts b/tests/unit/authorization-ap2/replay-store.test.ts
new file mode 100644
index 0000000..6c7fc5a
--- /dev/null
+++ b/tests/unit/authorization-ap2/replay-store.test.ts
@@ -0,0 +1,217 @@
+/**
+ * One mandate authorises one settlement. These are the ways a second could be
+ * got out of the same approval, and the state machine that refuses them.
+ */
+import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import Database from 'better-sqlite3';
+import { afterAll, beforeEach, describe, expect, it } from 'vitest';
+import {
+ type Ap2ReplayStore,
+ type Ap2ReservationRequest,
+ createAp2ReplayStore,
+} from '../../../src/authorization/ap2/replay-store.js';
+
+const scratch = mkdtempSync(join(tmpdir(), 'ap2-replay-'));
+afterAll(() => rmSync(scratch, { recursive: true, force: true }));
+
+function request(overrides: Partial = {}): Ap2ReservationRequest {
+ return {
+ reference: 'sha256:AAAA',
+ checkoutJti: 'checkout_01',
+ mandateIssuer: 'https://trusted-surface.example',
+ checkoutIssuer: 'https://merchant.example',
+ resourceId: 'market_report',
+ requestId: 'req-1',
+ ...overrides,
+ };
+}
+
+let store: Ap2ReplayStore;
+beforeEach(() => {
+ store = createAp2ReplayStore({ path: ':memory:' });
+});
+
+describe('reserving', () => {
+ it('accepts a mandate never seen before', () => {
+ expect(store.reserve(request())).toEqual({ kind: 'reserved' });
+ expect(store.stateOf('sha256:AAAA')).toBe('reserved');
+ });
+
+ it('refuses the same mandate while a first reservation is still open', () => {
+ store.reserve(request());
+ expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({
+ kind: 'replayed',
+ state: 'reserved',
+ });
+ });
+
+ it('refuses a mandate that has already been spent', () => {
+ store.reserve(request());
+ store.consume('sha256:AAAA');
+ expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({
+ kind: 'replayed',
+ state: 'consumed',
+ });
+ });
+
+ it('refuses a mandate whose settlement outcome was never learned', () => {
+ // The buyer may well have paid. Handing it back risks a second payment
+ // for one approval, which is worth refusing a legitimate retry to avoid.
+ store.reserve(request());
+ store.markUncertain('sha256:AAAA');
+ expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({
+ kind: 'replayed',
+ state: 'uncertain',
+ });
+ });
+
+ it('lets a released mandate be presented again, which is the retry path', () => {
+ store.reserve(request());
+ store.release('sha256:AAAA');
+ expect(store.reserve(request({ requestId: 'req-2' }))).toEqual({ kind: 'reserved' });
+ expect(store.stateOf('sha256:AAAA')).toBe('reserved');
+ });
+
+ it('refuses a different mandate that binds a checkout already in flight', () => {
+ // Two mandates, one checkout document: different references, so the
+ // reference alone would let the second through
+ store.reserve(request());
+ expect(store.reserve(request({ reference: 'sha256:BBBB', requestId: 'req-2' }))).toEqual({
+ kind: 'replayed',
+ state: 'reserved',
+ });
+ });
+
+ it('refuses a different mandate binding a checkout that was already spent', () => {
+ store.reserve(request());
+ store.consume('sha256:AAAA');
+ expect(store.reserve(request({ reference: 'sha256:BBBB', requestId: 'req-2' }))).toEqual({
+ kind: 'replayed',
+ state: 'consumed',
+ });
+ });
+
+ it('allows a different mandate for a checkout whose reservation was released', () => {
+ store.reserve(request());
+ store.release('sha256:AAAA');
+ expect(store.reserve(request({ reference: 'sha256:BBBB', requestId: 'req-2' }))).toEqual({
+ kind: 'reserved',
+ });
+ });
+
+ it('keeps unrelated mandates independent', () => {
+ expect(store.reserve(request())).toEqual({ kind: 'reserved' });
+ expect(
+ store.reserve(
+ request({ reference: 'sha256:CCCC', checkoutJti: 'checkout_02', requestId: 'req-2' }),
+ ),
+ ).toEqual({ kind: 'reserved' });
+ });
+});
+
+describe('finalising', () => {
+ it('never lets a consumed mandate be released back into circulation', () => {
+ // Stops a backend failure after settlement handing back a spent mandate
+ store.reserve(request());
+ store.consume('sha256:AAAA');
+ store.release('sha256:AAAA');
+ expect(store.stateOf('sha256:AAAA')).toBe('consumed');
+ });
+
+ it('never lets an uncertain mandate be released', () => {
+ store.reserve(request());
+ store.markUncertain('sha256:AAAA');
+ store.release('sha256:AAAA');
+ expect(store.stateOf('sha256:AAAA')).toBe('uncertain');
+ });
+
+ it('never lets a released mandate be consumed without a fresh reservation', () => {
+ store.reserve(request());
+ store.release('sha256:AAAA');
+ store.consume('sha256:AAAA');
+ expect(store.stateOf('sha256:AAAA')).toBe('released');
+ });
+
+ it('ignores a finalise for a mandate nobody reserved', () => {
+ store.consume('sha256:NEVER');
+ expect(store.stateOf('sha256:NEVER')).toBeUndefined();
+ });
+});
+
+describe('durability', () => {
+ it('still refuses a consumed mandate after the database is reopened', () => {
+ const path = join(scratch, 'reopen.sqlite');
+ const first = createAp2ReplayStore({ path });
+ first.reserve(request());
+ first.consume('sha256:AAAA');
+ first.close();
+
+ const second = createAp2ReplayStore({ path });
+ expect(second.stateOf('sha256:AAAA')).toBe('consumed');
+ expect(second.reserve(request({ requestId: 'req-2' }))).toEqual({
+ kind: 'replayed',
+ state: 'consumed',
+ });
+ second.close();
+ expect(existsSync(path)).toBe(true);
+ });
+
+ it('reopens an existing file without re-running the migration', () => {
+ const path = join(scratch, 'migrate-once.sqlite');
+ const first = createAp2ReplayStore({ path });
+ first.reserve(request());
+ first.close();
+ const second = createAp2ReplayStore({ path });
+ expect(second.stateOf('sha256:AAAA')).toBe('reserved');
+ second.close();
+ });
+});
+
+describe('what is written down', () => {
+ it('stores identifiers and a digest, never the mandate itself', () => {
+ // A leaked database must not hand anyone a mandate, its disclosures, the
+ // checkout JWT, or anything about the buyer
+ const path = join(scratch, 'contents.sqlite');
+ const s = createAp2ReplayStore({ path });
+ s.reserve(request());
+ s.close();
+
+ const db = new Database(path);
+ const columns = (
+ db.prepare('SELECT name FROM pragma_table_info(?)').all('ap2_authorizations') as {
+ name: string;
+ }[]
+ ).map((c) => c.name);
+ db.close();
+
+ expect(columns.sort()).toEqual([
+ 'checkout_issuer',
+ 'checkout_jti',
+ 'created_at',
+ 'mandate_issuer',
+ 'reference',
+ 'request_id',
+ 'resource_id',
+ 'state',
+ 'updated_at',
+ ]);
+ for (const forbidden of ['mandate', 'presentation', 'checkout_jwt', 'disclosure', 'payload']) {
+ expect(columns).not.toContain(forbidden);
+ }
+ });
+
+ it('rejects a state the schema does not know', () => {
+ const path = join(scratch, 'check.sqlite');
+ const s = createAp2ReplayStore({ path });
+ s.reserve(request());
+ s.close();
+
+ const db = new Database(path);
+ expect(() =>
+ db.prepare("UPDATE ap2_authorizations SET state = 'spent-ish'").run(),
+ ).toThrowError(/CHECK/i);
+ db.close();
+ });
+});
diff --git a/tests/unit/authorization-ap2/verifier.test.ts b/tests/unit/authorization-ap2/verifier.test.ts
new file mode 100644
index 0000000..9c0c07c
--- /dev/null
+++ b/tests/unit/authorization-ap2/verifier.test.ts
@@ -0,0 +1,459 @@
+/**
+ * The Direct Checkout Mandate verifier, with real ES256 signatures. See
+ * fixtures.ts for what these vectors are and are not.
+ *
+ * Every negative case asserts the error CODE too: reporting a bad mandate as a
+ * payment problem would tell an auto-paying client to spend money on a request
+ * that was never going to be delivered.
+ */
+import { beforeAll, describe, expect, it } from 'vitest';
+import type { EnabledAp2Config } from '../../../src/authorization/ap2/types.js';
+import {
+ type Ap2MandateVerifier,
+ createAp2MandateVerifier,
+} from '../../../src/authorization/ap2/verifier.js';
+import { type CommerceError, isCommerceError } from '../../../src/core/index.js';
+import {
+ CHECKOUT_AUDIENCE,
+ CHECKOUT_ISSUER,
+ checkoutPayload,
+ createParties,
+ disclosure,
+ fixedClock,
+ MANDATE_AUDIENCE,
+ MANDATE_ISSUER,
+ mintMandate,
+ NOW,
+ type Party,
+ sha256Base64url,
+ signCheckoutJwt,
+ trustedIssuer,
+} from './fixtures.js';
+
+let parties: Party;
+let verifier: Ap2MandateVerifier;
+let validPresentation: string;
+let checkoutJwt: string;
+
+function configFor(party: Party, overrides: Partial = {}): EnabledAp2Config {
+ return {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: { mandateIssuers: party.mandateIssuers, checkoutIssuers: party.checkoutIssuers },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ ...overrides,
+ };
+}
+
+function verifierFor(config: EnabledAp2Config, at: Date = NOW): Ap2MandateVerifier {
+ return createAp2MandateVerifier({ config, clock: fixedClock(at) });
+}
+
+// Returns the CommerceError a rejected verification produced
+async function rejection(run: Promise): Promise {
+ try {
+ await run;
+ } catch (error) {
+ expect(isCommerceError(error)).toBe(true);
+ return error as CommerceError;
+ }
+ return expect.unreachable('expected the mandate to be refused') as never;
+}
+
+// Every refusal here must be an authorization failure, never a payment one
+async function expectRefused(run: Promise, reason?: string): Promise {
+ const error = await rejection(run);
+ expect(error.code).toBe('AUTHORIZATION_INVALID');
+ expect(error.httpStatus).toBe(403);
+ expect(error.retryable).toBe(false);
+ if (reason !== undefined) expect(error.details?.['reason']).toBe(reason);
+ return error;
+}
+
+beforeAll(async () => {
+ parties = await createParties();
+ checkoutJwt = await signCheckoutJwt(parties.checkoutSigner);
+ validPresentation = await mintMandate(parties.mandateSigner, checkoutJwt);
+ verifier = verifierFor(configFor(parties));
+});
+
+describe('a valid Direct closed Checkout Mandate', () => {
+ it('verifies and reports both issuers and the checkout identity', async () => {
+ const result = await verifier.verify(validPresentation);
+ expect(result.mandateIssuer).toBe(MANDATE_ISSUER);
+ expect(result.checkoutIssuer).toBe(CHECKOUT_ISSUER);
+ expect(result.checkoutJwtId).toBe('checkout_01KTEST');
+ });
+
+ it('resolves the selectively disclosed checkout JWT into the mandate claims', async () => {
+ const result = await verifier.verify(validPresentation);
+ expect(result.mandateClaims['checkout_jwt']).toBe(checkoutJwt);
+ expect(result.mandateClaims['vct']).toBe('mandate.checkout.1');
+ });
+
+ it('hands back the checkout profile the purchase binding will read', async () => {
+ const result = await verifier.verify(validPresentation);
+ expect(result.checkoutClaims['agent_commerce']).toMatchObject({
+ resource_id: 'market_report',
+ amount: '0.01',
+ currency: 'USDC',
+ payment_method: 'x402',
+ });
+ });
+
+ it('verifies a second time without the key cache changing the answer', async () => {
+ await expect(verifier.verify(validPresentation)).resolves.toBeDefined();
+ await expect(verifier.verify(validPresentation)).resolves.toBeDefined();
+ });
+
+ it('reports its trusted issuers for diagnostics without exposing keys', () => {
+ const issuers = verifier.trustedIssuers();
+ expect(issuers.mandate).toEqual([MANDATE_ISSUER]);
+ expect(issuers.checkout).toEqual([CHECKOUT_ISSUER]);
+ expect(JSON.stringify(issuers)).not.toContain('"x"');
+ });
+});
+
+describe('malformed presentations', () => {
+ it.each([
+ ['empty', ''],
+ ['not a JWT at all', 'hello~'],
+ ['a JWT with no disclosure separator', 'a.b.c'],
+ ['a truncated JWS', 'eyJhbGciOiJFUzI1NiJ9.eyJ2Y3QiOiJ4In0~'],
+ ])('refuses one that is %s', async (_label, value) => {
+ await expectRefused(verifier.verify(value), 'malformed_presentation');
+ });
+
+ it('refuses an undecodable disclosure', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ extraDisclosures: ['!!!not-base64!!!'],
+ });
+ await expectRefused(verifier.verify(presentation), 'malformed_presentation');
+ });
+
+ it('refuses a disclosure appended that no digest in the payload references', async () => {
+ // The forged-claim attack: append `[salt, "amount", "0.01"]` and hope the
+ // verifier merges it in without checking it was ever committed to
+ const forged = disclosure('salt-forged', 'amount', '0.01');
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ extraDisclosures: [forged],
+ });
+ await expectRefused(verifier.verify(presentation), 'malformed_presentation');
+ });
+
+ it('refuses the same disclosure presented twice', async () => {
+ const twice = disclosure('salt-checkout', 'checkout_jwt', checkoutJwt);
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ extraDisclosures: [twice],
+ });
+ await expectRefused(verifier.verify(presentation), 'malformed_presentation');
+ });
+
+ it('refuses a digest algorithm other than sha-256 rather than assuming sha-256', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { _sd_alg: 'sha-512' },
+ });
+ await expectRefused(verifier.verify(presentation), 'malformed_presentation');
+ });
+});
+
+describe('signature and trust', () => {
+ it('refuses a tampered payload', async () => {
+ const [header, payload, signature, ...rest] = validPresentation.split(/[.~]/);
+ const patched = Buffer.from(
+ JSON.stringify({
+ ...JSON.parse(Buffer.from(payload as string, 'base64url').toString()),
+ aud: 'someone.else',
+ }),
+ ).toString('base64url');
+ await expectRefused(verifier.verify(`${header}.${patched}.${signature}~${rest.join('~')}`));
+ });
+
+ it('refuses a mandate from an issuer that is not configured', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { iss: 'https://attacker.example' },
+ });
+ await expectRefused(verifier.verify(presentation), 'untrusted_issuer');
+ });
+
+ it('refuses a kid the configured issuer does not have', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ header: { kid: 'some-other-key' },
+ });
+ await expectRefused(verifier.verify(presentation), 'unknown_key');
+ });
+
+ it('refuses a presentation with no kid rather than guessing the only key', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ header: { kid: undefined },
+ });
+ await expectRefused(verifier.verify(presentation), 'unknown_key');
+ });
+
+ it('refuses a mandate signed by a key that is trusted for checkout documents only', async () => {
+ // Signing the merchant's checkout documents must not confer the power to
+ // issue mandates authorising purchases from them
+ const presentation = await mintMandate(parties.checkoutSigner, checkoutJwt, {
+ header: { kid: parties.mandateSigner.kid },
+ });
+ await expectRefused(verifier.verify(presentation));
+ });
+
+ it('refuses a mandate signed by a stranger under a trusted issuer and kid', async () => {
+ const presentation = await mintMandate(
+ { ...parties.stranger, kid: parties.mandateSigner.kid },
+ checkoutJwt,
+ );
+ await expectRefused(verifier.verify(presentation), 'invalid_signature');
+ });
+
+ it('refuses alg=none', async () => {
+ const payload = Buffer.from(
+ JSON.stringify({
+ vct: 'mandate.checkout.1',
+ iss: MANDATE_ISSUER,
+ aud: MANDATE_AUDIENCE,
+ iat: Math.floor(NOW.getTime() / 1000),
+ exp: Math.floor(NOW.getTime() / 1000) + 300,
+ }),
+ ).toString('base64url');
+ const header = Buffer.from(
+ JSON.stringify({ alg: 'none', kid: parties.mandateSigner.kid }),
+ ).toString('base64url');
+ await expectRefused(verifier.verify(`${header}.${payload}.~`), 'invalid_signature');
+ });
+
+ it('refuses HS256 forged against the public key', async () => {
+ // Take the public EC key, treat it as an HMAC secret, sign. Refused twice
+ // over (allowlist, and an EC key cannot do HMAC); this asserts the
+ // outcome, not which one got there first.
+ const header = Buffer.from(
+ JSON.stringify({ alg: 'HS256', kid: parties.mandateSigner.kid }),
+ ).toString('base64url');
+ const payload = Buffer.from(
+ JSON.stringify({ vct: 'mandate.checkout.1', iss: MANDATE_ISSUER, aud: MANDATE_AUDIENCE }),
+ ).toString('base64url');
+ await expectRefused(verifier.verify(`${header}.${payload}.deadbeef~`), 'invalid_signature');
+ });
+});
+
+describe('mandate claims', () => {
+ it.each([
+ ['the open variant', 'mandate.checkout.open.1'],
+ ['an unversioned type', 'mandate.checkout'],
+ ['a future version', 'mandate.checkout.2'],
+ ['a prefix extension', 'mandate.checkout.1x'],
+ ])('refuses %s', async (_label, vct) => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { vct },
+ });
+ await expectRefused(verifier.verify(presentation), 'unsupported_mandate_type');
+ });
+
+ it('refuses a mandate addressed to a different merchant', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { aud: 'other-merchant.example' },
+ });
+ await expectRefused(verifier.verify(presentation), 'wrong_audience');
+ });
+
+ it('refuses an expired mandate', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { exp: Math.floor(NOW.getTime() / 1000) - 600 },
+ });
+ await expectRefused(verifier.verify(presentation), 'expired');
+ });
+
+ it('refuses a mandate that is not yet valid', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { nbf: Math.floor(NOW.getTime() / 1000) + 600 },
+ });
+ await expectRefused(verifier.verify(presentation), 'expired');
+ });
+
+ it('refuses a mandate with no exp, which would otherwise never expire', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { exp: undefined },
+ });
+ await expectRefused(verifier.verify(presentation), 'invalid_claims');
+ });
+
+ it('refuses a mandate issued in the future', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { iat: Math.floor(NOW.getTime() / 1000) + 600 },
+ });
+ await expectRefused(verifier.verify(presentation), 'expired');
+ });
+
+ it('accepts an expiry inside the configured clock skew', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { exp: Math.floor(NOW.getTime() / 1000) - 30 },
+ });
+ await expect(verifier.verify(presentation)).resolves.toBeDefined();
+ });
+
+ it('refuses an expiry just outside it', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { exp: Math.floor(NOW.getTime() / 1000) - 90 },
+ });
+ await expectRefused(verifier.verify(presentation), 'expired');
+ });
+
+ it('refuses a key-binding JWT rather than ignoring a proof that was sent', async () => {
+ await expectRefused(
+ verifier.verify(`${validPresentation}eyJhbGciOiJFUzI1NiJ9.eyJub25jZSI6IngifQ.sig`),
+ 'unsupported_mandate_type',
+ );
+ });
+});
+
+describe('the merchant checkout JWT', () => {
+ it('refuses a mandate whose checkout disclosure was withheld', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ withholdCheckoutDisclosure: true,
+ });
+ await expectRefused(verifier.verify(presentation), 'invalid_claims');
+ });
+
+ it('refuses a swapped checkout JWT, caught by checkout_hash', async () => {
+ // A genuine merchant document, for a different purchase: the signature
+ // verifies, the hash the buyer approved does not
+ const other = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ jti: 'checkout_OTHER' }),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ checkoutJwtOverride: other,
+ });
+ await expectRefused(verifier.verify(presentation), 'malformed_presentation');
+ });
+
+ it('refuses a checkout_hash that does not match the bound document', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { checkout_hash: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' },
+ });
+ await expectRefused(verifier.verify(presentation), 'checkout_binding_failed');
+ });
+
+ it('refuses a mandate carrying no checkout_hash at all', async () => {
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { checkout_hash: undefined },
+ });
+ await expectRefused(verifier.verify(presentation), 'invalid_claims');
+ });
+
+ it('refuses a checkout JWT signed by an untrusted party', async () => {
+ const forged = await signCheckoutJwt(
+ { ...parties.stranger, kid: parties.checkoutSigner.kid },
+ checkoutPayload(),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, forged);
+ await expectRefused(verifier.verify(presentation), 'checkout_binding_failed');
+ });
+
+ it('refuses a checkout JWT from an issuer that is not configured', async () => {
+ const foreign = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ iss: 'https://not-the-merchant.example' }),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, foreign);
+ await expectRefused(verifier.verify(presentation), 'untrusted_issuer');
+ });
+
+ it('refuses a checkout JWT addressed to someone other than the gateway', async () => {
+ const misaddressed = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ aud: 'somewhere.else' }),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, misaddressed);
+ await expectRefused(verifier.verify(presentation), 'checkout_binding_failed');
+ });
+
+ it('refuses an expired checkout JWT even under a live mandate', async () => {
+ const stale = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ exp: Math.floor(NOW.getTime() / 1000) - 600 }),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, stale);
+ await expectRefused(verifier.verify(presentation), 'checkout_binding_failed');
+ });
+
+ it.each([
+ ['no exp', { exp: undefined }],
+ ['no iat', { iat: undefined }],
+ ['no jti', { jti: undefined }],
+ ['an empty jti', { jti: '' }],
+ ])('refuses a checkout JWT with %s', async (_label, override) => {
+ const jwt = await signCheckoutJwt(parties.checkoutSigner, checkoutPayload(override));
+ const presentation = await mintMandate(parties.mandateSigner, jwt);
+ await expectRefused(verifier.verify(presentation), 'invalid_claims');
+ });
+
+ it('refuses a mandate and a checkout JWT that are each valid but unrelated', async () => {
+ // Both documents genuine, neither binding the other
+ const unrelated = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ jti: 'checkout_UNRELATED' }),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, checkoutJwt, {
+ payloadOverrides: { checkout_hash: await sha256Base64url(unrelated) },
+ });
+ await expectRefused(verifier.verify(presentation), 'checkout_binding_failed');
+ });
+});
+
+describe('error reporting', () => {
+ it('never leaks mandate content into a client-visible message', async () => {
+ const secret = 'buyer@example.com-and-their-order-history';
+ const jwt = await signCheckoutJwt(
+ parties.checkoutSigner,
+ checkoutPayload({ buyer_email: secret, aud: 'wrong' }),
+ );
+ const presentation = await mintMandate(parties.mandateSigner, jwt);
+ const error = await expectRefused(verifier.verify(presentation));
+ const onTheWire = JSON.stringify(error.toInfo());
+ expect(onTheWire).not.toContain(secret);
+ expect(onTheWire).not.toContain(presentation.slice(0, 40));
+ });
+
+ it('carries the request id so the refusal correlates with the rest of the flow', async () => {
+ const error = await expectRefused(verifier.verify('nonsense~', { requestId: 'req-9' }));
+ expect(error.requestId).toBe('req-9');
+ });
+
+ it('reports a broken configured key as our fault, not the buyer', async () => {
+ // Config would refuse this key, so reaching the verifier means our
+ // deployment is broken. Blaming the payer burns a mandate that is fine.
+ const broken = configFor(parties, {
+ trust: {
+ mandateIssuers: [
+ {
+ issuer: MANDATE_ISSUER,
+ audience: MANDATE_AUDIENCE,
+ keys: [{ kid: parties.mandateSigner.kid, jwk: { kty: 'EC', crv: 'P-256', x: 'nope' } }],
+ },
+ ],
+ checkoutIssuers: parties.checkoutIssuers,
+ },
+ });
+ const error = await rejection(verifierFor(broken).verify(validPresentation));
+ expect(error.code).toBe('AUTHORIZATION_PROVIDER_UNAVAILABLE');
+ expect(error.httpStatus).toBe(503);
+ expect(error.retryable).toBe(true);
+ });
+});
+
+describe('trust list separation', () => {
+ it('does not accept a mandate issuer as a checkout issuer', async () => {
+ const config = configFor(parties, {
+ trust: {
+ mandateIssuers: parties.mandateIssuers,
+ // Only the mandate issuer is trusted for checkout documents now
+ checkoutIssuers: [trustedIssuer(MANDATE_ISSUER, CHECKOUT_AUDIENCE, parties.mandateSigner)],
+ },
+ });
+ await expectRefused(verifierFor(config).verify(validPresentation), 'untrusted_issuer');
+ });
+});
diff --git a/tests/unit/cli/doctor.test.ts b/tests/unit/cli/doctor.test.ts
index d8425a3..2d8dd18 100644
--- a/tests/unit/cli/doctor.test.ts
+++ b/tests/unit/cli/doctor.test.ts
@@ -1203,3 +1203,164 @@ describe('runDoctor - ACP', () => {
}
});
});
+
+describe('runDoctor - AP2', () => {
+ const KEY = {
+ kty: 'EC',
+ crv: 'P-256',
+ // RFC 7515 A.3.1's public P-256 key. A published test vector, not a key
+ // anything here can sign with.
+ x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
+ y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
+ };
+
+ function enabledAp2(overrides: Record = {}): Record {
+ return {
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ trust: {
+ mandateIssuers: [
+ {
+ issuer: 'https://surface.example',
+ audience: 'merchant.example',
+ keys: [{ kid: 'mandate-2026-01', jwk: KEY }],
+ },
+ ],
+ checkoutIssuers: [
+ {
+ issuer: 'https://merchant.example',
+ audience: 'agent-commerce',
+ keys: [{ kid: 'checkout-2026-01', jwk: KEY }],
+ },
+ ],
+ },
+ clockSkewSeconds: 60,
+ replay: { path: ':memory:' },
+ ...overrides,
+ };
+ }
+
+ function gatedResource(): CommerceResource {
+ return {
+ id: 'market_report',
+ name: 'Market Report',
+ handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' },
+ pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' },
+ exposedVia: ['http'],
+ paymentMethods: ['x402'],
+ authorization: { required: ['ap2'] },
+ };
+ }
+
+ async function ap2Report(
+ ap2: unknown,
+ resources: readonly CommerceResource[] = [gatedResource()],
+ ): Promise>> {
+ const base = makeGatewayConfig();
+ return runDoctor(
+ { gatewayUrl: GATEWAY },
+ {
+ fetchImpl: healthyFetch(),
+ loadConfig: async () => ({
+ ...base,
+ resources,
+ ...(ap2 === undefined
+ ? {}
+ : {
+ authorization: { ap2: ap2 as NonNullable['ap2'] },
+ }),
+ }),
+ createStore: () => makeFakeReceiptStore(),
+ },
+ );
+ }
+
+ it('reports AP2 as disabled without any further AP2 checks', async () => {
+ const report = await ap2Report({ enabled: false }, []);
+
+ expect(report.checks.find((c) => c.name === 'AP2')?.detail).toBe('disabled');
+ for (const name of ['AP2 trust', 'AP2 replay store', 'AP2 resources', 'AP2 unsupported']) {
+ expect(report.checks.find((c) => c.name === name)).toBeUndefined();
+ }
+ });
+
+ it('reports AP2 as disabled when no authorization block is configured at all', async () => {
+ const report = await ap2Report(undefined, []);
+
+ expect(report.checks.find((c) => c.name === 'AP2')?.detail).toBe('disabled');
+ });
+
+ it('reports the pinned spec, the mode, the mandate type and the profile', async () => {
+ const report = await ap2Report(enabledAp2());
+
+ const ap2 = report.checks.find((c) => c.name === 'AP2');
+ expect(ap2?.status).toBe('PASS');
+ expect(ap2?.detail).toContain('experimental');
+ expect(ap2?.detail).toContain('spec 0.2.0');
+ expect(ap2?.detail).toContain('mode direct');
+ expect(ap2?.detail).toContain('mandate.checkout.1');
+ expect(ap2?.detail).toContain('agent-commerce/ap2/checkout/v1');
+ expect(ap2?.detail).toContain('clock skew 60s');
+ });
+
+ it('names the trusted issuers and how many keys each has, never a key', async () => {
+ const report = await ap2Report(enabledAp2());
+
+ const trust = report.checks.find((c) => c.name === 'AP2 trust');
+ expect(trust?.detail).toContain('mandate issuers: https://surface.example (1 key)');
+ expect(trust?.detail).toContain('checkout issuers: https://merchant.example (1 key)');
+ // Key material is public, but it is trust policy nobody asked this report
+ // to print, and a report is pasted into issues
+ expect(JSON.stringify(report)).not.toContain(KEY.x);
+ expect(JSON.stringify(report)).not.toContain(KEY.y);
+ });
+
+ it('warns that an in-memory replay store forgets every spent mandate', async () => {
+ const report = await ap2Report(enabledAp2());
+
+ const replay = report.checks.find((c) => c.name === 'AP2 replay store');
+ expect(replay?.status).toBe('WARN');
+ expect(replay?.detail).toContain('in-memory');
+ });
+
+ it('warns when a store file does not exist yet, without creating one', async () => {
+ const path = join(tmpdir(), `ap2-doctor-${Date.now()}.db`);
+ const report = await ap2Report(enabledAp2({ replay: { path } }));
+
+ const replay = report.checks.find((c) => c.name === 'AP2 replay store');
+ expect(replay?.status).toBe('WARN');
+ expect(existsSync(path)).toBe(false);
+ });
+
+ it('names the resources a mandate now gates', async () => {
+ const report = await ap2Report(enabledAp2());
+
+ const resources = report.checks.find((c) => c.name === 'AP2 resources');
+ expect(resources?.status).toBe('PASS');
+ expect(resources?.detail).toBe('market_report');
+ });
+
+ it('warns when AP2 is enabled but gates nothing', async () => {
+ const report = await ap2Report(enabledAp2(), []);
+
+ const resources = report.checks.find((c) => c.name === 'AP2 resources');
+ expect(resources?.status).toBe('WARN');
+ expect(resources?.detail).toContain('no resource requires it');
+ });
+
+ it('lists what AP2 does not do in full, rather than as a count', async () => {
+ const report = await ap2Report(enabledAp2());
+
+ const unsupported = report.checks.find((c) => c.name === 'AP2 unsupported');
+ for (const capability of [
+ 'autonomous mode',
+ 'open checkout mandates (mandate.checkout.open.1)',
+ 'spending constraint evaluation',
+ 'JWKS and any key discovery by URL (jku, x5u)',
+ 'AP2 over the ACP checkout adapter',
+ ]) {
+ expect(unsupported?.detail).toContain(capability);
+ }
+ });
+});
diff --git a/tests/unit/cli/packaging.test.ts b/tests/unit/cli/packaging.test.ts
index 8e1b590..cec0c4e 100644
--- a/tests/unit/cli/packaging.test.ts
+++ b/tests/unit/cli/packaging.test.ts
@@ -159,6 +159,26 @@ describe('published package metadata', () => {
expect(manifest.pnpm).toBeUndefined();
});
+ it('keeps the AP2 crypto libraries optional and exactly pinned', () => {
+ // A mandate decides whether a purchase was authorised, so nothing in that
+ // path floats. `jose` and `@sd-jwt/core` are optional peers because a
+ // consumer serving a free HTTP resource should not install a JOSE stack,
+ // and they are pinned because a signature verifier is not somewhere to
+ // accept whatever a fresh install resolves to.
+ for (const peer of ['jose', '@sd-jwt/core']) {
+ expect(manifest.peerDependencies?.[peer]).toBeDefined();
+ expect(manifest.peerDependenciesMeta?.[peer]?.optional).toBe(true);
+ expect(manifest.dependencies?.[peer]).toBeUndefined();
+ expect(manifest.devDependencies?.[peer]).toBe(manifest.peerDependencies?.[peer]);
+ }
+ // `@sd-jwt/core` ships a caret range on a 0.x package, which is the one
+ // transitive in the whole graph that sits inside signature verification.
+ const overrides = manifest.overrides as Record | undefined;
+ expect(
+ (overrides?.['@sd-jwt/core'] as Record | undefined)?.['@owf/identity-common'],
+ ).toMatch(/^\d+\.\d+\.\d+$/);
+ });
+
it('ships a library entry alongside the CLI', () => {
const exportsField = manifest.exports as Record | undefined;
expect(exportsField?.['.']).toBeDefined();
@@ -169,14 +189,17 @@ describe('published package metadata', () => {
// a consumer serving a free HTTP resource. None of these is needed by the
// main entry or the CLI, so they are optional peers reached by subpath.
const exportsField = manifest.exports as Record | undefined;
- for (const subpath of ['./mcp', './x402']) {
+ for (const subpath of ['./ap2', './mcp', './x402']) {
expect(exportsField?.[subpath]).toBeDefined();
}
for (const peer of [
'@coinbase/x402',
'@modelcontextprotocol/sdk',
+ '@sd-jwt/core',
'@x402/core',
'@x402/evm',
+ 'canonicalize',
+ 'jose',
'viem',
]) {
expect(manifest.peerDependencies?.[peer]).toBeDefined();
@@ -350,6 +373,7 @@ describe.skipIf(!existsSync(libEntry))('built library entry', () => {
});
describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => {
+ const ap2Entry = join(pkgRoot, 'dist', 'ap2.js');
const mcpEntry = join(pkgRoot, 'dist', 'mcp.js');
const x402Entry = join(pkgRoot, 'dist', 'x402.js');
@@ -372,6 +396,15 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => {
it('imports only its own peer, in each subpath', () => {
expect(bareImportsOf(mcpEntry)).toEqual(['@modelcontextprotocol/sdk']);
expect(bareImportsOf(x402Entry).sort()).toEqual(['@x402/core', '@x402/evm', 'viem']);
+ // `better-sqlite3` rides along through the shared storage chunk: the AP2
+ // replay store is a SQLite file. It is a real dependency, not a peer, so
+ // it is always installed anyway.
+ expect(bareImportsOf(ap2Entry).sort()).toEqual([
+ '@sd-jwt/core',
+ 'better-sqlite3',
+ 'canonicalize',
+ 'jose',
+ ]);
});
it('exports its factory under both the short and the full name', () => {
@@ -386,6 +419,9 @@ describe.skipIf(!existsSync(libEntry))('optional-peer subpaths', () => {
);
expect(probe(mcpEntry, ['mcp', 'createMcpAdapter'])).toBe('');
expect(probe(x402Entry, ['x402', 'createX402PaymentProvider', 'createPaymentProof'])).toBe('');
+ expect(probe(ap2Entry, ['ap2', 'createAp2AuthorizationProvider', 'createCheckoutJwt'])).toBe(
+ '',
+ );
});
it('shares one CommerceError class with the main entry', () => {
diff --git a/tests/unit/config/ap2.test.ts b/tests/unit/config/ap2.test.ts
new file mode 100644
index 0000000..ed90bc0
--- /dev/null
+++ b/tests/unit/config/ap2.test.ts
@@ -0,0 +1,349 @@
+/**
+ * AP2 trust and resource policy, validated at load.
+ *
+ * A trust policy that cannot be enforced has to fail at startup, not at the
+ * first purchase. The two shapes worth catching are an AP2-required resource
+ * that settles unprotected because the provider was off, and a verification
+ * key that turns out to be malformed only once a buyer presents a valid
+ * mandate.
+ */
+import { describe, expect, it } from 'vitest';
+import { parseConfig } from '../../../src/config/schema.js';
+import { isCommerceError } from '../../../src/core/index.js';
+import { validRawConfig } from './fixtures.js';
+
+/**
+ * The P-256 public key from RFC 7515 appendix A.3.1. A published example, so
+ * it is a genuine point on the curve with no private half anyone has to keep.
+ */
+const PUBLIC_JWK = {
+ kty: 'EC',
+ crv: 'P-256',
+ x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
+ y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
+};
+
+function issuer(overrides: Record = {}): Record {
+ return {
+ issuer: 'https://trusted-surface.example',
+ audience: 'merchant.example',
+ keys: [{ kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } }],
+ ...overrides,
+ };
+}
+
+function withAp2(
+ ap2: Record = {},
+ resourceAuthorization?: Record,
+): Record {
+ const raw = validRawConfig();
+ raw['authorization'] = {
+ ap2: {
+ enabled: true,
+ replay: { path: './data/ap2-authorizations.sqlite' },
+ trust: {
+ mandateIssuers: [issuer()],
+ checkoutIssuers: [
+ issuer({ issuer: 'https://merchant.example', audience: 'agent-commerce' }),
+ ],
+ },
+ ...ap2,
+ },
+ };
+ if (resourceAuthorization !== undefined) {
+ (raw['resources'] as Record>)['market_report'] = {
+ ...(raw['resources'] as Record>)['market_report'],
+ authorization: resourceAuthorization,
+ };
+ }
+ return raw;
+}
+
+function messageFor(raw: Record): string {
+ try {
+ parseConfig(raw, {});
+ } catch (error) {
+ expect(isCommerceError(error)).toBe(true);
+ return (error as Error).message;
+ }
+ return expect.unreachable('expected config to be rejected') as never;
+}
+
+function expectRejected(raw: Record): string {
+ const message = messageFor(raw);
+ try {
+ parseConfig(raw, {});
+ } catch (error) {
+ if (isCommerceError(error)) expect(error.code).toBe('CONFIG_INVALID');
+ }
+ return message;
+}
+
+describe('configs without AP2', () => {
+ it('parse unchanged and report no authorization block at all', () => {
+ const config = parseConfig(validRawConfig(), {});
+ expect(config.authorization).toBeUndefined();
+ expect(config.resources.every((r) => r.authorization === undefined)).toBe(true);
+ });
+});
+
+describe('authorization.ap2 trust policy', () => {
+ it('normalises an enabled block, defaulting the version, mode and skew', () => {
+ const config = parseConfig(withAp2(), {});
+ const ap2 = config.authorization?.ap2;
+ expect(ap2).toMatchObject({
+ enabled: true,
+ specVersion: '0.2.0',
+ mode: 'direct',
+ clockSkewSeconds: 60,
+ replay: { path: './data/ap2-authorizations.sqlite' },
+ });
+ });
+
+ it('keeps a disabled block as a placeholder without demanding a trust policy', () => {
+ const raw = validRawConfig();
+ raw['authorization'] = { ap2: { enabled: false } };
+ expect(parseConfig(raw, {}).authorization?.ap2).toEqual({ enabled: false });
+ });
+
+ it('carries both issuer lists through with their keys', () => {
+ const ap2 = parseConfig(withAp2(), {}).authorization?.ap2;
+ if (ap2?.enabled !== true) return expect.unreachable();
+ expect(ap2.trust.mandateIssuers[0]).toEqual({
+ issuer: 'https://trusted-surface.example',
+ audience: 'merchant.example',
+ keys: [{ kid: 'key-2026-01', jwk: PUBLIC_JWK }],
+ });
+ expect(ap2.trust.checkoutIssuers[0]?.audience).toBe('agent-commerce');
+ });
+
+ it.each([
+ ['an unsupported spec version', { specVersion: '0.1.0' }, '0.2.0'],
+ ['autonomous mode', { mode: 'autonomous' }, 'direct'],
+ ])('rejects %s', (_label, override, hint) => {
+ expect(expectRejected(withAp2(override))).toContain(hint);
+ });
+
+ it('rejects an enabled block with no replay store', () => {
+ const raw = withAp2();
+ const ap2 = (raw['authorization'] as { ap2: Record }).ap2;
+ delete ap2['replay'];
+ expect(expectRejected(raw)).toContain('replay');
+ });
+
+ it.each([
+ ['no mandate issuers', 'mandateIssuers'],
+ ['no checkout issuers', 'checkoutIssuers'],
+ ])('rejects a trust policy with %s', (_label, list) => {
+ const raw = withAp2();
+ const trust = (raw['authorization'] as { ap2: { trust: Record } }).ap2.trust;
+ trust[list] = [];
+ expect(expectRejected(raw)).toContain(list);
+ });
+
+ it('rejects the same issuer listed twice, pointing at key rotation instead', () => {
+ const message = expectRejected(
+ withAp2({ trust: { mandateIssuers: [issuer(), issuer()], checkoutIssuers: [issuer()] } }),
+ );
+ expect(message).toContain('listed twice');
+ expect(message).toContain('rotation');
+ });
+
+ it('rejects two keys sharing a kid, which would make signature selection undefined', () => {
+ const duplicate = issuer({
+ keys: [
+ { kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } },
+ { kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } },
+ ],
+ });
+ expect(
+ expectRejected(
+ withAp2({ trust: { mandateIssuers: [duplicate], checkoutIssuers: [issuer()] } }),
+ ),
+ ).toContain('listed twice');
+ });
+
+ it('accepts an overlapping old and new key under one issuer, which is how rotation works', () => {
+ const rotating = issuer({
+ keys: [
+ { kid: 'key-2025-07', jwk: { ...PUBLIC_JWK } },
+ { kid: 'key-2026-01', jwk: { ...PUBLIC_JWK } },
+ ],
+ });
+ const ap2 = parseConfig(
+ withAp2({ trust: { mandateIssuers: [rotating], checkoutIssuers: [issuer()] } }),
+ {},
+ ).authorization?.ap2;
+ if (ap2?.enabled !== true) return expect.unreachable();
+ expect(ap2.trust.mandateIssuers[0]?.keys.map((k) => k.kid)).toEqual([
+ 'key-2025-07',
+ 'key-2026-01',
+ ]);
+ });
+
+ it('rejects an issuer with no audience', () => {
+ const raw = withAp2();
+ const trust = (
+ raw['authorization'] as { ap2: { trust: { mandateIssuers: [Record] } } }
+ ).ap2.trust;
+ delete trust.mandateIssuers[0]['audience'];
+ expect(expectRejected(raw)).toContain('audience');
+ });
+
+ it('clamps nothing silently: a skew past the ceiling is refused', () => {
+ expect(expectRejected(withAp2({ clockSkewSeconds: 3600 }))).toContain('300');
+ });
+
+ it('accepts a skew inside the ceiling', () => {
+ const ap2 = parseConfig(withAp2({ clockSkewSeconds: 120 }), {}).authorization?.ap2;
+ if (ap2?.enabled !== true) return expect.unreachable();
+ expect(ap2.clockSkewSeconds).toBe(120);
+ });
+});
+
+describe('verification key validation', () => {
+ function withJwk(jwk: Record): Record {
+ return withAp2({
+ trust: {
+ mandateIssuers: [issuer({ keys: [{ kid: 'key-2026-01', jwk }] })],
+ checkoutIssuers: [issuer()],
+ },
+ });
+ }
+
+ it('rejects private key material and says to rotate the key', () => {
+ const message = expectRejected(withJwk({ ...PUBLIC_JWK, d: 'not-a-real-private-scalar' }));
+ expect(message).toContain('private key material');
+ expect(message).toContain('rotate');
+ });
+
+ it.each([
+ ['a URL-valued member', { ...PUBLIC_JWK, x5u: 'https://attacker.example/keys.json' }],
+ ['a smuggled jku', { ...PUBLIC_JWK, jku: 'https://attacker.example/jwks' }],
+ ])('refuses %s rather than ever fetching it', (_label, jwk) => {
+ expect(expectRejected(withJwk(jwk))).toContain('never fetched');
+ });
+
+ it.each([
+ ['an RSA key', { ...PUBLIC_JWK, kty: 'RSA' }],
+ ['a symmetric key', { ...PUBLIC_JWK, kty: 'oct' }],
+ ['the wrong curve', { ...PUBLIC_JWK, crv: 'P-384' }],
+ ['a non-ES256 alg', { ...PUBLIC_JWK, alg: 'ES384' }],
+ ])('rejects %s', (_label, jwk) => {
+ expectRejected(withJwk(jwk));
+ });
+
+ it.each([
+ ['a truncated coordinate', { ...PUBLIC_JWK, x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8' }],
+ [
+ 'standard base64 rather than base64url',
+ { ...PUBLIC_JWK, y: `x/FEzRu9m36HLN+tue659LNpXW6pCyStikYjKIWI5a0` },
+ ],
+ ['a missing coordinate', { kty: 'EC', crv: 'P-256', x: PUBLIC_JWK.x }],
+ ['a non-string coordinate', { ...PUBLIC_JWK, y: 42 }],
+ ])('rejects %s', (_label, jwk) => {
+ expectRejected(withJwk(jwk as Record));
+ });
+
+ it('rejects a key whose own kid disagrees with the configured one', () => {
+ expect(expectRejected(withJwk({ ...PUBLIC_JWK, kid: 'something-else' }))).toContain(
+ 'disagrees',
+ );
+ });
+
+ it('rejects an encryption key offered for verification', () => {
+ expect(expectRejected(withJwk({ ...PUBLIC_JWK, use: 'enc' }))).toContain('sig');
+ });
+
+ it('accepts the optional members it does allow', () => {
+ const config = parseConfig(
+ withJwk({ ...PUBLIC_JWK, kid: 'key-2026-01', alg: 'ES256', use: 'sig' }),
+ {},
+ );
+ const ap2 = config.authorization?.ap2;
+ if (ap2?.enabled !== true) return expect.unreachable();
+ expect(ap2.trust.mandateIssuers[0]?.keys[0]?.jwk).toMatchObject({ alg: 'ES256', use: 'sig' });
+ });
+});
+
+describe('replay store isolation', () => {
+ it('rejects a replay path shared with the receipt store', () => {
+ expect(expectRejected(withAp2({ replay: { path: './data/receipts.sqlite' } }))).toContain(
+ 'storage.receipts.path',
+ );
+ });
+
+ it('rejects a replay path shared with the ACP idempotency store', () => {
+ const raw = withAp2({ replay: { path: './data/acp.sqlite' } });
+ const protocols = raw['protocols'] as Record;
+ protocols['acp'] = {
+ enabled: true,
+ mountPath: '/acp',
+ auth: { type: 'bearer', token: 'tok' },
+ idempotency: { path: './data/acp.sqlite' },
+ checkout: {
+ operations: {
+ createCheckoutSession: 'c1',
+ updateCheckoutSession: 'c2',
+ getCheckoutSession: 'c3',
+ completeCheckoutSession: 'c4',
+ cancelCheckoutSession: 'c5',
+ },
+ },
+ };
+ expect(expectRejected(raw)).toContain('protocols.acp.idempotency.path');
+ });
+
+ it('allows two in-memory stores, which are separate databases', () => {
+ const raw = withAp2({ replay: { path: ':memory:' } });
+ (raw['storage'] as { receipts: { path: string } }).receipts.path = ':memory:';
+ expect(parseConfig(raw, {}).authorization?.ap2.enabled).toBe(true);
+ });
+});
+
+describe('resource authorization policy', () => {
+ it('attaches the requirement to the canonical resource', () => {
+ const config = parseConfig(withAp2({}, { required: ['ap2'] }), {});
+ const resource = config.resources.find((r) => r.id === 'market_report');
+ expect(resource?.authorization).toEqual({ required: ['ap2'] });
+ // And leaves every other resource untouched.
+ expect(config.resources.find((r) => r.id === 'weather_basic')?.authorization).toBeUndefined();
+ });
+
+ it('rejects a required method the gateway does not implement', () => {
+ expect(expectRejected(withAp2({}, { required: ['ap3'] }))).toContain('ap3');
+ });
+
+ it('rejects the same method listed twice', () => {
+ expect(expectRejected(withAp2({}, { required: ['ap2', 'ap2'] }))).toContain('twice');
+ });
+
+ it('rejects an empty requirement list rather than reading it as "none"', () => {
+ expectRejected(withAp2({}, { required: [] }));
+ });
+
+ it('rejects a resource requiring AP2 while the provider is disabled', () => {
+ const raw = withAp2({}, { required: ['ap2'] });
+ (raw['authorization'] as { ap2: Record }).ap2['enabled'] = false;
+ expect(expectRejected(raw)).toContain('not configured or not enabled');
+ });
+
+ it('rejects a resource requiring AP2 with no authorization block configured at all', () => {
+ const raw = validRawConfig();
+ (raw['resources'] as Record>)['market_report'] = {
+ ...(raw['resources'] as Record>)['market_report'],
+ authorization: { required: ['ap2'] },
+ };
+ expect(expectRejected(raw)).toContain('not configured or not enabled');
+ });
+
+ it('rejects AP2 required on a free resource', () => {
+ const raw = withAp2();
+ (raw['resources'] as Record>)['weather_basic'] = {
+ ...(raw['resources'] as Record>)['weather_basic'],
+ authorization: { required: ['ap2'] },
+ };
+ const message = expectRejected(raw);
+ expect(message).toContain('never replaces payment');
+ });
+});
diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts
index 88eb6ad..115f0ec 100644
--- a/tests/unit/config/schema.test.ts
+++ b/tests/unit/config/schema.test.ts
@@ -4,7 +4,7 @@ import {
compileJsonSchema,
validateBackendRequestShape,
} from '../../../src/core/execution/index.js';
-import { isCommerceError, PAYMENT_INPUT_FIELD } from '../../../src/core/index.js';
+import { isCommerceError, RESERVED_INPUT_FIELDS } from '../../../src/core/index.js';
import { validRawConfig } from './fixtures.js';
function expectConfigInvalid(fn: () => unknown): void {
@@ -608,21 +608,24 @@ describe('parseConfig', () => {
});
});
- it(`rejects a resource whose input.properties declares the reserved "${PAYMENT_INPUT_FIELD}" field`, () => {
- const raw = validRawConfig();
- (
- raw['resources'] as { weather_basic: { input: { properties: Record } } }
- ).weather_basic.input.properties[PAYMENT_INPUT_FIELD] = { type: 'string' };
- expectConfigInvalid(() => parseConfig(raw, {}));
- try {
- parseConfig(raw, {});
- } catch (error) {
- if (isCommerceError(error)) {
- expect(error.message).toContain(PAYMENT_INPUT_FIELD);
- expect(error.message).toContain('reserved');
+ it.each(RESERVED_INPUT_FIELDS)(
+ 'rejects a resource whose input.properties declares the reserved "%s" field',
+ (reserved) => {
+ const raw = validRawConfig();
+ (
+ raw['resources'] as { weather_basic: { input: { properties: Record } } }
+ ).weather_basic.input.properties[reserved] = { type: 'string' };
+ expectConfigInvalid(() => parseConfig(raw, {}));
+ try {
+ parseConfig(raw, {});
+ } catch (error) {
+ if (isCommerceError(error)) {
+ expect(error.message).toContain(reserved);
+ expect(error.message).toContain('reserved');
+ }
}
- }
- });
+ },
+ );
it('rejects a paid resource declaring no payment methods', () => {
const raw = validRawConfig();
@@ -1795,9 +1798,9 @@ describe('parseConfig backend.inputBindings', () => {
);
});
- it('rejects a binding to the reserved payment input field', () => {
+ it.each(RESERVED_INPUT_FIELDS)('rejects a binding to the reserved "%s" input field', (field) => {
expectConfigInvalid(() =>
- parseConfig(bindingConfig({ bindings: { ...bindings, body: PAYMENT_INPUT_FIELD } }), {}),
+ parseConfig(bindingConfig({ bindings: { ...bindings, body: field } }), {}),
);
});
diff --git a/tests/unit/core/domain/authorization-wire.test.ts b/tests/unit/core/domain/authorization-wire.test.ts
new file mode 100644
index 0000000..e94d9b6
--- /dev/null
+++ b/tests/unit/core/domain/authorization-wire.test.ts
@@ -0,0 +1,302 @@
+/**
+ * The generic authorization carrier, at the one place every surface shares.
+ *
+ * MCP and A2A both call `extractReservedInputFields` and HTTP calls
+ * `parseAuthorizationHeader`. The envelope rules therefore only have to be
+ * right once, which is why the extraction lives in core rather than being
+ * written out per adapter.
+ */
+import { describe, expect, it } from 'vitest';
+import type { CommerceResource, PaymentRequiredOutcome } from '../../../../src/core/index.js';
+import {
+ AUTHORIZATION_INPUT_FIELD,
+ type CommerceError,
+ extractReservedInputFields,
+ isCommerceError,
+ MAX_AUTHORIZATION_HEADER_BYTES,
+ PAYMENT_INPUT_FIELD,
+ parseAuthorizationHeader,
+ parseAuthorizationSubmission,
+ RESERVED_INPUT_FIELDS,
+ toPaymentRequiredEnvelope,
+} from '../../../../src/core/index.js';
+
+const PROOF = 'eyJhbGciOiJFUzI1NiJ9.mandate~disclosure~';
+
+const paidResource: CommerceResource = {
+ id: 'premium_report',
+ name: 'Premium Report',
+ handler: { type: 'http', method: 'GET', url: 'http://backend.local/report' },
+ pricing: { type: 'fixed', amount: '0.10', currency: 'USDC' },
+ exposedVia: ['http', 'mcp', 'a2a'],
+ paymentMethods: ['x402'],
+};
+
+const freeResource: CommerceResource = {
+ ...paidResource,
+ id: 'free_report',
+ pricing: { type: 'free' },
+ paymentMethods: [],
+};
+
+function encodeHeader(value: unknown): string {
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
+}
+
+function codeOf(fn: () => unknown): string {
+ try {
+ fn();
+ } catch (error) {
+ return isCommerceError(error) ? error.code : `unexpected: ${String(error)}`;
+ }
+ return 'no error thrown';
+}
+
+describe('reserved input fields', () => {
+ it('names both gateway-reserved fields in one list', () => {
+ expect(RESERVED_INPUT_FIELDS).toEqual([PAYMENT_INPUT_FIELD, AUTHORIZATION_INPUT_FIELD]);
+ });
+});
+
+describe('parseAuthorizationSubmission', () => {
+ it('accepts a well-formed envelope', () => {
+ expect(parseAuthorizationSubmission({ method: 'ap2', payload: PROOF })).toEqual({
+ method: 'ap2',
+ payload: PROOF,
+ });
+ });
+
+ it('preserves the payload byte for byte', () => {
+ // Providers hash this string to derive a replay identity, so any
+ // normalisation here would give the same proof two identities.
+ const awkward = ' a~b.c \n';
+ expect(parseAuthorizationSubmission({ method: 'ap2', payload: awkward })?.payload).toBe(
+ awkward,
+ );
+ });
+
+ it.each([
+ ['absent', undefined],
+ ['null', null],
+ ])('treats %s as no authorization at all', (_label, value) => {
+ expect(parseAuthorizationSubmission(value)).toBeUndefined();
+ });
+
+ it.each([
+ ['a bare string', 'just-the-proof'],
+ ['an array', [{ method: 'ap2', payload: PROOF }]],
+ ['a number', 42],
+ ['an unknown method', { method: 'ap3', payload: PROOF }],
+ ['a missing method', { payload: PROOF }],
+ ['a non-string payload', { method: 'ap2', payload: { jwt: PROOF } }],
+ ['an empty payload', { method: 'ap2', payload: '' }],
+ ['a missing payload', { method: 'ap2' }],
+ ])('rejects %s as AUTHORIZATION_INVALID', (_label, value) => {
+ expect(codeOf(() => parseAuthorizationSubmission(value))).toBe('AUTHORIZATION_INVALID');
+ });
+
+ it('never reports an authorization failure as a payment failure', () => {
+ // The distinction is the point of the feature: a buyer whose mandate is
+ // malformed has not paid wrongly, and must not be told to pay again.
+ try {
+ parseAuthorizationSubmission({ method: 'ap2' });
+ expect.unreachable();
+ } catch (error) {
+ const commerce = error as CommerceError;
+ expect(commerce.code).not.toMatch(/^PAYMENT_/);
+ expect(commerce.httpStatus).toBe(403);
+ expect(commerce.retryable).toBe(false);
+ }
+ });
+
+ it('carries the request id so the failure correlates with the rest of the flow', () => {
+ try {
+ parseAuthorizationSubmission({ method: 'ap2' }, 'req-7');
+ expect.unreachable();
+ } catch (error) {
+ expect((error as CommerceError).requestId).toBe('req-7');
+ }
+ });
+});
+
+describe('parseAuthorizationHeader', () => {
+ it('decodes a base64url JSON envelope', () => {
+ expect(parseAuthorizationHeader(encodeHeader({ method: 'ap2', payload: PROOF }))).toEqual({
+ method: 'ap2',
+ payload: PROOF,
+ });
+ });
+
+ it.each([
+ ['absent', undefined],
+ ['empty', ''],
+ ])('treats an %s header as no authorization', (_label, value) => {
+ expect(parseAuthorizationHeader(value)).toBeUndefined();
+ });
+
+ it('reads the first value when a client sends the header twice', () => {
+ const first = encodeHeader({ method: 'ap2', payload: PROOF });
+ const second = encodeHeader({ method: 'ap2', payload: 'other' });
+ expect(parseAuthorizationHeader([first, second])?.payload).toBe(PROOF);
+ });
+
+ it.each([
+ ['not base64url', '!!!not base64!!!'],
+ ['base64url of something that is not JSON', Buffer.from('nope').toString('base64url')],
+ ['base64url of a valid JSON non-envelope', encodeHeader(['ap2', PROOF])],
+ ])('rejects a header that is %s', (_label, value) => {
+ expect(codeOf(() => parseAuthorizationHeader(value))).toBe('AUTHORIZATION_INVALID');
+ });
+
+ it('rejects an oversized header and names the limit', () => {
+ const oversized = 'a'.repeat(MAX_AUTHORIZATION_HEADER_BYTES + 1);
+ try {
+ parseAuthorizationHeader(oversized);
+ expect.unreachable();
+ } catch (error) {
+ const commerce = error as CommerceError;
+ expect(commerce.code).toBe('AUTHORIZATION_INVALID');
+ expect(commerce.message).toContain(String(MAX_AUTHORIZATION_HEADER_BYTES));
+ }
+ });
+
+ it('accepts a header exactly at the limit', () => {
+ // base64url expands by 4/3, so the payload that fits is the limit scaled
+ // down, minus room for the JSON envelope around it.
+ const payload = 'x'.repeat(Math.floor((MAX_AUTHORIZATION_HEADER_BYTES * 3) / 4) - 64);
+ const header = encodeHeader({ method: 'ap2', payload });
+ expect(header.length).toBeLessThanOrEqual(MAX_AUTHORIZATION_HEADER_BYTES);
+ expect(parseAuthorizationHeader(header)?.payload).toBe(payload);
+ });
+
+ it('does not echo the caller input back in the message', () => {
+ // A JSON parse error quotes what it choked on; relaying that would put
+ // attacker-chosen bytes into our own error response.
+ const probe = '';
+ try {
+ parseAuthorizationHeader(Buffer.from(probe).toString('base64url'));
+ expect.unreachable();
+ } catch (error) {
+ expect((error as CommerceError).message).not.toContain(probe);
+ }
+ });
+});
+
+describe('extractReservedInputFields', () => {
+ it('leaves ordinary input completely alone', () => {
+ const result = extractReservedInputFields({ city: 'Berlin' }, paidResource);
+ expect(result).toEqual({ input: { city: 'Berlin' } });
+ expect(result.payment).toBeUndefined();
+ expect(result.authorization).toBeUndefined();
+ });
+
+ it('lifts a payment proof out of the input and takes the rail from the resource', () => {
+ const result = extractReservedInputFields(
+ { city: 'Berlin', [PAYMENT_INPUT_FIELD]: 'proof-abc' },
+ paidResource,
+ );
+ expect(result.input).toEqual({ city: 'Berlin' });
+ expect(result.payment).toEqual({ method: 'x402', payload: 'proof-abc' });
+ });
+
+ it.each([
+ ['the resource has no payment rail', freeResource],
+ ['the resource is unknown', undefined],
+ ])('drops a payment proof when %s rather than inventing a rail', (_label, resource) => {
+ const result = extractReservedInputFields(
+ { city: 'Berlin', [PAYMENT_INPUT_FIELD]: 'proof-abc' },
+ resource,
+ );
+ expect(result.input).toEqual({ city: 'Berlin' });
+ expect(result.payment).toBeUndefined();
+ });
+
+ it('lifts an authorization envelope out of the input', () => {
+ const result = extractReservedInputFields(
+ { city: 'Berlin', [AUTHORIZATION_INPUT_FIELD]: { method: 'ap2', payload: PROOF } },
+ paidResource,
+ );
+ expect(result.input).toEqual({ city: 'Berlin' });
+ expect(result.authorization).toEqual({ method: 'ap2', payload: PROOF });
+ });
+
+ it('strips the reserved field even when the envelope is unusable', () => {
+ // An adapter that forwarded the raw field would fail schema validation
+ // with INPUT_INVALID, hiding the real reason from the caller.
+ expect(
+ codeOf(() =>
+ extractReservedInputFields(
+ { city: 'Berlin', [AUTHORIZATION_INPUT_FIELD]: 'bare-string' },
+ paidResource,
+ ),
+ ),
+ ).toBe('AUTHORIZATION_INVALID');
+ });
+
+ it('carries both reserved fields at once', () => {
+ const result = extractReservedInputFields(
+ {
+ city: 'Berlin',
+ [PAYMENT_INPUT_FIELD]: 'proof-abc',
+ [AUTHORIZATION_INPUT_FIELD]: { method: 'ap2', payload: PROOF },
+ },
+ paidResource,
+ );
+ expect(result.input).toEqual({ city: 'Berlin' });
+ expect(result.payment).toEqual({ method: 'x402', payload: 'proof-abc' });
+ expect(result.authorization).toEqual({ method: 'ap2', payload: PROOF });
+ });
+
+ it('keeps an authorization even for a resource that requires none', () => {
+ // Whether one is required is the pipeline's decision, made against the
+ // resource policy. The carrier does not get to pre-empt it.
+ const result = extractReservedInputFields(
+ { [AUTHORIZATION_INPUT_FIELD]: { method: 'ap2', payload: PROOF } },
+ freeResource,
+ );
+ expect(result.authorization).toEqual({ method: 'ap2', payload: PROOF });
+ });
+});
+
+describe('payment-required envelope', () => {
+ const outcome: PaymentRequiredOutcome = {
+ kind: 'payment-required',
+ requestId: 'req-1',
+ resourceId: 'premium_report',
+ requirement: {
+ id: 'pr-1',
+ requestId: 'req-1',
+ resourceId: 'premium_report',
+ provider: 'x402',
+ amount: '0.10',
+ currency: 'USDC',
+ destination: '0xmerchant',
+ challenge: { provider: 'x402', version: '2', accepts: [] },
+ },
+ };
+
+ it('is byte-identical to before when the resource requires no authorization', () => {
+ expect(toPaymentRequiredEnvelope(outcome)).not.toHaveProperty('authorization');
+ });
+
+ it('omits the field for an empty requirement list rather than advertising nothing', () => {
+ expect(toPaymentRequiredEnvelope({ ...outcome, authorization: [] })).not.toHaveProperty(
+ 'authorization',
+ );
+ });
+
+ it('advertises the requirement so a buyer learns before paying that payment alone will not do', () => {
+ const envelope = toPaymentRequiredEnvelope({
+ ...outcome,
+ authorization: [
+ { method: 'ap2', version: '0.2.0', profile: 'https://example.test/checkout/v1' },
+ ],
+ });
+ expect(envelope.authorization).toEqual({
+ required: [{ method: 'ap2', version: '0.2.0', profile: 'https://example.test/checkout/v1' }],
+ });
+ // Still a 402 challenge in every other respect.
+ expect(envelope.code).toBe('PAYMENT_REQUIRED');
+ expect(envelope.payment.amount).toBe('0.10');
+ });
+});
diff --git a/tests/unit/core/execution/helpers.ts b/tests/unit/core/execution/helpers.ts
index 063869e..1014ba1 100644
--- a/tests/unit/core/execution/helpers.ts
+++ b/tests/unit/core/execution/helpers.ts
@@ -5,6 +5,11 @@
*/
import type {
AdapterDescriptor,
+ AuthorizationMethodName,
+ AuthorizationProvider,
+ AuthorizationRequirement,
+ AuthorizationVerification,
+ AuthorizationVerificationContext,
BackendExecutor,
BackendHandler,
BackendRequest,
@@ -272,3 +277,69 @@ export function makeResource(overrides: Partial = {}): Commerc
...overrides,
};
}
+
+export interface FakeAuthorizationProviderOptions {
+ readonly name?: AuthorizationMethodName;
+ readonly requirement?: AuthorizationRequirement;
+ readonly verifyAndReserve?: (
+ ctx: AuthorizationVerificationContext,
+ ) => Promise;
+ readonly consume?: (reservationId: string) => Promise;
+ readonly release?: (reservationId: string) => Promise;
+ readonly markUncertain?: (reservationId: string) => Promise;
+ /** Called with each lifecycle action, for cross-fake ordering assertions */
+ readonly onCall?: (action: string) => void;
+}
+
+export interface FakeAuthorizationProvider extends AuthorizationProvider {
+ /** Lifecycle calls in order, so a test can assert counts and transitions */
+ readonly calls: string[];
+ /** What verifyAndReserve was last asked to bind against */
+ readonly contexts: AuthorizationVerificationContext[];
+}
+
+export function createFakeAuthorizationProvider(
+ options: FakeAuthorizationProviderOptions = {},
+): FakeAuthorizationProvider {
+ const name = options.name ?? 'ap2';
+ const calls: string[] = [];
+ const contexts: AuthorizationVerificationContext[] = [];
+
+ const record = (action: string): void => {
+ calls.push(action);
+ options.onCall?.(action);
+ };
+
+ return {
+ name,
+ descriptor: { ...fakeDescriptor, kind: 'authorization', name },
+ requirement: options.requirement ?? { method: name, version: '0.2.0', profile: 'test/v1' },
+ calls,
+ contexts,
+ async verifyAndReserve(ctx) {
+ record('verifyAndReserve');
+ contexts.push(ctx);
+ if (options.verifyAndReserve) return options.verifyAndReserve(ctx);
+ return {
+ status: 'verified',
+ method: name,
+ reference: 'sha256:REFERENCE',
+ reservationId: 'reservation-1',
+ metadata: { checkoutId: 'checkout-1' },
+ };
+ },
+ async consume(reservationId) {
+ record('consume');
+ await options.consume?.(reservationId);
+ },
+ async release(reservationId) {
+ record('release');
+ await options.release?.(reservationId);
+ },
+ async markUncertain(reservationId) {
+ record('markUncertain');
+ await options.markUncertain?.(reservationId);
+ },
+ health: async () => ({ status: 'pass', checkedAt: '2026-01-01T00:00:00.000Z' }),
+ };
+}
diff --git a/tests/unit/core/execution/pipeline-authorization.test.ts b/tests/unit/core/execution/pipeline-authorization.test.ts
new file mode 100644
index 0000000..989fa99
--- /dev/null
+++ b/tests/unit/core/execution/pipeline-authorization.test.ts
@@ -0,0 +1,622 @@
+/**
+ * Authorization is a gate on settlement, so these tests assert call counts and
+ * ordering rather than return values: "the resource was refused" is worth
+ * little if the payment settled on the way to refusing it.
+ */
+import { describe, expect, it } from 'vitest';
+import type { AuthorizationMethodName } from '../../../../src/core/domain/common.js';
+import type { CommerceEvent } from '../../../../src/core/domain/event.js';
+import type {
+ CanonicalRequest,
+ DeliveredOutcome,
+ PaymentRequiredOutcome,
+} from '../../../../src/core/domain/request.js';
+import { CommerceError, isCommerceError } from '../../../../src/core/errors/index.js';
+import {
+ type CreateExecutionPipelineOptions,
+ createExecutionPipeline,
+} from '../../../../src/core/execution/pipeline.js';
+import { createResourceRegistry } from '../../../../src/core/execution/registry.js';
+import {
+ createCapturingLogger,
+ createFakeAuthorizationProvider,
+ createFakeBackendExecutor,
+ createFakeClock,
+ createFakeIdGenerator,
+ createFakePaymentProvider,
+ createFakeStore,
+ type FakeStore,
+ makeResource,
+} from './helpers.js';
+
+const PAID = {
+ pricing: { type: 'fixed', amount: '0.01', currency: 'USDC' },
+ paymentMethods: ['x402'],
+ authorization: { required: ['ap2'] },
+} as const;
+
+// Omits a key outright: `exactOptionalPropertyTypes` refuses an explicit undefined
+function without(
+ request: CanonicalRequest,
+ ...keys: readonly ('payment' | 'authorization')[]
+): CanonicalRequest {
+ const copy = { ...request };
+ for (const key of keys) delete copy[key];
+ return copy;
+}
+
+function makeRequest(overrides: Partial = {}): CanonicalRequest {
+ return {
+ requestId: 'req-1',
+ resourceId: 'res-1',
+ input: { city: 'berlin' },
+ protocol: 'http',
+ receivedAt: '2026-01-01T00:00:00.000Z',
+ payment: { method: 'x402', payload: 'proof' },
+ authorization: { method: 'ap2', payload: 'mandate~disclosure~' },
+ ...overrides,
+ };
+}
+
+function buildPipeline(overrides: Partial & { store?: FakeStore }) {
+ const store = overrides.store ?? createFakeStore();
+ return {
+ store,
+ pipeline: createExecutionPipeline({
+ resources: createResourceRegistry([makeResource({ ...PAID })]),
+ paymentProviders: [createFakePaymentProvider()],
+ backend: createFakeBackendExecutor(),
+ logger: createCapturingLogger(),
+ clock: createFakeClock(),
+ ids: createFakeIdGenerator(),
+ ...overrides,
+ store,
+ events: store,
+ }),
+ };
+}
+
+function codeOf(error: unknown): string {
+ return isCommerceError(error) ? error.code : `not-a-commerce-error:${String(error)}`;
+}
+
+function types(store: FakeStore): string[] {
+ return store.events.map((e: CommerceEvent) => e.type);
+}
+
+describe('execution pipeline authorization', () => {
+ it('advertises the authorization requirement with the payment challenge', async () => {
+ const auth = createFakeAuthorizationProvider({
+ requirement: { method: 'ap2', version: '0.2.0', profile: 'agent-commerce/ap2/checkout/v1' },
+ });
+ const { pipeline } = buildPipeline({ authorizationProviders: [auth] });
+
+ const outcome = (await pipeline.execute(
+ without(makeRequest(), 'payment', 'authorization'),
+ )) as PaymentRequiredOutcome;
+
+ expect(outcome.kind).toBe('payment-required');
+ expect(outcome.authorization).toEqual([
+ { method: 'ap2', version: '0.2.0', profile: 'agent-commerce/ap2/checkout/v1' },
+ ]);
+ expect(auth.calls).toEqual([]);
+ });
+
+ it('leaves the challenge untouched for a paid resource that requires no authorization', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({
+ resources: createResourceRegistry([
+ makeResource({ pricing: PAID.pricing, paymentMethods: ['x402'] }),
+ ]),
+ authorizationProviders: [auth],
+ });
+
+ const outcome = (await pipeline.execute(
+ without(makeRequest(), 'payment', 'authorization'),
+ )) as PaymentRequiredOutcome;
+
+ expect(outcome.authorization).toBeUndefined();
+ });
+
+ it('settles, consumes and delivers in that order on the happy path', async () => {
+ const order: string[] = [];
+ const auth = createFakeAuthorizationProvider({
+ onCall: (action) => order.push(`auth.${action}`),
+ });
+ const store = createFakeStore({
+ reservePaymentAttempt: async (reservation) => {
+ order.push('store.reservePaymentAttempt');
+ return {
+ id: 'attempt-1',
+ requestId: reservation.requestId,
+ resourceId: reservation.resourceId,
+ provider: reservation.provider,
+ replayKey: reservation.replayKey,
+ status: 'reserved',
+ amount: reservation.amount,
+ currency: reservation.currency,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ };
+ },
+ });
+ const payment = createFakePaymentProvider({
+ verify: async () => {
+ order.push('payment.verify');
+ return {
+ status: 'verified',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ replayKey: 'replay-key-1',
+ };
+ },
+ settle: async () => {
+ order.push('payment.settle');
+ return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' };
+ },
+ });
+ const { pipeline } = buildPipeline({
+ store,
+ paymentProviders: [payment],
+ authorizationProviders: [auth],
+ backend: createFakeBackendExecutor(async () => {
+ order.push('backend.call');
+ return { status: 200, headers: {}, body: { ok: true }, durationMs: 1 };
+ }),
+ });
+
+ const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome;
+
+ expect(outcome.kind).toBe('delivered');
+ expect(order).toEqual([
+ 'payment.verify',
+ 'auth.verifyAndReserve',
+ 'store.reservePaymentAttempt',
+ 'payment.settle',
+ 'auth.consume',
+ 'backend.call',
+ ]);
+ });
+
+ it('binds the proof to the validated input and the resolved price', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({ authorizationProviders: [auth] });
+
+ await pipeline.execute(makeRequest({ input: { city: 'berlin', _payment: 'proof' } }));
+
+ const context = auth.contexts[0];
+ expect(context?.resourceId).toBe('res-1');
+ // Reserved wire fields stripped: the provider hashes this, and the backend
+ // is called with the same bytes
+ expect(context?.input).toEqual({ city: 'berlin' });
+ expect(context?.requirement.amount).toBe('0.01');
+ expect(context?.requirement.currency).toBe('USDC');
+ expect(context?.submission).toEqual({ method: 'ap2', payload: 'mandate~disclosure~' });
+ });
+
+ it('records a digest on the receipt and never the reservation handle', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline, store } = buildPipeline({ authorizationProviders: [auth] });
+
+ const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome;
+
+ expect(outcome.receipt.authorization).toEqual({
+ method: 'ap2',
+ reference: 'sha256:REFERENCE',
+ metadata: { checkoutId: 'checkout-1' },
+ });
+ expect(JSON.stringify(store.receipts[0])).not.toContain('reservation-1');
+ expect(JSON.stringify(store.receipts[0])).not.toContain('mandate~disclosure~');
+ });
+
+ it('emits authorization.verified between payment verification and settlement', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline, store } = buildPipeline({ authorizationProviders: [auth] });
+
+ await pipeline.execute(makeRequest());
+
+ expect(types(store)).toEqual([
+ 'resource.requested',
+ 'authorization.verified',
+ 'payment.verified',
+ 'payment.settled',
+ 'backend.called',
+ 'resource.delivered',
+ ]);
+ const verified = store.events.find((e) => e.type === 'authorization.verified');
+ expect(verified?.data).toEqual({ method: 'ap2', reference: 'sha256:REFERENCE' });
+ });
+
+ describe('refusals before any money moves', () => {
+ it('refuses a request with no authorization at all', async () => {
+ const auth = createFakeAuthorizationProvider();
+ let settled = 0;
+ let backendCalls = 0;
+ const { pipeline, store } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => {
+ settled += 1;
+ return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' };
+ },
+ }),
+ ],
+ backend: createFakeBackendExecutor(async () => {
+ backendCalls += 1;
+ return { status: 200, headers: {}, body: {}, durationMs: 1 };
+ }),
+ });
+
+ await expect(pipeline.execute(without(makeRequest(), 'authorization'))).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'AUTHORIZATION_REQUIRED',
+ );
+ expect(settled).toBe(0);
+ expect(backendCalls).toBe(0);
+ expect(auth.calls).toEqual([]);
+ expect(store.attempts.size).toBe(0);
+ expect(types(store)).toContain('authorization.rejected');
+ });
+
+ it('refuses a proof presented under a method the resource does not require', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({ authorizationProviders: [auth] });
+
+ await expect(
+ pipeline.execute(
+ makeRequest({
+ authorization: { method: 'other' as 'ap2', payload: 'x' },
+ }),
+ ),
+ ).rejects.toSatisfy((error: unknown) => codeOf(error) === 'AUTHORIZATION_REQUIRED');
+ expect(auth.calls).toEqual([]);
+ });
+
+ it('refuses a resource requiring two methods, since one request carries one proof', async () => {
+ // The union has one member today; the cast stands in for a second method
+ // and pins that it is refused rather than quietly skipped
+ const second = 'mock' as AuthorizationMethodName;
+ const ap2 = createFakeAuthorizationProvider();
+ const other = createFakeAuthorizationProvider({ name: second });
+ const { pipeline } = buildPipeline({
+ resources: createResourceRegistry([
+ makeResource({
+ pricing: PAID.pricing,
+ paymentMethods: ['x402'],
+ authorization: { required: ['ap2', second] },
+ }),
+ ]),
+ authorizationProviders: [ap2, other],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'AUTHORIZATION_REQUIRED',
+ );
+ expect(ap2.calls).toEqual([]);
+ expect(other.calls).toEqual([]);
+ });
+
+ it('names the unsatisfied methods, not the satisfied one', async () => {
+ const second = 'mock' as AuthorizationMethodName;
+ const { pipeline } = buildPipeline({
+ resources: createResourceRegistry([
+ makeResource({
+ pricing: PAID.pricing,
+ paymentMethods: ['x402'],
+ authorization: { required: ['ap2', second] },
+ }),
+ ]),
+ authorizationProviders: [
+ createFakeAuthorizationProvider(),
+ createFakeAuthorizationProvider({ name: second }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) =>
+ isCommerceError(error) &&
+ JSON.stringify(error.details?.['missing']) === JSON.stringify(['mock']),
+ );
+ });
+
+ it('propagates an invalid proof as AUTHORIZATION_INVALID, with no settlement', async () => {
+ let settled = 0;
+ const auth = createFakeAuthorizationProvider({
+ verifyAndReserve: async () => {
+ throw new CommerceError('AUTHORIZATION_INVALID', 'mandate rejected');
+ },
+ });
+ const { pipeline, store } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => {
+ settled += 1;
+ return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' };
+ },
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'AUTHORIZATION_INVALID',
+ );
+ expect(settled).toBe(0);
+ expect(store.attempts.size).toBe(0);
+ expect(auth.calls).toEqual(['verifyAndReserve']);
+ const rejected = store.events.find((e) => e.type === 'authorization.rejected');
+ expect(rejected?.data).toEqual({ reason: 'AUTHORIZATION_INVALID', method: 'ap2' });
+ });
+
+ it('propagates a replayed proof as AUTHORIZATION_REPLAYED', async () => {
+ const auth = createFakeAuthorizationProvider({
+ verifyAndReserve: async () => {
+ throw new CommerceError('AUTHORIZATION_REPLAYED', 'already spent');
+ },
+ });
+ const { pipeline } = buildPipeline({ authorizationProviders: [auth] });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'AUTHORIZATION_REPLAYED',
+ );
+ });
+
+ it('reports an untyped provider failure as unavailable, not as a bad proof', async () => {
+ const auth = createFakeAuthorizationProvider({
+ verifyAndReserve: async () => {
+ throw new Error('sqlite: database is locked');
+ },
+ });
+ const { pipeline } = buildPipeline({ authorizationProviders: [auth] });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'AUTHORIZATION_PROVIDER_UNAVAILABLE',
+ );
+ });
+
+ it('never reserves a proof for a payment that failed verification', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ verify: async () => ({
+ status: 'rejected',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ rejectionReason: 'wrong amount',
+ }),
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'PAYMENT_INVALID',
+ );
+ expect(auth.calls).toEqual([]);
+ });
+
+ it('refuses a resource requiring a method no provider implements', async () => {
+ let createRequirementCalls = 0;
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [],
+ paymentProviders: [
+ createFakePaymentProvider({
+ createRequirement: async (ctx) => {
+ createRequirementCalls += 1;
+ return {
+ id: 'r',
+ requestId: ctx.requestId,
+ resourceId: ctx.resource.id,
+ provider: 'x402',
+ amount: ctx.amount,
+ currency: ctx.currency,
+ destination: '0xM',
+ challenge: { provider: 'x402', version: '1', accepts: [] },
+ };
+ },
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'CONFIG_INVALID',
+ );
+ expect(createRequirementCalls).toBe(0);
+ });
+
+ it('refuses a free resource that requires authorization rather than serving it unchecked', async () => {
+ const auth = createFakeAuthorizationProvider();
+ let backendCalls = 0;
+ const { pipeline } = buildPipeline({
+ resources: createResourceRegistry([
+ makeResource({ pricing: { type: 'free' }, authorization: { required: ['ap2'] } }),
+ ]),
+ authorizationProviders: [auth],
+ backend: createFakeBackendExecutor(async () => {
+ backendCalls += 1;
+ return { status: 200, headers: {}, body: {}, durationMs: 1 };
+ }),
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'CONFIG_INVALID',
+ );
+ expect(backendCalls).toBe(0);
+ expect(auth.calls).toEqual([]);
+ });
+ });
+
+ describe('finalization', () => {
+ it('releases the reservation when the payment replay key is already spent', async () => {
+ let settled = 0;
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [auth],
+ store: createFakeStore({
+ reservePaymentAttempt: async () => {
+ throw new CommerceError('PAYMENT_REPLAYED', 'already reserved');
+ },
+ }),
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => {
+ settled += 1;
+ return { status: 'settled', provider: 'x402', amount: '0.01', currency: 'USDC' };
+ },
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'PAYMENT_REPLAYED',
+ );
+ expect(auth.calls).toEqual(['verifyAndReserve', 'release']);
+ expect(settled).toBe(0);
+ });
+
+ it('releases the reservation when settlement is definitively rejected', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => ({
+ status: 'rejected',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ rejectionReason: 'insufficient balance',
+ }),
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED',
+ );
+ expect(auth.calls).toEqual(['verifyAndReserve', 'release']);
+ });
+
+ it('releases the reservation when settlement throws without moving funds', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => {
+ throw new Error('facilitator refused the request');
+ },
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED',
+ );
+ expect(auth.calls).toEqual(['verifyAndReserve', 'release']);
+ });
+
+ it('marks the reservation uncertain when a broadcast settlement was never confirmed', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => {
+ throw new CommerceError('PAYMENT_PROVIDER_UNAVAILABLE', 'confirmation timed out', {
+ details: { transactionHash: '0xabc' },
+ });
+ },
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED',
+ );
+ // Not released: the buyer's funds may already have moved, and a released
+ // mandate is spendable again
+ expect(auth.calls).toEqual(['verifyAndReserve', 'markUncertain']);
+ });
+
+ it('keeps the reservation consumed when the backend fails after settlement', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline, store } = buildPipeline({
+ authorizationProviders: [auth],
+ backend: createFakeBackendExecutor(async () => {
+ throw new CommerceError('BACKEND_ERROR', 'merchant returned 500', {
+ details: { status: 500 },
+ });
+ }),
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'BACKEND_ERROR',
+ );
+ expect(auth.calls).toEqual(['verifyAndReserve', 'consume']);
+ expect(store.receipts[0]?.authorization?.reference).toBe('sha256:REFERENCE');
+ });
+
+ it('delivers even when consuming the reservation fails', async () => {
+ const logger = createCapturingLogger();
+ const auth = createFakeAuthorizationProvider({
+ consume: async () => {
+ throw new Error('sqlite: disk I/O error');
+ },
+ });
+ const { pipeline } = buildPipeline({ authorizationProviders: [auth], logger });
+
+ const outcome = (await pipeline.execute(makeRequest())) as DeliveredOutcome;
+
+ expect(outcome.kind).toBe('delivered');
+ expect(logger.errors.some((e) => e.obj['action'] === 'consume')).toBe(true);
+ });
+
+ it('does not mask the original failure when releasing the reservation fails', async () => {
+ const auth = createFakeAuthorizationProvider({
+ release: async () => {
+ throw new Error('sqlite: disk I/O error');
+ },
+ });
+ const { pipeline } = buildPipeline({
+ authorizationProviders: [auth],
+ paymentProviders: [
+ createFakePaymentProvider({
+ settle: async () => ({
+ status: 'rejected',
+ provider: 'x402',
+ amount: '0.01',
+ currency: 'USDC',
+ }),
+ }),
+ ],
+ });
+
+ await expect(pipeline.execute(makeRequest())).rejects.toSatisfy(
+ (error: unknown) => codeOf(error) === 'PAYMENT_SETTLEMENT_FAILED',
+ );
+ });
+ });
+
+ it('leaves a paid resource without authorization exactly as it was', async () => {
+ const auth = createFakeAuthorizationProvider();
+ const { pipeline, store } = buildPipeline({
+ resources: createResourceRegistry([
+ makeResource({ pricing: PAID.pricing, paymentMethods: ['x402'] }),
+ ]),
+ authorizationProviders: [auth],
+ });
+
+ const outcome = (await pipeline.execute(
+ without(makeRequest(), 'authorization'),
+ )) as DeliveredOutcome;
+
+ expect(outcome.kind).toBe('delivered');
+ expect(auth.calls).toEqual([]);
+ expect(outcome.receipt.authorization).toBeUndefined();
+ expect(types(store)).not.toContain('authorization.verified');
+ });
+});
diff --git a/tests/unit/core/execution/pipeline.test.ts b/tests/unit/core/execution/pipeline.test.ts
index 0f93943..bc8cbaa 100644
--- a/tests/unit/core/execution/pipeline.test.ts
+++ b/tests/unit/core/execution/pipeline.test.ts
@@ -471,6 +471,41 @@ describe('createExecutionPipeline', () => {
expect(outcome.kind).toBe('delivered');
});
+ it('strips every reserved field, not only _payment, before validating input', async () => {
+ // Defence in depth. Adapters lift these out already, so reaching here
+ // means one of them stopped doing so - and the closed schema below would
+ // then turn a gateway-reserved field into the caller's INPUT_INVALID.
+ const resource = makeResource({
+ id: 'res-1',
+ pricing: { type: 'free' },
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ });
+ const store = createFakeStore();
+ const backendInputs: unknown[] = [];
+ const pipeline = createExecutionPipeline({
+ resources: createResourceRegistry([resource]),
+ paymentProviders: [],
+ store,
+ backend: createFakeBackendExecutor(async (_handler, request) => {
+ backendInputs.push(request.input);
+ return { status: 200, headers: {}, body: { ok: true }, durationMs: 1 };
+ }),
+ events: store,
+ logger: createCapturingLogger(),
+ clock: createFakeClock(),
+ ids: createFakeIdGenerator(),
+ });
+
+ const outcome = await pipeline.execute(
+ makeRequest({
+ input: { _payment: 'whatever', _authorization: { method: 'ap2', payload: 'proof' } },
+ }),
+ );
+
+ expect(outcome.kind).toBe('delivered');
+ expect(backendInputs).toEqual([{}]);
+ });
+
it('returns a PaymentRequiredOutcome and emits payment.required when no proof is supplied', async () => {
const resource = makeResource({
id: 'res-1',
diff --git a/tests/unit/gateway/logger.test.ts b/tests/unit/gateway/logger.test.ts
index ea6e871..1854103 100644
--- a/tests/unit/gateway/logger.test.ts
+++ b/tests/unit/gateway/logger.test.ts
@@ -4,6 +4,7 @@ import { PassThrough } from 'node:stream';
import Fastify from 'fastify';
import pino from 'pino';
import { describe, expect, it } from 'vitest';
+import { AUTHORIZATION_HEADER, PAYMENT_HEADER } from '../../../src/core/index.js';
import {
buildNotFoundHandler,
createGatewayLogger,
@@ -26,7 +27,11 @@ describe('createGatewayLogger', () => {
instance.info(
{
req: {
- headers: { authorization: 'Bearer secret-token', 'payment-signature': 'base64proof' },
+ headers: {
+ authorization: 'Bearer secret-token',
+ 'payment-signature': 'base64proof',
+ 'agent-authorization': 'base64mandate',
+ },
},
},
'request',
@@ -37,9 +42,30 @@ describe('createGatewayLogger', () => {
expect(combined).not.toContain('0xSUPER_SECRET');
expect(combined).not.toContain('secret-token');
expect(combined).not.toContain('base64proof');
+ expect(combined).not.toContain('base64mandate');
expect(combined).toContain('[REDACTED]');
});
+ it('redacts every wire header that carries a credential or a proof', async () => {
+ // Asserts the redaction, not the spelling of the path: a new header
+ // constant with no path has to fail here, and rewriting an existing path
+ // in another notation that still redacts must not
+ for (const header of ['authorization', PAYMENT_HEADER, AUTHORIZATION_HEADER]) {
+ const stream = new PassThrough();
+ const chunks: string[] = [];
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk.toString('utf8')));
+ const instance = pino(
+ { level: 'info', redact: { paths: [...REDACT_PATHS], censor: '[REDACTED]' } },
+ stream,
+ );
+
+ instance.info({ req: { headers: { [header]: 'SENSITIVE-VALUE' } } }, 'request');
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(chunks.join(''), header).not.toContain('SENSITIVE-VALUE');
+ }
+ });
+
it('exposes a Logger-shaped wrapper whose child() also redacts', async () => {
const { core } = createGatewayLogger({ level: 'silent', prettyPrint: false });
const child = core.child({ requestId: 'req-1' });
diff --git a/tests/unit/gateway/readiness.test.ts b/tests/unit/gateway/readiness.test.ts
index 90b1f4c..45726f3 100644
--- a/tests/unit/gateway/readiness.test.ts
+++ b/tests/unit/gateway/readiness.test.ts
@@ -41,6 +41,7 @@ describe('createReadinessProbe', () => {
store,
adapterRuntimes: [],
paymentProviders: [provider],
+ authorizationProviders: [],
clock,
logger: NOOP_LOGGER,
});
@@ -59,7 +60,14 @@ describe('createReadinessProbe', () => {
};
const clock = createFakeClock();
const probe = createReadinessProbe(
- { store, adapterRuntimes: [], paymentProviders: [], clock, logger: NOOP_LOGGER },
+ {
+ store,
+ adapterRuntimes: [],
+ paymentProviders: [],
+ authorizationProviders: [],
+ clock,
+ logger: NOOP_LOGGER,
+ },
2000,
);
@@ -78,7 +86,14 @@ describe('createReadinessProbe', () => {
};
const clock = createFakeClock();
const probe = createReadinessProbe(
- { store, adapterRuntimes: [], paymentProviders: [], clock, logger: NOOP_LOGGER },
+ {
+ store,
+ adapterRuntimes: [],
+ paymentProviders: [],
+ authorizationProviders: [],
+ clock,
+ logger: NOOP_LOGGER,
+ },
2000,
);
@@ -93,7 +108,14 @@ describe('createReadinessProbe', () => {
store.healthStatus = 'pass';
const clock = createFakeClock();
const probe = createReadinessProbe(
- { store, adapterRuntimes: [], paymentProviders: [], clock, logger: NOOP_LOGGER },
+ {
+ store,
+ adapterRuntimes: [],
+ paymentProviders: [],
+ authorizationProviders: [],
+ clock,
+ logger: NOOP_LOGGER,
+ },
100,
);
diff --git a/tests/unit/storage-receipts/helpers.ts b/tests/unit/storage-receipts/helpers.ts
index 52b450d..3926151 100644
--- a/tests/unit/storage-receipts/helpers.ts
+++ b/tests/unit/storage-receipts/helpers.ts
@@ -48,6 +48,7 @@ export function makeReceipt(overrides: Partial = {}): CommerceR
...(overrides.durationMs !== undefined ? { durationMs: overrides.durationMs } : {}),
...(overrides.protocol !== undefined ? { protocol: overrides.protocol } : {}),
...(overrides.metadata !== undefined ? { metadata: overrides.metadata } : {}),
+ ...(overrides.authorization !== undefined ? { authorization: overrides.authorization } : {}),
};
}
diff --git a/tests/unit/storage-receipts/no-secrets.test.ts b/tests/unit/storage-receipts/no-secrets.test.ts
index 481d717..030dc90 100644
--- a/tests/unit/storage-receipts/no-secrets.test.ts
+++ b/tests/unit/storage-receipts/no-secrets.test.ts
@@ -80,6 +80,23 @@ describe('no-secrets guarantee', () => {
expect(JSON.stringify(fetched)).not.toContain('0xSHOULD_NOT_PERSIST');
});
+ it('strips a secret-shaped field from receipt.authorization.metadata', async () => {
+ const receipt = makeReceipt({
+ id: 'r_auth',
+ authorization: {
+ method: 'ap2',
+ reference: 'sha256:abc',
+ metadata: { mandateToken: 'eyJ...', checkoutId: 'checkout-1' },
+ },
+ });
+ await store.saveReceipt(receipt);
+
+ const fetched = await store.getReceipt('r_auth');
+ expect(fetched?.authorization?.metadata?.['mandateToken']).toBe('[REDACTED]');
+ expect(fetched?.authorization?.metadata?.['checkoutId']).toBe('checkout-1');
+ expect(fetched?.authorization?.reference).toBe('sha256:abc');
+ });
+
it('strips a raw payment proof / Authorization header from receipt.payment.metadata', async () => {
const receipt = makeReceipt({
id: 'r_secret_payment',
diff --git a/tests/unit/storage-receipts/receipts-events.test.ts b/tests/unit/storage-receipts/receipts-events.test.ts
index 14bc28d..2fc3ab1 100644
--- a/tests/unit/storage-receipts/receipts-events.test.ts
+++ b/tests/unit/storage-receipts/receipts-events.test.ts
@@ -121,11 +121,18 @@ describe('receipts', () => {
});
});
- it('round-trips optional fields (payment, protocol, metadata) exactly', async () => {
+ it('round-trips optional fields (payment, protocol, metadata, authorization) exactly', async () => {
const receipt = makeReceipt({
id: 'r_full',
protocol: 'http',
metadata: { note: 'ok' },
+ authorization: {
+ method: 'ap2',
+ reference: 'sha256:abc',
+ // Not `checkoutJwtId`: the redactor strips any key containing "jwt",
+ // so a provider naming its audit fields carelessly persists nothing
+ metadata: { checkoutId: 'checkout-1' },
+ },
payment: {
status: 'settled',
provider: 'x402',
@@ -149,6 +156,15 @@ describe('receipts', () => {
expect('durationMs' in (fetched ?? {})).toBe(false);
expect('protocol' in (fetched ?? {})).toBe(false);
expect('metadata' in (fetched ?? {})).toBe(false);
+ expect('authorization' in (fetched ?? {})).toBe(false);
+ });
+
+ it('reads a receipt written before the authorization column existed', async () => {
+ // Migration 2 added the column, so a v1 row has NULL there. That must read
+ // back as "required none", not as a receipt the mapper refuses.
+ await store.saveReceipt(makeReceipt({ id: 'r_legacy' }));
+ const fetched = await store.getReceipt('r_legacy');
+ expect(fetched?.authorization).toBeUndefined();
});
});
diff --git a/tsup.config.ts b/tsup.config.ts
index f3cc846..52f2448 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -12,8 +12,8 @@ const pkg = require('./package.json') as {
/**
* Two builds, four entry points.
*
- * **Library** — `dist/index.js` plus the two optional-peer subpaths,
- * `dist/mcp.js` and `dist/x402.js`. These are built together with
+ * **Library** — `dist/index.js` plus the three optional-peer subpaths,
+ * `dist/ap2.js`, `dist/mcp.js` and `dist/x402.js`. These are built together with
* `splitting: true` so everything they share — `src/core`, `CommerceError`,
* the canonical types — lands in one shared chunk that all three import.
* That is not a size optimisation, it is a correctness requirement: built as
@@ -74,7 +74,7 @@ const shared = {
export default defineConfig([
{
...shared,
- entry: { index: 'src/index.ts', mcp: 'src/mcp.ts', x402: 'src/x402.ts' },
+ entry: { index: 'src/index.ts', ap2: 'src/ap2.ts', mcp: 'src/mcp.ts', x402: 'src/x402.ts' },
clean: true,
splitting: true,
},