s402 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/compat.mjs CHANGED
@@ -8,7 +8,7 @@ import { isValidAmount, pickRequirementsFields, validateRequirementsShape } from
8
8
  * Handles both V1 (`maxAmountRequired`) and V2 (`amount`) wire formats.
9
9
  * Maps x402's single scheme to s402's accepts array.
10
10
  */
11
- function fromX402Requirements(x402) {
11
+ function fromX402Requirements(x402, now) {
12
12
  const amount = x402.amount ?? x402.maxAmountRequired;
13
13
  if (!amount) throw new s402Error("INVALID_PAYLOAD", "x402 requirements missing both \"amount\" (V2) and \"maxAmountRequired\" (V1)");
14
14
  if (!isValidAmount(amount)) throw new s402Error("INVALID_PAYLOAD", `Invalid amount "${amount}": must be a non-negative integer string`);
@@ -20,6 +20,7 @@ function fromX402Requirements(x402) {
20
20
  if (e instanceof s402Error) throw e;
21
21
  throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
22
22
  }
23
+ const expiresAt = x402.maxTimeoutSeconds != null && x402.maxTimeoutSeconds > 0 ? (now ?? Date.now()) + x402.maxTimeoutSeconds * 1e3 : void 0;
23
24
  return {
24
25
  s402Version: S402_VERSION,
25
26
  accepts: ["exact"],
@@ -28,6 +29,7 @@ function fromX402Requirements(x402) {
28
29
  amount,
29
30
  payTo: x402.payTo,
30
31
  facilitatorUrl: x402.facilitatorUrl,
32
+ expiresAt,
31
33
  extensions: x402.extensions
32
34
  };
33
35
  }
@@ -50,7 +52,7 @@ function fromX402Payload(x402) {
50
52
  }
51
53
  /**
52
54
  * Convert outbound s402 requirements to x402 V1 wire format.
53
- * Strips s402-only fields (mandate, stream, escrow, unlock extensions).
55
+ * Strips s402-only fields (mandate, upto, prepaid, stream, escrow, unlock extensions).
54
56
  * Only works for "exact" scheme — other schemes have no x402 equivalent.
55
57
  *
56
58
  * Includes both `maxAmountRequired` (V1) and `amount` (V2) for maximum interop.
@@ -113,14 +115,14 @@ function isX402Envelope(obj) {
113
115
  * Picks the first requirement from the `accepts` array.
114
116
  * Copies `x402Version` from the envelope onto the requirement for downstream processing.
115
117
  */
116
- function fromX402Envelope(envelope) {
118
+ function fromX402Envelope(envelope, now) {
117
119
  if (!envelope.accepts || envelope.accepts.length === 0) throw new s402Error("INVALID_PAYLOAD", "x402 V2 envelope has empty accepts array");
118
120
  const req = {
119
121
  ...envelope.accepts[0],
120
122
  x402Version: envelope.x402Version
121
123
  };
122
124
  validateX402Shape(req);
123
- return fromX402Requirements(req);
125
+ return fromX402Requirements(req, now);
124
126
  }
125
127
  /**
126
128
  * Auto-detect and normalize: if x402, convert to s402. If already s402, validate and pass through.
@@ -143,20 +145,20 @@ function fromX402Envelope(envelope) {
143
145
  * // Always returns s402PaymentRequirements regardless of input format
144
146
  * ```
145
147
  */
146
- function normalizeRequirements(obj) {
148
+ function normalizeRequirements(obj, now) {
147
149
  if (obj == null || typeof obj !== "object" || Array.isArray(obj)) throw new s402Error("INVALID_PAYLOAD", `Payment requirements must be a plain object, got ${obj === null ? "null" : Array.isArray(obj) ? "array" : typeof obj}`);
148
150
  if (isS402(obj)) {
149
151
  validateRequirementsShape(obj);
150
152
  return pickRequirementsFields(obj);
151
153
  }
152
154
  if (isX402Envelope(obj)) {
153
- const record = fromX402Envelope(obj);
155
+ const record = fromX402Envelope(obj, now);
154
156
  validateRequirementsShape(record);
155
157
  return pickRequirementsFields(record);
156
158
  }
157
159
  if (isX402(obj)) {
158
160
  validateX402Shape(obj);
159
- const record = fromX402Requirements(obj);
161
+ const record = fromX402Requirements(obj, now);
160
162
  validateRequirementsShape(record);
161
163
  return pickRequirementsFields(record);
162
164
  }
package/dist/errors.d.mts CHANGED
@@ -24,6 +24,9 @@ declare const s402ErrorCode: {
24
24
  readonly VERIFICATION_FAILED: "VERIFICATION_FAILED";
25
25
  readonly SETTLEMENT_FAILED: "SETTLEMENT_FAILED";
26
26
  readonly DIGEST_MISMATCH: "DIGEST_MISMATCH";
27
+ readonly EXTENSION_FAILED: "EXTENSION_FAILED";
28
+ readonly S402_TX_BINDING_MISMATCH: "S402_TX_BINDING_MISMATCH";
29
+ readonly S402_UNKNOWN_ALGORITHM: "S402_UNKNOWN_ALGORITHM";
27
30
  };
28
31
  type s402ErrorCodeType = (typeof s402ErrorCode)[keyof typeof s402ErrorCode];
29
32
  interface s402ErrorInfo {
@@ -35,7 +38,7 @@ interface s402ErrorInfo {
35
38
  /**
36
39
  * Create a typed s402 error with recovery hints.
37
40
  *
38
- * @param code - One of the 16 s402 error codes (e.g. 'SETTLEMENT_FAILED')
41
+ * @param code - One of the s402 error codes (e.g. 'SETTLEMENT_FAILED')
39
42
  * @param message - Optional human-readable message (defaults to the code)
40
43
  * @returns Error info object with code, message, retryable flag, and suggestedAction
41
44
  *
package/dist/errors.mjs CHANGED
@@ -23,7 +23,10 @@ const s402ErrorCode = {
23
23
  REQUIREMENTS_EXPIRED: "REQUIREMENTS_EXPIRED",
24
24
  VERIFICATION_FAILED: "VERIFICATION_FAILED",
25
25
  SETTLEMENT_FAILED: "SETTLEMENT_FAILED",
26
- DIGEST_MISMATCH: "DIGEST_MISMATCH"
26
+ DIGEST_MISMATCH: "DIGEST_MISMATCH",
27
+ EXTENSION_FAILED: "EXTENSION_FAILED",
28
+ S402_TX_BINDING_MISMATCH: "S402_TX_BINDING_MISMATCH",
29
+ S402_UNKNOWN_ALGORITHM: "S402_UNKNOWN_ALGORITHM"
27
30
  };
28
31
  /** Error recovery hints for each error code */
29
32
  const ERROR_HINTS = {
@@ -90,12 +93,24 @@ const ERROR_HINTS = {
90
93
  DIGEST_MISMATCH: {
91
94
  retryable: false,
92
95
  suggestedAction: "Facilitator returned a transaction digest that does not match the signed payload. Do NOT retry — treat payment as non-settled and investigate the facilitator."
96
+ },
97
+ EXTENSION_FAILED: {
98
+ retryable: false,
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."
93
108
  }
94
109
  };
95
110
  /**
96
111
  * Create a typed s402 error with recovery hints.
97
112
  *
98
- * @param code - One of the 16 s402 error codes (e.g. 'SETTLEMENT_FAILED')
113
+ * @param code - One of the s402 error codes (e.g. 'SETTLEMENT_FAILED')
99
114
  * @param message - Optional human-readable message (defaults to the code)
100
115
  * @returns Error info object with code, message, retryable flag, and suggestedAction
101
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: 'sui:mainnet',
18
- * asset: '0x2::sui::SUI',
17
+ * network: 'your-chain:mainnet',
18
+ * asset: 'NATIVE_TOKEN',
19
19
  * amount: '1000000',
20
- * payTo: '0xYOUR_ADDRESS',
20
+ * payTo: 'YOUR_ADDRESS',
21
21
  * });
22
22
  * response.headers.set('payment-required', header);
23
23
  * ```
@@ -117,9 +117,13 @@ declare function isValidU64Amount(s: string): boolean;
117
117
  */
118
118
  declare function validateMandateShape(value: unknown): void;
119
119
  /**
120
- * Validate stream sub-object.
121
- * Checks required fields are present and correctly typed.
120
+ * Validate upto sub-object (usage-based variable settlement).
122
121
  */
122
+ declare function validateUptoShape(value: unknown): void;
123
+ /**
124
+ * Validate settlementOverrides sub-object.
125
+ */
126
+ declare function validateSettlementOverridesShape(value: unknown): void;
123
127
  declare function validateStreamShape(value: unknown): void;
124
128
  /**
125
129
  * Validate escrow sub-object.
@@ -140,8 +144,23 @@ declare function validatePrepaidShape(value: unknown): void;
140
144
  declare function validateSubObjects(record: Record<string, unknown>): void;
141
145
  /** Validate that decoded payment requirements have the required shape. */
142
146
  declare function validateRequirementsShape(obj: unknown): void;
143
- /** Content type for s402 JSON body transport */
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
+ */
144
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;
145
164
  /** Encode payment requirements as JSON string (for response body) */
146
165
  declare function encodeRequirementsBody(requirements: s402PaymentRequirements): string;
147
166
  /** Decode payment requirements from JSON string (from response body) */
@@ -157,8 +176,10 @@ declare function decodeSettleBody(body: string): s402SettleResponse;
157
176
  /**
158
177
  * Detect transport mode from an incoming request.
159
178
  *
160
- * Checks Content-Type for body transport, then falls back to header detection.
161
- * Returns 'body' if Content-Type is application/s402+json.
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.
162
183
  * Returns 'header' if x-payment header is present.
163
184
  * Returns 'unknown' otherwise.
164
185
  */
@@ -195,4 +216,4 @@ declare function detectProtocol(headers: Headers): 's402' | 'x402' | 'unknown';
195
216
  */
196
217
  declare function extractRequirementsFromResponse(response: Response): s402PaymentRequirements | null;
197
218
  //#endregion
198
- 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, validateStreamShape, validateSubObjects, validateUnlockShape };
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: 'sui:mainnet',
30
- * asset: '0x2::sui::SUI',
29
+ * network: 'your-chain:mainnet',
30
+ * asset: 'NATIVE_TOKEN',
31
31
  * amount: '1000000',
32
- * payTo: '0xYOUR_ADDRESS',
32
+ * payTo: 'YOUR_ADDRESS',
33
33
  * });
34
34
  * response.headers.set('payment-required', header);
35
35
  * ```
@@ -83,10 +83,12 @@ const S402_REQUIREMENTS_KEYS = new Set([
83
83
  "receiptRequired",
84
84
  "settlementMode",
85
85
  "expiresAt",
86
+ "upto",
87
+ "settlementOverrides",
88
+ "prepaid",
86
89
  "stream",
87
90
  "escrow",
88
91
  "unlock",
89
- "prepaid",
90
92
  "extensions"
91
93
  ]);
92
94
  /** Known keys for each sub-object type — used to strip extra keys at the trust boundary. */
@@ -96,6 +98,21 @@ const S402_SUB_OBJECT_KEYS = {
96
98
  "minPerTx",
97
99
  "coinType"
98
100
  ]),
101
+ upto: new Set([
102
+ "maxAmount",
103
+ "settlementDeadlineMs",
104
+ "usageReportUrl",
105
+ "estimatedAmount"
106
+ ]),
107
+ settlementOverrides: new Set(["actualAmount"]),
108
+ prepaid: new Set([
109
+ "ratePerCall",
110
+ "maxCalls",
111
+ "minDeposit",
112
+ "withdrawalDelayMs",
113
+ "providerPubkey",
114
+ "disputeWindowMs"
115
+ ]),
99
116
  stream: new Set([
100
117
  "ratePerSecond",
101
118
  "budgetCap",
@@ -111,14 +128,6 @@ const S402_SUB_OBJECT_KEYS = {
111
128
  "encryptionId",
112
129
  "encryptedContentId",
113
130
  "encryptionServiceId"
114
- ]),
115
- prepaid: new Set([
116
- "ratePerCall",
117
- "maxCalls",
118
- "minDeposit",
119
- "withdrawalDelayMs",
120
- "providerPubkey",
121
- "disputeWindowMs"
122
131
  ])
123
132
  };
124
133
  /** Strip unknown keys from a sub-object, returning a clean copy. */
@@ -178,22 +187,29 @@ const S402_PAYLOAD_TOP_KEYS = new Set([
178
187
  ]);
179
188
  /**
180
189
  * Known inner payload keys per scheme. All schemes share transaction + signature;
181
- * unlock adds encryptionId, prepaid adds ratePerCall + maxCalls.
190
+ * upto adds maxAmount + settlementCeiling, prepaid adds ratePerCall + maxCalls,
191
+ * unlock adds encryptionId.
182
192
  */
183
193
  const S402_PAYLOAD_INNER_KEYS = {
184
194
  exact: new Set(["transaction", "signature"]),
185
- stream: new Set(["transaction", "signature"]),
186
- escrow: new Set(["transaction", "signature"]),
187
- unlock: new Set([
195
+ upto: new Set([
188
196
  "transaction",
189
197
  "signature",
190
- "encryptionId"
198
+ "maxAmount",
199
+ "settlementCeiling"
191
200
  ]),
192
201
  prepaid: new Set([
193
202
  "transaction",
194
203
  "signature",
195
204
  "ratePerCall",
196
205
  "maxCalls"
206
+ ]),
207
+ stream: new Set(["transaction", "signature"]),
208
+ escrow: new Set(["transaction", "signature"]),
209
+ unlock: new Set([
210
+ "transaction",
211
+ "signature",
212
+ "encryptionId"
197
213
  ])
198
214
  };
199
215
  /** Return a clean payload object with only known s402 payload fields. */
@@ -249,9 +265,11 @@ const S402_SETTLE_RESPONSE_KEYS = new Set([
249
265
  "txDigest",
250
266
  "receiptId",
251
267
  "finalityMs",
268
+ "actualAmount",
269
+ "depositId",
270
+ "balanceId",
252
271
  "streamId",
253
272
  "escrowId",
254
- "balanceId",
255
273
  "error",
256
274
  "errorCode"
257
275
  ]);
@@ -277,10 +295,11 @@ function decodeSettleResponse(header) {
277
295
  /** Valid s402 payment scheme values */
278
296
  const VALID_SCHEMES = new Set([
279
297
  "exact",
298
+ "upto",
299
+ "prepaid",
280
300
  "stream",
281
301
  "escrow",
282
- "unlock",
283
- "prepaid"
302
+ "unlock"
284
303
  ]);
285
304
  /**
286
305
  * Check that a string represents a canonical non-negative integer.
@@ -338,12 +357,37 @@ function validateMandateShape(value) {
338
357
  const obj = value;
339
358
  if (typeof obj.required !== "boolean") throw new s402Error("INVALID_PAYLOAD", `mandate.required must be a boolean, got ${typeof obj.required}`);
340
359
  assertOptionalString(obj, "minPerTx", "mandate");
360
+ if (typeof obj.minPerTx === "string" && !isValidAmount(obj.minPerTx)) throw new s402Error("INVALID_PAYLOAD", `mandate.minPerTx must be a non-negative integer string, got "${obj.minPerTx}"`);
341
361
  assertOptionalString(obj, "coinType", "mandate");
342
362
  }
343
363
  /**
344
- * Validate stream sub-object.
345
- * Checks required fields are present and correctly typed.
364
+ * Validate upto sub-object (usage-based variable settlement).
365
+ */
366
+ function validateUptoShape(value) {
367
+ assertPlainObject(value, "upto");
368
+ const obj = value;
369
+ assertString(obj, "maxAmount", "upto");
370
+ if (typeof obj.maxAmount === "string" && !isValidAmount(obj.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto.maxAmount must be a non-negative integer string, got "${obj.maxAmount}"`);
371
+ assertString(obj, "settlementDeadlineMs", "upto");
372
+ if (typeof obj.settlementDeadlineMs === "string" && !isValidAmount(obj.settlementDeadlineMs)) throw new s402Error("INVALID_PAYLOAD", `upto.settlementDeadlineMs must be a non-negative integer string (Unix timestamp ms), got "${obj.settlementDeadlineMs}"`);
373
+ assertOptionalString(obj, "usageReportUrl", "upto");
374
+ assertOptionalString(obj, "estimatedAmount", "upto");
375
+ if (typeof obj.estimatedAmount === "string") {
376
+ if (!isValidAmount(obj.estimatedAmount)) throw new s402Error("INVALID_PAYLOAD", `upto.estimatedAmount must be a non-negative integer string, got "${obj.estimatedAmount}"`);
377
+ if (typeof obj.maxAmount === "string" && isValidAmount(obj.maxAmount)) {
378
+ if (BigInt(obj.estimatedAmount) > BigInt(obj.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto.estimatedAmount (${obj.estimatedAmount}) must be <= maxAmount (${obj.maxAmount})`);
379
+ }
380
+ }
381
+ }
382
+ /**
383
+ * Validate settlementOverrides sub-object.
346
384
  */
385
+ function validateSettlementOverridesShape(value) {
386
+ assertPlainObject(value, "settlementOverrides");
387
+ const obj = value;
388
+ assertString(obj, "actualAmount", "settlementOverrides");
389
+ if (typeof obj.actualAmount === "string" && !isValidAmount(obj.actualAmount)) throw new s402Error("INVALID_PAYLOAD", `settlementOverrides.actualAmount must be a non-negative integer string, got "${obj.actualAmount}"`);
390
+ }
347
391
  function validateStreamShape(value) {
348
392
  assertPlainObject(value, "stream");
349
393
  const obj = value;
@@ -404,10 +448,12 @@ function validatePrepaidShape(value) {
404
448
  */
405
449
  function validateSubObjects(record) {
406
450
  if (record.mandate !== void 0) validateMandateShape(record.mandate);
451
+ if (record.upto !== void 0) validateUptoShape(record.upto);
452
+ if (record.settlementOverrides !== void 0) validateSettlementOverridesShape(record.settlementOverrides);
453
+ if (record.prepaid !== void 0) validatePrepaidShape(record.prepaid);
407
454
  if (record.stream !== void 0) validateStreamShape(record.stream);
408
455
  if (record.escrow !== void 0) validateEscrowShape(record.escrow);
409
456
  if (record.unlock !== void 0) validateUnlockShape(record.unlock);
410
- if (record.prepaid !== void 0) validatePrepaidShape(record.prepaid);
411
457
  }
412
458
  /** Validate that decoded payment requirements have the required shape. */
413
459
  function validateRequirementsShape(obj) {
@@ -474,8 +520,25 @@ function validatePayloadShape(obj) {
474
520
  if (typeof inner.transaction !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.transaction must be a string, got ${typeof inner.transaction}`);
475
521
  if (typeof inner.signature !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.signature must be a string, got ${typeof inner.signature}`);
476
522
  if (record.scheme === "unlock" && typeof inner.encryptionId !== "string") throw new s402Error("INVALID_PAYLOAD", `unlock payload requires encryptionId (string), got ${typeof inner.encryptionId}`);
477
- if (record.scheme === "prepaid" && typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
478
- if (record.scheme === "prepaid" && inner.maxCalls !== void 0 && typeof inner.maxCalls !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a string if provided, got ${typeof inner.maxCalls}`);
523
+ if (record.scheme === "upto") {
524
+ if (typeof inner.maxAmount !== "string") throw new s402Error("INVALID_PAYLOAD", `upto payload requires maxAmount (string), got ${typeof inner.maxAmount}`);
525
+ if (!isValidAmount(inner.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto payload maxAmount must be a non-negative integer string, got "${inner.maxAmount}"`);
526
+ if (inner.settlementCeiling !== void 0) {
527
+ if (typeof inner.settlementCeiling !== "string") throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling must be a string if provided, got ${typeof inner.settlementCeiling}`);
528
+ if (!isValidAmount(inner.settlementCeiling)) throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling must be a non-negative integer string, got "${inner.settlementCeiling}"`);
529
+ const ceiling = BigInt(inner.settlementCeiling);
530
+ if (ceiling < 1n) throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling must be >= 1, got "${inner.settlementCeiling}"`);
531
+ if (ceiling > BigInt(inner.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling (${inner.settlementCeiling}) must be <= maxAmount (${inner.maxAmount})`);
532
+ }
533
+ }
534
+ if (record.scheme === "prepaid") {
535
+ if (typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
536
+ if (!isValidAmount(inner.ratePerCall)) throw new s402Error("INVALID_PAYLOAD", `prepaid payload ratePerCall must be a non-negative integer string, got "${inner.ratePerCall}"`);
537
+ if (inner.maxCalls !== void 0) {
538
+ if (typeof inner.maxCalls !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a string if provided, got ${typeof inner.maxCalls}`);
539
+ if (!isValidAmount(inner.maxCalls)) throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a non-negative integer string, got "${inner.maxCalls}"`);
540
+ }
541
+ }
479
542
  }
480
543
  /** Validate that a decoded settle response has the required shape. */
481
544
  function validateSettleShape(obj) {
@@ -488,11 +551,28 @@ function validateSettleShape(obj) {
488
551
  if (record.streamId !== void 0 && typeof record.streamId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: streamId must be a string, got ${typeof record.streamId}`);
489
552
  if (record.escrowId !== void 0 && typeof record.escrowId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: escrowId must be a string, got ${typeof record.escrowId}`);
490
553
  if (record.balanceId !== void 0 && typeof record.balanceId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: balanceId must be a string, got ${typeof record.balanceId}`);
554
+ if (record.actualAmount !== void 0 && typeof record.actualAmount !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: actualAmount must be a string, got ${typeof record.actualAmount}`);
555
+ if (record.depositId !== void 0 && typeof record.depositId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: depositId must be a string, got ${typeof record.depositId}`);
491
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}`);
492
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}`);
493
558
  }
494
- /** Content type for s402 JSON body transport */
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
+ */
495
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;
496
576
  /** Encode payment requirements as JSON string (for response body) */
497
577
  function encodeRequirementsBody(requirements) {
498
578
  return JSON.stringify(requirements);
@@ -500,6 +580,7 @@ function encodeRequirementsBody(requirements) {
500
580
  /** Decode payment requirements from JSON string (from response body) */
501
581
  function decodeRequirementsBody(body) {
502
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})`);
503
584
  let parsed;
504
585
  try {
505
586
  parsed = JSON.parse(body);
@@ -516,6 +597,7 @@ function encodePayloadBody(payload) {
516
597
  /** Decode payment payload from JSON string (from request body) */
517
598
  function decodePayloadBody(body) {
518
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})`);
519
601
  let parsed;
520
602
  try {
521
603
  parsed = JSON.parse(body);
@@ -532,6 +614,7 @@ function encodeSettleBody(response) {
532
614
  /** Decode settlement response from JSON string (from response body) */
533
615
  function decodeSettleBody(body) {
534
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})`);
535
618
  let parsed;
536
619
  try {
537
620
  parsed = JSON.parse(body);
@@ -544,13 +627,17 @@ function decodeSettleBody(body) {
544
627
  /**
545
628
  * Detect transport mode from an incoming request.
546
629
  *
547
- * Checks Content-Type for body transport, then falls back to header detection.
548
- * Returns 'body' if Content-Type is application/s402+json.
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.
549
634
  * Returns 'header' if x-payment header is present.
550
635
  * Returns 'unknown' otherwise.
551
636
  */
552
637
  function detectTransport(request) {
553
- if (request.headers.get("content-type")?.includes(S402_CONTENT_TYPE)) return "body";
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";
554
641
  if (request.headers.get(S402_HEADERS.PAYMENT)) return "header";
555
642
  return "unknown";
556
643
  }
@@ -605,4 +692,4 @@ function extractRequirementsFromResponse(response) {
605
692
  }
606
693
 
607
694
  //#endregion
608
- 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, validateStreamShape, validateSubObjects, validateUnlockShape };
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 };