s402 0.4.0 → 0.6.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
@@ -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
  * ```
@@ -140,10 +140,11 @@ var s402ResourceServer = class {
140
140
  protocolFeeBps: config.protocolFeeBps,
141
141
  receiptRequired: config.receiptRequired,
142
142
  settlementMode: config.settlementMode,
143
+ upto: config.upto,
144
+ prepaid: config.prepaid,
143
145
  stream: config.stream,
144
146
  escrow: config.escrow,
145
- unlock: config.unlock,
146
- prepaid: config.prepaid
147
+ unlock: config.unlock
147
148
  };
148
149
  }
149
150
  /**
@@ -186,11 +187,136 @@ var s402ResourceServer = class {
186
187
  }
187
188
  };
188
189
 
190
+ //#endregion
191
+ //#region src/extensions.ts
192
+ /**
193
+ * Type-safe extension data retrieval.
194
+ *
195
+ * The wire format is `Record<string, unknown>` for interop, but this helper
196
+ * provides typed access for TypeScript consumers.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * interface DiscoveryData { services: string[] }
201
+ * const data = getExtensionData<DiscoveryData>(requirements.extensions, 'org.s402.discovery');
202
+ * if (data) console.log(data.services);
203
+ * ```
204
+ */
205
+ function getExtensionData(extensions, key) {
206
+ return extensions?.[key];
207
+ }
208
+ /**
209
+ * Set extension data on an extensions record (creates if needed).
210
+ * Returns a new extensions object (does not mutate the input).
211
+ */
212
+ function setExtensionData(extensions, key, data) {
213
+ return {
214
+ ...extensions,
215
+ [key]: data
216
+ };
217
+ }
218
+ /**
219
+ * Registry for extensions with dependency-ordered execution.
220
+ *
221
+ * Extensions are stored by key and sorted topologically based on `dependsOn`.
222
+ * Within the same dependency level, registration order is preserved.
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * const registry = new s402ExtensionRegistry<s402FacilitatorExtension>();
227
+ * registry.register(rateLimitExtension);
228
+ * registry.register(analyticsExtension);
229
+ * const sorted = registry.sorted(); // dependency-ordered list
230
+ * ```
231
+ */
232
+ var s402ExtensionRegistry = class {
233
+ extensions = /* @__PURE__ */ new Map();
234
+ sortedCache = null;
235
+ /**
236
+ * Register an extension. Throws on duplicate key or dependency cycle.
237
+ */
238
+ register(ext) {
239
+ if (this.extensions.has(ext.key)) throw new s402Error("EXTENSION_FAILED", `Extension "${ext.key}" is already registered`);
240
+ this.extensions.set(ext.key, ext);
241
+ this.sortedCache = null;
242
+ }
243
+ /** Get a registered extension by key. */
244
+ get(key) {
245
+ return this.extensions.get(key);
246
+ }
247
+ /** Number of registered extensions. */
248
+ get size() {
249
+ return this.extensions.size;
250
+ }
251
+ /**
252
+ * Return extensions in topological (dependency) order.
253
+ * Cached until a new extension is registered.
254
+ */
255
+ sorted() {
256
+ if (this.sortedCache) return this.sortedCache;
257
+ this.sortedCache = topologicalSort(this.extensions);
258
+ return this.sortedCache;
259
+ }
260
+ };
261
+ /**
262
+ * Topological sort of extensions based on `dependsOn` declarations.
263
+ * Uses Kahn's algorithm. Throws on cycles.
264
+ */
265
+ function topologicalSort(extensions) {
266
+ if (extensions.size === 0) return [];
267
+ const inDegree = /* @__PURE__ */ new Map();
268
+ const dependents = /* @__PURE__ */ new Map();
269
+ for (const [key] of extensions) {
270
+ inDegree.set(key, 0);
271
+ dependents.set(key, []);
272
+ }
273
+ for (const [key, ext] of extensions) if (ext.dependsOn) for (const dep of ext.dependsOn) {
274
+ if (!extensions.has(dep)) throw new s402Error("EXTENSION_FAILED", `Extension "${key}" depends on "${dep}" which is not registered`);
275
+ dependents.get(dep).push(key);
276
+ inDegree.set(key, (inDegree.get(key) ?? 0) + 1);
277
+ }
278
+ const queue = [];
279
+ for (const [key, degree] of inDegree) if (degree === 0) queue.push(key);
280
+ const sorted = [];
281
+ while (queue.length > 0) {
282
+ const key = queue.shift();
283
+ sorted.push(extensions.get(key));
284
+ for (const dependent of dependents.get(key)) {
285
+ const newDegree = inDegree.get(dependent) - 1;
286
+ inDegree.set(dependent, newDegree);
287
+ if (newDegree === 0) queue.push(dependent);
288
+ }
289
+ }
290
+ if (sorted.length !== extensions.size) throw new s402Error("EXTENSION_FAILED", `Extension dependency cycle detected involving: ${[...extensions.keys()].filter((k) => !sorted.some((e) => e.key === k)).join(", ")}`);
291
+ return sorted;
292
+ }
293
+ /**
294
+ * Run an async hook on all extensions in order.
295
+ * Critical extensions throw on failure; advisory extensions call the error handler.
296
+ */
297
+ async function runExtensionHooks(extensions, hookName, runner, onError) {
298
+ for (const ext of extensions) try {
299
+ await runner(ext);
300
+ } catch (e) {
301
+ if (ext.critical) throw new s402Error("EXTENSION_FAILED", `Critical extension "${ext.key}" failed in ${hookName}: ${e instanceof Error ? e.message : String(e)}`);
302
+ onError?.(ext, e);
303
+ }
304
+ }
305
+
189
306
  //#endregion
