Skip to content
11 changes: 8 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,8 @@ are `none`, `bearer` (a static token) and `cdp` (a fresh JWT per request).
For `cdp`, `apiKeySecret` must be CDP's PEM EC P-256 private key or base64
Ed25519 key. JWTs bind requests to CDP's host and path, so config load rejects
any `url` other than `https://api.cdp.coinbase.com/platform/v2/x402`. An
unreadable secret fails the health check with a detail naming `apiKeySecret`.
unreadable secret stops `start` with an error naming `apiKeySecret`. PEM
values may contain literal `\n` escapes copied from CDP's JSON key file.

Deployment mode - `local`, `testnet` or `mainnet` - comes from the network:
`eip155:31337` is local, and every other network is the testnet or mainnet it
Expand Down Expand Up @@ -836,9 +837,13 @@ with `x402` in `payments` · an `idempotency` block on a resource that is not
fixed-price, or with neither `merchantKey` nor `redelivery` · a
`redelivery.retentionHours` that is not an integer from 1 to 8760 ·
`redelivery.required: true` on a resource without `x402` in `payments` · an MPP
`challengeSecret` with length below 32 or a multi-line `realm`.
`challengeSecret` with length below 32 or a multi-line `realm` · a remote
facilitator `url` with a query string or fragment.

It exits non-zero on any of them.
Validation exits non-zero for these errors. It warns without failing if
`merchant.publicBaseUrl` is not an http(s) URL, or if
`redelivery.required: true` is set on a resource whose first enabled rail is not
x402.

## Environment

