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/CHANGELOG.md CHANGED
@@ -5,6 +5,69 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-04-19
9
+
10
+ ### Added
11
+
12
+ - **`s402/compat-mpp` — MPP read-path interop (DAN-339).** New entry point for consuming Stripe/Tempo Machine Payment Protocol 402 responses as native s402 types. All parsing is grounded against the actual MPP spec drafts in `tempoxyz/mpp-specs` (draft-httpauth-payment-00, draft-payment-intent-charge-00), not hearsay.
13
+ - `parseWwwAuthenticatePayment(header)` — RFC 9110 auth-params parser for `WWW-Authenticate: Payment`. Handles quoted-string escapes, unquoted tokens, enforces required `id`/`realm`/`method`/`intent`/`request`, preserves optional `digest`/`expires`/`description`/`opaque`.
14
+ - `parseMppAcceptPayment(header)` — method/intent pair grammar with wildcards on either side (`tempo/charge`, `tempo/*`, `*/session`, `*/*`) and q-values per core spec §6.1. Stable sort by descending q, preserves client order on ties.
15
+ - `matchMppRange(range, method, intent)` — specificity scoring (exact=2, one-wild=1, all-wild=0, no-match=−1) for the "prefer most specific matching range" rule.
16
+ - `decodeMppChargeRequest(challenge)` — decodes the base64url JCS `request` blob for the charge intent. Validates `amount` as non-negative integer, requires `currency`, preserves `methodDetails` untouched.
17
+ - `decodeMppCredential(authorizationHeader)` — base64url-nopad `Authorization: Payment <...>` decoder with trust-boundary shape validation on `challenge` and `payload`.
18
+ - `fromMppChargeChallenge(challenge, now?)` — translates blockchain-method Charge challenges (`tempo`/`evm`/`solana`/`lightning`/`stellar`) into `s402PaymentRequirements` with `scheme: 'exact'`. Resolves network via `eip155:{chainId}` / `tempo:{chainId}` conventions, carries challenge provenance into `extensions.mpp` for downstream routing, rejects processor-based methods (Stripe/card have no payTo in the Charge request), rejects expired challenges.
19
+ - **40 spec-grounded unit tests** at `test/compat-mpp.test.ts` drawn from the spec's own §5.1.4 / §6.1 / §Request Schema fixtures.
20
+ - **ADR-005 — Interop When Possible, Superset When Wise.** The governing strategic principle behind the compat layer: absorb x402/MPP as payment-in formats where their design is legitimate; superset them on primitives their business models forbid. See `docs/adr/005-interop-superset-principle.md`.
21
+
22
+ ### Scope (intentionally deferred to v0.7+)
23
+
24
+ - Session intent (cumulative voucher ↔ Prepaid translation shim)
25
+ - Method-specific credential-tier dispatch (EVM `permit2`/`authorization`/`transaction`/`hash`; Tempo `transaction`/`hash`/`proof`)
26
+ - HMAC-SHA256 challenge-binding verification (server-side, needs secret)
27
+ - Write path — emitting MPP-shaped `WWW-Authenticate: Payment` challenges from an s402 server
28
+
29
+ ### Changed
30
+
31
+ - **956 tests across 21 files** (was 916). The 40 new compat-mpp tests join 30 unit + 6 live-server integration tests for `Accept-Payment` that shipped earlier in the 0.5 dev cycle.
32
+ - Migration guide (`docs/guide/upgrade-mpp.md`) updated to reference real exported APIs rather than placeholder code.
33
+ - `docs/integrations.md` compat-layer table updated: MPP Charge (read) is 🟡 v0.3, MPP `Accept-Payment` is ✅ Production, MPP Charge (write) and Session remain 📋 roadmap.
34
+
35
+ ### Compatibility
36
+
37
+ - **Purely additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors. Existing 0.5.x consumers require no code changes.
38
+ - **New sub-path export**: `s402/compat-mpp` sits alongside the existing `s402/compat` (x402 interop). Both are opt-in — importing from the root `s402` entry does not pull the compat bundles.
39
+
40
+ ## [0.5.0] - 2026-04-12
41
+
42
+ ### Added
43
+
44
+ - **`upto` scheme V2 features (DAN-284).** Two new fields close x402's upto overcharge vulnerability:
45
+ - `estimatedAmount` on `s402UptoExtra` — server's advisory cost estimate so clients can set tight ceilings
46
+ - `settlementCeiling` on `s402UptoPayload` — client-chosen, on-chain-enforced cap. Move contract rejects `actualAmount > settlementCeiling`. Must satisfy `1 <= settlementCeiling <= maxAmount`. See ADR-003 §Decision 3 and §Decision 8.
47
+ - **Extension system (DAN-285, ADR-004).** Typed, lifecycle-aware plugin architecture:
48
+ - Three actor-specific interfaces: `s402ClientExtension`, `s402ServerExtension`, `s402FacilitatorExtension`
49
+ - Four facilitator hooks in pipeline order: `beforeVerify` → `afterVerify` → `beforeSettle` → `afterSettle`
50
+ - `s402ExtensionRegistry` with dependency ordering via Kahn's topological sort
51
+ - Critical vs advisory error handling: `critical: true` extensions throw, advisory extensions log and continue
52
+ - `getExtensionData<T>()` / `setExtensionData()` type-safe helpers
53
+ - `./extensions` sub-path export added to package.json
54
+ - **`skipVerify` option on `process()`.** New `s402ProcessOptions` interface with `skipVerify?: boolean`. Eliminates the verify() dry-run RPC round-trip (~200-400ms) for chains where failed transactions cost zero gas (Sui PTBs). All pre-flight checks (expiration, scheme-mismatch, dedup) still run.
55
+ - **`EXTENSION_FAILED` error code** — `retryable: false`, for critical extension pipeline failures.
56
+ - **154 conformance test vectors** (was ~130). New vectors for: upto requirements with estimatedAmount, upto payloads with settlementCeiling, settle responses with actualAmount/depositId, V2 rejection vectors, upto roundtrips, mandate.minPerTx validation.
57
+
58
+ ### Fixed
59
+
60
+ - **Settle response type validation (M1).** `validateSettleShape` now rejects non-string `actualAmount` and `depositId` — a malicious facilitator could previously inject numeric types that passed through to consumer code.
61
+ - **Prepaid payload amount validation (M2).** `ratePerCall` and `maxCalls` in payload now validated with `isValidAmount()`, matching the requirements-side validation. Previously only type-checked as strings.
62
+ - **Mandate minPerTx amount validation (L1).** `mandate.minPerTx` now validated with `isValidAmount()` for consistency with other amount fields.
63
+ - **afterSettle error observability.** Catch block now forwards to `extensionErrorHandler` instead of silently swallowing critical extension errors (the settlement result is still never changed — tx is already on-chain).
64
+ - **Stale comment in validatePayloadShape.** Updated to document upto's scheme-specific inner keys alongside prepaid and unlock.
65
+
66
+ ### Changed
67
+
68
+ - **831 tests across 17 files** (was 798). New coverage: standalone verify/settle guard tests, V2 validation edge cases, settle response type checks, prepaid amount validation, extension system integration.
69
+ - Conformance README updated with `estimatedAmount` in upto sub-object keys and `settlementCeiling` in payload inner keys.
70
+
8
71
  ## [0.4.0] - 2026-04-11
