@devlab.group/agent-commerce 1.3.0 → 1.4.1

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.js CHANGED
@@ -1,16 +1,15 @@
1
- import { resolveX402Deployment, resolveDeploymentMode, requireNetworkProfile } from './chunk-TBZA6Z5B.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-N3DNLXGC.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-N3DNLXGC.js';
4
- import { promises, accessSync, constants, existsSync, mkdirSync, closeSync, openSync, chmodSync } from 'fs';
1
+ import { AP2_SPEC_VERSION, AP2_MODES, AP2_DEFAULT_CLOCK_SKEW_SECONDS, AP2_JWK_MEMBERS, AP2_KEY_TYPE, AP2_SIGNING_ALGORITHM, AP2_JWK_CURVE, AP2_JWK_COORDINATE_BYTES, openSqliteDatabase, AP2_MAX_CLOCK_SKEW_SECONDS } from './chunk-GF6KHHKL.js';
2
+ import { resolveX402Deployment, resolveDeploymentMode, requireNetworkProfile } from './chunk-NWIQLJNU.js';
3
+ import { RESERVED_INPUT_FIELDS, PROTOCOL_NAMES, PAYMENT_HEADER, AUTHORIZATION_HEADER, CommerceError, systemClock, NOOP_LOGGER, PACKAGE_VERSION, toCommerceError, DEFAULT_BACKEND_TIMEOUT_MS, toErrorEnvelope, isHttpProtocolAdapter, isCommerceError, parseAuthorizationHeader, toPaymentRequiredEnvelope, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER } from './chunk-ZEGFLRGV.js';
4
+ export { AUTHORIZATION_HEADER, AUTHORIZATION_INPUT_FIELD, COMMERCE_ERROR_CODES, COMMERCE_ERROR_HTTP_STATUS, COMMERCE_EVENT_TYPES, CommerceError, DEFAULT_BACKEND_TIMEOUT_MS, DELIVERY_SUMMARY_META_KEY, MAX_AUTHORIZATION_HEADER_BYTES, NOOP_LOGGER, PAYMENT_HEADER, PAYMENT_INPUT_FIELD, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, PROTOCOL_NAMES, RESERVED_INPUT_FIELDS, RETRYABLE_ERROR_CODES, extractReservedInputFields, isCommerceError, isHttpProtocolAdapter, isPaymentRequiredEnvelope, parseAuthorizationHeader, parseAuthorizationSubmission, systemClock, toCommerceError, toDeliverySummary, toErrorEnvelope, toPaymentRequiredEnvelope } from './chunk-ZEGFLRGV.js';
5
+ import { promises, accessSync, constants } from 'fs';
5
6
  import * as path from 'path';
6
- import { dirname } from 'path';
7
7
  import { parse, YAMLError } from 'yaml';
8
8
  import { z } from 'zod';
9
9
  import { randomUUID, timingSafeEqual, createHash } from 'crypto';
10
10
  import { createRequire } from 'module';
11
11
  import pino from 'pino';
12
12
  import Fastify from 'fastify';
13
- import Database from 'better-sqlite3';
14
13
  import Ajv2020 from 'ajv/dist/2020.js';
15
14
  import addFormats from 'ajv-formats';
16
15
 
@@ -86,6 +85,7 @@ var DEFAULT_CONFIG_FILENAME = "config.yaml";
86
85
 
87
86
  // src/core/execution/backend-http.ts
88
87
  var MAX_BODY_SNIPPET_LENGTH = 512;
88
+ var IDEMPOTENCY_KEY_HEADER = "idempotency-key";
89
89
  var PATH_PARAM_PATTERN = /\{([a-zA-Z0-9_.-]+)\}/g;
90
90
  var MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
