@devlab.group/agent-commerce 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { I as IsoTimestamp, D as DecimalAmount, P as PaymentMethodName, B as BackendHandler, A as AdapterDescriptor, a as AdapterHealth, C as CommerceResource, R as ResourceRegistry, b as PaymentProvider, L as Logger, c as Clock, d as IdGenerator } from './runtime-B-9IjMbO.js';
2
- export { e as BackendMethod, f as DEFAULT_BACKEND_TIMEOUT_MS, J as JsonSchema, N as NOOP_LOGGER, g as PaymentChallenge, h as PaymentContext, i as PaymentRequirement, j as PaymentResult, k as PaymentSettlementContext, l as PaymentSubmission, m as PaymentVerificationContext, n as Pricing, o as ProtocolName, s as systemClock } from './runtime-B-9IjMbO.js';
1
+ import { I as IsoTimestamp, D as DecimalAmount, P as PaymentMethodName, B as BackendHandler, A as AdapterDescriptor, a as AdapterHealth, C as CommerceResource, R as ResourceRegistry, b as PaymentProvider, L as Logger, c as Clock, d as IdGenerator } from './runtime-Bqg9qW3t.js';
2
+ export { e as BackendMethod, f as DEFAULT_BACKEND_TIMEOUT_MS, J as JsonSchema, N as NOOP_LOGGER, g as PROTOCOL_NAMES, h as PaymentChallenge, i as PaymentContext, j as PaymentRequirement, k as PaymentResult, l as PaymentSettlementContext, m as PaymentSubmission, n as PaymentVerificationContext, o as Pricing, p as ProtocolName, s as systemClock } from './runtime-Bqg9qW3t.js';
3
3
  import { X as X402FacilitatorConfig } from './guardrails-Dmwi63KB.js';
4
- import { D as DeliveredOutcome, P as PaymentRequiredOutcome, C as CommerceEvent, a as PaymentAttempt, b as CommerceReceipt, E as ExecutionPipeline, c as ProtocolAdapter } from './protocol-adapter-CZPOb9uG.js';
5
- export { d as COMMERCE_EVENT_TYPES, e as CanonicalRequest, f as CommerceEventType, g as EventSink, h as ExecutionOutcome, H as HttpProtocolAdapter, i as ProtocolAdapterContext, j as isHttpProtocolAdapter } from './protocol-adapter-CZPOb9uG.js';
4
+ import { D as DeliveredOutcome, P as PaymentRequiredOutcome, C as CommerceEvent, a as PaymentAttempt, b as CommerceReceipt, E as ExecutionPipeline, c as ProtocolAdapter } from './protocol-adapter-BAKGnuZ4.js';
5
+ export { A as AdapterHttpRoute, d as COMMERCE_EVENT_TYPES, e as CanonicalRequest, f as CommerceEventType, g as EventSink, h as ExecutionOutcome, H as HttpProtocolAdapter, i as ProtocolAdapterContext, j as isHttpProtocolAdapter } from './protocol-adapter-BAKGnuZ4.js';
6
6
  import { FastifyInstance } from 'fastify';
7
7
  import 'node:http';
8
8
 
@@ -316,7 +316,7 @@ interface ReceiptStore {
316
316
  * Two-phase validation:
317
317
  * 1. Zod validates *shape* (types, required fields, unknown-key rejection).
318
318
  * Numeric/boolean leaves accept either their native type or a string
319
- * (env substitution always produces a string), and are left as-is here —
319
+ * (env substitution always produces a string), and are left as-is here -
320
320
  * Zod's typed transforms have surprising inference interactions with
321
321
  * `.strict()` objects, so numeric/boolean coercion is done explicitly,
322
322
  * in plain TypeScript, in the normalisation pass below.
@@ -362,6 +362,10 @@ interface GatewayConfig {
362
362
  readonly enabled: boolean;
363
363
  readonly mountPath: string;
364
364
  };
365
+ readonly a2a: {
366
+ readonly enabled: boolean;
367
+ readonly mountPath: string;
368
+ };
365
369
  };
366
370
  /** Canonical resources, already normalised. */