9
72
 
10
73
  ### Changed
package/README.md CHANGED
@@ -16,6 +16,16 @@ deno add npm:s402
16
16
 
17
17
  > **ESM-only.** This package ships ES modules only (`"type": "module"`). Requires Node.js >= 18. CommonJS `require()` is not supported.
18
18
 
19
+ ## Governing Principle
20
+
21
+ > **We interop when possible. We superset when wise.**
22
+
23
+ s402 does not fight x402 or Stripe MPP head-on. s402 **absorbs** them as payment-in formats where their design choices are legitimate (exact, upto), and **supersets** them on primitives their business models cannot ship (prepaid with on-chain ceiling, streaming with rate enforcement, escrow with arbiter, Seal-encrypted unlock).
24
+
25
+ This is the Postgres-eats-MySQL move: the superset always eats the subset because adopters never lose what they had — they only gain. The asymmetry is in s402's favor because competitors' constraints forbid reciprocating. Stripe cannot accept s402 schemes without bypassing card-processing margin. x402's 2-scheme governance envelope cannot absorb s402's 5 without re-ratification.
26
+
27
+ See [ADR-005](./docs/adr/005-interop-superset-principle.md) for the full reasoning.
28
+
19
29
  ## Why s402?
20
30
 
21
31
  HTTP 402 ("Payment Required") has been reserved since 1999 — waiting for a payment protocol that actually works. Coinbase's x402 proved the concept on EVM. s402 takes it further by leveraging what makes Sui different.
