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 +63 -0
- package/README.md +10 -0
- package/dist/compat-mpp.d.mts +160 -0
- package/dist/compat-mpp.mjs +363 -0
- package/dist/compat.d.mts +4 -4
- package/dist/compat.mjs +9 -7
- package/dist/errors.d.mts +4 -1
- package/dist/errors.mjs +17 -2
- package/dist/http.d.mts +30 -9
- package/dist/http.mjs +117 -30
- package/dist/index.d.mts +460 -8
- package/dist/index.mjs +734 -30
- package/dist/{scheme-tVj4sOr-.d.mts → scheme-CKinOhyx.d.mts} +16 -8
- package/dist/test-utils.d.mts +1 -1
- package/dist/types.d.mts +143 -49
- package/dist/types.mjs +2 -1
- package/package.json +16 -2
- package/test/conformance/vectors/body-transport.json +42 -0
- package/test/conformance/vectors/compat-normalize.json +2 -1
- package/test/conformance/vectors/payload-decode.json +33 -0
- package/test/conformance/vectors/payload-encode.json +33 -0
- package/test/conformance/vectors/requirements-decode.json +44 -0
- package/test/conformance/vectors/requirements-encode.json +44 -0
- package/test/conformance/vectors/roundtrip.json +52 -0
- package/test/conformance/vectors/settle-decode.json +14 -0
- package/test/conformance/vectors/settle-encode.json +28 -0
- package/test/conformance/vectors/validation-reject.json +69 -0
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
|
|
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
|
|
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: '
|
|
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
|
* ```
|
|
@@ -117,9 +117,13 @@ declare function isValidU64Amount(s: string): boolean;
|
|
|
117
117
|
*/
|
|
118
118
|
declare function validateMandateShape(value: unknown): void;
|
|
119
119
|
/**
|
|
120
|
-
* Validate
|
|
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
|
-
/**
|
|
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
|
|
161
|
-
*
|
|
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: '
|
|
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
|
* ```
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
186
|
-
escrow: new Set(["transaction", "signature"]),
|
|
187
|
-
unlock: new Set([
|
|
195
|
+
upto: new Set([
|
|
188
196
|
"transaction",
|
|
189
197
|
"signature",
|
|
190
|
-
"
|
|
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
|
|
345
|
-
|
|
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 === "
|
|
478
|
-
|
|
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
|
-
/**
|
|
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
|
|
548
|
-
*
|
|
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
|
-
|
|
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 };
|