s402 0.5.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/CHANGELOG.md +32 -0
- package/README.md +10 -0
- package/dist/compat-mpp.d.mts +160 -0
- package/dist/compat-mpp.mjs +363 -0
- package/dist/errors.d.mts +3 -1
- package/dist/errors.mjs +12 -2
- package/dist/http.d.mts +24 -7
- package/dist/http.mjs +30 -8
- package/dist/index.d.mts +301 -6
- package/dist/index.mjs +595 -86
- package/dist/test-utils.d.mts +1 -1
- package/dist/types.d.mts +2 -1
- package/dist/types.mjs +2 -1
- package/package.json +8 -1
- /package/dist/{scheme-m-uk4zyH.d.mts → scheme-CKinOhyx.d.mts} +0 -0
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: '
|
|
112
|
-
* payTo: '
|
|
113
|
-
* asset: '
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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, "
|
|
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
|
|
514
|
+
error: "Extension beforeVerify failed",
|
|
551
515
|
errorCode: "EXTENSION_FAILED"
|
|
552
516
|
};
|
|
553
517
|
}
|
|
554
|
-
let
|
|
518
|
+
let verifyResult;
|
|
555
519
|
try {
|
|
556
|
-
let
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
})]).finally(() => clearTimeout(
|
|
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 : "
|
|
564
|
-
errorCode: "
|
|
527
|
+
error: e instanceof Error ? e.message : "Verification threw an unexpected error",
|
|
528
|
+
errorCode: "VERIFICATION_FAILED"
|
|
565
529
|
};
|
|
566
530
|
}
|
|
567
|
-
if (
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
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
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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 };
|