@@ -0,0 +1,160 @@
1
+ import { s402PaymentRequirements } from "./types.mjs";
2
+
3
+ //#region src/compat-mpp.d.ts
4
+ /**
5
+ * Parsed `WWW-Authenticate: Payment` challenge parameters.
6
+ *
7
+ * Required params per core spec §5.1.1: id, realm, method, intent, request.
8
+ * Optional params per §5.1.2: digest, expires, description, opaque.
9
+ * `request` is a base64url-nopad JCS-encoded JSON object — decoded separately
10
+ * by intent-specific parsers (see {@link decodeMppChargeRequest}).
11
+ */
12
+ interface MppChallenge {
13
+ id: string;
14
+ realm: string;
15
+ method: string;
16
+ intent: string;
17
+ request: string;
18
+ digest?: string;
19
+ expires?: string;
20
+ description?: string;
21
+ opaque?: string;
22
+ }
23
+ /**
24
+ * Shared Charge-intent request fields per `draft-payment-intent-charge-00` §Request Schema.
25
+ *
26
+ * `amount` + `currency` are required across every method. `recipient` is
27
+ * REQUIRED for blockchain methods and OPTIONAL for processor-based methods
28
+ * (Stripe routes internally). `methodDetails` holds method-specific extension
29
+ * data (chainId, permit2Address, invoice, networkId, paymentMethodTypes, ...).
30
+ */
31
+ interface MppChargeRequest {
32
+ amount: string;
33
+ currency: string;
34
+ recipient?: string;
35
+ description?: string;
36
+ externalId?: string;
37
+ methodDetails?: Record<string, unknown>;
38
+ }
39
+ /**
40
+ * Decoded `Authorization: Payment <base64url>` credential per core spec §5.2.
41
+ *
42
+ * `challenge` echoes the original challenge params (verified server-side).
43
+ * `payload` is method-specific (Permit2 signature, Lightning preimage, Stripe
44
+ * confirmation id, ...). `source` is RECOMMENDED DID format for payer identity.
45
+ */
46
+ interface MppCredential {
47
+ challenge: {
48
+ id: string;
49
+ realm: string;
50
+ method: string;
51
+ intent: string;
52
+ request: string;
53
+ digest?: string;
54
+ expires?: string;
55
+ description?: string;
56
+ opaque?: string;
57
+ };
58
+ source?: string;
59
+ payload: Record<string, unknown>;
60
+ }
61
+ /**
62
+ * Entry in an MPP `Accept-Payment` header.
63
+ *
64
+ * MPP preference uses method/intent pairs with wildcards on either side
65
+ * (`tempo/x`, `x/session`, `x/x` where `x` is the literal `*`) and q-values
66
+ * per RFC 9110. This differs from s402's flat scheme tokens (`s402/exact`,
67
+ * `s402/prepaid`) which `parseAcceptPayment` treats as opaque strings.
68
+ */
69
+ interface MppPaymentRange {
70
+ /** Lowercase method id or "*" wildcard. */
71
+ method: string;
72
+ /** Intent token or "*" wildcard. */
73
+ intent: string;
74
+ /** Quality factor in [0,1]; 0 means "do not use". */
75
+ q: number;
76
+ }
77
+ /**
78
+ * Parse a `WWW-Authenticate: Payment ...` header into an {@link MppChallenge}.
79
+ *
80
+ * Returns null if the header is absent, empty, or doesn't start with `Payment`.
81
+ * Throws `INVALID_PAYLOAD` if the Payment scheme is present but required params
82
+ * are missing. Accepts a single Payment challenge per header line — per core
83
+ * spec §7.1 (Intent Negotiation), servers emitting multiple challenges send
84
+ * one header per challenge.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * const header = res.headers.get('WWW-Authenticate');
89
+ * const challenge = parseWwwAuthenticatePayment(header);
90
+ * if (challenge?.intent === 'charge') {
91
+ * const req = decodeMppChargeRequest(challenge);
92
+ * // ...
93
+ * }
94
+ * ```
95
+ */
96
+ declare function parseWwwAuthenticatePayment(header: string | null | undefined): MppChallenge | null;
97
+ /**
98
+ * Parse an MPP `Accept-Payment` header per core spec §6.1.
99
+ *
100
+ * Grammar: `Accept-Payment = #(method-or-* "/" intent-or-* [weight])`.
101
+ * Drops malformed entries silently — spec §6.1: "If Accept-Payment is
102
+ * malformed, servers MAY ignore it." Stable sort: descending q, original
103
+ * order on ties (preserves client preference per §6.1).
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const ranges = parseMppAcceptPayment('tempo/charge, tempo/session;q=0, stripe/*;q=0.5');
108
+ * // [{ method: 'tempo', intent: 'charge', q: 1 },
109
+ * // { method: 'stripe', intent: '*', q: 0.5 },
110
+ * // { method: 'tempo', intent: 'session', q: 0 }]
111
+ * ```
112
+ */
113
+ declare function parseMppAcceptPayment(header: string | null | undefined): MppPaymentRange[];
114
+ /**
115
+ * Match an MPP payment range against a concrete `method/intent` pair.
116
+ *
117
+ * Returns a specificity score: 2 (exact match on both), 1 (one wildcard),
118
+ * 0 (both wildcards), -1 (no match). Higher specificity wins per spec §6.1:
119
+ * "Prefer the most specific matching range when multiple ranges match."
120
+ */
121
+ declare function matchMppRange(range: MppPaymentRange, method: string, intent: string): number;
122
+ /**
123
+ * Decode the `request` parameter of an MPP Charge challenge into its shared
124
+ * fields. Per `draft-payment-intent-charge-00` §Request Schema, every Charge
125
+ * method emits `amount` + `currency` as REQUIRED shared fields; blockchain
126
+ * methods additionally require `recipient`.
127
+ *
128
+ * @throws {s402Error} `INVALID_PAYLOAD` if the request blob is not
129
+ * base64url-JSON, or is missing `amount` / `currency`, or if `amount` is
130
+ * not a non-negative integer string.
131
+ */
132
+ declare function decodeMppChargeRequest(challenge: MppChallenge): MppChargeRequest;
133
+ /**
134
+ * Decode an `Authorization: Payment <base64url>` credential into its JSON form.
135
+ * Does not verify HMAC challenge-binding — that requires the server's secret
136
+ * and is intentionally out of scope for this client-facing helper.
137
+ *
138
+ * @throws {s402Error} `INVALID_PAYLOAD` if the header is missing/malformed,
139
+ * the blob is not base64url-JSON, or required fields (`challenge`, `payload`)
140
+ * are missing.
141
+ */
142
+ declare function decodeMppCredential(authorizationHeader: string | null | undefined): MppCredential;
143
+ /**
144
+ * Translate an MPP Charge challenge into s402 requirements using the `exact`
145
+ * scheme. This is the inbound half of the coexistence pattern documented in
146
+ * `guide/upgrade-mpp.md`: an s402 client receives an MPP 402, lifts it into
147
+ * s402 types, then reuses its existing payment machinery.
148
+ *
149
+ * Only blockchain-like methods are translated here. Processor methods (Stripe
150
+ * card, etc.) route internally and do not expose the payTo/asset fields s402
151
+ * requires — keep those on the MPP path.
152
+ *
153
+ * @throws {s402Error} `INVALID_PAYLOAD` if the method is not a known
154
+ * blockchain-style Charge method, if the request is missing a recipient
155
+ * (REQUIRED for blockchain methods per charge spec), or if the challenge
156
+ * has expired at `now`.
157
+ */
158
+ declare function fromMppChargeChallenge(challenge: MppChallenge, now?: number): s402PaymentRequirements;
159
+ //#endregion
160
+ export { MppChallenge, MppChargeRequest, MppCredential, MppPaymentRange, decodeMppChargeRequest, decodeMppCredential, fromMppChargeChallenge, matchMppRange, parseMppAcceptPayment, parseWwwAuthenticatePayment };
@@ -0,0 +1,363 @@
1
+ import { S402_VERSION } from "./types.mjs";
2
+ import { s402Error } from "./errors.mjs";
3
+ import { isValidAmount } from "./http.mjs";
4
+
5
+ //#region src/compat-mpp.ts
6
+ const TOKEN_CHARS = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
7
+ const AUTH_SCHEME_PATTERN = /^\s*Payment(?:\s+(.*))?$/i;
8
+ /**
9
+ * Parse a `WWW-Authenticate: Payment ...` header into an {@link MppChallenge}.
10
+ *
11
+ * Returns null if the header is absent, empty, or doesn't start with `Payment`.
12
+ * Throws `INVALID_PAYLOAD` if the Payment scheme is present but required params
13
+ * are missing. Accepts a single Payment challenge per header line — per core
14
+ * spec §7.1 (Intent Negotiation), servers emitting multiple challenges send
15
+ * one header per challenge.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const header = res.headers.get('WWW-Authenticate');
20
+ * const challenge = parseWwwAuthenticatePayment(header);
21
+ * if (challenge?.intent === 'charge') {
22
+ * const req = decodeMppChargeRequest(challenge);
23
+ * // ...
24
+ * }
25
+ * ```
26
+ */
27
+ function parseWwwAuthenticatePayment(header) {
28
+ if (!header) return null;
29
+ const match = AUTH_SCHEME_PATTERN.exec(header);
30
+ if (!match) return null;
31
+ const paramString = (match[1] ?? "").trim();
32
+ if (paramString.length === 0) throw new s402Error("INVALID_PAYLOAD", "Payment challenge missing auth-params");
33
+ const params = parseAuthParams(paramString);
34
+ const missing = [
35
+ "id",
36
+ "realm",
37
+ "method",
38
+ "intent",
39
+ "request"
40
+ ].filter((k) => typeof params[k] !== "string");
41
+ if (missing.length > 0) throw new s402Error("INVALID_PAYLOAD", `Payment challenge missing required auth-params: ${missing.join(", ")}`);
42
+ return {
43
+ id: params.id,
44
+ realm: params.realm,
45
+ method: params.method.toLowerCase(),
46
+ intent: params.intent,
47
+ request: params.request,
48
+ digest: params.digest,
49
+ expires: params.expires,
50
+ description: params.description,
51
+ opaque: params.opaque
52
+ };
53
+ }
54
+ /**
55
+ * Parse an `auth-params` string per RFC 9110 §11.2: `token "=" ( token / quoted-string )`
56
+ * list separated by `OWS "," OWS`. Unknown parameters are preserved in the
57
+ * returned map so callers can ignore them per core spec §5.1.2.
58
+ */
59
+ function parseAuthParams(input) {
60
+ const out = {};
61
+ let i = 0;
62
+ const n = input.length;
63
+ while (i < n) {
64
+ while (i < n && (input[i] === " " || input[i] === " " || input[i] === ",")) i++;
65
+ if (i >= n) break;
66
+ const keyStart = i;
67
+ while (i < n && input[i] !== "=" && input[i] !== " " && input[i] !== " ") i++;
68
+ const key = input.slice(keyStart, i).toLowerCase();
69
+ if (key.length === 0 || !TOKEN_CHARS.test(key)) throw new s402Error("INVALID_PAYLOAD", `Malformed auth-param name at position ${keyStart}`);
70
+ while (i < n && (input[i] === " " || input[i] === " ")) i++;
71
+ if (input[i] !== "=") throw new s402Error("INVALID_PAYLOAD", `Missing "=" after auth-param "${key}"`);
72
+ i++;
73
+ while (i < n && (input[i] === " " || input[i] === " ")) i++;
74
+ let value;
75
+ if (input[i] === "\"") {
76
+ i++;
77
+ const valueStart = i;
78
+ let raw = "";
79
+ while (i < n && input[i] !== "\"") if (input[i] === "\\" && i + 1 < n) {
80
+ raw += input[i + 1];
81
+ i += 2;
82
+ } else {
83
+ raw += input[i];
84
+ i++;
85
+ }
86
+ if (input[i] !== "\"") throw new s402Error("INVALID_PAYLOAD", `Unterminated quoted-string starting at position ${valueStart}`);
87
+ i++;
88
+ value = raw;
89
+ } else {
90
+ const valueStart = i;
91
+ while (i < n && input[i] !== "," && input[i] !== " " && input[i] !== " ") i++;
92
+ value = input.slice(valueStart, i);
93
+ if (value.length === 0 || !TOKEN_CHARS.test(value)) throw new s402Error("INVALID_PAYLOAD", `Malformed auth-param value for "${key}"`);
94
+ }
95
+ out[key] = value;
96
+ }
97
+ return out;
98
+ }
99
+ const METHOD_ID_PATTERN = /^[a-z]+$|^\*$/;
100
+ const INTENT_PATTERN = /^[a-zA-Z0-9\-_]+$|^\*$/;
101
+ const Q_VALUE_PATTERN = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
102
+ /**
103
+ * Parse an MPP `Accept-Payment` header per core spec §6.1.
104
+ *
105
+ * Grammar: `Accept-Payment = #(method-or-* "/" intent-or-* [weight])`.
106
+ * Drops malformed entries silently — spec §6.1: "If Accept-Payment is
107
+ * malformed, servers MAY ignore it." Stable sort: descending q, original
108
+ * order on ties (preserves client preference per §6.1).
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * const ranges = parseMppAcceptPayment('tempo/charge, tempo/session;q=0, stripe/*;q=0.5');
113
+ * // [{ method: 'tempo', intent: 'charge', q: 1 },
114
+ * // { method: 'stripe', intent: '*', q: 0.5 },
115
+ * // { method: 'tempo', intent: 'session', q: 0 }]
116
+ * ```
117
+ */
118
+ function parseMppAcceptPayment(header) {
119
+ if (!header) return [];
120
+ const entries = [];
121
+ const parts = header.split(",");
122
+ for (let i = 0; i < parts.length; i++) {
123
+ const segment = parts[i].trim();
124
+ if (segment.length === 0) continue;
125
+ const [tokenRaw, ...paramParts] = segment.split(";");
126
+ const token = tokenRaw.trim().toLowerCase();
127
+ const slash = token.indexOf("/");
128
+ if (slash <= 0 || slash === token.length - 1) continue;
129
+ const method = token.slice(0, slash);
130
+ const intent = token.slice(slash + 1);
131
+ if (!METHOD_ID_PATTERN.test(method) || !INTENT_PATTERN.test(intent)) continue;
132
+ let q = 1;
133
+ let qSeen = false;
134
+ let valid = true;
135
+ for (const p of paramParts) {
136
+ const [nameRaw, valRaw] = p.split("=");
137
+ if (!nameRaw || valRaw === void 0) continue;
138
+ if (nameRaw.trim().toLowerCase() !== "q") continue;
139
+ if (qSeen) {
140
+ valid = false;
141
+ break;
142
+ }
143
+ qSeen = true;
144
+ const val = valRaw.trim();
145
+ if (!Q_VALUE_PATTERN.test(val)) {
146
+ valid = false;
147
+ break;
148
+ }
149
+ const parsed = Number.parseFloat(val);
150
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
151
+ valid = false;
152
+ break;
153
+ }
154
+ q = parsed;
155
+ }
156
+ if (!valid) continue;
157
+ entries.push({
158
+ range: {
159
+ method,
160
+ intent,
161
+ q
162
+ },
163
+ order: i
164
+ });
165
+ }
166
+ entries.sort((a, b) => b.range.q - a.range.q || a.order - b.order);
167
+ return entries.map((e) => e.range);
168
+ }
169
+ /**
170
+ * Match an MPP payment range against a concrete `method/intent` pair.
171
+ *
172
+ * Returns a specificity score: 2 (exact match on both), 1 (one wildcard),
173
+ * 0 (both wildcards), -1 (no match). Higher specificity wins per spec §6.1:
174
+ * "Prefer the most specific matching range when multiple ranges match."
175
+ */
176
+ function matchMppRange(range, method, intent) {
177
+ const methodMatch = range.method === "*" || range.method === method.toLowerCase();
178
+ const intentMatch = range.intent === "*" || range.intent === intent;
179
+ if (!methodMatch || !intentMatch) return -1;
180
+ return (range.method !== "*" ? 1 : 0) + (range.intent !== "*" ? 1 : 0);
181
+ }
182
+ function base64urlDecodeToString(input) {
183
+ if (!/^[A-Za-z0-9_-]*$/.test(input)) throw new s402Error("INVALID_PAYLOAD", "Value is not valid base64url (no-padding)");
184
+ const pad = input.length % 4;
185
+ const b64 = (pad === 0 ? input : input + "=".repeat(4 - pad)).replace(/-/g, "+").replace(/_/g, "/");
186
+ try {
187
+ if (typeof globalThis.atob === "function") {
188
+ const bin = globalThis.atob(b64);
189
+ const bytes = new Uint8Array(bin.length);
190
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
191
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
192
+ }
193
+ const BufferCtor = globalThis.Buffer;
194
+ if (BufferCtor) return BufferCtor.from(b64, "base64").toString("utf-8");
195
+ throw new s402Error("INVALID_PAYLOAD", "No base64 decoder available in this runtime");
196
+ } catch (e) {
197
+ if (e instanceof s402Error) throw e;
198
+ throw new s402Error("INVALID_PAYLOAD", "Failed to decode base64url value");
199
+ }
200
+ }
201
+ /**
202
+ * Decode the `request` parameter of an MPP Charge challenge into its shared
203
+ * fields. Per `draft-payment-intent-charge-00` §Request Schema, every Charge
204
+ * method emits `amount` + `currency` as REQUIRED shared fields; blockchain
205
+ * methods additionally require `recipient`.
206
+ *
207
+ * @throws {s402Error} `INVALID_PAYLOAD` if the request blob is not
208
+ * base64url-JSON, or is missing `amount` / `currency`, or if `amount` is
209
+ * not a non-negative integer string.
210
+ */
211
+ function decodeMppChargeRequest(challenge) {
212
+ if (challenge.intent !== "charge") throw new s402Error("INVALID_PAYLOAD", `Expected intent="charge", got "${challenge.intent}"`);
213
+ const json = base64urlDecodeToString(challenge.request);
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(json);
217
+ } catch {
218
+ throw new s402Error("INVALID_PAYLOAD", "Charge request is not valid JSON");
219
+ }
220
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) throw new s402Error("INVALID_PAYLOAD", "Charge request must be a JSON object");
221
+ const obj = parsed;
222
+ if (typeof obj.amount !== "string") throw new s402Error("INVALID_PAYLOAD", "Charge request missing \"amount\" (string)");
223
+ if (typeof obj.currency !== "string") throw new s402Error("INVALID_PAYLOAD", "Charge request missing \"currency\" (string)");
224
+ if (!isValidAmount(obj.amount)) throw new s402Error("INVALID_PAYLOAD", `Charge "amount" must be a non-negative integer string, got "${obj.amount}"`);
225
+ const out = {
226
+ amount: obj.amount,
227
+ currency: obj.currency
228
+ };
229
+ if (typeof obj.recipient === "string") out.recipient = obj.recipient;
230
+ if (typeof obj.description === "string") out.description = obj.description;
231
+ if (typeof obj.externalId === "string") out.externalId = obj.externalId;
232
+ if (obj.methodDetails != null && typeof obj.methodDetails === "object" && !Array.isArray(obj.methodDetails)) out.methodDetails = obj.methodDetails;
233
+ return out;
234
+ }
235
+ /**
236
+ * Decode an `Authorization: Payment <base64url>` credential into its JSON form.
237
+ * Does not verify HMAC challenge-binding — that requires the server's secret
238
+ * and is intentionally out of scope for this client-facing helper.
239
+ *
240
+ * @throws {s402Error} `INVALID_PAYLOAD` if the header is missing/malformed,
241
+ * the blob is not base64url-JSON, or required fields (`challenge`, `payload`)
242
+ * are missing.
243
+ */
244
+ function decodeMppCredential(authorizationHeader) {
245
+ if (!authorizationHeader) throw new s402Error("INVALID_PAYLOAD", "Authorization header missing");
246
+ const match = /^\s*Payment\s+([A-Za-z0-9_-]+)\s*$/i.exec(authorizationHeader);
247
+ if (!match) throw new s402Error("INVALID_PAYLOAD", "Authorization header must be \"Payment <base64url>\" (RFC 4648 §5 no-padding)");
248
+ const json = base64urlDecodeToString(match[1]);
249
+ let parsed;
250
+ try {
251
+ parsed = JSON.parse(json);
252
+ } catch {
253
+ throw new s402Error("INVALID_PAYLOAD", "Credential blob is not valid JSON");
254
+ }
255
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) throw new s402Error("INVALID_PAYLOAD", "Credential must be a JSON object");
256
+ const obj = parsed;
257
+ if (obj.challenge == null || typeof obj.challenge !== "object" || Array.isArray(obj.challenge)) throw new s402Error("INVALID_PAYLOAD", "Credential missing \"challenge\" object");
258
+ if (obj.payload == null || typeof obj.payload !== "object" || Array.isArray(obj.payload)) throw new s402Error("INVALID_PAYLOAD", "Credential missing \"payload\" object");
259
+ const ch = obj.challenge;
260
+ for (const k of [
261
+ "id",
262
+ "realm",
263
+ "method",
264
+ "intent",
265
+ "request"
266
+ ]) if (typeof ch[k] !== "string") throw new s402Error("INVALID_PAYLOAD", `Credential challenge missing "${k}" (string)`);
267
+ const credential = {
268
+ challenge: {
269
+ id: ch.id,
270
+ realm: ch.realm,
271
+ method: ch.method.toLowerCase(),
272
+ intent: ch.intent,
273
+ request: ch.request
274
+ },
275
+ payload: obj.payload
276
+ };
277
+ if (typeof ch.digest === "string") credential.challenge.digest = ch.digest;
278
+ if (typeof ch.expires === "string") credential.challenge.expires = ch.expires;
279
+ if (typeof ch.description === "string") credential.challenge.description = ch.description;
280
+ if (typeof ch.opaque === "string") credential.challenge.opaque = ch.opaque;
281
+ if (typeof obj.source === "string") credential.source = obj.source;
282
+ return credential;
283
+ }
284
+ /**
285
+ * Known-mappable MPP methods. The set is deliberately conservative:
286
+ * a method is only listed here if its Charge request shape reliably carries
287
+ * the fields s402 needs (`recipient` as payTo, `currency` as asset). Processor
288
+ * methods (`stripe`, `card`) route internally — their Charge requests do not
289
+ * expose a payTo, so they need the write-path emitter, not this translator.
290
+ */
291
+ const BLOCKCHAIN_CHARGE_METHODS = new Set([
292
+ "tempo",
293
+ "evm",
294
+ "solana",
295
+ "lightning",
296
+ "stellar"
297
+ ]);
298
+ /**
299
+ * Network identifier resolution for MPP Charge requests per method.
300
+ *
301
+ * The core spec leaves network naming to individual method specs. This helper
302
+ * encodes the conventions from the published drafts — `evm:{chainId}` and
303
+ * `tempo:{chainId}` follow EIP-155-style identifiers; Solana/Lightning/Stellar
304
+ * fall back to a method-qualified default since their chain is implicit.
305
+ */
306
+ function resolveNetwork(method, methodDetails) {
307
+ const chainId = methodDetails?.chainId;
308
+ if (typeof chainId === "number" && Number.isInteger(chainId) && chainId >= 0) {
309
+ if (method === "evm") return `eip155:${chainId}`;
310
+ if (method === "tempo") return `tempo:${chainId}`;
311
+ }
312
+ if (typeof chainId === "string" && /^[0-9]+$/.test(chainId)) {
313
+ if (method === "evm") return `eip155:${chainId}`;
314
+ if (method === "tempo") return `tempo:${chainId}`;
315
+ }
316
+ return `${method}:unknown`;
317
+ }
318
+ /**
319
+ * Translate an MPP Charge challenge into s402 requirements using the `exact`
320
+ * scheme. This is the inbound half of the coexistence pattern documented in
321
+ * `guide/upgrade-mpp.md`: an s402 client receives an MPP 402, lifts it into
322
+ * s402 types, then reuses its existing payment machinery.
323
+ *
324
+ * Only blockchain-like methods are translated here. Processor methods (Stripe
325
+ * card, etc.) route internally and do not expose the payTo/asset fields s402
326
+ * requires — keep those on the MPP path.
327
+ *
328
+ * @throws {s402Error} `INVALID_PAYLOAD` if the method is not a known
329
+ * blockchain-style Charge method, if the request is missing a recipient
330
+ * (REQUIRED for blockchain methods per charge spec), or if the challenge
331
+ * has expired at `now`.
332
+ */
333
+ function fromMppChargeChallenge(challenge, now) {
334
+ if (challenge.intent !== "charge") throw new s402Error("INVALID_PAYLOAD", `fromMppChargeChallenge requires intent="charge", got "${challenge.intent}"`);
335
+ if (!BLOCKCHAIN_CHARGE_METHODS.has(challenge.method)) throw new s402Error("INVALID_PAYLOAD", `MPP method "${challenge.method}" is not mappable to s402 requirements — processor-based methods (stripe, card) have no payTo/asset exposed in the Charge request`);
336
+ const request = decodeMppChargeRequest(challenge);
337
+ if (typeof request.recipient !== "string" || request.recipient.length === 0) throw new s402Error("INVALID_PAYLOAD", "Blockchain Charge request missing \"recipient\" — required by charge-intent spec for blockchain methods");
338
+ let expiresAt;
339
+ if (challenge.expires) {
340
+ const ts = Date.parse(challenge.expires);
341
+ if (Number.isNaN(ts)) throw new s402Error("INVALID_PAYLOAD", `Challenge "expires" is not a valid RFC 3339 date-time: "${challenge.expires}"`);
342
+ expiresAt = ts;
343
+ if (ts <= (now ?? Date.now())) throw new s402Error("INVALID_PAYLOAD", "MPP challenge has already expired");
344
+ }
345
+ return {
346
+ s402Version: S402_VERSION,
347
+ accepts: ["exact"],
348
+ network: resolveNetwork(challenge.method, request.methodDetails),
349
+ asset: request.currency,
350
+ amount: request.amount,
351
+ payTo: request.recipient,
352
+ expiresAt,
353
+ extensions: { mpp: {
354
+ challengeId: challenge.id,
355
+ method: challenge.method,
356
+ intent: challenge.intent,
357
+ realm: challenge.realm
358
+ } }
359
+ };
360
+ }
361
+
362
+ //#endregion
363
+ export { decodeMppChargeRequest, decodeMppCredential, fromMppChargeChallenge, matchMppRange, parseMppAcceptPayment, parseWwwAuthenticatePayment };
package/dist/compat.d.mts CHANGED
@@ -58,7 +58,7 @@ interface x402PaymentPayload {
58
58
  * Handles both V1 (`maxAmountRequired`) and V2 (`amount`) wire formats.
59
59
  * Maps x402's single scheme to s402's accepts array.
60
60
  */
61
- declare function fromX402Requirements(x402: x402PaymentRequirements): s402PaymentRequirements;
61
+ declare function fromX402Requirements(x402: x402PaymentRequirements, now?: number): s402PaymentRequirements;
62
62
  /**
63
63
  * Convert inbound x402 payment payload to s402 format.
64
64
  * Validates that required fields are present and correctly typed.
@@ -66,7 +66,7 @@ declare function fromX402Requirements(x402: x402PaymentRequirements): s402Paymen
66
66
  declare function fromX402Payload(x402: x402PaymentPayload): s402ExactPayload;
67
67
  /**
68
68
  * Convert outbound s402 requirements to x402 V1 wire format.
69
- * Strips s402-only fields (mandate, stream, escrow, unlock extensions).
69
+ * Strips s402-only fields (mandate, upto, prepaid, stream, escrow, unlock extensions).
70
70
  * Only works for "exact" scheme — other schemes have no x402 equivalent.
71
71
  *
72
72
  * Includes both `maxAmountRequired` (V1) and `amount` (V2) for maximum interop.
@@ -101,7 +101,7 @@ declare function isX402Envelope(obj: Record<string, unknown>): boolean;
101
101
  * Picks the first requirement from the `accepts` array.
102
102
  * Copies `x402Version` from the envelope onto the requirement for downstream processing.
103
103
  */
104
- declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope): s402PaymentRequirements;
104
+ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope, now?: number): s402PaymentRequirements;
105
105
  /**
106
106
  * Auto-detect and normalize: if x402, convert to s402. If already s402, validate and pass through.
107
107
  * Handles x402 V1 (flat), x402 V2 (envelope with accepts array), and s402 formats.
@@ -123,6 +123,6 @@ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope): s402Pa
123
123
  * // Always returns s402PaymentRequirements regardless of input format
124
124
  * ```
125
125
  */
126
- declare function normalizeRequirements(obj: Record<string, unknown>): s402PaymentRequirements;
126
+ declare function normalizeRequirements(obj: Record<string, unknown>, now?: number): s402PaymentRequirements;
127
127
  //#endregion
128
128
  export { fromX402Envelope, fromX402Payload, fromX402Requirements, isS402, isX402, isX402Envelope, normalizeRequirements, toX402Payload, toX402Requirements, x402PaymentPayload, x402PaymentRequiredEnvelope, x402PaymentRequirements };