367
371
  readonly resources: readonly CommerceResource[];
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { resolveX402Deployment, resolveDeploymentMode, requireNetworkProfile } from './chunk-MEPETSP6.js';
2
- import { CommerceError, PAYMENT_INPUT_FIELD, systemClock, NOOP_LOGGER, PACKAGE_VERSION, toCommerceError, DEFAULT_BACKEND_TIMEOUT_MS, toErrorEnvelope, isHttpProtocolAdapter, isCommerceError, PAYMENT_HEADER, toPaymentRequiredEnvelope, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER } from './chunk-BQNE3A5H.js';
3
- export { COMMERCE_ERROR_CODES, COMMERCE_ERROR_HTTP_STATUS, COMMERCE_EVENT_TYPES, CommerceError, DEFAULT_BACKEND_TIMEOUT_MS, DELIVERY_SUMMARY_META_KEY, NOOP_LOGGER, PAYMENT_HEADER, PAYMENT_INPUT_FIELD, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, RETRYABLE_ERROR_CODES, isCommerceError, isHttpProtocolAdapter, isPaymentRequiredEnvelope, systemClock, toCommerceError, toDeliverySummary, toErrorEnvelope, toPaymentRequiredEnvelope } from './chunk-BQNE3A5H.js';
1
+ import { resolveX402Deployment, resolveDeploymentMode, requireNetworkProfile } from './chunk-5TKNTWMC.js';
2
+ import { PROTOCOL_NAMES, CommerceError, PAYMENT_INPUT_FIELD, systemClock, NOOP_LOGGER, PACKAGE_VERSION, toCommerceError, DEFAULT_BACKEND_TIMEOUT_MS, toErrorEnvelope, isHttpProtocolAdapter, isCommerceError, PAYMENT_HEADER, toPaymentRequiredEnvelope, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER } from './chunk-EIACECVM.js';
3
+ export { COMMERCE_ERROR_CODES, COMMERCE_ERROR_HTTP_STATUS, COMMERCE_EVENT_TYPES, CommerceError, DEFAULT_BACKEND_TIMEOUT_MS, DELIVERY_SUMMARY_META_KEY, NOOP_LOGGER, PAYMENT_HEADER, PAYMENT_INPUT_FIELD, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, PROTOCOL_NAMES, RETRYABLE_ERROR_CODES, isCommerceError, isHttpProtocolAdapter, isPaymentRequiredEnvelope, systemClock, toCommerceError, toDeliverySummary, toErrorEnvelope, toPaymentRequiredEnvelope } from './chunk-EIACECVM.js';
4
4
  import { promises, existsSync, mkdirSync, accessSync, constants, closeSync, openSync, chmodSync } from 'fs';
5
5
  import * as path from 'path';
6
6
  import { dirname } from 'path';
@@ -96,33 +96,15 @@ var HttpBackendExecutor = class {
96
96
  async call(handler, request) {
97
97
  const timeoutMs = handler.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
98
98
  const inputRecord = isPlainObject2(request.input) ? request.input : {};
99
+ const context = { requestId: request.requestId, resourceId: request.resourceId };
100
+ const parts = buildBackendRequestParts(handler, inputRecord, context);
99
101
  let target;
100
- let remaining;
101
102
  try {
102
- const templated = applyPathTemplate(handler.url, inputRecord);
103
- target = new URL(templated.url);
104
- remaining = templated.remaining;
103
+ target = new URL(parts.url);
105
104
  } catch (error) {
106
- if (error instanceof Error && error.message.startsWith("invalid-path-parameter:")) {
107
- const field = error.message.slice("invalid-path-parameter:".length);
108
- throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" is not a valid value`, {
109
- requestId: request.requestId,
110
- resourceId: request.resourceId,
111
- details: { field }
112
- });
113
- }
114
- if (error instanceof Error && error.message.startsWith("missing-path-parameter:")) {
115
- const field = error.message.slice("missing-path-parameter:".length);
116
- throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" was not supplied`, {
117
- requestId: request.requestId,
118
- resourceId: request.resourceId,
119
- details: { field }
120
- });
121
- }
122
105
  throw new CommerceError("BACKEND_ERROR", "Backend URL could not be constructed from input", {
123
- requestId: request.requestId,
124
- resourceId: request.resourceId,
125
- details: { reason: describeConstructionError(error) },
106
+ ...context,
107
+ details: { reason: "invalid-url" },
126
108
  cause: error
127
109
  });
128
110
  }
@@ -144,16 +126,12 @@ var HttpBackendExecutor = class {
144
126
  }
145
127
  const headers = { ...handler.headers ?? {} };
146
128
  let body;
