s402 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +11 -1
- package/dist/compat/l402.d.mts +94 -0
- package/dist/compat/l402.mjs +225 -0
- package/dist/compat/mpp.d.mts +160 -0
- package/dist/compat/mpp.mjs +363 -0
- package/dist/{compat.d.mts → compat/x402.d.mts} +3 -3
- package/dist/{compat.mjs → compat/x402.mjs} +5 -5
- package/dist/errors.d.mts +3 -1
- package/dist/errors.mjs +12 -2
- package/dist/http.d.mts +24 -7
- package/dist/http.mjs +31 -9
- package/dist/index.d.mts +302 -7
- package/dist/index.mjs +596 -87
- package/dist/test-utils.d.mts +1 -1
- package/dist/types.d.mts +2 -1
- package/dist/types.mjs +2 -1
- package/package.json +20 -6
- /package/dist/{scheme-m-uk4zyH.d.mts → scheme-M-z-UV0c.d.mts} +0 -0
|
@@ -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 };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { s402ExactPayload, s402PaymentPayload, s402PaymentRequirements } from "
|
|
1
|
+
import { s402ExactPayload, s402PaymentPayload, s402PaymentRequirements } from "../types.mjs";
|
|
2
2
|
|
|
3
|
-
//#region src/compat.d.ts
|
|
3
|
+
//#region src/compat/x402.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* x402 PaymentRequirements shape — supports both V1 and V2 wire formats.
|
|
6
6
|
*
|
|
@@ -115,7 +115,7 @@ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope, now?: n
|
|
|
115
115
|
*
|
|
116
116
|
* @example
|
|
117
117
|
* ```ts
|
|
118
|
-
* import { normalizeRequirements } from 's402/compat';
|
|
118
|
+
* import { normalizeRequirements } from 's402/compat/x402';
|
|
119
119
|
*
|
|
120
120
|
* // Works with any format — auto-detects s402 vs x402
|
|
121
121
|
* const rawJson = JSON.parse(atob(header));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { S402_VERSION } from "
|
|
2
|
-
import { s402Error } from "
|
|
3
|
-
import { isValidAmount, pickRequirementsFields, validateRequirementsShape } from "
|
|
1
|
+
import { S402_VERSION } from "../types.mjs";
|
|
2
|
+
import { s402Error } from "../errors.mjs";
|
|
3
|
+
import { isValidAmount, pickRequirementsFields, validateRequirementsShape } from "../http.mjs";
|
|
4
4
|
|
|
5
|
-
//#region src/compat.ts
|
|
5
|
+
//#region src/compat/x402.ts
|
|
6
6
|
/**
|
|
7
7
|
* Convert inbound x402 requirements to s402 format.
|
|
8
8
|
* Handles both V1 (`maxAmountRequired`) and V2 (`amount`) wire formats.
|
|
@@ -137,7 +137,7 @@ function fromX402Envelope(envelope, now) {
|
|
|
137
137
|
*
|
|
138
138
|
* @example
|
|
139
139
|
* ```ts
|
|
140
|
-
* import { normalizeRequirements } from 's402/compat';
|
|
140
|
+
* import { normalizeRequirements } from 's402/compat/x402';
|
|
141
141
|
*
|
|
142
142
|
* // Works with any format — auto-detects s402 vs x402
|
|
143
143
|
* const rawJson = JSON.parse(atob(header));
|
package/dist/errors.d.mts
CHANGED
|
@@ -25,6 +25,8 @@ declare const s402ErrorCode: {
|
|
|
25
25
|
readonly SETTLEMENT_FAILED: "SETTLEMENT_FAILED";
|
|
26
26
|
readonly DIGEST_MISMATCH: "DIGEST_MISMATCH";
|
|
27
27
|
readonly EXTENSION_FAILED: "EXTENSION_FAILED";
|
|
28
|
+
readonly S402_TX_BINDING_MISMATCH: "S402_TX_BINDING_MISMATCH";
|
|
29
|
+
readonly S402_UNKNOWN_ALGORITHM: "S402_UNKNOWN_ALGORITHM";
|
|
28
30
|
};
|
|
29
31
|
type s402ErrorCodeType = (typeof s402ErrorCode)[keyof typeof s402ErrorCode];
|
|
30
32
|
interface s402ErrorInfo {
|
|
@@ -36,7 +38,7 @@ interface s402ErrorInfo {
|
|
|
36
38
|
/**
|
|
37
39
|
* Create a typed s402 error with recovery hints.
|
|
38
40
|
*
|
|
39
|
-
* @param code - One of the
|
|
41
|
+
* @param code - One of the s402 error codes (e.g. 'SETTLEMENT_FAILED')
|
|
40
42
|
* @param message - Optional human-readable message (defaults to the code)
|
|
41
43
|
* @returns Error info object with code, message, retryable flag, and suggestedAction
|
|
42
44
|
*
|
package/dist/errors.mjs
CHANGED
|
@@ -24,7 +24,9 @@ const s402ErrorCode = {
|
|
|
24
24
|
VERIFICATION_FAILED: "VERIFICATION_FAILED",
|
|
25
25
|
SETTLEMENT_FAILED: "SETTLEMENT_FAILED",
|
|
26
26
|
DIGEST_MISMATCH: "DIGEST_MISMATCH",
|
|
27
|
-
EXTENSION_FAILED: "EXTENSION_FAILED"
|
|
27
|
+
EXTENSION_FAILED: "EXTENSION_FAILED",
|
|
28
|
+
S402_TX_BINDING_MISMATCH: "S402_TX_BINDING_MISMATCH",
|
|
29
|
+
S402_UNKNOWN_ALGORITHM: "S402_UNKNOWN_ALGORITHM"
|
|
28
30
|
};
|
|
29
31
|
/** Error recovery hints for each error code */
|
|
30
32
|
const ERROR_HINTS = {
|
|
@@ -95,12 +97,20 @@ const ERROR_HINTS = {
|
|
|
95
97
|
EXTENSION_FAILED: {
|
|
96
98
|
retryable: false,
|
|
97
99
|
suggestedAction: "A critical extension blocked the payment flow. Contact the extension provider or disable the extension."
|
|
100
|
+
},
|
|
101
|
+
S402_TX_BINDING_MISMATCH: {
|
|
102
|
+
retryable: false,
|
|
103
|
+
suggestedAction: "Envelope txBinding does not match locally recomputed value — the facilitator is bound to a different request than the one you sent. Do NOT retry against the same facilitator. Treat as misbehavior and escalate out-of-band."
|
|
104
|
+
},
|
|
105
|
+
S402_UNKNOWN_ALGORITHM: {
|
|
106
|
+
retryable: false,
|
|
107
|
+
suggestedAction: "Envelope uses an algorithm (digest or signature) not in your accepted set. Upgrade the client to support it or reject the envelope. Never fall through to a weaker default."
|
|
98
108
|
}
|
|
99
109
|
};
|
|
100
110
|
/**
|
|
101
111
|
* Create a typed s402 error with recovery hints.
|
|
102
112
|
*
|
|
103
|
-
* @param code - One of the
|
|
113
|
+
* @param code - One of the s402 error codes (e.g. 'SETTLEMENT_FAILED')
|
|
104
114
|
* @param message - Optional human-readable message (defaults to the code)
|
|
105
115
|
* @returns Error info object with code, message, retryable flag, and suggestedAction
|
|
106
116
|
*
|
package/dist/http.d.mts
CHANGED
|
@@ -14,10 +14,10 @@ import { s402PaymentPayload, s402PaymentRequirements, s402SettleResponse } from
|
|
|
14
14
|
* const header = encodePaymentRequired({
|
|
15
15
|
* s402Version: '1',
|
|
16
16
|
* accepts: ['exact'],
|
|
17
|
-
* network: '
|
|
18
|
-
* asset: '
|
|
17
|
+
* network: 'your-chain:mainnet',
|
|
18
|
+
* asset: 'NATIVE_TOKEN',
|
|
19
19
|
* amount: '1000000',
|
|
20
|
-
* payTo: '
|
|
20
|
+
* payTo: 'YOUR_ADDRESS',
|
|
21
21
|
* });
|
|
22
22
|
* response.headers.set('payment-required', header);
|
|
23
23
|
* ```
|
|
@@ -144,8 +144,23 @@ declare function validatePrepaidShape(value: unknown): void;
|
|
|
144
144
|
declare function validateSubObjects(record: Record<string, unknown>): void;
|
|
145
145
|
/** Validate that decoded payment requirements have the required shape. */
|
|
146
146
|
declare function validateRequirementsShape(obj: unknown): void;
|
|
147
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Content type for s402 JSON body transport.
|
|
149
|
+
*
|
|
150
|
+
* Covers generic s402 request/response bodies (payload, requirements, legacy
|
|
151
|
+
* settle). The settlement envelope (ADR-007) has its own more specific media
|
|
152
|
+
* type — see `S402_ENVELOPE_CONTENT_TYPE` in `./envelope`.
|
|
153
|
+
*/
|
|
148
154
|
declare const S402_CONTENT_TYPE: "application/s402+json";
|
|
155
|
+
/**
|
|
156
|
+
* Maximum body size (1 MB). Defense-in-depth against oversized JSON payloads.
|
|
157
|
+
* Bigger than MAX_HEADER_BYTES (64KB) because body transport is designed for
|
|
158
|
+
* large PTBs; still bounded so a malicious client can't exhaust memory via
|
|
159
|
+
* JSON.parse. Hosts should also enforce their own limits upstream (Express
|
|
160
|
+
* `limit`, Nginx `client_max_body_size`), but a wire format library should
|
|
161
|
+
* not rely on runtime enforcement.
|
|
162
|
+
*/
|
|
163
|
+
declare const MAX_BODY_BYTES: number;
|
|
149
164
|
/** Encode payment requirements as JSON string (for response body) */
|
|
150
165
|
declare function encodeRequirementsBody(requirements: s402PaymentRequirements): string;
|
|
151
166
|
/** Decode payment requirements from JSON string (from response body) */
|
|
@@ -161,8 +176,10 @@ declare function decodeSettleBody(body: string): s402SettleResponse;
|
|
|
161
176
|
/**
|
|
162
177
|
* Detect transport mode from an incoming request.
|
|
163
178
|
*
|
|
164
|
-
* Checks Content-Type for body transport
|
|
165
|
-
*
|
|
179
|
+
* Checks Content-Type for body transport (generic `application/s402+json`
|
|
180
|
+
* OR the envelope-specific `application/vnd.s402.envelope+json`), then falls
|
|
181
|
+
* back to header detection.
|
|
182
|
+
* Returns 'body' if either s402 body media type is present.
|
|
166
183
|
* Returns 'header' if x-payment header is present.
|
|
167
184
|
* Returns 'unknown' otherwise.
|
|
168
185
|
*/
|
|
@@ -199,4 +216,4 @@ declare function detectProtocol(headers: Headers): 's402' | 'x402' | 'unknown';
|
|
|
199
216
|
*/
|
|
200
217
|
declare function extractRequirementsFromResponse(response: Response): s402PaymentRequirements | null;
|
|
201
218
|
//#endregion
|
|
202
|
-
export { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateSettlementOverridesShape, validateStreamShape, validateSubObjects, validateUnlockShape, validateUptoShape };
|
|
219
|
+
export { MAX_BODY_BYTES, S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateSettlementOverridesShape, validateStreamShape, validateSubObjects, validateUnlockShape, validateUptoShape };
|
package/dist/http.mjs
CHANGED
|
@@ -26,10 +26,10 @@ function fromBase64(b64) {
|
|
|
26
26
|
* const header = encodePaymentRequired({
|
|
27
27
|
* s402Version: '1',
|
|
28
28
|
* accepts: ['exact'],
|
|
29
|
-
* network: '
|
|
30
|
-
* asset: '
|
|
29
|
+
* network: 'your-chain:mainnet',
|
|
30
|
+
* asset: 'NATIVE_TOKEN',
|
|
31
31
|
* amount: '1000000',
|
|
32
|
-
* payTo: '
|
|
32
|
+
* payTo: 'YOUR_ADDRESS',
|
|
33
33
|
* });
|
|
34
34
|
* response.headers.set('payment-required', header);
|
|
35
35
|
* ```
|
|
@@ -459,7 +459,7 @@ function validateSubObjects(record) {
|
|
|
459
459
|
function validateRequirementsShape(obj) {
|
|
460
460
|
if (obj == null || typeof obj !== "object") throw new s402Error("INVALID_PAYLOAD", "Payment requirements is not an object");
|
|
461
461
|
const record = obj;
|
|
462
|
-
if (record.s402Version === void 0) throw new s402Error("INVALID_PAYLOAD", "Missing s402Version. For x402 format, use normalizeRequirements() from s402/compat.");
|
|
462
|
+
if (record.s402Version === void 0) throw new s402Error("INVALID_PAYLOAD", "Missing s402Version. For x402 format, use normalizeRequirements() from s402/compat/x402.");
|
|
463
463
|
if (record.s402Version !== "1") throw new s402Error("INVALID_PAYLOAD", `Unsupported s402 version "${record.s402Version}". This library supports version "1".`);
|
|
464
464
|
const missing = [];
|
|
465
465
|
if (!Array.isArray(record.accepts)) missing.push("accepts (array)");
|
|
@@ -556,8 +556,23 @@ function validateSettleShape(obj) {
|
|
|
556
556
|
if (record.error !== void 0 && typeof record.error !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: error must be a string, got ${typeof record.error}`);
|
|
557
557
|
if (record.errorCode !== void 0 && typeof record.errorCode !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: errorCode must be a string, got ${typeof record.errorCode}`);
|
|
558
558
|
}
|
|
559
|
-
/**
|
|
559
|
+
/**
|
|
560
|
+
* Content type for s402 JSON body transport.
|
|
561
|
+
*
|
|
562
|
+
* Covers generic s402 request/response bodies (payload, requirements, legacy
|
|
563
|
+
* settle). The settlement envelope (ADR-007) has its own more specific media
|
|
564
|
+
* type — see `S402_ENVELOPE_CONTENT_TYPE` in `./envelope`.
|
|
565
|
+
*/
|
|
560
566
|
const S402_CONTENT_TYPE = "application/s402+json";
|
|
567
|
+
/**
|
|
568
|
+
* Maximum body size (1 MB). Defense-in-depth against oversized JSON payloads.
|
|
569
|
+
* Bigger than MAX_HEADER_BYTES (64KB) because body transport is designed for
|
|
570
|
+
* large PTBs; still bounded so a malicious client can't exhaust memory via
|
|
571
|
+
* JSON.parse. Hosts should also enforce their own limits upstream (Express
|
|
572
|
+
* `limit`, Nginx `client_max_body_size`), but a wire format library should
|
|
573
|
+
* not rely on runtime enforcement.
|
|
574
|
+
*/
|
|
575
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
561
576
|
/** Encode payment requirements as JSON string (for response body) */
|
|
562
577
|
function encodeRequirementsBody(requirements) {
|
|
563
578
|
return JSON.stringify(requirements);
|
|
@@ -565,6 +580,7 @@ function encodeRequirementsBody(requirements) {
|
|
|
565
580
|
/** Decode payment requirements from JSON string (from response body) */
|
|
566
581
|
function decodeRequirementsBody(body) {
|
|
567
582
|
if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 requirements body must be a string, got ${typeof body}`);
|
|
583
|
+
if (body.length > MAX_BODY_BYTES) throw new s402Error("INVALID_PAYLOAD", `s402 requirements body exceeds maximum size (${body.length} > ${MAX_BODY_BYTES})`);
|
|
568
584
|
let parsed;
|
|
569
585
|
try {
|
|
570
586
|
parsed = JSON.parse(body);
|
|
@@ -581,6 +597,7 @@ function encodePayloadBody(payload) {
|
|
|
581
597
|
/** Decode payment payload from JSON string (from request body) */
|
|
582
598
|
function decodePayloadBody(body) {
|
|
583
599
|
if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 payload body must be a string, got ${typeof body}`);
|
|
600
|
+
if (body.length > MAX_BODY_BYTES) throw new s402Error("INVALID_PAYLOAD", `s402 payload body exceeds maximum size (${body.length} > ${MAX_BODY_BYTES})`);
|
|
584
601
|
let parsed;
|
|
585
602
|
try {
|
|
586
603
|
parsed = JSON.parse(body);
|
|
@@ -597,6 +614,7 @@ function encodeSettleBody(response) {
|
|
|
597
614
|
/** Decode settlement response from JSON string (from response body) */
|
|
598
615
|
function decodeSettleBody(body) {
|
|
599
616
|
if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 settle body must be a string, got ${typeof body}`);
|
|
617
|
+
if (body.length > MAX_BODY_BYTES) throw new s402Error("INVALID_PAYLOAD", `s402 settle body exceeds maximum size (${body.length} > ${MAX_BODY_BYTES})`);
|
|
600
618
|
let parsed;
|
|
601
619
|
try {
|
|
602
620
|
parsed = JSON.parse(body);
|
|
@@ -609,13 +627,17 @@ function decodeSettleBody(body) {
|
|
|
609
627
|
/**
|
|
610
628
|
* Detect transport mode from an incoming request.
|
|
611
629
|
*
|
|
612
|
-
* Checks Content-Type for body transport
|
|
613
|
-
*
|
|
630
|
+
* Checks Content-Type for body transport (generic `application/s402+json`
|
|
631
|
+
* OR the envelope-specific `application/vnd.s402.envelope+json`), then falls
|
|
632
|
+
* back to header detection.
|
|
633
|
+
* Returns 'body' if either s402 body media type is present.
|
|
614
634
|
* Returns 'header' if x-payment header is present.
|
|
615
635
|
* Returns 'unknown' otherwise.
|
|
616
636
|
*/
|
|
617
637
|
function detectTransport(request) {
|
|
618
|
-
|
|
638
|
+
const contentType = request.headers.get("content-type");
|
|
639
|
+
if (contentType?.includes(S402_CONTENT_TYPE)) return "body";
|
|
640
|
+
if (contentType?.includes("application/vnd.s402.envelope+json")) return "body";
|
|
619
641
|
if (request.headers.get(S402_HEADERS.PAYMENT)) return "header";
|
|
620
642
|
return "unknown";
|
|
621
643
|
}
|
|
@@ -670,4 +692,4 @@ function extractRequirementsFromResponse(response) {
|
|
|
670
692
|
}
|
|
671
693
|
|
|
672
694
|
//#endregion
|
|
673
|
-
export { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateSettlementOverridesShape, validateStreamShape, validateSubObjects, validateUnlockShape, validateUptoShape };
|
|
695
|
+
export { MAX_BODY_BYTES, S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateSettlementOverridesShape, validateStreamShape, validateSubObjects, validateUnlockShape, validateUptoShape };
|