s402 0.5.0 → 0.7.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.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { S402_HEADERS, S402_VERSION } from "./types.mjs";
2
2
  import { createS402Error, s402Error, s402ErrorCode } from "./errors.mjs";
3
- import { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, validateRequirementsShape } from "./http.mjs";
3
+ import { MAX_BODY_BYTES, S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, validateRequirementsShape } from "./http.mjs";
4
4
  import { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader } from "./receipts.mjs";
5
5
 
6
6
  //#region src/client.ts
@@ -35,7 +35,7 @@ var s402Client = class {
35
35
  * `accepts` array that we have a registered implementation for.
36
36
  *
37
37
  * Accepts typed s402PaymentRequirements only. For x402 input, normalize
38
- * first via `normalizeRequirements()` from 's402/compat'.
38
+ * first via `normalizeRequirements()` from 's402/compat/x402'.
39
39
  *
40
40
  * @param requirements - Server's payment requirements (from a 402 response)
41
41
  * @returns Payment payload ready to send in the `x-payment` header
@@ -108,9 +108,9 @@ var s402ResourceServer = class {
108
108
  * const requirements = server.buildRequirements({
109
109
  * schemes: ['exact'],
110
110
  * price: '1000000',
111
- * network: 'sui:mainnet',
112
- * payTo: '0xYOUR_ADDRESS',
113
- * asset: '0x2::sui::SUI',
111
+ * network: 'your-chain:mainnet',
112
+ * payTo: 'YOUR_ADDRESS',
113
+ * asset: 'NATIVE_TOKEN',
114
114
  * });
115
115
  * res.status(402).setHeader('payment-required', encodePaymentRequired(requirements));
116
116
  * ```
@@ -307,9 +307,16 @@ async function runExtensionHooks(extensions, hookName, runner, onError) {
307
307
  //#region src/facilitator.ts
308
308
  var s402Facilitator = class {
309
309
  schemes = /* @__PURE__ */ new Map();
310
- inFlight = /* @__PURE__ */ new Set();
310
+ inFlight = /* @__PURE__ */ new Map();
311
+ completed = /* @__PURE__ */ new Map();
312
+ dedupTtlMs;
313
+ dedupMaxEntries;
311
314
  extensionRegistry = new s402ExtensionRegistry();
312
315
  extensionErrorHandler;
316
+ constructor(options = {}) {
317
+ this.dedupTtlMs = options.dedupTtlMs ?? 3e5;
318
+ this.dedupMaxEntries = options.dedupMaxEntries ?? 1e4;
319
+ }
313
320
  /**
314
321
  * Register a scheme-specific facilitator for a network.
315
322
  */
@@ -475,70 +482,27 @@ var s402Facilitator = class {
475
482
  errorCode: "SCHEME_NOT_SUPPORTED"
476
483
  };
477
484
  }
478
- const dedupeKey = JSON.stringify(payload);
479
- if (this.inFlight.has(dedupeKey)) return {
480
- success: false,
481
- error: "Duplicate payment request already in flight",
482
- errorCode: "INVALID_PAYLOAD"
483
- };
484
- this.inFlight.add(dedupeKey);
485
- const extensions = this.extensionRegistry.size > 0 ? this.extensionRegistry.sorted() : null;
485
+ const dedupeKey = options?.idempotencyKey ?? JSON.stringify(payload);
486
+ const cached = this.getCached(dedupeKey);
487
+ if (cached) return cached;
488
+ const inFlight = this.inFlight.get(dedupeKey);
489
+ if (inFlight) return inFlight;
490
+ const resultPromise = this.executeProcess(payload, requirements, scheme, options).then((result) => {
491
+ this.cacheResult(dedupeKey, result);
492
+ return result;
493
+ });
494
+ this.inFlight.set(dedupeKey, resultPromise);
486
495
  try {
487
- if (!options?.skipVerify) {
488
- if (extensions) try {
489
- await runExtensionHooks(extensions, "beforeVerify", (ext) => ext.beforeVerify ? ext.beforeVerify(payload, requirements) : Promise.resolve(), this.extensionErrorHandler);
490
- } catch (e) {
491
- if (e instanceof s402Error) return {
492
- success: false,
493
- error: e.message,
494
- errorCode: e.code
495
- };
496
- return {
497
- success: false,
498
- error: "Extension beforeVerify failed",
499
- errorCode: "EXTENSION_FAILED"
500
- };
501
- }
502
- let verifyResult;
503
- try {
504
- let verifyTimer;
505
- verifyResult = await Promise.race([scheme.verify(payload, requirements), new Promise((_, reject) => {
506
- verifyTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Verification timed out after 5s")), 5e3);
507
- })]).finally(() => clearTimeout(verifyTimer));
508
- } catch (e) {
509
- return {
510
- success: false,
511
- error: e instanceof Error ? e.message : "Verification threw an unexpected error",
512
- errorCode: "VERIFICATION_FAILED"
513
- };
514
- }
515
- if (!verifyResult.valid) return {
516
- success: false,
517
- error: verifyResult.invalidReason ?? "Payment verification failed",
518
- errorCode: "VERIFICATION_FAILED"
519
- };
520
- if (extensions) try {
521
- await runExtensionHooks(extensions, "afterVerify", (ext) => ext.afterVerify ? ext.afterVerify(payload, verifyResult) : Promise.resolve(), this.extensionErrorHandler);
522
- } catch (e) {
523
- if (e instanceof s402Error) return {
524
- success: false,
525
- error: e.message,
526
- errorCode: e.code
527
- };
528
- return {
529
- success: false,
530
- error: "Extension afterVerify failed",
531
- errorCode: "EXTENSION_FAILED"
532
- };
533
- }
534
- if (typeof requirements.expiresAt === "number" && Date.now() > requirements.expiresAt) return {
535
- success: false,
536
- error: `Payment requirements expired during verification at ${new Date(requirements.expiresAt).toISOString()}`,
537
- errorCode: "REQUIREMENTS_EXPIRED"
538
- };
539
- }
496
+ return await resultPromise;
497
+ } finally {
498
+ this.inFlight.delete(dedupeKey);
499
+ }
500
+ }
501
+ async executeProcess(payload, requirements, scheme, options) {
502
+ const extensions = this.extensionRegistry.size > 0 ? this.extensionRegistry.sorted() : null;
503
+ if (!options?.skipVerify) {
540
504
  if (extensions) try {
541
- await runExtensionHooks(extensions, "beforeSettle", (ext) => ext.beforeSettle ? ext.beforeSettle(payload, requirements) : Promise.resolve(), this.extensionErrorHandler);
505
+ await runExtensionHooks(extensions, "beforeVerify", (ext) => ext.beforeVerify ? ext.beforeVerify(payload, requirements) : Promise.resolve(), this.extensionErrorHandler);
542
506
  } catch (e) {
543
507
  if (e instanceof s402Error) return {
544
508
  success: false,
@@ -547,35 +511,103 @@ var s402Facilitator = class {
547
511
  };
548
512
  return {
549
513
  success: false,
550
- error: "Extension beforeSettle failed",
514
+ error: "Extension beforeVerify failed",
551
515
  errorCode: "EXTENSION_FAILED"
552
516
  };
553
517
  }
554
- let settleResult;
518
+ let verifyResult;
555
519
  try {
556
- let settleTimer;
557
- settleResult = await Promise.race([scheme.settle(payload, requirements), new Promise((_, reject) => {
558
- settleTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Settlement timed out after 15s")), 15e3);
559
- })]).finally(() => clearTimeout(settleTimer));
520
+ let verifyTimer;
521
+ verifyResult = await Promise.race([scheme.verify(payload, requirements), new Promise((_, reject) => {
522
+ verifyTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Verification timed out after 5s")), 5e3);
523
+ })]).finally(() => clearTimeout(verifyTimer));
560
524
  } catch (e) {
561
525
  return {
562
526
  success: false,
563
- error: e instanceof Error ? e.message : "Settlement failed with an unexpected error",
564
- errorCode: "SETTLEMENT_FAILED"
527
+ error: e instanceof Error ? e.message : "Verification threw an unexpected error",
528
+ errorCode: "VERIFICATION_FAILED"
565
529
  };
566
530
  }
567
- if (extensions && settleResult.success) try {
568
- await runExtensionHooks(extensions, "afterSettle", (ext) => ext.afterSettle ? ext.afterSettle(payload, settleResult) : Promise.resolve(), this.extensionErrorHandler);
531
+ if (!verifyResult.valid) return {
532
+ success: false,
533
+ error: verifyResult.invalidReason ?? "Payment verification failed",
534
+ errorCode: "VERIFICATION_FAILED"
535
+ };
536
+ if (extensions) try {
537
+ await runExtensionHooks(extensions, "afterVerify", (ext) => ext.afterVerify ? ext.afterVerify(payload, verifyResult) : Promise.resolve(), this.extensionErrorHandler);
569
538
  } catch (e) {
570
- this.extensionErrorHandler?.({
571
- key: "afterSettle",
572
- version: "0",
573
- critical: true
574
- }, e);
539
+ if (e instanceof s402Error) return {
540
+ success: false,
541
+ error: e.message,
542
+ errorCode: e.code
543
+ };
544
+ return {
545
+ success: false,
546
+ error: "Extension afterVerify failed",
547
+ errorCode: "EXTENSION_FAILED"
548
+ };
575
549
  }
576
- return settleResult;
577
- } finally {
578
- this.inFlight.delete(dedupeKey);
550
+ if (typeof requirements.expiresAt === "number" && Date.now() > requirements.expiresAt) return {
551
+ success: false,
552
+ error: `Payment requirements expired during verification at ${new Date(requirements.expiresAt).toISOString()}`,
553
+ errorCode: "REQUIREMENTS_EXPIRED"
554
+ };
555
+ }
556
+ if (extensions) try {
557
+ await runExtensionHooks(extensions, "beforeSettle", (ext) => ext.beforeSettle ? ext.beforeSettle(payload, requirements) : Promise.resolve(), this.extensionErrorHandler);
558
+ } catch (e) {
559
+ if (e instanceof s402Error) return {
560
+ success: false,
561
+ error: e.message,
562
+ errorCode: e.code
563
+ };
564
+ return {
565
+ success: false,
566
+ error: "Extension beforeSettle failed",
567
+ errorCode: "EXTENSION_FAILED"
568
+ };
569
+ }
570
+ let settleResult;
571
+ try {
572
+ let settleTimer;
573
+ settleResult = await Promise.race([scheme.settle(payload, requirements), new Promise((_, reject) => {
574
+ settleTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Settlement timed out after 15s")), 15e3);
575
+ })]).finally(() => clearTimeout(settleTimer));
576
+ } catch (e) {
577
+ return {
578
+ success: false,
579
+ error: e instanceof Error ? e.message : "Settlement failed with an unexpected error",
580
+ errorCode: "SETTLEMENT_FAILED"
581
+ };
582
+ }
583
+ if (extensions && settleResult.success) try {
584
+ await runExtensionHooks(extensions, "afterSettle", (ext) => ext.afterSettle ? ext.afterSettle(payload, settleResult) : Promise.resolve(), this.extensionErrorHandler);
585
+ } catch (e) {
586
+ this.extensionErrorHandler?.({
587
+ key: "afterSettle",
588
+ version: "0",
589
+ critical: true
590
+ }, e);
591
+ }
592
+ return settleResult;
593
+ }
594
+ getCached(key) {
595
+ const entry = this.completed.get(key);
596
+ if (!entry) return null;
597
+ if (Date.now() > entry.expiresAt) {
598
+ this.completed.delete(key);
599
+ return null;
600
+ }
601
+ return entry.result;
602
+ }
603
+ cacheResult(key, result) {
604
+ this.completed.set(key, {
605
+ result,
606
+ expiresAt: Date.now() + this.dedupTtlMs
607
+ });
608
+ if (this.completed.size > this.dedupMaxEntries) {
609
+ const oldestKey = this.completed.keys().next().value;
610
+ if (oldestKey !== void 0) this.completed.delete(oldestKey);
579
611
  }
580
612
  }
581
613
  /**
@@ -601,4 +633,481 @@ var s402Facilitator = class {
601
633
  };
602
634
 
603
635
  //#endregion
604
- export { S402_CONTENT_TYPE, S402_HEADERS, S402_RECEIPT_HEADER, S402_VERSION, createS402Error, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, formatReceiptHeader, getExtensionData, isValidAmount, isValidU64Amount, parseReceiptHeader, runExtensionHooks, s402Client, s402Error, s402ErrorCode, s402ExtensionRegistry, s402Facilitator, s402ResourceServer, setExtensionData, validateRequirementsShape };
636
+ //#region src/canonicalization.ts
637
+ /**
638
+ * s402 canonicalization — RFC 8785 JSON Canonicalization Scheme (JCS).
639
+ *
640
+ * JCS produces a deterministic byte sequence from a JSON value. Two parsers
641
+ * that honor JCS produce identical bytes for semantically equal JSON, enabling
642
+ * content-hashing and cross-language interop.
643
+ *
644
+ * Full spec: see `spec/canonicalization.md` (project-local, normative).
645
+ * Summary applied here:
646
+ * - Object keys sorted by UTF-16 code unit order (RFC 8785 §3.2.3)
647
+ * - Numbers rendered per ECMA-404 shortest-roundtrip (RFC 8785 §3.2.2)
648
+ * - Strings escape only the minimum required (RFC 8785 §3.2.1)
649
+ * - Arrays preserve order
650
+ * - No whitespace
651
+ * - Reject non-finite numbers, BigInt, undefined, functions, symbols
652
+ *
653
+ * This module implements JCS *serialization* only. Strict parsing with
654
+ * duplicate-key rejection is deferred to a later PR — envelope txBinding
655
+ * verification never parses untrusted canonical JSON, so dup-key handling is
656
+ * not on this critical path. (Client recomputes from its own objects.)
657
+ */
658
+ /**
659
+ * Serialize a JSON value to RFC 8785 canonical form.
660
+ *
661
+ * Returns a UTF-8 byte string (represented as `Uint8Array`). Callers hash the
662
+ * bytes directly; do NOT re-encode via `TextEncoder` (double-encoding risk).
663
+ *
664
+ * @throws {s402Error} INVALID_PAYLOAD on non-JSON-safe values (NaN, Infinity,
665
+ * bigint, undefined, functions, symbols, circular refs).
666
+ */
667
+ function canonicalize(value) {
668
+ const out = [];
669
+ writeValue(value, out, /* @__PURE__ */ new WeakSet());
670
+ return new TextEncoder().encode(out.join(""));
671
+ }
672
+ /**
673
+ * Convenience: canonicalize to a UTF-8 string.
674
+ *
675
+ * Use only when a downstream API demands a string; prefer the Uint8Array form
676
+ * for digest inputs to avoid an extra encode/decode round-trip.
677
+ */
678
+ function canonicalizeToString(value) {
679
+ const out = [];
680
+ writeValue(value, out, /* @__PURE__ */ new WeakSet());
681
+ return out.join("");
682
+ }
683
+ function writeValue(v, out, seen) {
684
+ if (v === null) {
685
+ out.push("null");
686
+ return;
687
+ }
688
+ const t = typeof v;
689
+ if (t === "boolean") {
690
+ out.push(v ? "true" : "false");
691
+ return;
692
+ }
693
+ if (t === "number") {
694
+ writeNumber(v, out);
695
+ return;
696
+ }
697
+ if (t === "string") {
698
+ writeString(v, out);
699
+ return;
700
+ }
701
+ if (t === "bigint") throw new s402Error("INVALID_PAYLOAD", "Canonicalization does not accept bigint — encode monetary amounts as decimal strings");
702
+ if (t === "undefined" || t === "function" || t === "symbol") throw new s402Error("INVALID_PAYLOAD", `Canonicalization does not accept ${t}`);
703
+ if (Array.isArray(v)) {
704
+ if (seen.has(v)) throw new s402Error("INVALID_PAYLOAD", "Canonicalization does not accept cyclic values");
705
+ seen.add(v);
706
+ out.push("[");
707
+ for (let i = 0; i < v.length; i++) {
708
+ if (i > 0) out.push(",");
709
+ writeValue(v[i], out, seen);
710
+ }
711
+ out.push("]");
712
+ seen.delete(v);
713
+ return;
714
+ }
715
+ if (t === "object") {
716
+ if (seen.has(v)) throw new s402Error("INVALID_PAYLOAD", "Canonicalization does not accept cyclic values");
717
+ seen.add(v);
718
+ const obj = v;
719
+ const keys = Object.keys(obj).sort();
720
+ out.push("{");
721
+ let first = true;
722
+ for (const k of keys) {
723
+ const val = obj[k];
724
+ if (val === void 0) continue;
725
+ if (!first) out.push(",");
726
+ first = false;
727
+ writeString(k, out);
728
+ out.push(":");
729
+ writeValue(val, out, seen);
730
+ }
731
+ out.push("}");
732
+ seen.delete(v);
733
+ return;
734
+ }
735
+ throw new s402Error("INVALID_PAYLOAD", `Canonicalization does not accept type ${t}`);
736
+ }
737
+ function writeNumber(n, out) {
738
+ if (!Number.isFinite(n)) throw new s402Error("INVALID_PAYLOAD", `Canonicalization does not accept non-finite numbers (got ${n})`);
739
+ if (n === 0) {
740
+ out.push("0");
741
+ return;
742
+ }
743
+ out.push(n.toString());
744
+ }
745
+ function writeString(s, out) {
746
+ out.push("\"");
747
+ for (let i = 0; i < s.length; i++) {
748
+ const c = s.charCodeAt(i);
749
+ switch (c) {
750
+ case 8:
751
+ out.push("\\b");
752
+ continue;
753
+ case 9:
754
+ out.push("\\t");
755
+ continue;
756
+ case 10:
757
+ out.push("\\n");
758
+ continue;
759
+ case 12:
760
+ out.push("\\f");
761
+ continue;
762
+ case 13:
763
+ out.push("\\r");
764
+ continue;
765
+ case 34:
766
+ out.push("\\\"");
767
+ continue;
768
+ case 92:
769
+ out.push("\\\\");
770
+ continue;
771
+ }
772
+ if (c < 32) {
773
+ out.push("\\u", c.toString(16).padStart(4, "0"));
774
+ continue;
775
+ }
776
+ out.push(s[i]);
777
+ }
778
+ out.push("\"");
779
+ }
780
+
781
+ //#endregion
782
+ //#region src/envelope.ts
783
+ /** Content type for the settlement envelope wire format. */
784
+ const S402_ENVELOPE_CONTENT_TYPE = "application/vnd.s402.envelope+json";
785
+ /**
786
+ * Domain-separation prefix for txBinding digest inputs.
787
+ * See `spec/canonicalization.md` §3.3 for the purpose registry.
788
+ */
789
+ const TX_BINDING_PREFIX = "s402-txbinding-v1\0";
790
+ /** ASCII record separator (U+001E) — unambiguous delimiter between canonical blobs. */
791
+ const RECORD_SEPARATOR = 30;
792
+ /**
793
+ * Compute the `txBinding` value for a request pair.
794
+ *
795
+ * ```
796
+ * txBinding = "sha256-" || base64url_no_pad(
797
+ * sha256(
798
+ * "s402-txbinding-v1\0"
799
+ * || JCS(requirements)
800
+ * || 0x1E
801
+ * || JCS(payload)
802
+ * )
803
+ * )
804
+ * ```
805
+ *
806
+ * Clients recompute this locally from their OWN `{requirements, payload}` and
807
+ * compare to `envelope.txBinding` using a constant-time primitive. See S14.
808
+ */
809
+ async function computeTxBinding(requirements, payload, alg = "sha256") {
810
+ if (alg !== "sha256") throw new s402Error("S402_UNKNOWN_ALGORITHM", `txBinding digest algorithm "${alg}" is not implemented in this build`);
811
+ const prefixBytes = new TextEncoder().encode(TX_BINDING_PREFIX);
812
+ const reqBytes = canonicalize(requirements);
813
+ const payloadBytes = canonicalize(payload);
814
+ const input = new Uint8Array(prefixBytes.length + reqBytes.length + 1 + payloadBytes.length);
815
+ let offset = 0;
816
+ input.set(prefixBytes, offset);
817
+ offset += prefixBytes.length;
818
+ input.set(reqBytes, offset);
819
+ offset += reqBytes.length;
820
+ input[offset] = RECORD_SEPARATOR;
821
+ offset += 1;
822
+ input.set(payloadBytes, offset);
823
+ const digestBuffer = await crypto.subtle.digest("SHA-256", input);
824
+ return `sha256-${toBase64UrlNoPad(new Uint8Array(digestBuffer))}`;
825
+ }
826
+ function toBase64UrlNoPad(bytes) {
827
+ let binary = "";
828
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
829
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
830
+ }
831
+ async function buildSettledEnvelope(ctx, settled) {
832
+ return {
833
+ ...await buildBase(ctx),
834
+ status: "settled",
835
+ settled
836
+ };
837
+ }
838
+ async function buildVerifiedEnvelope(ctx) {
839
+ return {
840
+ ...await buildBase(ctx),
841
+ status: "verified",
842
+ verified: {}
843
+ };
844
+ }
845
+ async function buildRejectedEnvelope(ctx, error) {
846
+ return {
847
+ ...await buildBase(ctx),
848
+ status: "rejected",
849
+ rejected: { error }
850
+ };
851
+ }
852
+ async function buildPendingEnvelope(ctx, pending) {
853
+ return {
854
+ ...await buildBase(ctx),
855
+ status: "pending",
856
+ pending
857
+ };
858
+ }
859
+ async function buildBase(ctx) {
860
+ const algs = ctx.algs ?? {
861
+ digest: "sha256",
862
+ sig: "ed25519"
863
+ };
864
+ const txBinding = await computeTxBinding(ctx.requirements, ctx.payload, algs.digest);
865
+ return {
866
+ s402Version: ctx.s402Version,
867
+ scheme: ctx.scheme,
868
+ specDigest: ctx.specDigest,
869
+ txBinding,
870
+ network: ctx.network,
871
+ algs,
872
+ timestamp: ctx.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
873
+ facilitatorIds: ctx.facilitatorIds
874
+ };
875
+ }
876
+ function encodeEnvelopeBody(envelope) {
877
+ return JSON.stringify(envelope);
878
+ }
879
+ function decodeEnvelopeBody(body) {
880
+ if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 envelope body must be a string, got ${typeof body}`);
881
+ if (body.length > MAX_BODY_BYTES) throw new s402Error("INVALID_PAYLOAD", `s402 envelope body exceeds maximum size (${body.length} > ${MAX_BODY_BYTES})`);
882
+ let parsed;
883
+ try {
884
+ parsed = JSON.parse(body);
885
+ } catch (e) {
886
+ throw new s402Error("INVALID_PAYLOAD", `Failed to parse s402 envelope: ${e instanceof Error ? e.message : "invalid JSON"}`);
887
+ }
888
+ validateEnvelopeShape(parsed);
889
+ return parsed;
890
+ }
891
+ const VALID_STATUSES = new Set([
892
+ "settled",
893
+ "verified",
894
+ "rejected",
895
+ "pending"
896
+ ]);
897
+ const VALID_DIGEST_ALGS = new Set([
898
+ "sha256",
899
+ "sha384",
900
+ "sha512",
901
+ "blake3"
902
+ ]);
903
+ const VALID_SIG_ALGS = new Set([
904
+ "ed25519",
905
+ "ed25519ph",
906
+ "secp256k1",
907
+ "ml-dsa-44"
908
+ ]);
909
+ function validateEnvelopeShape(v) {
910
+ if (v === null || typeof v !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope must be an object");
911
+ const e = v;
912
+ requireString(e, "s402Version");
913
+ requireString(e, "scheme");
914
+ requireString(e, "specDigest");
915
+ requireString(e, "txBinding");
916
+ requireString(e, "network");
917
+ requireString(e, "timestamp");
918
+ const status = e.status;
919
+ if (typeof status !== "string" || !VALID_STATUSES.has(status)) throw new s402Error("INVALID_PAYLOAD", `envelope.status must be one of settled|verified|rejected|pending, got ${String(status)}`);
920
+ const algs = e.algs;
921
+ if (algs === null || typeof algs !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope.algs must be an object");
922
+ const a = algs;
923
+ if (typeof a.digest !== "string" || !VALID_DIGEST_ALGS.has(a.digest)) throw new s402Error("S402_UNKNOWN_ALGORITHM", `envelope.algs.digest "${String(a.digest)}" is not recognized`);
924
+ if (typeof a.sig !== "string" || !VALID_SIG_ALGS.has(a.sig)) throw new s402Error("S402_UNKNOWN_ALGORITHM", `envelope.algs.sig "${String(a.sig)}" is not recognized`);
925
+ if (e.facilitatorIds !== void 0) {
926
+ if (!Array.isArray(e.facilitatorIds)) throw new s402Error("INVALID_PAYLOAD", "envelope.facilitatorIds must be an array if present");
927
+ for (const id of e.facilitatorIds) if (typeof id !== "string") throw new s402Error("INVALID_PAYLOAD", "envelope.facilitatorIds entries must be strings");
928
+ }
929
+ switch (status) {
930
+ case "settled":
931
+ if (e.settled === null || typeof e.settled !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope.settled must be an object");
932
+ requireString(e.settled, "settledAt");
933
+ break;
934
+ case "verified":
935
+ if (e.verified === null || typeof e.verified !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope.verified must be an object");
936
+ break;
937
+ case "rejected": {
938
+ if (e.rejected === null || typeof e.rejected !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope.rejected must be an object");
939
+ const rej = e.rejected;
940
+ if (rej.error === null || typeof rej.error !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope.rejected.error must be an object");
941
+ requireString(rej.error, "code");
942
+ requireString(rej.error, "message");
943
+ break;
944
+ }
945
+ case "pending":
946
+ if (e.pending === null || typeof e.pending !== "object") throw new s402Error("INVALID_PAYLOAD", "envelope.pending must be an object");
947
+ requireString(e.pending, "reason");
948
+ break;
949
+ }
950
+ }
951
+ function requireString(obj, key) {
952
+ if (typeof obj[key] !== "string" || obj[key] === "") throw new s402Error("INVALID_PAYLOAD", `envelope field "${key}" must be a non-empty string`);
953
+ }
954
+ /**
955
+ * Perform the ADR-007 client-side MUST checks. Throws on any failure.
956
+ *
957
+ * Scheme-match, spec-digest, network, txBinding (constant-time), timestamp,
958
+ * and algorithm acceptance. Resource-binding and unlock-attestation checks
959
+ * live in higher layers (they need request-intent + scheme-specific context).
960
+ */
961
+ async function verifyEnvelope(envelope, options) {
962
+ const { originalRequest, expectedSpecDigest, acceptedDigestAlgs = ["sha256"], acceptedSigAlgs = ["ed25519"], maxTimestampSkewMs = 300 * 1e3, now = Date.now } = options;
963
+ if (envelope.scheme !== originalRequest.payload.scheme) throw new s402Error("INVALID_PAYLOAD", `envelope.scheme "${envelope.scheme}" does not match payload scheme "${originalRequest.payload.scheme}"`);
964
+ if (!originalRequest.requirements.accepts.includes(envelope.scheme)) throw new s402Error("SCHEME_NOT_SUPPORTED", `envelope.scheme "${envelope.scheme}" is not in requirements.accepts`);
965
+ if (envelope.specDigest !== expectedSpecDigest) throw new s402Error("DIGEST_MISMATCH", `envelope.specDigest does not match expected scheme-digest`);
966
+ if (envelope.network !== originalRequest.requirements.network) throw new s402Error("NETWORK_MISMATCH", `envelope.network "${envelope.network}" does not match request network "${originalRequest.requirements.network}"`);
967
+ if (!acceptedDigestAlgs.includes(envelope.algs.digest)) throw new s402Error("S402_UNKNOWN_ALGORITHM", `envelope.algs.digest "${envelope.algs.digest}" is not in accepted set`);
968
+ if (!acceptedSigAlgs.includes(envelope.algs.sig)) throw new s402Error("S402_UNKNOWN_ALGORITHM", `envelope.algs.sig "${envelope.algs.sig}" is not in accepted set`);
969
+ const envTs = Date.parse(envelope.timestamp);
970
+ if (!Number.isFinite(envTs)) throw new s402Error("INVALID_PAYLOAD", `envelope.timestamp "${envelope.timestamp}" is not a valid ISO-8601 date`);
971
+ if (Math.abs(envTs - now()) > maxTimestampSkewMs) throw new s402Error("INVALID_PAYLOAD", `envelope.timestamp skew exceeds ${maxTimestampSkewMs}ms`);
972
+ const expectedTxBinding = await computeTxBinding(originalRequest.requirements, originalRequest.payload, envelope.algs.digest);
973
+ if (!constantTimeStringEqual(envelope.txBinding, expectedTxBinding)) throw new s402Error("S402_TX_BINDING_MISMATCH", `envelope.txBinding does not match locally recomputed binding`);
974
+ }
975
+ /**
976
+ * Constant-time string equality — S14 invariant.
977
+ *
978
+ * Compares every character regardless of mismatches, so the time-to-answer
979
+ * does not leak digest prefix information. Lengths are compared first (their
980
+ * difference is not secret — a mismatched length means the response is
981
+ * definitely not ours).
982
+ */
983
+ function constantTimeStringEqual(a, b) {
984
+ if (a.length !== b.length) return false;
985
+ let diff = 0;
986
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
987
+ return diff === 0;
988
+ }
989
+
990
+ //#endregion
991
+ //#region src/accept-payment.ts
992
+ const Q_VALUE_PATTERN = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
993
+ /**
994
+ * Parse an `Accept-Payment` header into sorted preference entries.
995
+ *
996
+ * - Entries are returned in descending q-value order; ties preserve input order
997
+ * (stable sort).
998
+ * - Malformed entries are dropped silently (robustness principle).
999
+ * - Scheme tokens are lowercased. Whitespace around `;` and `,` is tolerated.
1000
+ * - Duplicate schemes: the highest-q occurrence wins.
1001
+ *
1002
+ * @example
1003
+ * parseAcceptPayment('s402/prepaid, s402/exact;q=0.8, tempo/charge;q=0.5');
1004
+ * // [
1005
+ * // { scheme: 's402/prepaid', q: 1 },
1006
+ * // { scheme: 's402/exact', q: 0.8 },
1007
+ * // { scheme: 'tempo/charge', q: 0.5 },
1008
+ * // ]
1009
+ */
1010
+ function parseAcceptPayment(header) {
1011
+ if (!header) return [];
1012
+ const entries = [];
1013
+ const seen = /* @__PURE__ */ new Map();
1014
+ const segments = header.split(",");
1015
+ for (let i = 0; i < segments.length; i++) {
1016
+ const segment = segments[i].trim();
1017
+ if (!segment) continue;
1018
+ const parts = segment.split(";");
1019
+ const scheme = parts[0].trim().toLowerCase();
1020
+ if (!isValidSchemeToken(scheme)) continue;
1021
+ let q = 1;
1022
+ let valid = true;
1023
+ for (let j = 1; j < parts.length; j++) {
1024
+ const param = parts[j].trim();
1025
+ if (!param) continue;
1026
+ const eq = param.indexOf("=");
1027
+ if (eq === -1) {
1028
+ valid = false;
1029
+ break;
1030
+ }
1031
+ const key = param.slice(0, eq).trim().toLowerCase();
1032
+ const value = param.slice(eq + 1).trim();
1033
+ if (key === "q") {
1034
+ if (!Q_VALUE_PATTERN.test(value)) {
1035
+ valid = false;
1036
+ break;
1037
+ }
1038
+ q = Number.parseFloat(value);
1039
+ }
1040
+ }
1041
+ if (!valid) continue;
1042
+ const existing = seen.get(scheme);
1043
+ if (existing !== void 0) {
1044
+ if (entries[existing].q < q) entries[existing] = {
1045
+ scheme,
1046
+ q,
1047
+ order: i
1048
+ };
1049
+ continue;
1050
+ }
1051
+ seen.set(scheme, entries.length);
1052
+ entries.push({
1053
+ scheme,
1054
+ q,
1055
+ order: i
1056
+ });
1057
+ }
1058
+ entries.sort((a, b) => b.q - a.q || a.order - b.order);
1059
+ return entries.map(({ scheme, q }) => ({
1060
+ scheme,
1061
+ q
1062
+ }));
1063
+ }
1064
+ /**
1065
+ * Format a list of entries back into an `Accept-Payment` header string.
1066
+ *
1067
+ * Entries with `q=1` omit the parameter (it's the default). Other q-values
1068
+ * are emitted with up to 3 decimals, trailing zeros trimmed.
1069
+ *
1070
+ * @example
1071
+ * formatAcceptPayment([
1072
+ * { scheme: 's402/prepaid', q: 1 },
1073
+ * { scheme: 'tempo/charge', q: 0.5 },
1074
+ * ]);
1075
+ * // "s402/prepaid, tempo/charge;q=0.5"
1076
+ */
1077
+ function formatAcceptPayment(entries) {
1078
+ return entries.filter((e) => isValidSchemeToken(e.scheme) && e.q >= 0 && e.q <= 1).map((e) => e.q === 1 ? e.scheme : `${e.scheme};q=${formatQ(e.q)}`).join(", ");
1079
+ }
1080
+ /**
1081
+ * Select the best scheme both sides agree on.
1082
+ *
1083
+ * - Walks `preferred` in order (parseAcceptPayment returns them sorted by q).
1084
+ * - Returns the first scheme that appears in `supported`.
1085
+ * - Entries with `q=0` are explicit rejections and are skipped.
1086
+ * - Scheme comparison is case-insensitive; the returned string matches the
1087
+ * casing from `supported`.
1088
+ * - If `preferred` is empty (no header), falls back to the first entry in
1089
+ * `supported` (server's default).
1090
+ * - Returns `null` if no overlap exists.
1091
+ */
1092
+ function selectBestScheme(preferred, supported) {
1093
+ if (supported.length === 0) return null;
1094
+ const supportedLower = /* @__PURE__ */ new Map();
1095
+ for (const s of supported) supportedLower.set(s.toLowerCase(), s);
1096
+ if (preferred.length === 0) return supported[0];
1097
+ for (const entry of preferred) {
1098
+ if (entry.q === 0) continue;
1099
+ const match = supportedLower.get(entry.scheme);
1100
+ if (match) return match;
1101
+ }
1102
+ return null;
1103
+ }
1104
+ function isValidSchemeToken(token) {
1105
+ if (token.length === 0) return false;
1106
+ return /^[A-Za-z0-9][A-Za-z0-9/\-_.+]*$/.test(token);
1107
+ }
1108
+ function formatQ(q) {
1109
+ return q.toFixed(3).replace(/\.?0+$/, "") || "0";
1110
+ }
1111
+
1112
+ //#endregion
1113
+ export { MAX_BODY_BYTES, S402_CONTENT_TYPE, S402_ENVELOPE_CONTENT_TYPE, S402_HEADERS, S402_RECEIPT_HEADER, S402_VERSION, buildPendingEnvelope, buildRejectedEnvelope, buildSettledEnvelope, buildVerifiedEnvelope, canonicalize, canonicalizeToString, computeTxBinding, constantTimeStringEqual, createS402Error, decodeEnvelopeBody, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodeEnvelopeBody, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, formatAcceptPayment, formatReceiptHeader, getExtensionData, isValidAmount, isValidU64Amount, parseAcceptPayment, parseReceiptHeader, runExtensionHooks, s402Client, s402Error, s402ErrorCode, s402ExtensionRegistry, s402Facilitator, s402ResourceServer, selectBestScheme, setExtensionData, validateEnvelopeShape, validateRequirementsShape, verifyEnvelope };