Expand Down
3 changes: 2 additions & 1 deletion docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@ persistence must not fail the commerce flow.
- `GatewayOptions.authorizationProviders` and readiness reporting are additive.
A failing authorization provider blocks readiness. `/ready` replaces its raw
detail with `authorization-provider-unreachable`; thrown health errors are
logged at `error`, while returned details are logged at `debug`.
logged at `error`. Returned failures log at `warn` when the check first
fails or fails again after recovery; other returned details use `debug`.
- The `./ap2` entry exports the AP2 provider and `createCheckoutJwt`. 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
Expand Down
2 changes: 1 addition & 1 deletion docs/payment-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ have moved. See [AP2](protocols/ap2.md#replay-states).
## Retry after a lost response

Experimental and disabled by default. A resource with
[`idempotency.redelivery`](configuration.md#resources) stores delivered
[`idempotency.redelivery`](configuration.md#resources) stores the delivered
response under a buyer-chosen key so a lost response can be retrieved without
another charge. The key is the x402
[`payment-identifier`](protocols/x402.md#payment-identifier) `id`, the MPP
Expand Down
512 changes: 257 additions & 255 deletions docs/security.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion plugins/agent-commerce-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ party it belongs to:
## Requirements

Node.js 22 or later, npm 10 or later, and `@devlab.group/agent-commerce`
2.2.0 or later.
2.7.0 or later.

## Install in Claude Code

Expand Down
4 changes: 2 additions & 2 deletions plugins/agent-commerce-gateway/skills/agent-commerce/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: agent-commerce
description: Put the Agent Commerce Gateway (npm @devlab.group/agent-commerce) in front of an existing HTTP API so AI agents can call and pay for its endpoints over HTTP or MCP, with x402 stablecoin payments. Use when adding agent payments, x402 or paid MCP tools to an API, when configuring, validating or starting the gateway, or when fixing errors from its validate, doctor or start commands.
license: Apache-2.0
compatibility: Node.js >= 22 and npm >= 10; @devlab.group/agent-commerce >= 2.2.0. Needs a shell that can run npm and npx.
compatibility: Node.js >= 22 and npm >= 10; @devlab.group/agent-commerce >= 2.7.0. Needs a shell that can run npm and npx.
metadata:
package: "@devlab.group/agent-commerce"
repository: https://github.com/devlab-group/agent-commerce
Expand Down Expand Up @@ -57,7 +57,7 @@ npm --version # 10 or later
mkdir agent-commerce && cd agent-commerce
npm init -y
npm install @devlab.group/agent-commerce
npx --no -- agent-commerce --version # below 2.2.0: stop and propose an upgrade
npx --no -- agent-commerce --version # below 2.7.0: stop and propose an upgrade
npx --no -- agent-commerce start --help
```

Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/doctor/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ function railOffer(option: {
};
}

// Discovery exposes offer statuses; reasons stay in gateway debug logs
// Discovery exposes offer statuses; reasons stay in gateway logs
function offerHealthCheck(
live: RailTerms,
named: (network: string) => string,
Expand Down
9 changes: 8 additions & 1 deletion src/config/schema/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CommerceError } from '../../core';
import { normalizeAcp, validateAcpCheckoutMapping } from './acp';
import { normalizeAp2, validateLegacyStoresIsolated } from './ap2';
import { normalizeMpp } from './mpp';
import { type RawConfig, SUPPORTED_CONFIG_VERSION } from './raw';
import { isHttpUrl, type RawConfig, SUPPORTED_CONFIG_VERSION } from './raw';
import { normalizeResource } from './resources';
import type { GatewayConfig, NormalizedProtocols } from './types';
import { toBoolean, toNumber } from './values';
Expand Down Expand Up @@ -78,6 +78,13 @@ export function normalize(raw: RawConfig): GatewayConfig {
);
}

// Preserve 2.x compatibility: warn about invalid URLs instead of rejecting them
if (!isHttpUrl(raw.merchant.publicBaseUrl)) {
console.warn(
`[agent-commerce] merchant.publicBaseUrl ${JSON.stringify(raw.merchant.publicBaseUrl)} is not an http:// or https:// URL. The Host check may then refuse every caller but loopback, and the A2A Agent Card and ACP discovery publish broken URLs.`,
);
}

return {
version: SUPPORTED_CONFIG_VERSION,
merchant: {
Expand Down
2 changes: 1 addition & 1 deletion src/config/schema/raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const RpcUrl = z
.min(1)
.refine(isHttpUrl, { message: 'must be an http:// or https:// URL' });

function isHttpUrl(value: string): boolean {
export function isHttpUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
Expand Down
15 changes: 14 additions & 1 deletion src/config/schema/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,13 @@ export function normalizeResource(
);
}

const idempotency = normalizeResourceIdempotency(id, entry, paymentMethods);
// The gateway serves a resource on the first rail it lists that is enabled
const servingRail = paymentMethods.find(
(method) =>
(method === 'x402' && x402?.config.enabled === true) ||
(method === 'mpp' && mpp?.enabled === true),
);
const idempotency = normalizeResourceIdempotency(id, entry, paymentMethods, servingRail);

const pricing: Pricing =
entry.pricing.type === 'free'
Expand Down Expand Up @@ -283,6 +289,7 @@ function normalizeResourceIdempotency(
id: string,
entry: RawResourceEntry,
paymentMethods: readonly string[],
servingRail: string | undefined,
): CommerceResource['idempotency'] {
const raw = entry.idempotency;
if (raw === undefined) return undefined;
Expand All @@ -307,6 +314,12 @@ function normalizeResourceIdempotency(
`${path}.redelivery.required`,
);
}
// A warning, not a refusal: 2.7.0 loads this config
if (required && servingRail !== undefined && servingRail !== 'x402') {
console.warn(
`[agent-commerce] Resource "${id}" sets idempotency.redelivery.required, which applies to x402 only, but the gateway serves it on ${servingRail}, the first enabled rail in its payments. The setting has no effect; list "x402" first or remove it.`,
);
}
redelivery = {
retentionHours: toNumber(
raw.redelivery.retentionHours ?? DEFAULT_REDELIVERY_RETENTION_HOURS,
Expand Down
2 changes: 1 addition & 1 deletion src/core/interfaces/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export interface ReceiptStore {
* methods or none. Experimental: may change in a minor release.
*
* Atomically claim `scope` after deleting expired `completed` claims and
* expired `unresolved` claims on `PAYMENT_KEY_ENDPOINT_PREFIX` endpoints.
* expired `unresolved` claims on endpoints that start with `payment:`.
* Expiry occurs at `expiresAt`; other claims do not expire. Return `reserved`
* for a new scope, or `conflict` for a different `fingerprint` in any state.
*
Expand Down
46 changes: 35 additions & 11 deletions src/gateway/readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,20 @@ interface Probed {
readonly health: AdapterHealth;
}

// Names of the checks that failed at their last evaluation
type FailingChecks = Set<string>;

/**
* One probe for the store, the adapters and both provider kinds. A `health()`
* that throws counts as failing and is logged at error; a returned detail is
* logged at debug.
* Probe the store, an adapter or a provider. A thrown `health()` error fails
* the check and logs at error. Returned failures log at warn on the first
* failure or after recovery; other returned details log at debug.
*/
async function probe(
target: { readonly name: string; health(): Promise<AdapterHealth> },
kind: string,
details: HealthDetails,
options: Pick<CheckReadinessOptions, 'clock' | 'logger'>,
failing: FailingChecks,
): Promise<Probed> {
let health: AdapterHealth;
let threw = false;
Expand All @@ -112,7 +116,16 @@ async function probe(
health = { status: 'fail', checkedAt: options.clock.nowIso() };
threw = true;
}
if (health.detail !== undefined) {
const key = `${kind}:${target.name}`;
const startsFailing = health.status === 'fail' && !failing.has(key);
if (health.status === 'fail') failing.add(key);
else failing.delete(key);
if (startsFailing && !threw) {
options.logger.warn(
{ name: target.name, ...(health.detail !== undefined ? { detail: health.detail } : {}) },
`${kind} health check failed (detail not sent to the client)`,
);
} else if (health.detail !== undefined) {
options.logger.debug(
{ name: target.name, detail: health.detail },
`${kind} health detail (not sent to the client)`,
Expand Down Expand Up @@ -141,33 +154,43 @@ export interface ProbedProvider {
readonly health: AdapterHealth;
}

async function evaluate(options: CheckReadinessOptions): Promise<Evaluation> {
async function evaluate(
options: CheckReadinessOptions,
failing: FailingChecks,
): Promise<Evaluation> {
const [store, adapters, paymentProviders, authorizationProviders] = await Promise.all([
probe({ name: 'store', health: () => options.store.health() }, 'store', STORE_DETAIL, options),
probe(
{ name: 'store', health: () => options.store.health() },
'store',
STORE_DETAIL,
options,
failing,
),
Promise.all(
options.adapterRuntimes.map(async (runtime) => ({
runtime,
probed: await probe(
// getAdapterHealth turns a throw into a failed result, so an adapter
// failure is logged at debug as a detail rather than at error
// getAdapterHealth converts throws to failed results, which use
// the logging rules for returned results
{ name: runtime.adapter.name, health: () => getAdapterHealth(runtime, options.clock) },
'adapter',
ADAPTER_DETAIL,
options,
failing,
),
})),
),
Promise.all(
options.paymentProviders.map((provider) =>
probe(provider, 'payment provider', PAYMENT_PROVIDER_DETAIL, options),
probe(provider, 'payment provider', PAYMENT_PROVIDER_DETAIL, options, failing),
),
),
// 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 honor
Promise.all(
options.authorizationProviders.map((provider) =>
probe(provider, 'authorization provider', AUTHORIZATION_PROVIDER_DETAIL, options),
probe(provider, 'authorization provider', AUTHORIZATION_PROVIDER_DETAIL, options, failing),
),
),
]);
Expand Down Expand Up @@ -214,13 +237,14 @@ export function createReadinessProbe(
): ReadinessProbe {
let cached: { readonly evaluation: Evaluation; readonly at: number } | undefined;
let inFlight: Promise<Evaluation> | undefined;
const failing: FailingChecks = new Set();

const current = async (): Promise<Evaluation> => {
const now = options.clock.monotonicMs();
if (cached && now - cached.at < ttlMs) return cached.evaluation;
if (inFlight) return inFlight;

inFlight = evaluate(options)
inFlight = evaluate(options, failing)
.then((evaluation) => {
cached = { evaluation, at: options.clock.monotonicMs() };
return evaluation;
Expand Down
12 changes: 11 additions & 1 deletion src/gateway/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
toErrorEnvelope,
toPaymentRequiredEnvelope,
} from '../core';
import { toLogInfo } from '../core/errors';
import { journalOf, resolveManually } from '../core/execution/reconcile';
import { isUsableIdempotencyKey } from '../core/idempotency-key';
import { isRecord } from '../core/is-record';
Expand Down Expand Up @@ -448,7 +449,16 @@ async function handleInvoke(
}
reply.status(outcome.backendStatus).send(outcome.body);
} catch (error) {
sendInvokeError(reply, toCommerceError(error), paymentMethod);
const commerceError = toCommerceError(error);
// Preserve the cause chain omitted from responses. Log 5xx failures at warn
// and other errors at debug to limit noise from unauthenticated requests.
const entry = { resourceId, requestId: request.id, err: toLogInfo(commerceError) };
if (commerceError.httpStatus >= 500) {
options.logger.warn(entry, 'http adapter: execution failed');
} else {
options.logger.debug(entry, 'http adapter: execution failed');
}
sendInvokeError(reply, commerceError, paymentMethod);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/gateway/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Concrete startup shared by `main.ts` and `agent-commerce start`.
*
* Await enabled rail, AP2 and MCP imports before opening the receipt store,
* so missing startup peers leave no database behind. Solana, NEAR and CDP loads
* so missing startup peers leave no database behind. Solana and NEAR loads
* begin during provider construction, but their errors surface in health
* checks or requests. Store failures abort startup; adapter start failures
* are isolated. `createGateway` accepts injected implementations without
Expand Down
50 changes: 32 additions & 18 deletions src/payments/x402/cdp-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,43 +16,57 @@ interface SigningKey {
}

/**
* Create the SDK's path-keyed header factory. Invalid secrets throw
* CONFIG_INVALID when the factory is called, failing health checks and
* leaving payments without a verification verdict.
* Create the SDK's path-keyed header factory. Validate the secret during
* construction and throw CONFIG_INVALID before returning a factory if it is invalid.
*/
export function createCdpAuthHeaders(
apiKeyId: string,
apiKeySecret: string,
now: () => number = Date.now,
): () => Promise<Record<string, Record<string, string>>> {
const signingKey = readKey(apiKeySecret);
if (!signingKey) {
// Never echo the secret: it is the credential
throw new CommerceError(
'CONFIG_INVALID',
'x402 provider: facilitator.auth.apiKeySecret is not a CDP API key secret. ' +
'Use the PEM EC private key or the base64 Ed25519 key that CDP issued.',
);
}
const { host, pathname } = new URL(CDP_FACILITATOR_URL);

return async () => {
if (!signingKey) {
// Never echo the secret: it is the credential
throw new CommerceError(
'CONFIG_INVALID',
'x402 provider: facilitator.auth.apiKeySecret is not a CDP API key secret. ' +
'Use the PEM EC private key or the base64 Ed25519 key that CDP issued.',
);
}
const issuedAt = Math.floor(now() / 1000);
const header = (method: string, route: string) => ({
Authorization: `Bearer ${signJwt(signingKey, apiKeyId, `${method} ${host}${pathname}/${route}`, issuedAt)}`,
});
// Sign a route only on its first read from this header object
const header = (method: string, route: string) => {
let value: Record<string, string> | undefined;
return () =>
(value ??= {
Authorization: `Bearer ${signJwt(signingKey, apiKeyId, `${method} ${host}${pathname}/${route}`, issuedAt)}`,
});
};
const verify = header('POST', 'verify');
const settle = header('POST', 'settle');
const supported = header('GET', 'supported');
return {
verify: header('POST', 'verify'),
settle: header('POST', 'settle'),
supported: header('GET', 'supported'),
get verify() {
return verify();
},
get settle() {
return settle();
},
get supported() {
return supported();
},
};
};
}

function readKey(secret: string): SigningKey | undefined {
try {
if (secret.includes('-----BEGIN')) {
const key = createPrivateKey(secret);
// Expand literal newline escapes from CDP's JSON key file before parsing PEM
const key = createPrivateKey(secret.replace(/\\n/g, '\n'));
const p256 =
key.asymmetricKeyType === 'ec' && key.asymmetricKeyDetails?.namedCurve === 'prime256v1';
return p256 ? { alg: 'ES256', key } : undefined;
Expand Down
4 changes: 2 additions & 2 deletions src/payments/x402/facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ export function createRemoteFacilitatorBinding(
return { verify: headers, settle: headers, supported: headers };
};
} else if (auth.type === 'cdp') {
// CDP JWTs bind method, host and path. Unreadable secrets cause
// PAYMENT_PROVIDER_UNAVAILABLE during payment, rather than a payer rejection.
// CDP JWTs bind method, host and path. An unreadable secret throws
// CONFIG_INVALID here, before the gateway listens
createAuthHeaders = createCdpAuthHeaders(auth.apiKeyId, auth.apiKeySecret);
}

Expand Down
Loading
Loading