190
307
  //#region src/facilitator.ts
191
308
  var s402Facilitator = class {
192
309
  schemes = /* @__PURE__ */ new Map();
193
- inFlight = /* @__PURE__ */ new Set();
310
+ inFlight = /* @__PURE__ */ new Map();
311
+ completed = /* @__PURE__ */ new Map();
312
+ dedupTtlMs;
313
+ dedupMaxEntries;
314
+ extensionRegistry = new s402ExtensionRegistry();
315
+ extensionErrorHandler;
316
+ constructor(options = {}) {
317
+ this.dedupTtlMs = options.dedupTtlMs ?? 3e5;
318
+ this.dedupMaxEntries = options.dedupMaxEntries ?? 1e4;
319
+ }
194
320
  /**
195
321
  * Register a scheme-specific facilitator for a network.
196
322
  */
@@ -200,6 +326,25 @@ var s402Facilitator = class {
200
326
  return this;
201
327
  }
202
328
  /**
329
+ * Register a facilitator extension. Extensions fire in dependency order
330
+ * at four points in the process() pipeline: beforeVerify, afterVerify,
331
+ * beforeSettle, afterSettle.
332
+ *
333
+ * @throws {s402Error} `EXTENSION_FAILED` on duplicate key or dependency cycle
334
+ */
335
+ registerExtension(ext) {
336
+ this.extensionRegistry.register(ext);
337
+ return this;
338
+ }
339
+ /**
340
+ * Set the handler for advisory (non-critical) extension failures.
341
+ * Critical extensions always throw; advisory extensions call this handler.
342
+ */
343
+ onExtensionError(handler) {
344
+ this.extensionErrorHandler = handler;
345
+ return this;
346
+ }
347
+ /**
203
348
  * Verify a payment payload by dispatching to the correct scheme.
204
349
  * Includes expiration guard and scheme-mismatch check.
205
350
  */
@@ -284,6 +429,7 @@ var s402Facilitator = class {
284
429
  *
285
430
  * @param payload - Client's payment payload
286
431
  * @param requirements - Server's payment requirements
432
+ * @param options - Optional process configuration (e.g., `{ skipVerify: true }` for zero-cost-failure chains)
287
433
  * @returns Settlement result (check `result.success` and `result.errorCode`)
288
434
  *
289
435
  * @example
@@ -301,7 +447,7 @@ var s402Facilitator = class {
301
447
  * }
302
448
  * ```
303
449
  */
304
- async process(payload, requirements) {
450
+ async process(payload, requirements, options) {
305
451
  if (requirements.expiresAt != null) {
306
452
  if (typeof requirements.expiresAt !== "number" || !Number.isFinite(requirements.expiresAt)) return {
307
453
  success: false,
@@ -336,14 +482,39 @@ var s402Facilitator = class {
336
482
  errorCode: "SCHEME_NOT_SUPPORTED"
337
483
  };
338
484
  }
339
- const dedupeKey = JSON.stringify(payload);
340
- if (this.inFlight.has(dedupeKey)) return {
341
- success: false,
342
- error: "Duplicate payment request already in flight",
343
- errorCode: "INVALID_PAYLOAD"
344
- };
345
- this.inFlight.add(dedupeKey);
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);
346
495
  try {
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) {
504
+ if (extensions) try {
505
+ await runExtensionHooks(extensions, "beforeVerify", (ext) => ext.beforeVerify ? ext.beforeVerify(payload, requirements) : Promise.resolve(), this.extensionErrorHandler);
506
+ } catch (e) {
507
+ if (e instanceof s402Error) return {
508
+ success: false,
509
+ error: e.message,
510
+ errorCode: e.code
511
+ };
512
+ return {
513
+ success: false,
514
+ error: "Extension beforeVerify failed",
515
+ errorCode: "EXTENSION_FAILED"
516
+ };
517
+ }
347
518
  let verifyResult;
348
519
  try {
349
520
  let verifyTimer;
@@ -362,25 +533,81 @@ var s402Facilitator = class {
362
533
  error: verifyResult.invalidReason ?? "Payment verification failed",
363
534
  errorCode: "VERIFICATION_FAILED"
364
535
  };
365
- if (typeof requirements.expiresAt === "number" && Date.now() > requirements.expiresAt) return {
366
- success: false,
367
- error: `Payment requirements expired during verification at ${new Date(requirements.expiresAt).toISOString()}`,
368
- errorCode: "REQUIREMENTS_EXPIRED"
369
- };
370
- try {
371
- let settleTimer;
372
- return await Promise.race([scheme.settle(payload, requirements), new Promise((_, reject) => {
373
- settleTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Settlement timed out after 15s")), 15e3);
374
- })]).finally(() => clearTimeout(settleTimer));
536
+ if (extensions) try {
537
+ await runExtensionHooks(extensions, "afterVerify", (ext) => ext.afterVerify ? ext.afterVerify(payload, verifyResult) : Promise.resolve(), this.extensionErrorHandler);
375
538
  } catch (e) {
539
+ if (e instanceof s402Error) return {
540
+ success: false,
541
+ error: e.message,
542
+ errorCode: e.code
543
+ };
376
544
  return {
377
545
  success: false,
378
- error: e instanceof Error ? e.message : "Settlement failed with an unexpected error",
379
- errorCode: "SETTLEMENT_FAILED"
546
+ error: "Extension afterVerify failed",
547
+ errorCode: "EXTENSION_FAILED"
380
548
  };
381
549
  }
382
- } finally {
383
- 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);
384
611
  }
385
612
  }
386
613
  /**
@@ -406,4 +633,481 @@ var s402Facilitator = class {
406
633
  };
407
634
 
408
635
  //#endregion
409
- 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, isValidAmount, isValidU64Amount, parseReceiptHeader, s402Client, s402Error, s402ErrorCode, s402Facilitator, s402ResourceServer, 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 };