147
- if (handler.method === "GET" || handler.method === "DELETE") {
148
- checkQueryCollision(target, remaining, {
149
- requestId: request.requestId,
150
- resourceId: request.resourceId
151
- });
152
- for (const [key, value] of Object.entries(remaining)) {
153
- target.searchParams.set(key, stringifyPrimitive(value));
154
- }
155
- } else {
156
- body = JSON.stringify(remaining);
129
+ checkQueryCollision(target, parts.query, context);
130
+ for (const [key, value] of Object.entries(parts.query)) {
131
+ target.searchParams.set(key, stringifyPrimitive(value));
132
+ }
133
+ if (parts.body !== void 0) {
134
+ body = JSON.stringify(parts.body.value);
157
135
  if (!hasHeader(headers, "content-type")) {
158
136
  headers["content-type"] = "application/json";
159
137
  }
@@ -256,37 +234,42 @@ function findUnparsedBraceToken(url) {
256
234
  }
257
235
  function validateBackendRequestShape(handler, input, context) {
258
236
  const inputRecord = isPlainObject2(input) ? input : {};
259
- let templated;
237
+ const parts = buildBackendRequestParts(handler, inputRecord, context);
238
+ let target;
260
239
  try {
261
- templated = applyPathTemplate(handler.url, inputRecord);
262
- } catch (error) {
263
- if (error instanceof Error && error.message.startsWith("invalid-path-parameter:")) {
264
- const field = error.message.slice("invalid-path-parameter:".length);
265
- throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" is not a valid value`, {
266
- requestId: context.requestId,
267
- resourceId: context.resourceId,
268
- details: { field }
269
- });
270
- }
271
- if (error instanceof Error && error.message.startsWith("missing-path-parameter:")) {
272
- const field = error.message.slice("missing-path-parameter:".length);
273
- throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" was not supplied`, {
274
- requestId: context.requestId,
275
- resourceId: context.resourceId,
276
- details: { field }
277
- });
278
- }
240
+ target = new URL(parts.url);
241
+ } catch {
279
242
  return;
280
243
  }
281
- if (handler.method === "GET" || handler.method === "DELETE") {
282
- let target;
283
- try {
284
- target = new URL(templated.url);
285
- } catch {
286
- return;
287
- }
288
- checkQueryCollision(target, templated.remaining, context);
244
+ checkQueryCollision(target, parts.query, context);
245
+ }
246
+ function acceptsBody(method) {
247
+ return method !== "GET" && method !== "DELETE";
248
+ }
249
+ function buildBackendRequestParts(handler, input, context) {
250
+ const bindings = handler.inputBindings;
251
+ if (bindings === void 0) {
252
+ const { url: url2, remaining } = applyPathTemplate(handler.url, input, context);
253
+ return acceptsBody(handler.method) ? { url: url2, query: {}, body: { value: remaining } } : { url: url2, query: remaining };
254
+ }
255
+ const pathValues = bindings.path === void 0 ? {} : resolveBoundGroup(input, bindings.path, "path", context);
256
+ const { url } = applyPathTemplate(handler.url, pathValues, context);
257
+ const query = bindings.query === void 0 ? {} : resolveBoundGroup(input, bindings.query, "query", context);
258
+ const bodyValue = bindings.body === void 0 ? void 0 : input[bindings.body];
259
+ if (bodyValue === void 0 || !acceptsBody(handler.method)) return { url, query };
260
+ return { url, query, body: { value: bodyValue } };
261
+ }
262
+ function resolveBoundGroup(input, key, kind, context) {
263
+ const value = input[key];
264
+ if (value === void 0) return {};
265
+ if (!isPlainObject2(value)) {
266
+ throw new CommerceError(
267
+ "INPUT_INVALID",
268
+ `Input "${key}" must be an object of ${kind} parameters`,
269
+ { ...context, details: { field: key } }
270
+ );
289
271
  }
272
+ return value;
290
273
  }
