Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 58 additions & 40 deletions README.md

Large diffs are not rendered by default.

32 changes: 25 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## The problem

A merchant already has an HTTP API. AI agents are learning to discover, invoke
and pay for capabilities through a growing set of protocols MCP, x402, and
and pay for capabilities through a growing set of protocols - MCP, x402, and
several more arriving. Implementing each one inside every merchant backend does
not scale, and handing the money to a proprietary middleman defeats the point.

Expand Down Expand Up @@ -41,7 +41,24 @@ Three properties are load-bearing:
- **Non-custodial.** The gateway orchestrates a payment protocol; it never holds
funds or keys. See.
- **Configuration, not rewriting.** A merchant exposes an existing endpoint by
describing it in `config.yaml`.
describing it in `config.yaml`. If they already have an OpenAPI description,
`agent-commerce import openapi` writes that configuration for them - an
ingress tool, not a second runtime:

```text
OpenAPI -> importer -> resource definitions -> canonical model -> pipeline
```

and never:

```text
OpenAPI -> a separate runtime executor
```

The importer terminates at the config boundary. No OpenAPI type exists in
`src/core`, nothing reads the document after import, and an imported resource
is indistinguishable at runtime from one typed by hand. See
[openapi-import.md](openapi-import.md).

## Canonical model before protocol adapters

Expand All @@ -64,12 +81,12 @@ boundaries. Provider-native payment challenges ride through the core as opaque
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
adapter rather than a core rewrite - and semantics from one protocol cannot leak
into another. See.

## The execution pipeline

Every adapter converges here. Nothing bypasses it that is what makes payment
Every adapter converges here. Nothing bypasses it - that is what makes payment
enforcement a property of the system rather than of each adapter.

```text
Expand Down Expand Up @@ -103,7 +120,7 @@ funds move.

Every flow has one `requestId`, generated by the protocol adapter and carried
through every log line, event, payment attempt and receipt. That single id is
what makes a live demo and a post-incident investigation legible.
what makes a live demo - and a post-incident investigation - legible.

Event sequence for a successful paid request:

Expand All @@ -120,7 +137,7 @@ must never become a payment failure, and vice versa.

## Receipts and audit

SQLite, three tables `receipts`, `events`, `payment_attempts` behind a thin
SQLite, three tables - `receipts`, `events`, `payment_attempts` - behind a thin
repository. `payment_attempts.replay_key` carries a `UNIQUE` constraint, which
is what makes the replay defence atomic rather than advisory. No secrets and no
raw payment proofs are persisted.
Expand All @@ -142,6 +159,7 @@ demo buyer agent is a deterministic program, and that is the path CI runs.
| MCP adapter | `src/protocols/mcp` |
| x402 provider + local/remote facilitator | `src/payments/x402` |
| SQLite receipts/events/attempts | `src/storage/receipts` |
| CLI (`init`, `validate`, `doctor`, `demo`) | `src/cli` |
| 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/` |
78 changes: 71 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
One file, `config.yaml`, validated before the server starts. Start from
[`config.example.yaml`](../config.example.yaml) or generate one:

`config.yaml` is yours it is git-ignored, and `agent-commerce init` writes it
`config.yaml` is yours - it is git-ignored, and `agent-commerce init` writes it
by default. The demo stack in this repository runs its own
[`config-demo.yaml`](../config-demo.yaml) instead, so the two never collide.

Expand Down Expand Up @@ -66,11 +66,75 @@ the HTTP path segment, so it must be unique and a legal tool name.

`url` supports `{param}` templating from validated input; values are
URL-encoded. Remaining input becomes query string for `GET`/`DELETE` and a JSON
body otherwise.
body otherwise - unless `backend.inputBindings` says otherwise, below.

**Backend URLs are administrator configuration.** They are never taken from
request input, and redirects are not followed. See [security.md](security.md).

### `backend.inputBindings`

Optional. Names the top-level input properties carrying each part of the
backend request:

```yaml
input:
type: object
properties:
path:
type: object
properties:
userId: { type: string }
required: [userId]
query:
type: object
properties:
notify: { type: boolean }
body:
type: object
properties:
productId: { type: string }
required: [path]
backend:
type: http
method: POST
url: ${MERCHANT_API_BASE_URL}/users/{userId}/orders
inputBindings:
path: path
query: query
body: body
```

**Absent is the legacy behaviour, unchanged**: `{param}` values come from
top-level input, and everything left over becomes the query string (`GET`,
`DELETE`) or the entire JSON body (`POST`, `PUT`, `PATCH`). Every existing
configuration keeps working exactly as before.

