src/core/public-types.ts is the frozen cross-area API, established at
v0.1.0-alpha. Change it only through this sequence:
- Record the use case, proposed change, alternatives, and compatibility impact.
- Approve the change.
- Update the canonical type, this record, and affected consumers.
- Re-baseline with
node scripts/contract-surface.mjs --write. - Run
npm run verify.
Do not resolve disagreements by creating duplicate types.
| Source | Purpose |
|---|---|
src/core/public-types.ts |
Canonical frozen barrel |
contract-surface.txt |
Generated list of exported names and signatures |
docs/contracts.md |
Invariants, decisions, and integration boundaries |
contract-surface.txt is generated by scripts/contract-surface.mjs and checked
by npm run check:contract. If its enumeration differs from this document, the
generated file is authoritative.
This table is for navigation, not enumeration.
| Area | Defining modules |
|---|---|
| Resources and pricing | domain/resource.ts |
| Payments | domain/payment.ts |
| Authorization | domain/authorization.ts |
| Receipts and payment attempts | domain/receipt.ts |
| Events | domain/event.ts |
| Requests and outcomes | domain/request.ts |
| Shared names, descriptors, and scalar types | domain/common.ts |
| Wire envelopes, headers, and reserved inputs | domain/wire.ts |
| Errors and retryability | errors/** |
| Protocol adapter boundary | interfaces/protocol-adapter.ts |
| Storage boundary | interfaces/store.ts |
| Backend boundary | interfaces/backend.ts |
| Runtime utilities | interfaces/logger.ts, interfaces/runtime.ts |
- Each ingress path generates
CanonicalRequest.requestId; a client never chooses it. Over HTTP, a clientx-request-idmatching[A-Za-z0-9._:-]{1,64}is logged asclientRequestIdand nothing more. Records created for the request, including events, payment attempts, and receipts, reuse the generated ID. PaymentRequirement.challenge.acceptsis provider-native and opaque. Pass it through unchanged.- A payment provider derives
PaymentResult.replayKeyonly from the authorization, including payer, nonce, asset, and network. It must not use the request ID; replaying one authorization under another request must still collide. PaymentProvider.verifydoes not move funds.settleruns only after verification returnsverifiedwith a non-emptyreplayKey, any required authorization is verified and reserved, andreservePaymentAttemptsucceeds.EventSink.emitand event persistence must not fail a commerce flow.HttpBackendExecutoris the only built-in outbound HTTP path to merchant backends and always applies a timeout. A customGatewayOptions.backendreplaces it and must enforce its own timeout.- Amounts are decimal strings in display units, such as
"0.01". Payment providers convert them to base units. exactOptionalPropertyTypesis enabled. Add optional properties conditionally instead of assigningundefined.- Preserve
AuthorizationSubmission.payloadbyte-for-byte as opaque provider input; transport layers must not decode, normalize, or reserialize it. AP2 hashes the issuer-signed token inside its presentation, not the full payload. - Authorization providers must not use
PAYMENT_*codes. The pipeline passes a provider'sCommerceErrorthrough unchanged, so it cannot enforce this boundary itself. A 402 invites payment and retry, which cannot repair a missing or rejected mandate.
CommerceResource.paymentMethods is ordered. The pipeline charges through the
first method with an enabled provider and does not retry another method after
a rejection. createGateway lists each resource's provider-backed methods
first, keeping declared order, so every ingress path labels a proof with the
method the pipeline selects; GET /api/resources shows that order.
Never place private keys, raw payment proofs, authorization headers, tokens, or
credentials in receipt metadata, event data, or payment metadata. The SQLite
store recursively redacts secret-shaped keys as defense in depth. It replaces
the value with [REDACTED] instead of rejecting the write because event
persistence must not fail the commerce flow.
- The initial surface was frozen at v0.1.0-alpha. UCP was later removed; current
protocol names are defined by
ProtocolNameandPROTOCOL_NAMES. toCommerceErrorno longer exposes an arbitraryError.message. It keeps the original error as the non-serializedcause.PaymentAttempt.statusgainedsettlement-uncertain, recorded wheneversettle()throws, with or without a transaction hash.GATEWAY_BUSYis a retryable 503 used for transient load shedding.ReceiptStoregainedcountReceiptsandcountUndeliveredReceipts; list results are capped and cannot provide reliable totals.DeliverySummary,toDeliverySummary, andDELIVERY_SUMMARY_META_KEYexpose a buyer's settlement result. The key isagent-commerce/delivery.- x402 v2 changed the HTTP headers to
payment-signature,payment-required, andpayment-response. The v1x-payment*headers are not accepted. - An unexpected throw from the x402 SDK during
verify()maps toPAYMENT_PROVIDER_UNAVAILABLE, replacing the rejection reasonunexpected_verify_error. A throw carries no verdict, so it is not recorded against the payer. PaymentChallenge.envelopeandPaymentRequiredEnvelope.payment.envelopecarry the provider's challenge document unchanged.BackendHandler.inputBindingsoptionally maps top-level input fields to path, query, and body. If absent, legacy mapping applies; if present, unmapped fields are not forwarded. The mapping is explicit because the backend shape belongs to operator config, not schema-name inference. Shape errors remainINPUT_INVALIDbefore pricing.CanonicalRequest.idempotencyKeyandBackendRequest.idempotencyKeyare optional. ACP derives a stable, operation-scoped value becauserequestIdchanges on retry. The HTTP backend sends it asIdempotency-Key, replacing a static header of the same name from backend config. Two clients using the same key for one deployment and endpoint share both the derived key and the local claim; key uniqueness remains the client's responsibility.
ProtocolNamegaineda2aandacp;PROTOCOL_NAMESis the runtime list used by validation. It is derived from a record keyed by the union, so a missing or extra name fails to compile. The type remains hand-written so generated contract output uses its name instead of expanding a literal union everywhere.AdapterHttpRouteandHttpProtocolAdapter.additionalHttpRouteslet an adapter register fixed routes outside its mount. Startup rejects conflicting cross-adapter route claims, and fixed routes inherit the mount's body limit, concurrency cap, and failure isolation./.well-known/agent-commerceno longer publishes the facilitator URL or RPC URL because either may contain credentials. It publishes the x402 deployment mode instead.- ACP added its config block, checkout adapter, discovery constants, and five checkout-operation mappings. Enabled ACP config is discriminated so an incomplete checkout lifecycle fails during config load.
- A cancel resource whose input schema sets
additionalProperties: falsemust declarebody, which a cancel carries when the caller sends one. Config load rejects one that does not; before, it loaded and refused every cancel carryingintent_tracewithINPUT_INVALID. WellKnownDocument.authorizationProviderslists authorization descriptors separately from payment providers.- The operator SSE route
GET /api/events/streamwas removed.GET /api/eventsis the only event feed, and the demo dashboard polls it. - The main entry exports the A2A adapter:
createA2aAdapterand its options type, plusA2A_SPEC_VERSION,A2A_PROTOCOL_VERSIONandA2A_AGENT_CARD_PATH. It needs no optional peer. CreatePaymentProofOptionsdropped the deprecatedrpcUrl, which the helper ignored. TypeScript rejects it on a fresh object literal; callers passing a variable with extra properties should remove it as well.
- The generic authorization contract added AP2 as an authorization method,
provider lifecycle types, wire carriers, four
AUTHORIZATION_*errors, and optional authorization fields on requests, resources, 402 outcomes, and receipts. Authorization is neither a transport nor a payment rail. - The pipeline verifies and reserves authorization before payment replay
reservation, then consumes, releases, or marks it uncertain after settlement.
Receipts store an
AuthorizationRecord, not the live reservation handle or proof. An uncertain settlement moves the mandate touncertain; onlyreleasedmandates can be presented again, so an uncertain mandate cannot authorize a second charge. Receipt schema version 2 adds nullableauthorization_jsonwithALTER TABLE, preserving rows in existing databases. GatewayOptions.authorizationProvidersand readiness reporting are additive. A failing authorization provider blocks readiness./readyreplaces its raw detail withauthorization-provider-unreachable; thrown health errors are logged aterror, while returned details are logged atdebug.- The
./ap2entry exports the AP2 provider andcreateCheckoutJwt. The signing helper runs in the merchant process; the gateway only verifies its result. It uses the same RFC 8785 input hash and rejects invalid keys or amounts before they become opaque verification failures. AP2_REJECTION_REASONSdroppedunsupported_algorithm. A digest algorithm other than sha-256 ismalformed_presentation, and a signing algorithm other than the pinned one isinvalid_signature.
PaymentMethodNamegainedmpp, andPAYMENT_METHOD_NAMESexposes payment names at runtime. This changes the frozen union but adds no MPP-specific wire fields to core.GatewayConfig.paymentsgained an optionalmppblock. A paid resource still needs at least one of its named rails enabled, so a resource that names onlymppneedspayments.mppenabled but no x402 block.- The
./mppentry exportscreateMppPaymentProvider(also asmpp) beside the pinned profile metadata and descriptor. The provider runs local checks, then verifies and settles through the x402 facilitator named in its options, using x402-compatible replay keys.
| Entry | Surface | Imported optional peers |
|---|---|---|
@devlab.group/agent-commerce |
Core contract, config, gateway, receipt store, A2A and ACP adapters | None |
@devlab.group/agent-commerce/ap2 |
AP2 verification and checkout signing | jose, @sd-jwt/core, canonicalize |
@devlab.group/agent-commerce/mcp |
MCP adapter | @modelcontextprotocol/sdk |
@devlab.group/agent-commerce/mpp |
MPP provider and profile metadata | @coinbase/x402 (only for auth.type: cdp), @x402/core, @x402/evm, mppx, viem |
@devlab.group/agent-commerce/x402 |
x402 provider and client proof helper | @coinbase/x402 (only for auth.type: cdp), @x402/core, @x402/evm, viem |
The main entry and CLI must not import optional peers. package.json is the
authoritative export and dependency map.
The TypeScript declarations own the exact shapes. The excerpts below record the factory boundaries and constraints that consumers rely on.
export interface SqliteReceiptStoreOptions {
/** File path, or ':memory:' for tests. Parent directory is created if missing. */
readonly path: string;
readonly logger?: Logger;
readonly clock?: Clock;
readonly ids?: IdGenerator;
}
export function createSqliteReceiptStore(
options: SqliteReceiptStoreOptions,
): ReceiptStore;reservePaymentAttempt is atomic and throws
CommerceError('PAYMENT_REPLAYED', …) for a duplicate replayKey.
Published as @devlab.group/agent-commerce/x402.
export interface X402ProviderOptions {
readonly network: string; // CAIP-2, for example 'eip155:84532'
readonly rpcUrl: string;
readonly asset: `0x${string}`;
readonly assetName: string;
readonly assetVersion: string;
readonly assetDecimals: number;
/** Merchant-controlled settlement destination. */
readonly payTo: `0x${string}`;
readonly maxTimeoutSeconds?: number;
readonly facilitator: X402FacilitatorConfig;
/** Must be true before mainnet settlement. */
readonly allowMainnet?: boolean;
/** Must be true for an unauthenticated mainnet facilitator. */
readonly allowUnauthenticatedFacilitator?: boolean;
readonly logger?: Logger;
readonly clock?: Clock;
readonly ids?: IdGenerator;
}
export type FacilitatorAuth =
| { readonly type: 'none' }
| { readonly type: 'bearer'; readonly token: string }
| { readonly type: 'cdp'; readonly apiKeyId: string; readonly apiKeySecret: string };
export type X402FacilitatorConfig =
| { readonly mode: 'local'; readonly signerPrivateKey: string }
| { readonly mode: 'remote'; readonly url: string; readonly auth: FacilitatorAuth };
export function createX402PaymentProvider(
options: X402ProviderOptions,
): PaymentProvider;
export type DeploymentMode = 'local' | 'testnet' | 'mainnet';
export const SUPPORTED_NETWORK_IDS: readonly string[];
export interface NetworkProfile {
readonly id: string; // CAIP-2
readonly chainId: number;
readonly displayName: string;
readonly kind: 'testnet' | 'mainnet';
/** Canonical USDC and its EIP-712 domain, enforced on mainnet only */
readonly canonicalAsset?: {
readonly symbol: string;
readonly address: string;
readonly name: string;
readonly version: string;
};
}Local facilitator keys are for the development chain only and must never hold funds. Remote mode holds no facilitator signing key in the gateway.
Published as @devlab.group/agent-commerce/mpp.
export interface MppProviderOptions {
/** Merchant-controlled settlement destination. Never gateway-owned */
readonly recipient: `0x${string}`;
/** EIP-3009 token the charge is paid in */
readonly asset: `0x${string}`;
readonly assetName: string; // EIP-712 domain name, e.g. 'USDC'
readonly assetVersion: string; // EIP-712 domain version, e.g. '2'
readonly realm: string;
/** Key that binds each challenge to this gateway. Never logged or published */
readonly challengeSecret: string;
readonly rpcUrl: string;
/** x402 facilitator that verifies and broadcasts the authorization */
readonly facilitator: X402FacilitatorConfig;
/** Must be true before mainnet settlement */
readonly allowMainnet?: boolean;
/** Must be true for an unauthenticated mainnet facilitator */
readonly allowUnauthenticatedFacilitator?: boolean;
readonly logger?: Logger;
readonly challengeTtlSeconds?: number; // default 300
/** CAIP-2 network: 'eip155:84532' (default) or 'eip155:8453' */
readonly network?: string;
readonly clock?: Clock;
readonly ids?: IdGenerator;
}
export function createMppPaymentProvider(options: MppProviderOptions): PaymentProvider;Construction fails with CONFIG_INVALID for a recipient or asset that is not
an address, an empty EIP-712 domain name or version, an empty or multi-line
realm, a challenge secret with length below 32, a TTL that is not a
positive whole number, or an unsupported network. The supported networks are
eip155:84532 and eip155:8453. The provider then builds an internal x402
provider from the same network, asset, recipient and facilitator options, so
every x402 construction check applies, including the mainnet guardrails.
X402FacilitatorConfig is the type exported by the ./x402 entry.
createRequirement refuses a resource priced in anything but USDC
(CONFIG_INVALID) and an amount that is not a positive decimal with at most 6
fractional digits (PAYMENT_INVALID). Each challenge binds the resource id in
its HMAC-covered opaque field.
verify performs local checks, then the facilitator's read-only check through
the internal x402 provider, and confirms that both derived the same replay key.
The shared key makes an authorization collide across both rails. settle
rewraps the credential and hands it to x402 settlement. Negative
settlement results remain rejections; thrown settlement errors propagate, so
the pipeline records settlement-uncertain and returns
PAYMENT_SETTLEMENT_FAILED. Returned results name mpp; health returns the
internal x402 provider's health unchanged.
PaymentSubmission.payload is the serialized credential: the value of an
Authorization: Payment ... header, scheme included. PaymentChallenge.envelope
is { wwwAuthenticate }, the challenge as a WWW-Authenticate value, and a
settled result's metadata.receipt is the Payment-Receipt value.
Published as @devlab.group/agent-commerce/ap2.
export interface Ap2AuthorizationProviderOptions {
readonly config: EnabledAp2Config;
readonly clock?: Clock;
readonly logger?: Logger;
readonly replayStore?: Ap2ReplayStore;
}
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: {
readonly mandateIssuers: readonly Ap2TrustedIssuer[];
readonly checkoutIssuers: readonly Ap2TrustedIssuer[];
};
readonly clockSkewSeconds: number;
readonly replay: { readonly path: string };
};
export type EnabledAp2Config = Extract<Ap2AuthorizationConfig, { enabled: true }>;
export interface Ap2TrustedIssuer {
readonly issuer: string;
readonly audience: string;
readonly keys: readonly Ap2TrustedKey[];
}
export interface Ap2TrustedKey {
readonly kid: string;
readonly jwk: Readonly<Record<string, string>>; // public P-256 JWK
}The replay database is separate from payment replay storage. The caller that
creates the provider must close it. main.ts calls close() after
gateway.close(); gateway.close() does not close authorization providers.
The merchant-side checkout signer has this boundary:
export type Ap2SigningKey = Readonly<Record<string, unknown>> | string;
export interface CreateCheckoutJwtOptions {
readonly privateKey: Ap2SigningKey; // private ES256 JWK or PKCS#8 PEM
readonly kid: string;
readonly issuer: string;
readonly audience: string;
readonly resourceId: string;
readonly input: unknown;
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<string>;input excludes reserved fields, request IDs, and transport metadata. amount
is a decimal string and is compared as text, so 0.1 and 0.10 are distinct.
Published as @devlab.group/agent-commerce/mcp.
export interface McpAdapterOptions {
readonly mountPath?: string; // default '/mcp'
readonly serverName?: string; // default 'agent-commerce'
readonly serverVersion?: string; // default package version
}
export function createMcpAdapter(
options?: McpAdapterOptions,
): HttpProtocolAdapter;export interface GatewayOptions {
readonly config: GatewayConfig;
readonly store: ReceiptStore;
readonly paymentProviders: readonly PaymentProvider[];
readonly authorizationProviders?: readonly AuthorizationProvider[];
readonly protocolAdapters: readonly ProtocolAdapter[];
readonly logger?: Logger;
readonly clock?: Clock;
readonly ids?: IdGenerator;
readonly backend?: BackendExecutor;
}
export interface GatewayInstance {
readonly pipeline: ExecutionPipeline;
readonly resources: ResourceRegistry;
listen(): Promise<{ url: string }>;
close(): Promise<void>;
readonly server: FastifyInstance;
}
export function createGateway(
options: GatewayOptions,
): Promise<GatewayInstance>;Omit authorizationProviders when no resource requires authorization. backend
is an injectable test boundary.
export function loadConfig(options?: {
path?: string;
env?: NodeJS.ProcessEnv;
cwd?: string;
}): Promise<GatewayConfig>;
export function parseConfig(
raw: unknown,
env: NodeJS.ProcessEnv,
): GatewayConfig;
export type AcpCheckoutOperation =
| 'createCheckoutSession'
| 'updateCheckoutSession'
| 'getCheckoutSession'
| 'completeCheckoutSession'
| 'cancelCheckoutSession';
export interface AcpDiscoveryConfig {
readonly documentationUrl?: string;
readonly supportedCurrencies?: readonly string[];
readonly supportedLocales?: readonly string[];
readonly interventionTypes?: readonly string[];
}
export type AcpProtocolConfig =
| { readonly enabled: false; readonly mountPath: string }
| {
readonly enabled: true;
readonly mountPath: string;
readonly auth: { readonly type: 'bearer'; readonly token: string };
readonly idempotency: { readonly path: string; readonly retentionHours: number };
readonly checkout: {
readonly operations: Readonly<Record<AcpCheckoutOperation, string>>;
};
readonly discovery?: AcpDiscoveryConfig;
};
export interface GatewayConfig {
readonly version: 1;
readonly merchant: {
readonly id: string;
readonly name: string;
readonly publicBaseUrl: string;
};
readonly server: {
readonly port: number;
readonly host: string;
readonly adminToken?: string;
readonly allowedOrigins: readonly string[];
};
readonly storage: {
readonly receipts: { readonly driver: 'sqlite'; readonly path: string };
};
readonly protocols: {
readonly http: { readonly enabled: boolean };
readonly mcp: { readonly enabled: boolean; readonly mountPath: string };
readonly a2a: { readonly enabled: boolean; readonly mountPath: string };
readonly acp: AcpProtocolConfig;
};
readonly resources: readonly CommerceResource[];
readonly payments: {
readonly x402?: {
readonly enabled: boolean;
readonly network: string;
readonly rpcUrl: string;
readonly asset: string;
readonly assetName: string;
readonly assetVersion: string;
readonly assetDecimals: number;
readonly payTo: string;
readonly maxTimeoutSeconds: number;
readonly facilitator: X402FacilitatorConfig;
readonly allowMainnet?: boolean;
readonly allowUnauthenticatedFacilitator?: boolean;
};
readonly mpp?: {
readonly enabled: boolean;
readonly network: 'eip155:84532' | 'eip155:8453';
readonly rpcUrl: string;
readonly asset: string;
readonly assetName: string;
readonly assetVersion: string;
readonly recipient: string;
readonly realm: string;
readonly challengeSecret: string;
readonly challengeTtlSeconds?: number;
readonly facilitator: X402FacilitatorConfig;
readonly allowMainnet?: boolean;
readonly allowUnauthenticatedFacilitator?: boolean;
};
};
readonly authorization?: {
readonly ap2: Ap2AuthorizationConfig;
};
}GatewayConfig is validated, environment-substituted, and normalized. An
unset adminToken makes operator routes return 404; an empty
allowedOrigins permits no cross-origin browser access.
| Route | Behavior |
|---|---|
GET /health |
Returns 200 after the Host check and, when present, the Origin check pass; a rejected Host or Origin returns 403. |
GET /ready |
Returns 503 if the store, an adapter, or either provider kind reports fail; warn remains ready. Results are briefly cached and concurrent probes share one check. |
GET /.well-known/agent-commerce |
Publishes merchant and sanitized adapter, provider, store, and protocol metadata. |
GET /api/resources |
Lists canonical resources without secrets. |
POST /api/resources/:id/invoke |
Invokes the HTTP surface. Payment and authorization use their defined headers; an unpaid request returns 402. |
GET /api/receipts?limit= |
Lists recent receipts through the operator-token gate. |
GET /api/events?limit= |
Lists recent events through the operator-token gate. |
| Adapter mounts and fixed routes | Delegated to enabled protocol adapters, with collision checks and per-mount limits. |
Without server.adminToken, both operator routes return 404. With a token,
they require Authorization: Bearer <token>; query-string tokens are not
accepted.
npm run chain:deploy writes the git-ignored .deploy/local.json:
{
"chainId": 84532,
"rpcUrl": "http://127.0.0.1:8545",
"hostRpcUrl": "http://127.0.0.1:8545",
"asset": "0x...",
"assetName": "MockUSDC",
"assetVersion": "2",
"assetDecimals": 6,
"merchant": {
"address": "0x...",
"privateKeyLabel": "LOCAL DEVELOPMENT ONLY - DO NOT FUND"
},
"buyer": {
"address": "0x...",
"privateKey": "0x...",
"note": "LOCAL DEVELOPMENT ONLY - DO NOT FUND"
},
"facilitator": {
"address": "0x...",
"privateKey": "0x...",
"note": "LOCAL DEVELOPMENT ONLY - DO NOT FUND"
},
"buyerInitialBalance": "100.00"
}src/payments/x402/local-chain/manifest.ts owns LocalChainManifest,
LOCAL_CHAIN_MANIFEST_PATH, and readLocalChainManifest(cwd?). The deployment
script imports that module directly, and the internal testing surface
re-exports it.
hostRpcUrl is the host-reachable address of the same chain. Host-side tools
use hostRpcUrl ?? rpcUrl, which supports older manifests without the optional
field. readLocalChainManifest throws when the file is absent or malformed.
The internal src/payments/x402/testing.ts surface is for repository tests and
the demo buyer; it is not a published package entry.
createPaymentProof is the x402 client helper exported from the ./x402 entry.
The demo buyer and the tests use it. Signing is offline: the chain id comes from
the requirement's CAIP-2 network. The gateway does not call it or hold the buyer
key.
export interface CreatePaymentProofOptions {
/** The buyer's private key. It signs locally and is never sent anywhere */
readonly buyerPrivateKey: `0x${string}`;
/** One entry from PaymentRequiredEnvelope.payment.accepts, verbatim */
readonly accepts: Readonly<Record<string, unknown>>;
/** For negative tests only: a wrong amount, recipient, nonce or validity window */
readonly overrides?: {
readonly value?: string;
readonly payTo?: string;
readonly nonce?: `0x${string}`;
readonly validBefore?: number;
readonly validAfter?: number;
};
}
export function createPaymentProof(
options: CreatePaymentProofOptions,
): Promise<string>;The result is the base64 PAYMENT-SIGNATURE value returned to the gateway.
src/gateway/main.ts loads configuration, creates concrete providers and
adapters, starts the gateway, and closes owned resources during shutdown.
createGateway remains usable with injected fakes and does not depend on the
composition root.