91
91
  var HttpBackendExecutor = class {
@@ -127,6 +127,10 @@ var HttpBackendExecutor = class {
127
127
  if (error instanceof CommerceError) throw error;
128
128
  }
129
129
  const headers = { ...handler.headers ?? {} };
130
+ if (request.idempotencyKey !== void 0) {
131
+ deleteHeader(headers, IDEMPOTENCY_KEY_HEADER);
132
+ headers[IDEMPOTENCY_KEY_HEADER] = request.idempotencyKey;
133
+ }
130
134
  let body;
131
135
  checkQueryCollision(target, parts.query, context);
132
136
  for (const [key, value] of Object.entries(parts.query)) {
@@ -335,6 +339,12 @@ function hasHeader(headers, name) {
335
339
  const target = name.toLowerCase();
336
340
  return Object.keys(headers).some((key) => key.toLowerCase() === target);
337
341
  }
342
+ function deleteHeader(headers, name) {
343
+ const target = name.toLowerCase();
344
+ for (const key of Object.keys(headers)) {
345
+ if (key.toLowerCase() === target) delete headers[key];
346
+ }
347
+ }
338
348
  function headersToRecord(headers) {
339
349
  const record = {};
340
350
  headers.forEach((value, key) => {
@@ -599,6 +609,92 @@ function createExecutionPipeline(options) {
599
609
  function buildEvent(partial) {
600
610
  return { id: options.ids.next("evt"), at: options.clock.nowIso(), ...partial };
601
611
  }
612
+ async function authorizeAndReserve(request, resource, providers, requirement, input) {
613
+ if (providers.length === 0) return void 0;
614
+ const submission = request.authorization;
615
+ const provider = providers.find((candidate) => candidate.name === submission?.method);
616
+ const unmet = providers.filter((candidate) => candidate !== provider).map((c) => c.name);
617
+ if (submission === void 0 || provider === void 0 || unmet.length > 0) {
618
+ await safeEmit(
619
+ buildEvent({
620
+ type: "authorization.rejected",
621
+ requestId: request.requestId,
622
+ resourceId: resource.id,
623
+ adapter: request.protocol,
624
+ status: "error",
625
+ data: { reason: "AUTHORIZATION_REQUIRED", methods: unmet }
626
+ })
627
+ );
628
+ throw new CommerceError(
629
+ "AUTHORIZATION_REQUIRED",
630
+ `Resource "${resource.id}" requires authorization: ${unmet.join(", ")}`,
631
+ {
632
+ requestId: request.requestId,
633
+ resourceId: resource.id,
634
+ details: { required: providers.map((candidate) => candidate.name), missing: unmet }
635
+ }
636
+ );
637
+ }
638
+ let verification;
639
+ try {
640
+ verification = await provider.verifyAndReserve({
641
+ requestId: request.requestId,
642
+ resourceId: resource.id,
643
+ input,
644
+ submission,
645
+ requirement
646
+ });
647
+ } catch (error) {
648
+ const mapped = isCommerceError(error) ? error : new CommerceError(
649
+ "AUTHORIZATION_PROVIDER_UNAVAILABLE",
650
+ "Authorization provider is unavailable",
651
+ {
652
+ requestId: request.requestId,
653
+ resourceId: resource.id,
654
+ cause: error
655
+ }
656
+ );
657
+ await safeEmit(
658
+ buildEvent({
659
+ type: "authorization.rejected",
660
+ requestId: request.requestId,
661
+ resourceId: resource.id,
662
+ adapter: request.protocol,
663
+ status: "error",
664
+ data: { reason: mapped.code, method: provider.name }
665
+ })
666
+ );
667
+ throw mapped;
668
+ }
669
+ await safeEmit(
670
+ buildEvent({
671
+ type: "authorization.verified",
672
+ requestId: request.requestId,
673
+ resourceId: resource.id,
674
+ adapter: request.protocol,
675
+ status: "ok",
676
+ data: { method: verification.method, reference: verification.reference }
677
+ })
678
+ );
679
+ const context = { requestId: request.requestId, resourceId: resource.id };
680
+ return {
681
+ record: {
682
+ method: verification.method,
683
+ reference: verification.reference,
684
+ ...verification.metadata !== void 0 ? { metadata: verification.metadata } : {}
685
+ },
686
+ async finalize(action) {
687
+ try {
688
+ await provider[action](verification.reservationId, context);
689
+ } catch (error) {
690
+ options.logger.error(
691
+ { err: describeError2(error), requestId: request.requestId, action },
692
+ "Authorization finalization failed; the reservation stays reserved"
693
+ );
694
+ }
695
+ }
696
+ };
697
+ }
602
698
  async function execute(request) {
603
699
  const pipelineStart = options.clock.monotonicMs();
604
700
  const resource = options.resources.get(request.resourceId);
@@ -620,7 +716,7 @@ function createExecutionPipeline(options) {
620
716
  adapter: request.protocol
621
717
  })
622
718
  );
623
- const strippedInput = stripPaymentField(request.input);
719
+ const strippedInput = stripReservedFields(request.input);
624
720
  const validation = getValidator(resource)(strippedInput);
625
721
  if (!validation.valid) {
626
722
  throw new CommerceError(
@@ -648,7 +744,18 @@ function createExecutionPipeline(options) {
648
744
  }
649
745
  );
650
746
  }
747
+ if (resource.pricing.type !== "fixed" && (resource.authorization?.required.length ?? 0) > 0) {
748
+ throw new CommerceError(
749
+ "CONFIG_INVALID",
750
+ `Resource "${resource.id}" requires authorization but is not a paid resource`,
751
+ {
752
+ requestId: request.requestId,
753
+ resourceId: resource.id
754
+ }
755
+ );
756
+ }
651
757
  let paymentResult;
758
+ let authorization;
652
759
  if (resource.pricing.type === "fixed") {
653
760
  const pricing = resource.pricing;
654
761
  const provider = pickProvider(options.paymentProviders, resource.paymentMethods);
@@ -663,6 +770,11 @@ function createExecutionPipeline(options) {
663
770
  }
664
771
  );
665
772
  }
773
+ const authProviders = resolveAuthorizationProviders(
774
+ options.authorizationProviders ?? [],
775
+ resource,
776
+ request.requestId
777
+ );
666
778
  let requirement;
667
779
  try {
668
780
  requirement = await provider.createRequirement({
@@ -693,7 +805,8 @@ function createExecutionPipeline(options) {
693
805
  kind: "payment-required",
694
806
  requestId: request.requestId,
695
807
  resourceId: resource.id,
696
- requirement
808
+ requirement,
809
+ ...authProviders.length > 0 ? { authorization: authProviders.map((p) => p.requirement) } : {}
697
810
  };
698
811
  }
699
812
  const payment = request.payment;
@@ -763,6 +876,14 @@ function createExecutionPipeline(options) {
763
876
  });
764
877
  }
765
878
  const replayKey = verification.replayKey;
879
+ const hold = await authorizeAndReserve(
880
+ request,
881
+ resource,
882
+ authProviders,
883
+ requirement,
884
+ validInput
885
+ );
886
+ authorization = hold?.record;
766
887
  try {
767
888
  await options.store.reservePaymentAttempt({
768
889
  requestId: request.requestId,
@@ -780,6 +901,7 @@ function createExecutionPipeline(options) {
780
901
  "STORAGE_ERROR",
781
902
  "Payment attempt could not be recorded"
782
903
  );
904
+ await hold?.finalize("release");
783
905
  await safeEmit(
784
906
  buildEvent({
785
907
  type: "payment.rejected",
@@ -813,16 +935,16 @@ function createExecutionPipeline(options) {
813
935
  verification
814
936
  });
815
937
  } catch (error) {
816
- const uncertainTxHash = uncertainSettlementTxHash(error);
938
+ const txHash = settlementTxHash(error);
939
+ await hold?.finalize("markUncertain");
817
940
  await safePersist(
818
- () => options.store.updatePaymentAttempt(
819
- uncertainTxHash !== void 0 ? { replayKey, status: "settlement-uncertain", externalReference: uncertainTxHash } : {
820
- replayKey,
821
- status: "failed",
822
- ...error instanceof Error ? { rejectionReason: error.message } : {}
823
- }
824
- ),
825
- uncertainTxHash !== void 0 ? "updatePaymentAttempt(settlement-uncertain)" : "updatePaymentAttempt(failed)",
941
+ () => options.store.updatePaymentAttempt({
942
+ replayKey,
943
+ status: "settlement-uncertain",
944
+ ...txHash !== void 0 ? { externalReference: txHash } : {},
945
+ ...error instanceof Error ? { rejectionReason: error.message } : {}
946
+ }),
947
+ "updatePaymentAttempt(settlement-uncertain)",
826
948
  request.requestId
827
949
  );
828
950
  await safeEmit(
@@ -833,21 +955,24 @@ function createExecutionPipeline(options) {
833
955
  adapter: request.protocol,
834
956
  paymentProvider: provider.name,
835
957
  status: "error",
836
- data: uncertainTxHash !== void 0 ? { reason: "settlement-uncertain", transactionHash: uncertainTxHash } : { reason: "settlement-threw" }
958
+ data: {
959
+ reason: "settlement-uncertain",
960
+ ...txHash !== void 0 ? { transactionHash: txHash } : {}
961
+ }
837
962
  })
838
963
  );
839
- throw new CommerceError(
840
- "PAYMENT_SETTLEMENT_FAILED",
841
- uncertainTxHash !== void 0 ? "Settlement could not be confirmed" : "Payment settlement failed",
842
- {
843
- requestId: request.requestId,
844
- resourceId: resource.id,
845
- cause: error,
846
- ...uncertainTxHash !== void 0 ? { details: { settlementUncertain: true, transactionHash: uncertainTxHash } } : {}
964
+ throw new CommerceError("PAYMENT_SETTLEMENT_FAILED", "Settlement could not be confirmed", {
965
+ requestId: request.requestId,
966
+ resourceId: resource.id,
967
+ cause: error,
968
+ details: {
969
+ settlementUncertain: true,
970
+ ...txHash !== void 0 ? { transactionHash: txHash } : {}
847
971
  }
848
- );
972
+ });
849
973
  }
850
974
  if (settlement.status !== "settled") {
975
+ await hold?.finalize("release");
851
976
  await safePersist(
852
977
  () => options.store.updatePaymentAttempt({
853
978
  replayKey,
@@ -877,6 +1002,7 @@ function createExecutionPipeline(options) {
877
1002
  }
878
1003
  );
879
1004
  }
1005
+ await hold?.finalize("consume");
880
1006
  await safePersist(
881
1007
  () => options.store.updatePaymentAttempt({
882
1008
  replayKey,
@@ -904,7 +1030,8 @@ function createExecutionPipeline(options) {
904
1030
  backendResponse = await options.backend.call(resource.handler, {
905
1031
  requestId: request.requestId,
906
1032
  resourceId: resource.id,
907
- input: validInput
1033
+ input: validInput,
1034
+ ...request.idempotencyKey !== void 0 ? { idempotencyKey: request.idempotencyKey } : {}
908
1035
  });
909
1036
  } catch (error) {
910
1037
  const commerceError = toCommerceError(error, "BACKEND_ERROR", "Backend call failed");
@@ -928,6 +1055,7 @@ function createExecutionPipeline(options) {
928
1055
  backendStatus: backendErrorStatus(commerceError),
929
1056
  protocol: request.protocol,
930
1057
  payment: paymentResult,
1058
+ ...authorization !== void 0 ? { authorization } : {},
931
1059
  metadata: { delivered: false, backendErrorCode: commerceError.code }
932
1060
  };
933
1061
  await safePersist(
@@ -976,7 +1104,8 @@ function createExecutionPipeline(options) {
976
1104
  backendStatus: backendResponse.status,
977
1105
  durationMs: backendResponse.durationMs,
978
1106
  protocol: request.protocol,
979
- ...paymentResult !== void 0 ? { payment: paymentResult } : {}
1107
+ ...paymentResult !== void 0 ? { payment: paymentResult } : {},
1108
+ ...authorization !== void 0 ? { authorization } : {}
980
1109
  };
981
1110
  await safePersist(() => options.store.saveReceipt(receipt), "saveReceipt", request.requestId);
982
1111
  const totalDurationMs = Math.round(options.clock.monotonicMs() - pipelineStart);
@@ -1008,11 +1137,24 @@ function backendErrorStatus(error) {
1008
1137
  const status = error.details?.["status"];
1009
1138
  return typeof status === "number" ? status : 0;
1010
1139
  }
1011
- function uncertainSettlementTxHash(error) {
1012
- if (!isCommerceError(error) || error.code !== "PAYMENT_PROVIDER_UNAVAILABLE") return void 0;
1013
- const hash = error.details?.["transactionHash"];
1140
+ function settlementTxHash(error) {
1141
+ const hash = isCommerceError(error) ? error.details?.["transactionHash"] : void 0;
1014
1142
  return typeof hash === "string" ? hash : void 0;
1015
1143
  }
1144
+ function resolveAuthorizationProviders(providers, resource, requestId) {
1145
+ const required = resource.authorization?.required ?? [];
1146
+ return required.map((method) => {
1147
+ const found = providers.find((provider) => provider.name === method);
1148
+ if (!found) {
1149
+ throw new CommerceError(
1150
+ "CONFIG_INVALID",
1151
+ `Resource "${resource.id}" requires authorization method "${method}", which is not enabled`,
1152
+ { requestId, resourceId: resource.id }
1153
+ );
1154
+ }
1155
+ return found;
1156
+ });
1157
+ }
1016
1158
  function pickProvider(providers, methods) {
1017
1159
  for (const method of methods) {
1018
1160
  const found = providers.find((provider) => provider.name === method);
@@ -1020,8 +1162,8 @@ function pickProvider(providers, methods) {
1020
1162
  }
1021
1163
  return void 0;
1022
1164
  }
1023
- var RESERVED_INPUT_KEYS = /* @__PURE__ */ new Set([PAYMENT_INPUT_FIELD, "__proto__"]);
1024
- function stripPaymentField(input) {
1165
+ var RESERVED_INPUT_KEYS = /* @__PURE__ */ new Set([...RESERVED_INPUT_FIELDS, "__proto__"]);
1166
+ function stripReservedFields(input) {
1025
1167
  if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
1026
1168
  const record = input;
1027
1169
  const hasReserved = [...RESERVED_INPUT_KEYS].some((key) => Object.hasOwn(record, key));
@@ -1234,7 +1376,8 @@ var ResourceEntrySchema = z.object({
1234
1376
  backend: BackendHandlerSchema,
1235
1377
  pricing: PricingSchema,
1236
1378
  expose: z.array(z.string().min(1)).min(1),
1237
- payments: z.array(z.string().min(1)).optional()
1379
+ payments: z.array(z.string().min(1)).optional(),
1380
+ authorization: z.object({ required: z.array(z.string().min(1)).min(1) }).strict().optional()
1238
1381
  }).strict();
1239
1382
  var ResourcesMapSchema = z.record(z.string().min(1), ResourceEntrySchema);
1240
1383
  var FacilitatorAuthSchema = z.discriminatedUnion("type", [
@@ -1282,6 +1425,32 @@ var X402Schema = z.object({
1282
1425
  var PaymentsSchema = z.object({
1283
1426
  x402: X402Schema.optional()
1284
1427
  }).strict();
1428
+ var Ap2KeySchema = z.object({
1429
+ kid: z.string().min(1),
1430
+ jwk: z.record(z.string(), z.unknown())
1431
+ }).strict();
1432
+ var Ap2IssuerSchema = z.object({
1433
+ issuer: z.string().min(1),
1434
+ /**
1435
+ * Required, not defaulted. Without it, a mandate minted for another
1436
+ * merchant would verify here, and there is no value worth guessing for
1437
+ * something that decides that.
1438
+ */
1439
+ audience: z.string().min(1),
1440
+ keys: z.array(Ap2KeySchema).min(1)
1441
+ }).strict();
1442
+ var Ap2Schema = z.object({
1443
+ enabled: BooleanOrString,
1444
+ specVersion: z.string().min(1).optional(),
1445
+ mode: z.string().min(1).optional(),
1446
+ trust: z.object({
1447
+ mandateIssuers: z.array(Ap2IssuerSchema).optional(),
1448
+ checkoutIssuers: z.array(Ap2IssuerSchema).optional()
1449
+ }).strict().optional(),
1450
+ clockSkewSeconds: NumberOrString.optional(),
1451
+ replay: z.object({ path: z.string().min(1) }).strict().optional()
1452
+ }).strict();
1453
+ var AuthorizationSchema = z.object({ ap2: Ap2Schema.optional() }).strict();
1285
1454
  var RawConfigSchema = z.object({
1286
1455
  version: z.literal(SUPPORTED_CONFIG_VERSION),
1287
1456
  merchant: MerchantSchema,
@@ -1289,7 +1458,8 @@ var RawConfigSchema = z.object({
1289
1458
  storage: StorageSchema,
1290
1459
  protocols: ProtocolsSchema,
1291
1460
  resources: ResourcesMapSchema,
1292
- payments: PaymentsSchema
1461
+ payments: PaymentsSchema,
1462
+ authorization: AuthorizationSchema.optional()
1293
1463
  }).strict();
1294
1464
  function parseConfig(raw, env) {
1295
1465
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
@@ -1390,6 +1560,7 @@ function toBoolean(value, path2) {
1390
1560
  }
1391
1561
  var SUPPORTED_PROTOCOLS = new Set(PROTOCOL_NAMES);
1392
1562
  var SUPPORTED_PAYMENT_METHODS = /* @__PURE__ */ new Set(["x402"]);
1563
+ var SUPPORTED_AUTHORIZATION_METHODS = /* @__PURE__ */ new Set(["ap2"]);
1393
1564
  function normalise(raw) {
1394
1565
  const protocols = {
1395
1566
  http: { enabled: toBoolean(raw.protocols.http.enabled, "protocols.http.enabled") },
@@ -1452,8 +1623,12 @@ function normalise(raw) {
1452
1623
  ...x402.allowUnauthenticatedFacilitator !== void 0 ? { allowUnauthenticatedFacilitator: x402.allowUnauthenticatedFacilitator } : {}
1453
1624
  });
1454
1625
  }
1626
+ const ap2 = normaliseAp2(raw.authorization?.ap2);
1627
+ if (ap2?.enabled) {
1628
+ validateReplayStoreIsolated(ap2.replay.path, raw.storage.receipts.path, protocols.acp);
1629
+ }
1455
1630
  const resources = Object.entries(raw.resources).map(
1456
- ([id, entry]) => normaliseResource(id, entry, protocols, x402)
1631
+ ([id, entry]) => normaliseResource(id, entry, protocols, x402, ap2)
1457
1632
  );
1458
1633
  if (protocols.acp.enabled) validateAcpCheckoutMapping(protocols.acp, resources);
1459
1634
  return {
@@ -1476,7 +1651,8 @@ function normalise(raw) {
1476
1651
  resources,
1477
1652
  payments: {
1478
1653
  ...x402 !== void 0 ? { x402 } : {}
1479
- }
1654
+ },
1655
+ ...ap2 !== void 0 ? { authorization: { ap2 } } : {}
1480
1656
  };
1481
1657
  }
1482
1658
  function validateMountPaths(protocols) {
@@ -1499,6 +1675,194 @@ function validateMountPaths(protocols) {
1499
1675
  }
1500
1676
  }
1501
1677
  }
1678
+ function ap2Invalid(path2, message, extra = {}) {
1679
+ return new CommerceError("CONFIG_INVALID", message, { details: { path: path2, ...extra } });
1680
+ }
1681
+ function normaliseAp2(raw) {
1682
+ if (raw === void 0) return void 0;
1683
+ if (!toBoolean(raw.enabled, "authorization.ap2.enabled")) return { enabled: false };
1684
+ const specVersion = raw.specVersion ?? AP2_SPEC_VERSION;
1685
+ if (specVersion !== AP2_SPEC_VERSION) {
1686
+ throw ap2Invalid(
1687
+ "authorization.ap2.specVersion",
1688
+ `authorization.ap2.specVersion "${specVersion}" is not supported - this gateway verifies against the tagged AP2 release ${AP2_SPEC_VERSION} only`
1689
+ );
1690
+ }
1691
+ const mode = raw.mode ?? AP2_MODES[0];
1692
+ if (!AP2_MODES.includes(mode)) {
1693
+ throw ap2Invalid(
1694
+ "authorization.ap2.mode",
1695
+ `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.`
1696
+ );
1697
+ }
1698
+ if (raw.replay === void 0) {
1699
+ throw ap2Invalid(
1700
+ "authorization.ap2.replay",
1701
+ "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"
1702
+ );
1703
+ }
1704
+ const mandateIssuers = normaliseIssuers(
1705
+ raw.trust?.mandateIssuers,
1706
+ "authorization.ap2.trust.mandateIssuers"
1707
+ );
1708
+ const checkoutIssuers = normaliseIssuers(
1709
+ raw.trust?.checkoutIssuers,
1710
+ "authorization.ap2.trust.checkoutIssuers"
1711
+ );
1712
+ return {
1713
+ enabled: true,
1714
+ specVersion: AP2_SPEC_VERSION,
1715
+ mode,
1716
+ trust: { mandateIssuers, checkoutIssuers },
1717
+ clockSkewSeconds: toNumber(
1718
+ raw.clockSkewSeconds ?? AP2_DEFAULT_CLOCK_SKEW_SECONDS,
1719
+ "authorization.ap2.clockSkewSeconds",
1720
+ { min: 0, max: AP2_MAX_CLOCK_SKEW_SECONDS }
1721
+ ),
1722
+ replay: { path: raw.replay.path }
1723
+ };
1724
+ }
1725
+ function normaliseIssuers(raw, path2) {
1726
+ if (raw === void 0 || raw.length === 0) {
1727
+ throw ap2Invalid(
1728
+ path2,
1729
+ `${path2} 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`
1730
+ );
1731
+ }
1732
+ const seen = /* @__PURE__ */ new Set();
1733
+ return raw.map((entry, index) => {
1734
+ const entryPath = `${path2}.${index}`;
1735
+ if (seen.has(entry.issuer)) {
1736
+ throw ap2Invalid(
1737
+ `${entryPath}.issuer`,
1738
+ `${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).`,
1739
+ { issuer: entry.issuer }
1740
+ );
1741
+ }
1742
+ seen.add(entry.issuer);
1743
+ const kids = /* @__PURE__ */ new Set();
1744
+ const keys = entry.keys.map((key, keyIndex) => {
1745
+ const keyPath = `${entryPath}.keys.${keyIndex}`;
1746
+ if (kids.has(key.kid)) {
1747
+ throw ap2Invalid(
1748
+ `${keyPath}.kid`,
1749
+ `${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`,
1750
+ { issuer: entry.issuer, kid: key.kid }
1751
+ );
1752
+ }
1753
+ kids.add(key.kid);
1754
+ return { kid: key.kid, jwk: validateJwk(key.jwk, key.kid, keyPath) };
1755
+ });
1756
+ return { issuer: entry.issuer, audience: entry.audience, keys };
1757
+ });
1758
+ }
1759
+ function validateJwk(jwk, kid, path2) {
1760
+ const fail = (detail) => {
1761
+ throw ap2Invalid(`${path2}.jwk`, `${path2}.jwk (kid "${kid}") ${detail}`, { kid });
1762
+ };
1763
+ for (const member of Object.keys(jwk)) {
1764
+ if (AP2_JWK_MEMBERS.includes(member)) continue;
1765
+ if (member === "d" || member === "k") {
1766
+ fail(
1767
+ `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.`
1768
+ );
1769
+ }
1770
+ fail(
1771
+ `has unsupported member "${member}". Allowed: ${AP2_JWK_MEMBERS.join(", ")}. Members naming a URL are refused on purpose - keys are configured inline and never fetched.`
1772
+ );
1773
+ }
1774
+ const value = (member) => {
1775
+ const raw = jwk[member];
1776
+ if (typeof raw !== "string" || raw.length === 0) {
1777
+ fail(`must give "${member}" as a non-empty string`);
1778
+ }
1779
+ return raw;
1780
+ };
1781
+ if (value("kty") !== AP2_KEY_TYPE) {
1782
+ fail(
1783
+ `must have kty "${AP2_KEY_TYPE}" (got "${value("kty")}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`
1784
+ );
1785
+ }
1786
+ if (value("crv") !== AP2_JWK_CURVE) {
1787
+ fail(
1788
+ `must be on curve "${AP2_JWK_CURVE}" (got "${value("crv")}") - ${AP2_SIGNING_ALGORITHM} is the only accepted algorithm`
1789
+ );
1790
+ }
1791
+ for (const coordinate of ["x", "y"]) {
1792
+ const encoded = value(coordinate);
1793
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
1794
+ fail(`coordinate "${coordinate}" is not base64url (no padding, no "+" or "/")`);
1795
+ }
1796
+ if (Buffer.from(encoded, "base64url").length !== AP2_JWK_COORDINATE_BYTES) {
1797
+ fail(
1798
+ `coordinate "${coordinate}" decodes to ${Buffer.from(encoded, "base64url").length} bytes; a ${AP2_JWK_CURVE} coordinate is ${AP2_JWK_COORDINATE_BYTES}`
1799
+ );
1800
+ }
1801
+ }
1802
+ if (jwk["alg"] !== void 0 && value("alg") !== AP2_SIGNING_ALGORITHM) {
1803
+ fail(`declares alg "${value("alg")}"; only ${AP2_SIGNING_ALGORITHM} is accepted`);
1804
+ }
1805
+ if (jwk["use"] !== void 0 && value("use") !== "sig") {
1806
+ fail(`declares use "${value("use")}"; a verification key must be "sig"`);
1807
+ }
1808
+ if (jwk["kid"] !== void 0 && value("kid") !== kid) {
1809
+ fail(`declares kid "${value("kid")}", which disagrees with the configured kid "${kid}"`);
1810
+ }
1811
+ const normalised = {};
1812
+ for (const member of AP2_JWK_MEMBERS) {
1813
+ if (jwk[member] !== void 0) normalised[member] = value(member);
1814
+ }
1815
+ return normalised;
1816
+ }
1817
+ function validateReplayStoreIsolated(replayPath, receiptsPath, acp) {
1818
+ if (replayPath === ":memory:") return;
1819
+ const others = [["storage.receipts.path", receiptsPath]];
1820
+ if (acp.enabled) others.push(["protocols.acp.idempotency.path", acp.idempotency.path]);
1821
+ for (const [otherPath, other] of others) {
1822
+ if (replayPath !== other) continue;
1823
+ throw ap2Invalid(
1824
+ "authorization.ap2.replay.path",
1825
+ `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`
1826
+ );
1827
+ }
1828
+ }
1829
+ function normaliseResourceAuthorization(id, entry, pricing, ap2) {
1830
+ const required = entry.authorization?.required;
1831
+ if (required === void 0) return void 0;
1832
+ const path2 = `resources.${id}.authorization.required`;
1833
+ const methods = [];
1834
+ for (const method of required) {
1835
+ if (!SUPPORTED_AUTHORIZATION_METHODS.has(method)) {
1836
+ throw ap2Invalid(
1837
+ path2,
1838
+ `Resource "${id}" requires unsupported authorization method "${method}". Supported: ${[...SUPPORTED_AUTHORIZATION_METHODS].join(", ")}.`,
1839
+ { resourceId: id, method }
1840
+ );
1841
+ }
1842
+ if (methods.includes(method)) {
1843
+ throw ap2Invalid(path2, `Resource "${id}" lists authorization method "${method}" twice`, {
1844
+ resourceId: id,
1845
+ method
1846
+ });
1847
+ }
1848
+ if (method === "ap2" && (ap2 === void 0 || !ap2.enabled)) {
1849
+ throw ap2Invalid(
1850
+ path2,
1851
+ `Resource "${id}" requires authorization method "ap2", which is not configured or not enabled under authorization.ap2`,
1852
+ { resourceId: id, method }
1853
+ );
1854
+ }
1855
+ methods.push(method);
1856
+ }
1857
+ if (pricing.type !== "fixed") {
1858
+ throw ap2Invalid(
1859
+ path2,
1860
+ `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`,
1861
+ { resourceId: id }
1862
+ );
1863
+ }
1864
+ return { required: methods };
1865
+ }
1502
1866
  function acpInvalid(path2, message, extra = {}) {
1503
1867
  return new CommerceError("CONFIG_INVALID", message, { details: { path: path2, ...extra } });
1504
1868
  }
@@ -1638,20 +2002,23 @@ function validateAcpOperationInput(path2, operation, resource) {
1638
2002
  }
1639
2003
  }
1640
2004
  }
1641
- function normaliseResource(id, entry, protocols, x402) {
2005
+ function normaliseResource(id, entry, protocols, x402, ap2) {
1642
2006
  if (entry.input !== void 0) validateResourceSchemaKeywords(id, "input", entry.input);
1643
2007
  const inputProperties = entry.input?.["properties"];
1644
- if (inputProperties && typeof inputProperties === "object" && PAYMENT_INPUT_FIELD in inputProperties) {
1645
- throw new CommerceError(
1646
- "CONFIG_INVALID",
1647
- `Resource "${id}" declares an input property "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`,
1648
- {
1649
- details: {
1650
- path: `resources.${id}.input.properties.${PAYMENT_INPUT_FIELD}`,
1651
- resourceId: id
2008
+ if (inputProperties && typeof inputProperties === "object") {
2009
+ for (const reserved of RESERVED_INPUT_FIELDS) {
2010
+ if (!(reserved in inputProperties)) continue;
2011
+ throw new CommerceError(
2012
+ "CONFIG_INVALID",
2013
+ `Resource "${id}" declares an input property "${reserved}", which is reserved by the gateway`,
2014
+ {
2015
+ details: {
2016
+ path: `resources.${id}.input.properties.${reserved}`,
2017
+ resourceId: id
2018
+ }
1652
2019
  }
1653
- }
1654
- );
2020
+ );
2021
+ }
1655
2022
  }
1656
2023
  for (const protocol of entry.expose) {
1657
2024
  if (!SUPPORTED_PROTOCOLS.has(protocol)) {
@@ -1740,6 +2107,7 @@ function normaliseResource(id, entry, protocols, x402) {
1740
2107
  }
1741
2108
  }
1742
2109
  const pricing = entry.pricing.type === "free" ? { type: "free" } : { type: "fixed", amount: entry.pricing.amount, currency: entry.pricing.currency };
2110
+ const authorization = normaliseResourceAuthorization(id, entry, pricing, ap2);
1743
2111
  return {
1744
2112
  id,
1745
2113
  name: entry.name,
@@ -1760,7 +2128,8 @@ function normaliseResource(id, entry, protocols, x402) {
1760
2128
  },
1761
2129
  pricing,
1762
2130
  exposedVia: entry.expose,
1763
- paymentMethods
2131
+ paymentMethods,
2132
+ ...authorization !== void 0 ? { authorization } : {}
1764
2133
  };
1765
2134
  }
1766
2135
  function defaultClosedObjectSchema(schema) {
@@ -1938,11 +2307,8 @@ function validateInputBindings(id, backend, input) {
1938
2307
  Array.isArray(input?.["required"]) ? input["required"].filter((value) => typeof value === "string") : []
1939
2308
  );
1940
2309
  for (const [location, property] of entries) {
1941
- if (property === PAYMENT_INPUT_FIELD) {
1942
- fail(
1943
- `binds "${location}" to "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`,
1944
- { location }
1945
- );
2310
+ if (RESERVED_INPUT_FIELDS.includes(property)) {
2311
+ fail(`binds "${location}" to "${property}", which is reserved by the gateway`, { location });
1946
2312
  }
1947
2313
  const other = seen.get(property);
1948
2314
  if (other !== void 0) {
@@ -2163,9 +2529,9 @@ var SECRET_FIELD_NAMES = [
2163
2529
  "adminToken",
2164
2530
  "token"
2165
2531
  ];
2532
+ var SECRET_HEADERS = ["authorization", PAYMENT_HEADER, AUTHORIZATION_HEADER];
2166
2533
  var REDACT_PATHS = [
2167
- "req.headers.authorization",
2168
- 'req.headers["payment-signature"]',
2534
+ ...SECRET_HEADERS.map((name) => `req.headers[${JSON.stringify(name)}]`),
2169
2535
  ...SECRET_FIELD_NAMES,
2170
2536
  ...SECRET_FIELD_NAMES.map((name) => `*.${name}`)
2171
2537
  ];
@@ -2506,6 +2872,33 @@ var PAYMENT_PROVIDER_DETAIL = {
2506
2872
  warn: "payment-provider-degraded",
2507
2873
  fail: "payment-provider-unreachable"
2508
2874
  };
2875
+ var AUTHORIZATION_PROVIDER_DETAIL = {
2876
+ pass: void 0,
2877
+ warn: "authorization-provider-degraded",
2878
+ fail: "authorization-provider-unreachable"
2879
+ };
2880
+ async function probeProvider(provider, kind, details, options) {
2881
+ let health;
2882
+ try {
2883
+ health = await provider.health();
2884
+ } catch (error) {
2885
+ const message = error instanceof Error ? error.message : String(error);
2886
+ options.logger.error({ err: message, provider: provider.name }, `${kind} health() threw`);
2887
+ health = { status: "fail", checkedAt: options.clock.nowIso() };
2888
+ }
2889
+ if (health.detail !== void 0) {
2890
+ options.logger.debug(
2891
+ { provider: provider.name, detail: health.detail },
2892
+ `${kind} health detail (not sent to the client)`
2893
+ );
2894
+ }
2895
+ const detail = details[health.status];
2896
+ return {
2897
+ name: provider.name,
2898
+ status: health.status,
2899
+ ...detail !== void 0 ? { detail } : {}
2900
+ };
2901
+ }
2509
2902
  async function checkReadiness(options) {
2510
2903
  let storeHealth;
2511
2904
  let storeThrew = false;
@@ -2541,45 +2934,32 @@ async function checkReadiness(options) {
2541
2934
  })
2542
2935
  );
2543
2936
  const paymentProviderChecks = await Promise.all(
2544
- options.paymentProviders.map(async (provider) => {
2545
- let health;
2546
- try {
2547
- health = await provider.health();
2548
- } catch (error) {
2549
- const message = error instanceof Error ? error.message : String(error);
2550
- options.logger.error(
2551
- { err: message, provider: provider.name },
2552
- "payment provider health() threw"
2553
- );
2554
- health = { status: "fail", checkedAt: options.clock.nowIso() };
2555
- }
2556
- if (health.detail !== void 0) {
2557
- options.logger.debug(
2558
- { provider: provider.name, detail: health.detail },
2559
- "payment provider health detail (not sent to the client)"
2560
- );
2561
- }
2562
- const detail = PAYMENT_PROVIDER_DETAIL[health.status];
2563
- return {
2564
- name: provider.name,
2565
- status: health.status,
2566
- ...detail !== void 0 ? { detail } : {}
2567
- };
2568
- })
2937
+ options.paymentProviders.map(
2938
+ (provider) => probeProvider(provider, "payment provider", PAYMENT_PROVIDER_DETAIL, options)
2939
+ )
2940
+ );
2941
+ const authorizationProviderChecks = await Promise.all(
2942
+ options.authorizationProviders.map(
2943
+ (provider) => probeProvider(provider, "authorization provider", AUTHORIZATION_PROVIDER_DETAIL, options)
2944
+ )
2569
2945
  );
2570
2946
  const storeReady = storeHealth.status !== "fail";
2571
2947
  const adaptersReady = adapterChecks.every((check) => check.status !== "fail");
2572
2948
  const paymentProvidersReady = paymentProviderChecks.every((check) => check.status !== "fail");
2949
+ const authorizationProvidersReady = authorizationProviderChecks.every(
2950
+ (check) => check.status !== "fail"
2951
+ );
2573
2952
  const storeDetail = storeThrew ? "store-unreachable" : STORE_DETAIL[storeHealth.status];
2574
2953
  return {
2575
- ready: storeReady && adaptersReady && paymentProvidersReady,
2954
+ ready: storeReady && adaptersReady && paymentProvidersReady && authorizationProvidersReady,
2576
2955
  store: {
2577
2956
  name: "store",
2578
2957
  status: storeHealth.status,
2579
2958
  ...storeDetail !== void 0 ? { detail: storeDetail } : {}
2580
2959
  },
2581
2960
  adapters: adapterChecks,
2582
- paymentProviders: paymentProviderChecks
2961
+ paymentProviders: paymentProviderChecks,
2962
+ authorizationProviders: authorizationProviderChecks
2583
2963
  };
2584
2964
  }
2585
2965
  var READINESS_TTL_MS = 2e3;
@@ -2633,6 +3013,7 @@ async function buildWellKnownDocument(options) {
2633
3013
  protocols: publicProtocols(options.config.protocols),
2634
3014
  adapters,
2635
3015
  paymentProviders: options.paymentProviders.map((provider) => provider.descriptor),
3016
+ authorizationProviders: options.authorizationProviders.map((provider) => provider.descriptor),
2636
3017
  store: options.store.descriptor,
2637
3018
  payments: {
2638
3019
  ...x402 !== void 0 ? {
@@ -2672,6 +3053,7 @@ function registerRoutes(options) {
2672
3053
  store: options.store,
2673
3054
  adapterRuntimes: options.adapterRuntimes,
2674
3055
  paymentProviders: options.paymentProviders,
3056
+ authorizationProviders: options.authorizationProviders,
2675
3057
  clock: options.clock,
2676
3058
  logger: options.logger
2677
3059
  });
@@ -2686,6 +3068,7 @@ function registerRoutes(options) {
2686
3068
  async () => buildWellKnownDocument({
2687
3069
  config: options.config,
2688
3070
  paymentProviders: options.paymentProviders,
3071
+ authorizationProviders: options.authorizationProviders,
2689
3072
  store: options.store,
2690
3073
  adapterRuntimes: options.adapterRuntimes,
2691
3074
  clock: options.clock
@@ -2781,13 +3164,18 @@ async function handleInvoke(request, reply, options) {
2781
3164
  const paymentValue = Array.isArray(paymentHeader) ? paymentHeader[0] : paymentHeader;
2782
3165
  const paymentMethod = resource.paymentMethods[0];
2783
3166
  const payment = paymentValue !== void 0 && paymentMethod !== void 0 ? { method: paymentMethod, payload: paymentValue } : void 0;
3167
+ const authorization = parseAuthorizationHeader(
3168
+ request.headers[AUTHORIZATION_HEADER],
3169
+ request.id
3170
+ );
2784
3171
  const canonicalRequest = {
2785
3172
  requestId: request.id,
2786
3173
  resourceId,
2787
3174
  input: request.body ?? {},
2788
3175
  protocol: "http",
2789
3176
  receivedAt: options.clock.nowIso(),
2790
- ...payment !== void 0 ? { payment } : {}
3177
+ ...payment !== void 0 ? { payment } : {},
3178
+ ...authorization !== void 0 ? { authorization } : {}
2791
3179
  };
2792
3180
  const outcome = await options.pipeline.execute(canonicalRequest);
2793
3181
  if (outcome.kind === "payment-required") {
@@ -2873,11 +3261,13 @@ async function createGateway(options) {
2873
3261
  const ids = options.ids ?? createDefaultIdGenerator();
2874
3262
  const logger = options.logger ?? createGatewayLogger({ name: options.config.merchant.id }).core;
2875
3263
  const backend = options.backend ?? new HttpBackendExecutor({ logger });
3264
+ const authorizationProviders = options.authorizationProviders ?? [];
2876
3265
  const resources = createResourceRegistry(options.config.resources);
2877
3266
  const eventBus = createEventBus({ store: options.store, logger });
2878
3267
  const pipeline = createExecutionPipeline({
2879
3268
  resources,
2880
3269
  paymentProviders: options.paymentProviders,
3270
+ authorizationProviders,
2881
3271
  store: options.store,
2882
3272
  backend,
2883
3273
  events: eventBus,
@@ -2912,6 +3302,7 @@ async function createGateway(options) {
2912
3302
  resources,
2913
3303
  store: options.store,
2914
3304
  paymentProviders: options.paymentProviders,
3305
+ authorizationProviders,
2915
3306
  eventBus,
2916
3307
  clock,
2917
3308
  adapterRuntimes,
@@ -2955,13 +3346,14 @@ async function createGateway(options) {
2955
3346
 
2956
3347
  // src/protocols/acp/checkout-mapping.ts
2957
3348
  function toCanonicalRequest(options) {
2958
- const { request, resourceId, requestId, receivedAt } = options;
3349
+ const { request, resourceId, requestId, receivedAt, idempotencyKey } = options;
2959
3350
  return {
2960
3351
  requestId,
2961
3352
  resourceId,
2962
3353
  input: canonicalInput(request),
2963
3354
  protocol: "acp",
2964
- receivedAt
3355
+ receivedAt,
3356
+ ...idempotencyKey !== void 0 ? { idempotencyKey } : {}
2965
3357
  };
2966
3358
  }
2967
3359
  function canonicalInput(request) {
@@ -3069,8 +3461,8 @@ function writeAcpJson(res, status, body, headers = {}) {
3069
3461
  function writeAcpFailure(res, failure, headers = {}) {
3070
3462
  writeAcpJson(res, failure.status, failure.error, headers);
3071
3463
  }
3072
- function identityHash(token) {
3073
- return sha256(`acp-auth:${token}`);
3464
+ function operationKey(scope) {
3465
+ return sha256(`acp-operation:${scope.deployment}:${scope.endpoint}:${scope.key}`);
3074
3466
  }
3075
3467
  function requestFingerprint(body) {
3076
3468
  return sha256(canonicalize(body));
@@ -3087,54 +3479,9 @@ function canonicalize(value) {
3087
3479
  function sha256(value) {
3088
3480
  return createHash("sha256").update(value, "utf8").digest("hex");
3089
3481
  }
3090
- function openSqliteDatabase(options) {
3091
- const { path: path2, label, logger } = options;
3092
- const isFileBacked = path2 !== ":memory:";
3093
- if (isFileBacked) {
3094
- const dir = dirname(path2);
3095
- if (dir !== "" && dir !== "." && !existsSync(dir)) {
3096
- mkdirSync(dir, { recursive: true, mode: 448 });
3097
- }
3098
- }
3099
- if (isFileBacked && existsSync(path2)) {
3100
- try {
3101
- accessSync(path2, constants.W_OK);
3102
- } catch {
3103
- throw new CommerceError(
3104
- "STORAGE_ERROR",
3105
- `${label} "${path2}" exists but is not writable by this process (uid ${typeof process.getuid === "function" ? process.getuid() : "unknown"}). If this is a container that recently changed user, an existing volume may still be owned by the previous one - recreate it (docker compose down -v) or fix its ownership.`,
3106
- { details: { path: path2 } }
3107
- );
3108
- }
3109
- }
3110
- if (isFileBacked) {
3111
- try {
3112
- closeSync(openSync(path2, "a", 384));
3113
- } catch {
3114
- }
3115
- }
3116
- const db = new Database(path2);
3117
- db.pragma("journal_mode = WAL");
3118
- db.pragma("foreign_keys = ON");
3119
- if (isFileBacked) restrictDatabasePermissions(path2, label, logger);
3120
- return db;
3121
- }
3122
- function restrictDatabasePermissions(path2, label, logger) {
3123
- for (const file of [path2, `${path2}-wal`, `${path2}-shm`]) {
3124
- try {
3125
- chmodSync(file, 384);
3126
- } catch (error) {
3127
- const code = error.code;
3128
- if (code === "ENOENT") continue;
3129
- logger.warn(
3130
- { file, label, error: code ?? "unknown" },
3131
- "could not restrict database permissions to owner-only"
3132
- );
3133
- }
3134
- }
3135
- }
3136
3482
 
3137
3483
  // src/protocols/acp/idempotency/store.ts
3484
+ var SCHEMA_VERSION = 2;
3138
3485
  var ACP_MAX_IDEMPOTENCY_KEY_LENGTH = 255;
3139
3486
  function createAcpIdempotencyStore(options) {
3140
3487
  const logger = options.logger ?? NOOP_LOGGER;
@@ -3145,33 +3492,39 @@ function createAcpIdempotencyStore(options) {
3145
3492
  label: "ACP idempotency database",
3146
3493
  logger
3147
3494
  });
3148
- migrate(db);
3495
+ migrate(db, logger);
3149
3496
  const selectStmt = db.prepare(
3150
3497
  `SELECT state, fingerprint, status, body_json FROM acp_idempotency
3151
- WHERE identity_hash = ? AND endpoint = ? AND idempotency_key = ?`
3498
+ WHERE deployment = ? AND endpoint = ? AND idempotency_key = ?`
3152
3499
  );
3153
3500
  const insertStmt = db.prepare(
3154
3501
  `INSERT INTO acp_idempotency
3155
- (identity_hash, endpoint, idempotency_key, fingerprint, state, created_at, expires_at)
3156
- VALUES (@identity_hash, @endpoint, @idempotency_key, @fingerprint, 'in_flight', @created_at, @expires_at)`
3502
+ (deployment, endpoint, idempotency_key, fingerprint, state, created_at, expires_at)
3503
+ VALUES (@deployment, @endpoint, @idempotency_key, @fingerprint, 'in_flight', @created_at, @expires_at)`
3157
3504
  );
3158
3505
  const completeStmt = db.prepare(
3159
3506
  `UPDATE acp_idempotency SET state = 'completed', status = @status, body_json = @body_json
3160
- WHERE identity_hash = @identity_hash AND endpoint = @endpoint AND idempotency_key = @idempotency_key`
3507
+ WHERE deployment = @deployment AND endpoint = @endpoint AND idempotency_key = @idempotency_key`
3161
3508
  );
3162
3509
  const deleteStmt = db.prepare(
3163
3510
  `DELETE FROM acp_idempotency
3164
- WHERE identity_hash = ? AND endpoint = ? AND idempotency_key = ?`
3511
+ WHERE deployment = ? AND endpoint = ? AND idempotency_key = ?`
3512
+ );
3513
+ const unresolvedStmt = db.prepare(
3514
+ `UPDATE acp_idempotency SET state = 'unresolved'
3515
+ WHERE deployment = @deployment AND endpoint = @endpoint AND idempotency_key = @idempotency_key`
3516
+ );
3517
+ const expireStmt = db.prepare(
3518
+ "DELETE FROM acp_idempotency WHERE expires_at <= ? AND state = 'completed'"
3165
3519
  );
3166
- const expireStmt = db.prepare("DELETE FROM acp_idempotency WHERE expires_at <= ?");
3167
3520
  const claimTx = db.transaction(
3168
3521
  (scope, fingerprint) => {
3169
3522
  const at = now();
3170
3523
  expireStmt.run(new Date(at).toISOString());
3171
- const existing = selectStmt.get(scope.identityHash, scope.endpoint, scope.key);
3524
+ const existing = selectStmt.get(scope.deployment, scope.endpoint, scope.key);
3172
3525
  if (existing === void 0) {
3173
3526
  insertStmt.run({
3174
- identity_hash: scope.identityHash,
3527
+ deployment: scope.deployment,
3175
3528
  endpoint: scope.endpoint,
3176
3529
  idempotency_key: scope.key,
3177
3530
  fingerprint,
@@ -3181,6 +3534,7 @@ function createAcpIdempotencyStore(options) {
3181
3534
  return { kind: "reserved" };
3182
3535
  }
3183
3536
  if (existing.fingerprint !== fingerprint) return { kind: "conflict" };
3537
+ if (existing.state === "unresolved") return { kind: "unresolved" };
3184
3538
  if (existing.state !== "completed") return { kind: "in-flight" };
3185
3539
  return {
3186
3540
  kind: "replay",
@@ -3196,15 +3550,22 @@ function createAcpIdempotencyStore(options) {
3196
3550
  },
3197
3551
  complete(scope, response) {
3198
3552
  completeStmt.run({
3199
- identity_hash: scope.identityHash,
3553
+ deployment: scope.deployment,
3200
3554
  endpoint: scope.endpoint,
3201
3555
  idempotency_key: scope.key,
3202
3556
  status: response.status,
3203
3557
  body_json: JSON.stringify(response.body ?? null)
3204
3558
  });
3205
3559
  },
3560
+ markUnresolved(scope) {
3561
+ unresolvedStmt.run({
3562
+ deployment: scope.deployment,
3563
+ endpoint: scope.endpoint,
3564
+ idempotency_key: scope.key
3565
+ });
3566
+ },
3206
3567
  release(scope) {
3207
- deleteStmt.run(scope.identityHash, scope.endpoint, scope.key);
3568
+ deleteStmt.run(scope.deployment, scope.endpoint, scope.key);
3208
3569
  },
3209
3570
  close() {
3210
3571
  if (closed) return;
@@ -3213,13 +3574,23 @@ function createAcpIdempotencyStore(options) {
3213
3574
  }
3214
3575
  };
3215
3576
  }
3216
- function migrate(db) {
3577
+ function migrate(db, logger) {
3217
3578
  const current = db.pragma("user_version", { simple: true });
3218
- if (current >= 1) return;
3579
+ if (current >= SCHEMA_VERSION) return;
3219
3580
  const apply = db.transaction(() => {
3581
+ if (current === 1) {
3582
+ const carried = db.prepare("SELECT COUNT(*) AS count FROM acp_idempotency WHERE state != 'completed'").get();
3583
+ if (carried.count > 0) {
3584
+ logger.warn(
3585
+ { unresolved: carried.count },
3586
+ "acp idempotency: schema v1 -> v2 discards claims keyed by the old bearer-token digest; reconcile these operations against the merchant before trusting a retry"
3587
+ );
3588
+ }
3589
+ db.exec("DROP TABLE acp_idempotency");
3590
+ }
3220
3591
  db.exec(`
3221
3592
  CREATE TABLE IF NOT EXISTS acp_idempotency (
3222
- identity_hash TEXT NOT NULL,
3593
+ deployment TEXT NOT NULL,
3223
3594
  endpoint TEXT NOT NULL,
3224
3595
  idempotency_key TEXT NOT NULL,
3225
3596
  fingerprint TEXT NOT NULL,
@@ -3228,12 +3599,12 @@ function migrate(db) {
3228
3599
  body_json TEXT,
3229
3600
  created_at TEXT NOT NULL,
3230
3601
  expires_at TEXT NOT NULL,
3231
- PRIMARY KEY (identity_hash, endpoint, idempotency_key)
3602
+ PRIMARY KEY (deployment, endpoint, idempotency_key)
3232
3603
  );
3233
3604
  CREATE INDEX IF NOT EXISTS idx_acp_idempotency_expires_at
3234
3605
  ON acp_idempotency(expires_at);
3235
3606
  `);
3236
- db.pragma("user_version = 1");
3607
+ db.pragma(`user_version = ${SCHEMA_VERSION}`);
3237
3608
  });
3238
3609
  apply();
3239
3610
  }
@@ -7641,6 +8012,28 @@ function asResponse(failure) {
7641
8012
 
7642
8013
  // src/protocols/acp/adapter.ts
7643
8014
  var DISCOVERY_CACHE_CONTROL = "public, max-age=3600";
8015
+ var PRE_BACKEND_ERROR_CODES = /* @__PURE__ */ new Set([
8016
+ "CONFIG_INVALID",
8017
+ "RESOURCE_NOT_FOUND",
8018
+ "INPUT_INVALID",
8019
+ "PROTOCOL_UNSUPPORTED",
8020
+ "GATEWAY_BUSY",
8021
+ "PAYMENT_REQUIRED",
8022
+ "PAYMENT_INVALID",
8023
+ "PAYMENT_REPLAYED",
8024
+ "PAYMENT_PROVIDER_UNAVAILABLE",
8025
+ "PAYMENT_SETTLEMENT_FAILED",
8026
+ "AUTHORIZATION_REQUIRED",
8027
+ "AUTHORIZATION_INVALID",
8028
+ "AUTHORIZATION_REPLAYED",
8029
+ "AUTHORIZATION_PROVIDER_UNAVAILABLE"
8030
+ ]);
8031
+ function reachedFor(code) {
8032
+ return PRE_BACKEND_ERROR_CODES.has(code) ? "no" : "unknown";
8033
+ }
8034
+ function notReached(failure) {
8035
+ return { response: asResponse(failure), reached: "no" };
8036
+ }
7644
8037
  var AcpProtocolAdapter = class {
7645
8038
  name = "acp";
7646
8039
  mountPath;
@@ -7649,8 +8042,14 @@ var AcpProtocolAdapter = class {
7649
8042
  token;
7650
8043
  operations;
7651
8044
  idempotencyOptions;
7652
- /** Derived once: the token is fixed, and only its digest may be persisted. */
7653
- identityHash;
8045
+ /**
8046
+ * What an idempotency claim is scoped to, from `context.publicBaseUrl`.
8047
+ *
8048
+ * Not readonly because, unlike the token, it is known only once the adapter
8049
+ * starts. Worth that: scoping by the credential meant a rotation freed
8050
+ * every outstanding claim.
8051
+ */
8052
+ deployment;
7654
8053
  discoveryMetadata;
7655
8054
  idempotency;
7656
8055
  resources = /* @__PURE__ */ new Map();
@@ -7665,7 +8064,6 @@ var AcpProtocolAdapter = class {
7665
8064
  this.token = options.token;
7666
8065
  this.operations = options.operations;
7667
8066
  this.idempotencyOptions = options.idempotency;
7668
- this.identityHash = identityHash(options.token);
7669
8067
  this.discoveryMetadata = options.discovery;
7670
8068
  this.descriptor = buildDescriptor(PACKAGE_VERSION);
7671
8069
  this.additionalHttpRoutes = [
@@ -7709,6 +8107,7 @@ var AcpProtocolAdapter = class {
7709
8107
  resolved.set(operation, resource);
7710
8108
  }
7711
8109
  this.resources = resolved;
8110
+ this.deployment = context.publicBaseUrl;
7712
8111
  this.idempotency = createAcpIdempotencyStore({
7713
8112
  path: this.idempotencyOptions.path,
7714
8113
  retentionHours: this.idempotencyOptions.retentionHours,
@@ -7797,22 +8196,33 @@ var AcpProtocolAdapter = class {
7797
8196
  */
7798
8197
  async dispatch(request) {
7799
8198
  const store = this.idempotency;
8199
+ const deployment = this.deployment;
7800
8200
  const key = request.idempotencyKey;
7801
- if (store === void 0 || key === void 0) return this.runCheckout(request);
7802
- const scope = {
7803
- identityHash: this.identityHash,
7804
- endpoint: request.route.path,
7805
- key
7806
- };
8201
+ if (store === void 0 || deployment === void 0 || key === void 0) {
8202
+ return (await this.runCheckout(request)).response;
8203
+ }
8204
+ const scope = { deployment, endpoint: request.route.path, key };
7807
8205
  const claim = store.claim(scope, requestFingerprint(request.body));
7808
8206
  switch (claim.kind) {
7809
8207
  case "in-flight":
8208
+ return {
8209
+ ...asResponse(
8210
+ acpFailure(
8211
+ 409,
8212
+ "invalid_request",
8213
+ "idempotency_in_flight",
8214
+ "A request with this Idempotency-Key is still being processed."
8215
+ )
8216
+ ),
8217
+ retryAfterSeconds: ACP_IN_FLIGHT_RETRY_AFTER_SECONDS
8218
+ };
8219
+ case "unresolved":
7810
8220
  return asResponse(
7811
8221
  acpFailure(
7812
8222
  409,
7813
- "invalid_request",
7814
- "idempotency_in_flight",
7815
- "A request with this Idempotency-Key is still being processed."
8223
+ "processing_error",
8224
+ "idempotency_unresolved",
8225
+ "An earlier request with this Idempotency-Key reached the merchant and its outcome is unknown. Check the operation with the merchant before retrying."
7816
8226
  )
7817
8227
  );
7818
8228
  case "conflict":
@@ -7827,16 +8237,21 @@ var AcpProtocolAdapter = class {
7827
8237
  case "replay":
7828
8238
  return { status: claim.status, body: claim.body, replayed: true };
7829
8239
  }
7830
- let response;
8240
+ let outcome;
7831
8241
  try {
7832
- response = await this.runCheckout(request);
8242
+ outcome = await this.runCheckout(request, operationKey(scope));
7833
8243
  } catch (err) {
7834
- store.release(scope);
8244
+ store.markUnresolved(scope);
7835
8245
  throw err;
7836
8246
  }
7837
- if (response.status >= 500) store.release(scope);
7838
- else store.complete(scope, response);
7839
- return response;
8247
+ if (outcome.response.status < 500) {
8248
+ store.complete(scope, outcome.response);
8249
+ } else if (outcome.reached === "no") {
8250
+ store.release(scope);
8251
+ } else {
8252
+ store.markUnresolved(scope);
8253
+ }
8254
+ return outcome.response;
7840
8255
  }
7841
8256
  /**
7842
8257
  * One accepted request, one `pipeline.execute()`.
@@ -7845,11 +8260,11 @@ var AcpProtocolAdapter = class {
7845
8260
  * merchant backend: the adapter builds a canonical request and reads back
7846
8261
  * what the pipeline decided.
7847
8262
  */
7848
- async runCheckout(request) {
8263
+ async runCheckout(request, idempotencyKey) {
7849
8264
  const context = this.context;
7850
8265
  const resource = this.resources.get(request.route.operation);
7851
8266
  if (context === void 0 || resource === void 0) {
7852
- return asResponse(
8267
+ return notReached(
7853
8268
  acpFailure(
7854
8269
  503,
7855
8270
  "service_unavailable",
@@ -7862,7 +8277,8 @@ var AcpProtocolAdapter = class {
7862
8277
  request,
7863
8278
  resourceId: resource.id,
7864
8279
  requestId: context.ids.next("acp"),
7865
- receivedAt: context.clock.nowIso()
8280
+ receivedAt: context.clock.nowIso(),
8281
+ ...idempotencyKey !== void 0 ? { idempotencyKey } : {}
7866
8282
  });
7867
8283
  try {
7868
8284
  const outcome = await context.pipeline.execute(canonical);
@@ -7871,7 +8287,7 @@ var AcpProtocolAdapter = class {
7871
8287
  { resourceId: resource.id, requestId: canonical.requestId },
7872
8288
  "acp adapter: mapped checkout resource returned payment-required - it must be priced free"
7873
8289
  );
7874
- return asResponse(
8290
+ return notReached(
7875
8291
  acpFailure(
7876
8292
  500,
7877
8293
  "processing_error",
@@ -7887,14 +8303,22 @@ var AcpProtocolAdapter = class {
7887
8303
  "acp adapter: refusing to forward a non-conformant merchant response"
7888
8304
  );
7889
8305
  }
7890
- return mapped.response;
8306
+ return { response: mapped.response, reached: "yes" };
7891
8307
  } catch (err) {
7892
8308
  const error = toCommerceError(err);
7893
8309
  context.logger.warn(
7894
- { resourceId: resource.id, requestId: canonical.requestId, err: error.toInfo() },
8310
+ {
8311
+ resourceId: resource.id,
8312
+ requestId: canonical.requestId,
8313
+ reached: reachedFor(error.code),
8314
+ err: error.toInfo()
8315
+ },
7895
8316
  "acp adapter: checkout execution failed"
7896
8317
  );
7897
- return asResponse(mapCommerceErrorToAcp(error, request.route.operation));
8318
+ return {
8319
+ response: asResponse(mapCommerceErrorToAcp(error, request.route.operation)),
8320
+ reached: reachedFor(error.code)
8321
+ };
7898
8322
  }
7899
8323
  }
7900
8324
  /**
@@ -7907,7 +8331,7 @@ var AcpProtocolAdapter = class {
7907
8331
  ...request.requestId !== void 0 ? { [ACP_REQUEST_ID_HEADER]: request.requestId } : {},
7908
8332
  ...request.idempotencyKey !== void 0 ? { [ACP_IDEMPOTENCY_KEY_HEADER]: request.idempotencyKey } : {},
7909
8333
  ...replayed ? { [ACP_IDEMPOTENT_REPLAYED_HEADER]: "true" } : {},
7910
- ...response?.status === 409 ? { "retry-after": String(ACP_IN_FLIGHT_RETRY_AFTER_SECONDS) } : {}
8334
+ ...response?.retryAfterSeconds !== void 0 ? { "retry-after": String(response.retryAfterSeconds) } : {}
7911
8335
  };
7912
8336
  }
7913
8337
  fail(res, err, message) {
@@ -7934,6 +8358,7 @@ var AcpProtocolAdapter = class {
7934
8358
  this.resources = /* @__PURE__ */ new Map();
7935
8359
  this.idempotency?.close();
7936
8360
  this.idempotency = void 0;
8361
+ this.deployment = void 0;
7937
8362
  this.context = void 0;
7938
8363
  }
7939
8364
  };
@@ -7975,12 +8400,14 @@ function receiptToRow(receipt) {
7975
8400
  backend_status: receipt.backendStatus,
7976
8401
  duration_ms: receipt.durationMs !== void 0 ? receipt.durationMs : null,
7977
8402
  protocol: receipt.protocol !== void 0 ? receipt.protocol : null,
7978
- metadata_json: receipt.metadata !== void 0 ? JSON.stringify(redact(receipt.metadata)) : null
8403
+ metadata_json: receipt.metadata !== void 0 ? JSON.stringify(redact(receipt.metadata)) : null,
8404
+ authorization_json: receipt.authorization !== void 0 ? JSON.stringify(redact(receipt.authorization)) : null
7979
8405
  };
7980
8406
  }
7981
8407
  function rowToReceipt(row) {
7982
8408
  const payment = row.payment_json !== null ? JSON.parse(row.payment_json) : void 0;
7983
8409
  const metadata = row.metadata_json !== null ? JSON.parse(row.metadata_json) : void 0;
8410
+ const authorization = row.authorization_json !== null ? JSON.parse(row.authorization_json) : void 0;
7984
8411
  return {
7985
8412
  id: row.id,
7986
8413
  requestId: row.request_id,
@@ -7990,7 +8417,8 @@ function rowToReceipt(row) {
7990
8417
  ...payment !== void 0 ? { payment } : {},
7991
8418
  ...row.duration_ms !== null ? { durationMs: row.duration_ms } : {},
7992
8419
  ...row.protocol !== null ? { protocol: row.protocol } : {},
7993
- ...metadata !== void 0 ? { metadata } : {}
8420
+ ...metadata !== void 0 ? { metadata } : {},
8421
+ ...authorization !== void 0 ? { authorization } : {}
7994
8422
  };
7995
8423
  }
7996
8424
  function eventToRow(event) {
@@ -8099,9 +8527,15 @@ var MIGRATIONS = [
8099
8527
  CREATE INDEX IF NOT EXISTS idx_payment_attempts_created_at ON payment_attempts(created_at);
8100
8528
  `);
8101
8529
  }
8530
+ },
8531
+ {
8532
+ version: 2,
8533
+ up(db) {
8534
+ db.exec(`ALTER TABLE receipts ADD COLUMN authorization_json TEXT;`);
8535
+ }
8102
8536
  }
8103
8537
  ];
8104
- var SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
8538
+ var SCHEMA_VERSION2 = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
8105
8539
  function migrate2(db) {
8106
8540
  const current = db.pragma("user_version", { simple: true });
8107
8541
  for (const migration of MIGRATIONS) {
@@ -8136,8 +8570,8 @@ function createSqliteReceiptStore(options) {
8136
8570
  const db = openSqliteDatabase({ path: path2, label: "Receipt database", logger });
8137
8571
  migrate2(db);
8138
8572
  const insertReceiptStmt = db.prepare(
8139
- `INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json)
8140
- VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json)`
8573
+ `INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json, authorization_json)
8574
+ VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json, @authorization_json)`
8141
8575
  );
8142
8576
  const getReceiptStmt = db.prepare("SELECT * FROM receipts WHERE id = ?");
8143
8577
  const listReceiptsStmt = db.prepare(
@@ -8187,17 +8621,17 @@ function createSqliteReceiptStore(options) {
8187
8621
  name: "sqlite-receipt-store",
8188
8622
  kind: "storage",
8189
8623
  implementationVersion: PACKAGE_VERSION,
8190
- supportedSpec: `sqlite-schema-v${SCHEMA_VERSION}`,
8624
+ supportedSpec: `sqlite-schema-v${SCHEMA_VERSION2}`,
8191
8625
  capabilities: ["receipts", "events", "payment-attempts"],
8192
8626
  status: "stable"
8193
8627
  };
8194
8628
  const store = {
8195
8629
  async init() {
8196
8630
  const version = db.pragma("user_version", { simple: true });
8197
- if (version !== SCHEMA_VERSION) {
8631
+ if (version !== SCHEMA_VERSION2) {
8198
8632
  throw new CommerceError(
8199
8633
  "STORAGE_ERROR",
8200
- `Receipt store schema version mismatch (found ${version}, expected ${SCHEMA_VERSION})`
8634
+ `Receipt store schema version mismatch (found ${version}, expected ${SCHEMA_VERSION2})`
8201
8635
  );
8202
8636
  }
8203
8637
  },
@@ -8331,9 +8765,9 @@ function createSqliteReceiptStore(options) {
8331
8765
  }
8332
8766
  try {
8333
8767
  const version = db.pragma("user_version", { simple: true });
8334
- if (version !== SCHEMA_VERSION) {
8768
+ if (version !== SCHEMA_VERSION2) {
8335
8769
  logger.warn(
8336
- { found: version, expected: SCHEMA_VERSION },
8770
+ { found: version, expected: SCHEMA_VERSION2 },
8337
8771
  "receipt-store: health check failed (schema version mismatch)"
8338
8772
  );
8339
8773
  return {
@@ -8345,7 +8779,7 @@ function createSqliteReceiptStore(options) {
8345
8779
  }
8346
8780
  return {
8347
8781
  status: "pass",
8348
- detail: `sqlite schema v${SCHEMA_VERSION} writable`,
8782
+ detail: `sqlite schema v${SCHEMA_VERSION2} writable`,
8349
8783
  checkedAt: clock.nowIso(),
8350
8784
  durationMs: clock.monotonicMs() - startedAt
8351
8785
  };