**Present is explicit mode.** Each group is sourced independently, so one
operation can carry path parameters, query parameters *and* a JSON body at
once - which the leftover rule cannot express, because on a body-capable
method everything not consumed by the URL template becomes the body. Top-level
input that no binding names is **not forwarded to the backend at all**.

The names are yours; `path` / `query` / `body` is only the convention the
OpenAPI importer generates. Config load rejects, before the gateway starts:

- a binding to a property the input schema does not declare (schemas are
closed, so it could never be supplied);
- `path` or `query` bound to something that is not an object schema;
- two locations bound to one property;
- a binding to `_payment`, which is reserved for payment proofs;
- a `body` binding on `GET` or `DELETE`, which send none;
- explicit bindings with no `path` binding while `url` is templated;
- a path group that is not in the input's `required`, or a `{param}` not
declared and required inside it - a caller who cannot supply a path
parameter makes every call unservable, and a paid one settles first.

At request time the same rules run **before pricing**, so a malformed request
shape can never settle a payment and then fail to reach the backend.

Generated by [`agent-commerce import openapi`](openapi-import.md); nothing
about the field is OpenAPI-specific.

## Payments

```yaml
Expand Down Expand Up @@ -122,12 +186,12 @@ facilitator:
type: none # or: type: bearer, token: ${X402_FACILITATOR_TOKEN}
```

`auth` may be omitted, which means the same as `type: none` an explicit
`auth` may be omitted, which means the same as `type: none` - an explicit
statement that this facilitator takes no credential, not a fallback. Only
`none` and `bearer` exist; a facilitator requiring per-request signed
credentials (a CDP JWT, for instance) is refused rather than sent nothing.

**What this deployment is** `local`, `testnet` or `mainnet` is derived
**What this deployment is** - `local`, `testnet` or `mainnet` - is derived
from the pair, not from the network alone, because chain id 84532 is shared
between the local dev chain and public Base Sepolia. It is reported by
`doctor`, by `health()`, and at `/.well-known/agent-commerce`.
Expand All @@ -152,7 +216,7 @@ HTTP is allowed only to a local/private host, and a development `payTo` is
refused on testnet too.

`agent-commerce validate` and `agent-commerce doctor` run exactly the checks
the gateway runs at startup the same function, not a second copy of the
the gateway runs at startup - the same function, not a second copy of the
rules.

`facilitator.auth` has three types: `none`, `bearer` (a static token, needs
Expand All @@ -163,7 +227,7 @@ refused at config load rather than sent nothing.
### `assetName` is the EIP-712 domain, not the symbol

The two USDC deployments disagree. Base Sepolia's reports `"USDC"`; Base
mainnet's reports `"USD Coin"` it predates the rename. The buyer signs that
mainnet's reports `"USD Coin"` - it predates the rename. The buyer signs that
string into their EIP-712 domain and the scheme checks it, so naming the
obvious-looking value gets every payment refused
`invalid_exact_evm_token_name_mismatch` *after* they have signed. Both values
Expand All @@ -188,7 +252,7 @@ resource schema uses one.

That is not merely "weaker validation". If a resource's backend URL contains a
`{param}` template, you have **no configuration-level way to reject an empty
string** for it `minLength: 1` will not be enforced. The gateway rejects
string** for it - `minLength: 1` will not be enforced. The gateway rejects
empty, `.` and `..` path parameters itself, before any payment is taken, but
anything else you intended `pattern` to exclude will reach your backend.

Expand Down
5 changes: 4 additions & 1 deletion docs/contract-surface.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Semantic surface of src/core/public-types.ts
# Generated by scripts/contract-surface.mjs — do not edit by hand.
# 70 exported symbols.
# 71 exported symbols.

interface AdapterDescriptor {
readonly capabilities: ReadonlyArray<string>;
Expand Down Expand Up @@ -31,6 +31,7 @@ interface BackendExecutor {

interface BackendHandler {
readonly headers?: Readonly<Record<string, string>>;
readonly inputBindings?: { readonly path?: string; readonly query?: string; readonly body?: string; };
readonly method: BackendMethod;
readonly timeoutMs?: number;
readonly type: "http";
Expand Down Expand Up @@ -398,6 +399,8 @@ value PAYMENT_REQUIRED_HEADER: "payment-required"

value PAYMENT_RESPONSE_HEADER: "payment-response"

value PROTOCOL_NAMES: ReadonlyArray<ProtocolName>

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 isCommerceError: (value: unknown) => value is CommerceError
Expand Down
Loading
Loading