291
274
  function checkQueryCollision(target, remaining, context) {
292
275
  const templateKeys = new Set(target.searchParams.keys());
@@ -304,7 +287,7 @@ function isPlainObject2(value) {
304
287
  return typeof value === "object" && value !== null && !Array.isArray(value);
305
288
  }
306
289
  var TRAVERSAL_PATH_VALUES = /* @__PURE__ */ new Set(["", ".", ".."]);
307
- function applyPathTemplate(template, input) {
290
+ function applyPathTemplate(template, input, context) {
308
291
  const remaining = { ...input };
309
292
  let missing;
310
293
  let invalid;
@@ -323,19 +306,19 @@ function applyPathTemplate(template, input) {
323
306
  return encodeURIComponent(raw);
324
307
  });
325
308
  if (missing !== void 0) {
326
- throw new Error(`missing-path-parameter:${missing}`);
309
+ throw new CommerceError("INPUT_INVALID", `Path parameter "${missing}" was not supplied`, {
310
+ ...context,
311
+ details: { field: missing }
312
+ });
327
313
  }
328
314
  if (invalid !== void 0) {
329
- throw new Error(`invalid-path-parameter:${invalid}`);
315
+ throw new CommerceError("INPUT_INVALID", `Path parameter "${invalid}" is not a valid value`, {
316
+ ...context,
317
+ details: { field: invalid }
318
+ });
330
319
  }
331
320
  return { url, remaining };
332
321
  }
333
- function describeConstructionError(error) {
334
- if (error instanceof Error && error.message.startsWith("missing-path-parameter:")) {
335
- return error.message;
336
- }
337
- return "invalid-url";
338
- }
339
322
  function stringifyPrimitive(value) {
340
323
  if (value === void 0 || value === null) return "";
341
324
  if (typeof value === "string") return value;
@@ -1103,15 +1086,15 @@ var ServerSchema = z.object({
1103
1086
  adminToken: z.string().min(1).optional(),
1104
1087
  /** Browser origins allowed to read the dashboard-facing routes. Empty by default: closed. */
1105
1088
  // Entries are matched literally against the browser's
1106
- // `Origin` header, so `"*"` and a trailing slash match nothing at all —
1089
+ // `Origin` header, so `"*"` and a trailing slash match nothing at all -
1107
1090
  // fail-closed (a lockout, not a bypass), but silently, and a lockout with
1108
1091
  // no explanation is the kind of thing an operator "fixes" by disabling the
1109
1092
  // check. Reject those two shapes with a pointer instead.
1110
1093
  allowedOrigins: z.array(
1111
1094
  z.string().min(1).refine((origin) => origin !== "*", {
1112
- message: `wildcard "*" is not supported \u2014 allowedOrigins entries are matched literally against the browser's Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.`
1095
+ message: `wildcard "*" is not supported - allowedOrigins entries are matched literally against the browser's Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.`
1113
1096
  }).refine((origin) => !origin.endsWith("/"), {
1114
- message: 'must not end with "/" \u2014 a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".'
1097
+ message: 'must not end with "/" - a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".'
1115
1098
  })
1116
1099
  ).optional()
1117
1100
  }).strict();
@@ -1125,16 +1108,19 @@ var RESERVED_GATEWAY_PATHS = [
1125
1108
  "/health",
1126
1109
  "/ready",
1127
1110
  "/.well-known/agent-commerce",
1111
+ // Fixed by the A2A specification, so it is the adapter's to serve and never
1112
+ // a configurable mount's to claim.
1113
+ "/.well-known/agent-card.json",
1128
1114
  "/api/resources",
1129
1115
  "/api/resources/:id/invoke",
1130
1116
  "/api/receipts",
1131
1117
  "/api/events",
1132
1118
  "/api/events/stream"
1133
1119
  ];
1134
- var McpMountPathSchema = z.string().min(1).refine((value) => value.startsWith("/"), {
1135
- message: 'must start with "/" \u2014 it is an absolute gateway path, e.g. "/mcp".'
1120
+ var MountPathSchema = z.string().min(1).refine((value) => value.startsWith("/"), {
1121
+ message: 'must start with "/" - it is an absolute gateway path, e.g. "/mcp".'
1136
1122
  }).refine((value) => !/[:*?\s]/.test(value), {
1137
- message: 'must not contain ":", "*", "?" or whitespace \u2014 the mount is a literal path prefix, not a Fastify route pattern, and registers its own wildcard.'
1123
+ message: 'must not contain ":", "*", "?" or whitespace - the mount is a literal path prefix, not a Fastify route pattern, and registers its own wildcard.'
1138
1124
  }).refine(
1139
1125
  (value) => {
1140
1126
  const base = value.replace(/\/+$/, "");
@@ -1150,16 +1136,29 @@ var ProtocolsSchema = z.object({
1150
1136
  http: z.object({ enabled: BooleanOrString }).strict(),
1151
1137
  mcp: z.object({
1152
1138
  enabled: BooleanOrString,
1153
- mountPath: McpMountPathSchema
1154
- }).strict()
1139
+ mountPath: MountPathSchema
1140
+ }).strict(),
1141
+ // Optional block: A2A is off unless an operator asks for it, and an
1142
+ // existing config predating the adapter stays valid.
1143
+ a2a: z.object({
1144
+ enabled: BooleanOrString,
1145
+ mountPath: MountPathSchema.optional()
1146
+ }).strict().optional()
1155
1147
  }).strict();
1148
+ var DEFAULT_A2A_MOUNT_PATH = "/a2a";
1156
1149
  var BackendMethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]);
1150
+ var BackendInputBindingsSchema = z.object({
1151
+ path: z.string().min(1).optional(),
1152
+ query: z.string().min(1).optional(),
1153
+ body: z.string().min(1).optional()
1154
+ }).strict();
1157
1155
  var BackendHandlerSchema = z.object({
1158
1156
  type: z.literal("http"),
1159
1157
  method: BackendMethodSchema,
1160
1158
  url: z.string().min(1),
1161
1159
  headers: z.record(z.string(), z.string()).optional(),
1162
- timeoutMs: NumberOrString.optional()
1160
+ timeoutMs: NumberOrString.optional(),
1161
+ inputBindings: BackendInputBindingsSchema.optional()
1163
1162
  }).strict();
1164
1163
  var PricingSchema = z.discriminatedUnion("type", [
1165
1164
  z.object({ type: z.literal("free") }).strict(),
@@ -1204,7 +1203,7 @@ var FacilitatorSchema = z.discriminatedUnion("mode", [
1204
1203
  z.object({
1205
1204
  mode: z.literal("remote"),
1206
1205
  url: z.string().min(1),
1207
- // Absent means "this facilitator takes no credential" — an explicit
1206
+ // Absent means "this facilitator takes no credential" - an explicit
1208
1207
  // statement, normalised to `{ type: 'none' }` below. On a mainnet that
1209
1208
  // combination is refused outright, so the default can never quietly
1210
1209
  // become an unauthenticated production facilitator.
@@ -1222,7 +1221,7 @@ var X402Schema = z.object({
1222
1221
  payTo: z.string().min(1),
1223
1222
  maxTimeoutSeconds: NumberOrString,
1224
1223
  facilitator: FacilitatorSchema,
1225
- /** Real funds. Never defaulted — see src/payments/x402/guardrails.ts. */
1224
+ /** Real funds. Never defaulted - see src/payments/x402/guardrails.ts. */
1226
1225
  allowMainnet: BooleanOrString.optional(),
1227
1226
  /** Accepts a mainnet facilitator that takes no credential. Never defaulted. */
1228
1227
  allowUnauthenticatedFacilitator: BooleanOrString.optional()
@@ -1336,7 +1335,7 @@ function toBoolean(value, path2) {
1336
1335
  { details: { path: path2 } }
1337
1336
  );
1338
1337
  }
1339
- var SUPPORTED_PROTOCOLS = /* @__PURE__ */ new Set(["http", "mcp"]);
1338
+ var SUPPORTED_PROTOCOLS = new Set(PROTOCOL_NAMES);
1340
1339
  var SUPPORTED_PAYMENT_METHODS = /* @__PURE__ */ new Set(["x402"]);
1341
1340
  function normalise(raw) {
1342
1341
  const protocols = {
@@ -1344,8 +1343,13 @@ function normalise(raw) {
1344
1343
  mcp: {
1345
1344
  enabled: toBoolean(raw.protocols.mcp.enabled, "protocols.mcp.enabled"),
1346
1345
  mountPath: raw.protocols.mcp.mountPath
1346
+ },
1347
+ a2a: {
1348
+ enabled: toBoolean(raw.protocols.a2a?.enabled ?? false, "protocols.a2a.enabled"),
1349
+ mountPath: raw.protocols.a2a?.mountPath ?? DEFAULT_A2A_MOUNT_PATH
1347
1350
  }
1348
1351
  };
1352
+ validateMountPaths(protocols);
1349
1353
  const x402Raw = raw.payments.x402;
1350
1354
  const facilitator = x402Raw === void 0 ? void 0 : x402Raw.facilitator.mode === "local" ? { mode: "local", signerPrivateKey: x402Raw.facilitator.signerPrivateKey } : {
1351
1355
  mode: "remote",
@@ -1420,6 +1424,25 @@ function normalise(raw) {
1420
1424
  }
1421
1425
  };
1422
1426
  }
1427
+ function validateMountPaths(protocols) {
1428
+ const mounts = [
1429
+ ["mcp", protocols.mcp],
1430
+ ["a2a", protocols.a2a]
1431
+ ].filter(([, p]) => p.enabled);
1432
+ for (const [index, [nameA, a]] of mounts.entries()) {
1433
+ for (const [nameB, b] of mounts.slice(index + 1)) {
1434
+ const baseA = a.mountPath.replace(/\/+$/, "");
1435
+ const baseB = b.mountPath.replace(/\/+$/, "");
1436
+ if (baseA === baseB || baseA.startsWith(`${baseB}/`) || baseB.startsWith(`${baseA}/`)) {
1437
+ throw new CommerceError(
1438
+ "CONFIG_INVALID",
1439
+ `protocols.${nameA}.mountPath ("${a.mountPath}") collides with protocols.${nameB}.mountPath ("${b.mountPath}") - each mount registers a wildcard, so overlapping prefixes cannot both be served`,
1440
+ { details: { path: `protocols.${nameA}.mountPath` } }
1441
+ );
1442
+ }
1443
+ }
1444
+ }
1445
+ }
1423
1446
  function normaliseResource(id, entry, protocols, x402) {
1424
1447
  if (entry.input !== void 0) validateResourceSchemaKeywords(id, "input", entry.input);
1425
1448
  const inputProperties = entry.input?.["properties"];
@@ -1440,7 +1463,7 @@ function normaliseResource(id, entry, protocols, x402) {
1440
1463
  const hint = protocol === "ucp" ? " (UCP is planned, not supported in this release)" : "";
1441
1464
  throw new CommerceError(
1442
1465
  "CONFIG_INVALID",
1443
- `Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: http, mcp.`,
1466
+ `Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: http, mcp, a2a.`,
1444
1467
  { details: { path: `resources.${id}.expose`, resourceId: id, protocol } }
1445
1468
  );
1446
1469
  }
@@ -1461,6 +1484,13 @@ function normaliseResource(id, entry, protocols, x402) {
1461
1484
  );
1462
1485
  }
1463
1486
  }
1487
+ if (entry.expose.includes("a2a") && !protocols.a2a.enabled) {
1488
+ throw new CommerceError(
1489
+ "CONFIG_INVALID",
1490
+ `Resource "${id}" is exposed via "a2a" but protocols.a2a.enabled is false`,
1491
+ { details: { path: `resources.${id}.expose`, resourceId: id } }
1492
+ );
1493
+ }
1464
1494
  if (entry.expose.includes("http") && !protocols.http.enabled) {
1465
1495
  throw new CommerceError(
1466
1496
  "CONFIG_INVALID",
@@ -1469,7 +1499,8 @@ function normaliseResource(id, entry, protocols, x402) {
1469
1499
  );
1470
1500
  }
1471
1501
  validateBackendUrl(id, entry.backend.url);
1472
- validatePathParametersDeclared(id, entry.backend.url, entry.input);
1502
+ const pathScope = validateInputBindings(id, entry.backend, entry.input);
1503
+ validatePathParametersDeclared(id, entry.backend.url, pathScope.schema, pathScope.where);
1473
1504
  if (entry.pricing.type === "dynamic") {
1474
1505
  throw new CommerceError(
1475
1506
  "CONFIG_INVALID",
@@ -1522,7 +1553,8 @@ function normaliseResource(id, entry, protocols, x402) {
1522
1553
  timeoutMs: toNumber(entry.backend.timeoutMs, `resources.${id}.backend.timeoutMs`, {
1523
1554
  min: 1
1524
1555
  })
1525
- } : {}
1556
+ } : {},
1557
+ ...entry.backend.inputBindings !== void 0 ? { inputBindings: pickDefined(entry.backend.inputBindings) } : {}
1526
1558
  },
1527
1559
  pricing,
1528
1560
  exposedVia: entry.expose,
@@ -1602,7 +1634,7 @@ function validateResourceSchemaKeywords(id, path2, schema) {
1602
1634
  if ((Object.hasOwn(schema, "properties") || Object.hasOwn(schema, "required")) && excludesObjectType(schema)) {
1603
1635
  throw new CommerceError(
1604
1636
  "CONFIG_INVALID",
1605
- `Resource "${id}" ${path2} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema["type"])}) does not include "object" \u2014 the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`,
1637
+ `Resource "${id}" ${path2} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema["type"])}) does not include "object" - the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`,
1606
1638
  { details: { path: `resources.${id}.${path2}`, resourceId: id } }
1607
1639
  );
1608
1640
  }
@@ -1636,7 +1668,7 @@ function validateResourceSchemaKeywords(id, path2, schema) {
1636
1668
  validateResourceSchemaKeywords(id, `${path2}.items`, items);
1637
1669
  } else if (Array.isArray(items)) {
1638
1670
  console.warn(
1639
- `[agent-commerce] resource "${id}" ${path2}.items is a tuple (an array of schemas), which this gateway does not enforce \u2014 only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`
1671
+ `[agent-commerce] resource "${id}" ${path2}.items is a tuple (an array of schemas), which this gateway does not enforce - only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`
1640
1672
  );
1641
1673
  }
1642
1674
  }
@@ -1647,14 +1679,14 @@ function validatePricingAmount(id, amount, x402) {
1647
1679
  if (!PRICING_AMOUNT_PATTERN.test(amount)) {
1648
1680
  throw new CommerceError(
1649
1681
  "CONFIG_INVALID",
1650
- `Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent \u2014 e.g. "0.01")`,
1682
+ `Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent - e.g. "0.01")`,
1651
1683
  { details: { path: path2, resourceId: id } }
1652
1684
  );
1653
1685
  }
1654
1686
  if (ZERO_AMOUNT_PATTERN.test(amount)) {
1655
1687
  throw new CommerceError(
1656
1688
  "CONFIG_INVALID",
1657
- `Resource "${id}" has pricing.amount "0" \u2014 a paid resource cannot cost zero; use "pricing: { type: free }" instead`,
1689
+ `Resource "${id}" has pricing.amount "0" - a paid resource cannot cost zero; use "pricing: { type: free }" instead`,
1658
1690
  { details: { path: path2, resourceId: id } }
1659
1691
  );
1660
1692
  }
@@ -1669,7 +1701,86 @@ function validatePricingAmount(id, amount, x402) {
1669
1701
  }
1670
1702
  }
1671
1703
  }
1672
- function validatePathParametersDeclared(id, url, input) {
1704
+ function validateInputBindings(id, backend, input) {
1705
+ const legacy = { schema: input, where: "its input schema" };
1706
+ const bindings = backend.inputBindings;
1707
+ const templated = extractPathParameterNames(backend.url).length > 0;
1708
+ if (bindings === void 0) return legacy;
1709
+ const path2 = `resources.${id}.backend.inputBindings`;
1710
+ const fail = (message, details = {}) => {
1711
+ throw new CommerceError("CONFIG_INVALID", `Resource "${id}" ${message}`, {
1712
+ details: { path: path2, resourceId: id, ...details }
1713
+ });
1714
+ };
1715
+ const entries = Object.entries(bindings).filter(
1716
+ (entry) => entry[1] !== void 0
1717
+ );
1718
+ if (entries.length === 0) {
1719
+ fail("has an empty backend.inputBindings - remove the block to use the default mapping");
1720
+ }
1721
+ if (backend.method === "GET" || backend.method === "DELETE") {
1722
+ if (bindings.body !== void 0) {
1723
+ fail(
1724
+ `binds a request body on a ${backend.method}, which sends none - the value would be silently dropped`
1725
+ );
1726
+ }
1727
+ }
1728
+ if (templated && bindings.path === void 0) {
1729
+ fail(
1730
+ 'has backend.url path parameters but no "path" binding - in explicit binding mode nothing else supplies them, so every call would fail to reach the backend'
1731
+ );
1732
+ }
1733
+ const seen = /* @__PURE__ */ new Map();
1734
+ const properties = isPlainObject4(input?.["properties"]) ? input["properties"] : {};
1735
+ const required = new Set(
1736
+ Array.isArray(input?.["required"]) ? input["required"].filter((value) => typeof value === "string") : []
1737
+ );
1738
+ for (const [location, property] of entries) {
1739
+ if (property === PAYMENT_INPUT_FIELD) {
1740
+ fail(
1741
+ `binds "${location}" to "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`,
1742
+ { location }
1743
+ );
1744
+ }
1745
+ const other = seen.get(property);
1746
+ if (other !== void 0) {
1747
+ fail(`binds both "${other}" and "${location}" to the input property "${property}"`, {
1748
+ location,
1749
+ property
1750
+ });
1751
+ }
1752
+ seen.set(property, location);
1753
+ if (!Object.hasOwn(properties, property)) {
1754
+ fail(
1755
+ `binds "${location}" to input property "${property}", which the input schema does not declare - the schema is closed, so a caller could never supply it`,
1756
+ { location, property }
1757
+ );
1758
+ }
1759
+ const declared = properties[property];
1760
+ if (location !== "body" && isPlainObject4(declared) && !isObjectSchemaNode(declared)) {
1761
+ fail(
1762
+ `binds "${location}" to input property "${property}", which is not an object schema - ${location} parameters are read as an object of name/value pairs`,
1763
+ { location, property }
1764
+ );
1765
+ }
1766
+ }
1767
+ if (templated && bindings.path !== void 0 && !required.has(bindings.path)) {
1768
+ fail(
1769
+ `binds path parameters to input property "${bindings.path}" without listing it in the input schema's "required" - a caller that omits it cannot supply any path parameter, so the request could never be built`,
1770
+ { property: bindings.path }
1771
+ );
1772
+ }
1773
+ if (bindings.path === void 0) return { schema: void 0, where: "its input schema" };
1774
+ const group = properties[bindings.path];
1775
+ return {
1776
+ schema: isPlainObject4(group) ? group : void 0,
1777
+ where: `input.properties.${bindings.path}`
1778
+ };
1779
+ }
1780
+ function pickDefined(value) {
1781
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
1782
+ }
1783
+ function validatePathParametersDeclared(id, url, input, where) {
1673
1784
  const stray = findUnparsedBraceToken(url);
1674
1785
  if (stray !== void 0) {
1675
1786
  throw new CommerceError(
@@ -1708,14 +1819,14 @@ function validatePathParametersDeclared(id, url, input) {
1708
1819
  if (!Object.hasOwn(properties, param)) {
1709
1820
  throw new CommerceError(
1710
1821
  "CONFIG_INVALID",
1711
- `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in its input schema \u2014 the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`,
1822
+ `Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in ${where} - the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`,
1712
1823
  { details: { path: path2, resourceId: id, param } }
1713
1824
  );
1714
1825
  }
1715
1826
  if (!required.has(param)) {
1716
1827
  throw new CommerceError(
1717
1828
  "CONFIG_INVALID",
1718
- `Resource "${id}" has backend.url path parameter "{${param}}" declared in its input schema but not listed in "required" \u2014 a caller that omits it hits the same unservable-request problem as an undeclared parameter`,
1829
+ `Resource "${id}" has backend.url path parameter "{${param}}" declared in ${where} but not listed in its "required" - a caller that omits it hits the same unservable-request problem as an undeclared parameter`,
1719
1830
  { details: { path: path2, resourceId: id, param } }
1720
1831
  );
1721
1832
  }
@@ -1977,6 +2088,7 @@ function hashToken(value) {
1977
2088
  // src/gateway/adapters.ts
1978
2089
  var MOUNT_BODY_LIMIT_BYTES = 256 * 1024;
1979
2090
  async function startAndMountAdapters(options) {
2091
+ assertNoRouteConflicts(options.adapters);
1980
2092
  const runtimes = [];
1981
2093
  for (const adapter of options.adapters) {
1982
2094
  const adapterLogger = options.logger.child({ adapter: adapter.name });
@@ -2002,11 +2114,51 @@ async function startAndMountAdapters(options) {
2002
2114
  }
2003
2115
  return runtimes;
2004
2116
  }
2117
+ function routeClaims(adapters) {
2118
+ const claims = [];
2119
+ for (const adapter of adapters) {
2120
+ if (!isHttpProtocolAdapter(adapter)) continue;
2121
+ claims.push({
2122
+ adapter: adapter.name,
2123
+ path: adapter.mountPath.replace(/\/+$/, ""),
2124
+ wildcard: true,
2125
+ method: "ALL"
2126
+ });
2127
+ for (const route of adapter.additionalHttpRoutes ?? []) {
2128
+ claims.push({
2129
+ adapter: adapter.name,
2130
+ path: route.path.replace(/\/+$/, ""),
2131
+ wildcard: false,
2132
+ method: route.method
2133
+ });
2134
+ }
2135
+ }
2136
+ return claims;
2137
+ }
2138
+ function claimsCollide(a, b) {
2139
+ if (a.wildcard && (b.path === a.path || b.path.startsWith(`${a.path}/`))) return true;
2140
+ if (b.wildcard && a.path.startsWith(`${b.path}/`)) return true;
2141
+ return a.path === b.path && (a.method === b.method || a.method === "ALL" || b.method === "ALL");
2142
+ }
2143
+ function assertNoRouteConflicts(adapters) {
2144
+ const claims = routeClaims(adapters);
2145
+ for (const [index, a] of claims.entries()) {
2146
+ for (const b of claims.slice(index + 1)) {
2147
+ if (a.adapter === b.adapter || !claimsCollide(a, b)) continue;
2148
+ throw new CommerceError(
2149
+ "CONFIG_INVALID",
2150
+ `Protocol adapters "${a.adapter}" and "${b.adapter}" both claim the HTTP path "${b.path}"; each adapter path must be served by exactly one adapter`,
2151
+ { details: { adapters: [a.adapter, b.adapter], path: b.path } }
2152
+ );
2153
+ }
2154
+ }
2155
+ }
2005
2156
  var MOUNT_MAX_CONCURRENT_REQUESTS = 20;
2006
2157
  var MOUNT_BUSY_RETRY_AFTER_SECONDS = 1;
2007
2158
  function mountHttpAdapter(server, adapter, logger) {
2008
2159
  const mountPath = adapter.mountPath;
2009
2160
  const wildcard = mountPath.endsWith("/") ? `${mountPath}*` : `${mountPath}/*`;
2161
+ const additionalRoutes = adapter.additionalHttpRoutes ?? [];
2010
2162
  let inFlight = 0;
2011
2163
  void server.register(async (instance) => {
2012
2164
  instance.addContentTypeParser(
@@ -2018,7 +2170,7 @@ function mountHttpAdapter(server, adapter, logger) {
2018
2170
  instance.addContentTypeParser("*", (_request, _payload, done) => {
2019
2171
  done(null);
2020
2172
  });
2021
- const handler = async (request, reply) => {
2173
+ const makeHandler = (handleHttp) => async (request, reply) => {
2022
2174
  if (inFlight >= MOUNT_MAX_CONCURRENT_REQUESTS) {
2023
2175
  const error = new CommerceError(
2024
2176
  "GATEWAY_BUSY",
@@ -2036,7 +2188,7 @@ function mountHttpAdapter(server, adapter, logger) {
2036
2188
  logger
2037
2189
  );
2038
2190
  try {
2039
- await adapter.handleHttp(request.raw, reply.raw);
2191
+ await handleHttp(request.raw, reply.raw);
2040
2192
  } catch (error) {
2041
2193
  logger.error({ err: describeError3(error) }, "Protocol adapter request handler threw");
2042
2194
  if (!reply.raw.headersSent) {
@@ -2050,8 +2202,16 @@ function mountHttpAdapter(server, adapter, logger) {
2050
2202
  inFlight -= 1;
2051
2203
  }
2052
2204
  };
2053
- instance.all(mountPath, handler);
2054
- instance.all(wildcard, handler);
2205
+ const mountHandler = makeHandler((req, res) => adapter.handleHttp(req, res));
2206
+ instance.all(mountPath, mountHandler);
2207
+ instance.all(wildcard, mountHandler);
2208
+ for (const route of additionalRoutes) {
2209
+ instance.route({
2210
+ method: route.method,
2211
+ url: route.path,
2212
+ handler: makeHandler((req, res) => route.handleHttp(req, res))
2213
+ });
2214
+ }
2055
2215
  });
2056
2216
  }
2057
2217
  function enforceMountBodyLimit(req, res, maxBytes, logger) {