s402 0.1.0 → 0.1.2
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 +15 -0
- package/SECURITY.md +3 -3
- package/dist/http.d.mts +26 -2
- package/dist/http.mjs +147 -6
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +55 -3
- package/dist/types.d.mts +65 -2
- package/package.json +10 -11
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.1] - 2026-02-16
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Facilitator `verify()` and `settle()` now have the same defense-in-depth guards as `process()`:
|
|
13
|
+
- Reject non-number `expiresAt` values (prevents silent bypass with string types)
|
|
14
|
+
- Reject payload schemes not in `requirements.accepts` (scheme-mismatch guard)
|
|
15
|
+
- `protocolFeeBps` validation now requires an integer (rejects `50.5`)
|
|
16
|
+
- Sub-object fields (stream, escrow, unlock, prepaid, mandate) are now stripped of unknown keys at the trust boundary, matching the top-level field stripping behavior
|
|
17
|
+
- `process()` now catches exceptions thrown by `scheme.settle()` and returns them as error results instead of propagating unhandled
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `isValidU64Amount()` — validates amount strings fit in a Sui u64 (format + magnitude check). The existing `isValidAmount()` remains format-only for chain-agnostic use.
|
|
22
|
+
|
|
8
23
|
## [0.1.0] - 2026-02-15
|
|
9
24
|
|
|
10
25
|
### Added
|
package/SECURITY.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
**Please do not open public issues for security vulnerabilities.**
|
|
6
6
|
|
|
7
|
-
If you discover a security issue in s402, please report it privately:
|
|
7
|
+
If you discover a security issue in s402, please report it privately via either method:
|
|
8
8
|
|
|
9
|
-
- **Email:** dannydevs@proton.me
|
|
10
|
-
- **
|
|
9
|
+
- **Email:** dannydevs@proton.me (subject: `[s402 security] <brief description>`)
|
|
10
|
+
- **GitHub:** [Open a private security advisory](https://github.com/s402-protocol/core/security/advisories/new)
|
|
11
11
|
|
|
12
12
|
You will receive an acknowledgment within 48 hours. We aim to provide a fix or mitigation plan within 7 days of confirmation.
|
|
13
13
|
|
package/dist/http.d.mts
CHANGED
|
@@ -11,16 +11,40 @@ declare function encodeSettleResponse(response: s402SettleResponse): string;
|
|
|
11
11
|
declare function pickRequirementsFields(obj: Record<string, unknown>): s402PaymentRequirements;
|
|
12
12
|
/** Decode payment requirements from the `payment-required` header */
|
|
13
13
|
declare function decodePaymentRequired(header: string): s402PaymentRequirements;
|
|
14
|
+
/** Return a clean payload object with only known s402 payload fields. */
|
|
15
|
+
declare function pickPayloadFields(obj: Record<string, unknown>): s402PaymentPayload;
|
|
14
16
|
/** Decode payment payload from the `x-payment` header */
|
|
15
17
|
declare function decodePaymentPayload(header: string): s402PaymentPayload;
|
|
18
|
+
/** Return a clean settle response with only known s402 fields. */
|
|
19
|
+
declare function pickSettleResponseFields(obj: Record<string, unknown>): s402SettleResponse;
|
|
16
20
|
/** Decode settlement response from the `payment-response` header */
|
|
17
21
|
declare function decodeSettleResponse(header: string): s402SettleResponse;
|
|
18
22
|
/**
|
|
19
|
-
* Check that a string represents a canonical non-negative integer
|
|
23
|
+
* Check that a string represents a canonical non-negative integer.
|
|
20
24
|
* Rejects leading zeros ("007"), empty strings, negatives, decimals.
|
|
21
25
|
* Accepts "0" as the only zero representation.
|
|
26
|
+
*
|
|
27
|
+
* NOTE: This is a **format-only** check — it validates the string is a well-formed
|
|
28
|
+
* non-negative integer but does NOT enforce magnitude bounds. Arbitrarily large
|
|
29
|
+
* integers (e.g. 100+ digits) pass this check. For Sui-specific u64 validation,
|
|
30
|
+
* use `isValidU64Amount()` which also checks that the value fits in a u64.
|
|
31
|
+
*
|
|
32
|
+
* A-13 (Semantic gap): "0" passes validation because it's a valid u64 on-chain.
|
|
33
|
+
* However, amount="0" in payment requirements is semantically ambiguous — it could
|
|
34
|
+
* mean "free" or be a misconfiguration. The s402 wire format intentionally allows it
|
|
35
|
+
* (some schemes like prepaid use amount="0" for deposit-based flows). Resource servers
|
|
36
|
+
* that want to reject zero-amount payments should check this in their business logic,
|
|
37
|
+
* not at the protocol level.
|
|
22
38
|
*/
|
|
23
39
|
declare function isValidAmount(s: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Check that a string represents a valid Sui u64 amount.
|
|
42
|
+
* Like `isValidAmount` but also rejects values exceeding u64 max (2^64 - 1).
|
|
43
|
+
*
|
|
44
|
+
* Use this in scheme implementations that target Sui's u64 amounts (MIST, etc.).
|
|
45
|
+
* The wire-format validator uses `isValidAmount` (format-only) to stay chain-agnostic.
|
|
46
|
+
*/
|
|
47
|
+
declare function isValidU64Amount(s: string): boolean;
|
|
24
48
|
/**
|
|
25
49
|
* Validate mandate requirements sub-object.
|
|
26
50
|
* Mandate is protocol-level (used for authorization decisions), so we validate fully.
|
|
@@ -64,4 +88,4 @@ declare function detectProtocol(headers: Headers): 's402' | 'x402' | 'unknown';
|
|
|
64
88
|
*/
|
|
65
89
|
declare function extractRequirementsFromResponse(response: Response): s402PaymentRequirements | null;
|
|
66
90
|
//#endregion
|
|
67
|
-
export { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, pickRequirementsFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateStreamShape, validateSubObjects, validateUnlockShape };
|
|
91
|
+
export { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateStreamShape, validateSubObjects, validateUnlockShape };
|
package/dist/http.mjs
CHANGED
|
@@ -56,10 +56,50 @@ const S402_REQUIREMENTS_KEYS = new Set([
|
|
|
56
56
|
"prepaid",
|
|
57
57
|
"extensions"
|
|
58
58
|
]);
|
|
59
|
+
/** Known keys for each sub-object type — used to strip extra keys at the trust boundary. */
|
|
60
|
+
const S402_SUB_OBJECT_KEYS = {
|
|
61
|
+
mandate: new Set([
|
|
62
|
+
"required",
|
|
63
|
+
"minPerTx",
|
|
64
|
+
"coinType"
|
|
65
|
+
]),
|
|
66
|
+
stream: new Set([
|
|
67
|
+
"ratePerSecond",
|
|
68
|
+
"budgetCap",
|
|
69
|
+
"minDeposit",
|
|
70
|
+
"streamSetupUrl"
|
|
71
|
+
]),
|
|
72
|
+
escrow: new Set([
|
|
73
|
+
"seller",
|
|
74
|
+
"arbiter",
|
|
75
|
+
"deadlineMs"
|
|
76
|
+
]),
|
|
77
|
+
unlock: new Set([
|
|
78
|
+
"encryptionId",
|
|
79
|
+
"walrusBlobId",
|
|
80
|
+
"encryptionPackageId"
|
|
81
|
+
]),
|
|
82
|
+
prepaid: new Set([
|
|
83
|
+
"ratePerCall",
|
|
84
|
+
"maxCalls",
|
|
85
|
+
"minDeposit",
|
|
86
|
+
"withdrawalDelayMs"
|
|
87
|
+
])
|
|
88
|
+
};
|
|
89
|
+
/** Strip unknown keys from a sub-object, returning a clean copy. */
|
|
90
|
+
function pickSubObjectFields(key, value) {
|
|
91
|
+
const allowedKeys = S402_SUB_OBJECT_KEYS[key];
|
|
92
|
+
if (!allowedKeys || value == null || typeof value !== "object" || Array.isArray(value)) return value;
|
|
93
|
+
const obj = value;
|
|
94
|
+
const clean = {};
|
|
95
|
+
for (const k of allowedKeys) if (k in obj) clean[k] = obj[k];
|
|
96
|
+
return clean;
|
|
97
|
+
}
|
|
59
98
|
/** Return a clean object with only known s402 requirement fields. */
|
|
60
99
|
function pickRequirementsFields(obj) {
|
|
61
100
|
const result = {};
|
|
62
|
-
for (const key of S402_REQUIREMENTS_KEYS) if (key in obj) result[key] = obj[key];
|
|
101
|
+
for (const key of S402_REQUIREMENTS_KEYS) if (key in obj) if (key in S402_SUB_OBJECT_KEYS) result[key] = pickSubObjectFields(key, obj[key]);
|
|
102
|
+
else result[key] = obj[key];
|
|
63
103
|
return result;
|
|
64
104
|
}
|
|
65
105
|
/** Decode payment requirements from the `payment-required` header */
|
|
@@ -74,6 +114,50 @@ function decodePaymentRequired(header) {
|
|
|
74
114
|
validateRequirementsShape(parsed);
|
|
75
115
|
return pickRequirementsFields(parsed);
|
|
76
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Known top-level keys on s402PaymentPayload.
|
|
119
|
+
* Used by decodePaymentPayload to strip unknown keys at the HTTP trust boundary.
|
|
120
|
+
*/
|
|
121
|
+
const S402_PAYLOAD_TOP_KEYS = new Set([
|
|
122
|
+
"s402Version",
|
|
123
|
+
"scheme",
|
|
124
|
+
"payload"
|
|
125
|
+
]);
|
|
126
|
+
/**
|
|
127
|
+
* Known inner payload keys per scheme. All schemes share transaction + signature;
|
|
128
|
+
* unlock adds encryptionId, prepaid adds ratePerCall + maxCalls.
|
|
129
|
+
*/
|
|
130
|
+
const S402_PAYLOAD_INNER_KEYS = {
|
|
131
|
+
exact: new Set(["transaction", "signature"]),
|
|
132
|
+
stream: new Set(["transaction", "signature"]),
|
|
133
|
+
escrow: new Set(["transaction", "signature"]),
|
|
134
|
+
unlock: new Set([
|
|
135
|
+
"transaction",
|
|
136
|
+
"signature",
|
|
137
|
+
"encryptionId"
|
|
138
|
+
]),
|
|
139
|
+
prepaid: new Set([
|
|
140
|
+
"transaction",
|
|
141
|
+
"signature",
|
|
142
|
+
"ratePerCall",
|
|
143
|
+
"maxCalls"
|
|
144
|
+
])
|
|
145
|
+
};
|
|
146
|
+
/** Return a clean payload object with only known s402 payload fields. */
|
|
147
|
+
function pickPayloadFields(obj) {
|
|
148
|
+
const result = {};
|
|
149
|
+
for (const key of S402_PAYLOAD_TOP_KEYS) if (key in obj) result[key] = obj[key];
|
|
150
|
+
if (result.payload && typeof result.payload === "object" && typeof result.scheme === "string") {
|
|
151
|
+
const allowedInner = S402_PAYLOAD_INNER_KEYS[result.scheme];
|
|
152
|
+
if (allowedInner) {
|
|
153
|
+
const inner = result.payload;
|
|
154
|
+
const cleanInner = {};
|
|
155
|
+
for (const key of allowedInner) if (key in inner) cleanInner[key] = inner[key];
|
|
156
|
+
result.payload = cleanInner;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
77
161
|
/** Decode payment payload from the `x-payment` header */
|
|
78
162
|
function decodePaymentPayload(header) {
|
|
79
163
|
if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `x-payment header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
|
|
@@ -84,7 +168,28 @@ function decodePaymentPayload(header) {
|
|
|
84
168
|
throw new s402Error("INVALID_PAYLOAD", `Failed to decode x-payment header: ${e instanceof Error ? e.message : "invalid base64 or JSON"}`);
|
|
85
169
|
}
|
|
86
170
|
validatePayloadShape(parsed);
|
|
87
|
-
return parsed;
|
|
171
|
+
return pickPayloadFields(parsed);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Known top-level keys on s402SettleResponse.
|
|
175
|
+
* Used by decodeSettleResponse to strip unknown keys at the HTTP trust boundary.
|
|
176
|
+
*/
|
|
177
|
+
const S402_SETTLE_RESPONSE_KEYS = new Set([
|
|
178
|
+
"success",
|
|
179
|
+
"txDigest",
|
|
180
|
+
"receiptId",
|
|
181
|
+
"finalityMs",
|
|
182
|
+
"streamId",
|
|
183
|
+
"escrowId",
|
|
184
|
+
"balanceId",
|
|
185
|
+
"error",
|
|
186
|
+
"errorCode"
|
|
187
|
+
]);
|
|
188
|
+
/** Return a clean settle response with only known s402 fields. */
|
|
189
|
+
function pickSettleResponseFields(obj) {
|
|
190
|
+
const result = {};
|
|
191
|
+
for (const key of S402_SETTLE_RESPONSE_KEYS) if (key in obj) result[key] = obj[key];
|
|
192
|
+
return result;
|
|
88
193
|
}
|
|
89
194
|
/** Decode settlement response from the `payment-response` header */
|
|
90
195
|
function decodeSettleResponse(header) {
|
|
@@ -96,7 +201,7 @@ function decodeSettleResponse(header) {
|
|
|
96
201
|
throw new s402Error("INVALID_PAYLOAD", `Failed to decode payment-response header: ${e instanceof Error ? e.message : "invalid base64 or JSON"}`);
|
|
97
202
|
}
|
|
98
203
|
validateSettleShape(parsed);
|
|
99
|
-
return parsed;
|
|
204
|
+
return pickSettleResponseFields(parsed);
|
|
100
205
|
}
|
|
101
206
|
/** Valid s402 payment scheme values */
|
|
102
207
|
const VALID_SCHEMES = new Set([
|
|
@@ -107,13 +212,40 @@ const VALID_SCHEMES = new Set([
|
|
|
107
212
|
"prepaid"
|
|
108
213
|
]);
|
|
109
214
|
/**
|
|
110
|
-
* Check that a string represents a canonical non-negative integer
|
|
215
|
+
* Check that a string represents a canonical non-negative integer.
|
|
111
216
|
* Rejects leading zeros ("007"), empty strings, negatives, decimals.
|
|
112
217
|
* Accepts "0" as the only zero representation.
|
|
218
|
+
*
|
|
219
|
+
* NOTE: This is a **format-only** check — it validates the string is a well-formed
|
|
220
|
+
* non-negative integer but does NOT enforce magnitude bounds. Arbitrarily large
|
|
221
|
+
* integers (e.g. 100+ digits) pass this check. For Sui-specific u64 validation,
|
|
222
|
+
* use `isValidU64Amount()` which also checks that the value fits in a u64.
|
|
223
|
+
*
|
|
224
|
+
* A-13 (Semantic gap): "0" passes validation because it's a valid u64 on-chain.
|
|
225
|
+
* However, amount="0" in payment requirements is semantically ambiguous — it could
|
|
226
|
+
* mean "free" or be a misconfiguration. The s402 wire format intentionally allows it
|
|
227
|
+
* (some schemes like prepaid use amount="0" for deposit-based flows). Resource servers
|
|
228
|
+
* that want to reject zero-amount payments should check this in their business logic,
|
|
229
|
+
* not at the protocol level.
|
|
113
230
|
*/
|
|
114
231
|
function isValidAmount(s) {
|
|
115
232
|
return /^(0|[1-9][0-9]*)$/.test(s);
|
|
116
233
|
}
|
|
234
|
+
/** Maximum value representable as a Sui u64: 2^64 - 1 */
|
|
235
|
+
const U64_MAX = "18446744073709551615";
|
|
236
|
+
/**
|
|
237
|
+
* Check that a string represents a valid Sui u64 amount.
|
|
238
|
+
* Like `isValidAmount` but also rejects values exceeding u64 max (2^64 - 1).
|
|
239
|
+
*
|
|
240
|
+
* Use this in scheme implementations that target Sui's u64 amounts (MIST, etc.).
|
|
241
|
+
* The wire-format validator uses `isValidAmount` (format-only) to stay chain-agnostic.
|
|
242
|
+
*/
|
|
243
|
+
function isValidU64Amount(s) {
|
|
244
|
+
if (!isValidAmount(s)) return false;
|
|
245
|
+
if (s.length > 20) return false;
|
|
246
|
+
if (s.length < 20) return true;
|
|
247
|
+
return s <= U64_MAX;
|
|
248
|
+
}
|
|
117
249
|
/** Helper: assert obj is a plain object (not null/array/primitive). */
|
|
118
250
|
function assertPlainObject(value, label) {
|
|
119
251
|
if (value == null || typeof value !== "object" || Array.isArray(value)) throw new s402Error("INVALID_PAYLOAD", `${label} must be a plain object, got ${Array.isArray(value) ? "array" : typeof value}`);
|
|
@@ -145,8 +277,11 @@ function validateStreamShape(value) {
|
|
|
145
277
|
assertPlainObject(value, "stream");
|
|
146
278
|
const obj = value;
|
|
147
279
|
assertString(obj, "ratePerSecond", "stream");
|
|
280
|
+
if (typeof obj.ratePerSecond === "string" && !isValidAmount(obj.ratePerSecond)) throw new s402Error("INVALID_PAYLOAD", `stream.ratePerSecond must be a non-negative integer string, got "${obj.ratePerSecond}"`);
|
|
148
281
|
assertString(obj, "budgetCap", "stream");
|
|
282
|
+
if (typeof obj.budgetCap === "string" && !isValidAmount(obj.budgetCap)) throw new s402Error("INVALID_PAYLOAD", `stream.budgetCap must be a non-negative integer string, got "${obj.budgetCap}"`);
|
|
149
283
|
assertString(obj, "minDeposit", "stream");
|
|
284
|
+
if (typeof obj.minDeposit === "string" && !isValidAmount(obj.minDeposit)) throw new s402Error("INVALID_PAYLOAD", `stream.minDeposit must be a non-negative integer string, got "${obj.minDeposit}"`);
|
|
150
285
|
assertOptionalString(obj, "streamSetupUrl", "stream");
|
|
151
286
|
}
|
|
152
287
|
/**
|
|
@@ -157,6 +292,7 @@ function validateEscrowShape(value) {
|
|
|
157
292
|
const obj = value;
|
|
158
293
|
assertString(obj, "seller", "escrow");
|
|
159
294
|
assertString(obj, "deadlineMs", "escrow");
|
|
295
|
+
if (typeof obj.deadlineMs === "string" && !isValidAmount(obj.deadlineMs)) throw new s402Error("INVALID_PAYLOAD", `escrow.deadlineMs must be a non-negative integer string (Unix timestamp ms), got "${obj.deadlineMs}"`);
|
|
160
296
|
assertOptionalString(obj, "arbiter", "escrow");
|
|
161
297
|
}
|
|
162
298
|
/**
|
|
@@ -176,8 +312,11 @@ function validatePrepaidShape(value) {
|
|
|
176
312
|
assertPlainObject(value, "prepaid");
|
|
177
313
|
const obj = value;
|
|
178
314
|
assertString(obj, "ratePerCall", "prepaid");
|
|
315
|
+
if (typeof obj.ratePerCall === "string" && !isValidAmount(obj.ratePerCall)) throw new s402Error("INVALID_PAYLOAD", `prepaid.ratePerCall must be a non-negative integer string, got "${obj.ratePerCall}"`);
|
|
179
316
|
assertString(obj, "minDeposit", "prepaid");
|
|
317
|
+
if (typeof obj.minDeposit === "string" && !isValidAmount(obj.minDeposit)) throw new s402Error("INVALID_PAYLOAD", `prepaid.minDeposit must be a non-negative integer string, got "${obj.minDeposit}"`);
|
|
180
318
|
assertString(obj, "withdrawalDelayMs", "prepaid");
|
|
319
|
+
if (typeof obj.withdrawalDelayMs === "string" && !isValidAmount(obj.withdrawalDelayMs)) throw new s402Error("INVALID_PAYLOAD", `prepaid.withdrawalDelayMs must be a non-negative integer string (milliseconds), got "${obj.withdrawalDelayMs}"`);
|
|
181
320
|
assertOptionalString(obj, "maxCalls", "prepaid");
|
|
182
321
|
}
|
|
183
322
|
/**
|
|
@@ -204,12 +343,13 @@ function validateRequirementsShape(obj) {
|
|
|
204
343
|
if (typeof record.amount !== "string") missing.push("amount (string)");
|
|
205
344
|
else if (!isValidAmount(record.amount)) throw new s402Error("INVALID_PAYLOAD", `Invalid amount "${record.amount}": must be a non-negative integer string`);
|
|
206
345
|
if (typeof record.payTo !== "string") missing.push("payTo (string)");
|
|
346
|
+
else if (!record.payTo.startsWith("0x")) throw new s402Error("INVALID_PAYLOAD", `payTo must be a hex address starting with "0x", got "${record.payTo.substring(0, 20)}..."`);
|
|
207
347
|
if (missing.length > 0) throw new s402Error("INVALID_PAYLOAD", `Malformed payment requirements: missing ${missing.join(", ")}`);
|
|
208
348
|
if (Array.isArray(record.accepts) && record.accepts.length === 0) throw new s402Error("INVALID_PAYLOAD", "accepts array must contain at least one scheme");
|
|
209
349
|
const accepts = record.accepts;
|
|
210
350
|
for (const scheme of accepts) if (typeof scheme !== "string") throw new s402Error("INVALID_PAYLOAD", `Invalid entry in accepts array: expected string, got ${typeof scheme}`);
|
|
211
351
|
if (record.protocolFeeBps !== void 0) {
|
|
212
|
-
if (typeof record.protocolFeeBps !== "number" || !Number.isFinite(record.protocolFeeBps) || record.protocolFeeBps < 0 || record.protocolFeeBps > 1e4) throw new s402Error("INVALID_PAYLOAD", `protocolFeeBps must be
|
|
352
|
+
if (typeof record.protocolFeeBps !== "number" || !Number.isFinite(record.protocolFeeBps) || !Number.isInteger(record.protocolFeeBps) || record.protocolFeeBps < 0 || record.protocolFeeBps > 1e4) throw new s402Error("INVALID_PAYLOAD", `protocolFeeBps must be an integer between 0 and 10000, got ${record.protocolFeeBps}`);
|
|
213
353
|
}
|
|
214
354
|
if (record.expiresAt !== void 0) {
|
|
215
355
|
if (typeof record.expiresAt !== "number" || !Number.isFinite(record.expiresAt)) throw new s402Error("INVALID_PAYLOAD", `expiresAt must be a finite number (Unix timestamp ms), got ${typeof record.expiresAt}`);
|
|
@@ -246,6 +386,7 @@ function validateSettleShape(obj) {
|
|
|
246
386
|
function detectProtocol(headers) {
|
|
247
387
|
const paymentRequired = headers.get(S402_HEADERS.PAYMENT_REQUIRED);
|
|
248
388
|
if (!paymentRequired) return "unknown";
|
|
389
|
+
if (paymentRequired.length > MAX_HEADER_BYTES) return "unknown";
|
|
249
390
|
try {
|
|
250
391
|
const decoded = JSON.parse(fromBase64(paymentRequired));
|
|
251
392
|
if (decoded != null && typeof decoded === "object" && "s402Version" in decoded) return "s402";
|
|
@@ -271,4 +412,4 @@ function extractRequirementsFromResponse(response) {
|
|
|
271
412
|
}
|
|
272
413
|
|
|
273
414
|
//#endregion
|
|
274
|
-
export { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, pickRequirementsFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateStreamShape, validateSubObjects, validateUnlockShape };
|
|
415
|
+
export { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateStreamShape, validateSubObjects, validateUnlockShape };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createS402Error, s402Error, s402ErrorCode, s402ErrorCodeType, s402ErrorInfo } from "./errors.mjs";
|
|
2
|
-
import { S402_HEADERS, S402_VERSION, s402Discovery, s402EscrowExtra, s402EscrowPayload, s402ExactPayload, s402Mandate, s402MandateRequirements, s402PaymentPayload, s402PaymentPayloadBase, s402PaymentRequirements, s402PrepaidExtra, s402PrepaidPayload, s402Scheme, s402SettleResponse, s402SettlementMode, s402StreamExtra, s402StreamPayload, s402UnlockExtra, s402UnlockPayload, s402VerifyResponse } from "./types.mjs";
|
|
3
|
-
import { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, validateRequirementsShape } from "./http.mjs";
|
|
2
|
+
import { S402_HEADERS, S402_VERSION, s402Discovery, s402EscrowExtra, s402EscrowPayload, s402ExactPayload, s402Mandate, s402MandateRequirements, s402PaymentPayload, s402PaymentPayloadBase, s402PaymentRequirements, s402PaymentSession, s402PrepaidExtra, s402PrepaidPayload, s402RegistryQuery, s402Scheme, s402ServiceEntry, s402SettleResponse, s402SettlementMode, s402StreamExtra, s402StreamPayload, s402UnlockExtra, s402UnlockPayload, s402VerifyResponse } from "./types.mjs";
|
|
3
|
+
import { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, validateRequirementsShape } from "./http.mjs";
|
|
4
4
|
|
|
5
5
|
//#region src/scheme.d.ts
|
|
6
6
|
/** Implemented by each scheme on the client side */
|
|
@@ -128,10 +128,12 @@ declare class s402Facilitator {
|
|
|
128
128
|
register(network: string, scheme: s402FacilitatorScheme): this;
|
|
129
129
|
/**
|
|
130
130
|
* Verify a payment payload by dispatching to the correct scheme.
|
|
131
|
+
* Includes expiration guard and scheme-mismatch check.
|
|
131
132
|
*/
|
|
132
133
|
verify(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402VerifyResponse>;
|
|
133
134
|
/**
|
|
134
135
|
* Settle a payment by dispatching to the correct scheme.
|
|
136
|
+
* Includes expiration guard and scheme-mismatch check.
|
|
135
137
|
*/
|
|
136
138
|
settle(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
137
139
|
/**
|
|
@@ -189,4 +191,4 @@ declare class s402ResourceServer {
|
|
|
189
191
|
process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
190
192
|
}
|
|
191
193
|
//#endregion
|
|
192
|
-
export { S402_HEADERS, S402_VERSION, createS402Error, decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, s402Client, type s402ClientScheme, type s402DirectScheme, type s402Discovery, s402Error, s402ErrorCode, type s402ErrorCodeType, type s402ErrorInfo, type s402EscrowExtra, type s402EscrowPayload, type s402ExactPayload, s402Facilitator, type s402FacilitatorScheme, type s402Mandate, type s402MandateRequirements, type s402PaymentPayload, type s402PaymentPayloadBase, type s402PaymentRequirements, type s402PrepaidExtra, type s402PrepaidPayload, s402ResourceServer, type s402RouteConfig, type s402Scheme, type s402ServerScheme, type s402SettleResponse, type s402SettlementMode, type s402StreamExtra, type s402StreamPayload, type s402UnlockExtra, type s402UnlockPayload, type s402VerifyResponse, validateRequirementsShape };
|
|
194
|
+
export { S402_HEADERS, S402_VERSION, createS402Error, decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, s402Client, type s402ClientScheme, type s402DirectScheme, type s402Discovery, s402Error, s402ErrorCode, type s402ErrorCodeType, type s402ErrorInfo, type s402EscrowExtra, type s402EscrowPayload, type s402ExactPayload, s402Facilitator, type s402FacilitatorScheme, type s402Mandate, type s402MandateRequirements, type s402PaymentPayload, type s402PaymentPayloadBase, type s402PaymentRequirements, type s402PaymentSession, type s402PrepaidExtra, type s402PrepaidPayload, type s402RegistryQuery, s402ResourceServer, type s402RouteConfig, type s402Scheme, type s402ServerScheme, type s402ServiceEntry, type s402SettleResponse, type s402SettlementMode, type s402StreamExtra, type s402StreamPayload, type s402UnlockExtra, type s402UnlockPayload, type s402VerifyResponse, validateRequirementsShape };
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S402_HEADERS, S402_VERSION } from "./types.mjs";
|
|
2
2
|
import { createS402Error, s402Error, s402ErrorCode } from "./errors.mjs";
|
|
3
|
-
import { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, validateRequirementsShape } from "./http.mjs";
|
|
3
|
+
import { decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, validateRequirementsShape } from "./http.mjs";
|
|
4
4
|
|
|
5
5
|
//#region src/client.ts
|
|
6
6
|
var s402Client = class {
|
|
@@ -138,14 +138,51 @@ var s402Facilitator = class {
|
|
|
138
138
|
}
|
|
139
139
|
/**
|
|
140
140
|
* Verify a payment payload by dispatching to the correct scheme.
|
|
141
|
+
* Includes expiration guard and scheme-mismatch check.
|
|
141
142
|
*/
|
|
142
143
|
async verify(payload, requirements) {
|
|
144
|
+
if (requirements.expiresAt != null) {
|
|
145
|
+
if (typeof requirements.expiresAt !== "number" || !Number.isFinite(requirements.expiresAt)) return {
|
|
146
|
+
valid: false,
|
|
147
|
+
invalidReason: `Invalid expiresAt value: expected finite number, got ${typeof requirements.expiresAt}`
|
|
148
|
+
};
|
|
149
|
+
if (Date.now() > requirements.expiresAt) return {
|
|
150
|
+
valid: false,
|
|
151
|
+
invalidReason: `Payment requirements expired at ${new Date(requirements.expiresAt).toISOString()}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (requirements.accepts && requirements.accepts.length > 0) {
|
|
155
|
+
if (!requirements.accepts.includes(payload.scheme)) return {
|
|
156
|
+
valid: false,
|
|
157
|
+
invalidReason: `Scheme "${payload.scheme}" is not accepted by these requirements. Accepted: [${requirements.accepts.join(", ")}]`
|
|
158
|
+
};
|
|
159
|
+
}
|
|
143
160
|
return this.resolveScheme(payload.scheme, requirements.network).verify(payload, requirements);
|
|
144
161
|
}
|
|
145
162
|
/**
|
|
146
163
|
* Settle a payment by dispatching to the correct scheme.
|
|
164
|
+
* Includes expiration guard and scheme-mismatch check.
|
|
147
165
|
*/
|
|
148
166
|
async settle(payload, requirements) {
|
|
167
|
+
if (requirements.expiresAt != null) {
|
|
168
|
+
if (typeof requirements.expiresAt !== "number" || !Number.isFinite(requirements.expiresAt)) return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: `Invalid expiresAt value: expected finite number, got ${typeof requirements.expiresAt}`,
|
|
171
|
+
errorCode: "INVALID_PAYLOAD"
|
|
172
|
+
};
|
|
173
|
+
if (Date.now() > requirements.expiresAt) return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: `Payment requirements expired at ${new Date(requirements.expiresAt).toISOString()}`,
|
|
176
|
+
errorCode: "REQUIREMENTS_EXPIRED"
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (requirements.accepts && requirements.accepts.length > 0) {
|
|
180
|
+
if (!requirements.accepts.includes(payload.scheme)) return {
|
|
181
|
+
success: false,
|
|
182
|
+
error: `Scheme "${payload.scheme}" is not accepted by these requirements. Accepted: [${requirements.accepts.join(", ")}]`,
|
|
183
|
+
errorCode: "SCHEME_NOT_SUPPORTED"
|
|
184
|
+
};
|
|
185
|
+
}
|
|
149
186
|
return this.resolveScheme(payload.scheme, requirements.network).settle(payload, requirements);
|
|
150
187
|
}
|
|
151
188
|
/**
|
|
@@ -169,6 +206,13 @@ var s402Facilitator = class {
|
|
|
169
206
|
errorCode: "REQUIREMENTS_EXPIRED"
|
|
170
207
|
};
|
|
171
208
|
}
|
|
209
|
+
if (requirements.accepts && requirements.accepts.length > 0) {
|
|
210
|
+
if (!requirements.accepts.includes(payload.scheme)) return {
|
|
211
|
+
success: false,
|
|
212
|
+
error: `Scheme "${payload.scheme}" is not accepted by these requirements. Accepted: [${requirements.accepts.join(", ")}]`,
|
|
213
|
+
errorCode: "SCHEME_NOT_SUPPORTED"
|
|
214
|
+
};
|
|
215
|
+
}
|
|
172
216
|
const scheme = this.resolveScheme(payload.scheme, requirements.network);
|
|
173
217
|
const verifyResult = await scheme.verify(payload, requirements);
|
|
174
218
|
if (!verifyResult.valid) return {
|
|
@@ -181,7 +225,15 @@ var s402Facilitator = class {
|
|
|
181
225
|
error: `Payment requirements expired during verification at ${new Date(requirements.expiresAt).toISOString()}`,
|
|
182
226
|
errorCode: "REQUIREMENTS_EXPIRED"
|
|
183
227
|
};
|
|
184
|
-
|
|
228
|
+
try {
|
|
229
|
+
return await scheme.settle(payload, requirements);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
error: e instanceof Error ? e.message : "Settlement failed with an unexpected error",
|
|
234
|
+
errorCode: "VERIFICATION_FAILED"
|
|
235
|
+
};
|
|
236
|
+
}
|
|
185
237
|
}
|
|
186
238
|
/**
|
|
187
239
|
* Check if a scheme is supported for a network.
|
|
@@ -206,4 +258,4 @@ var s402Facilitator = class {
|
|
|
206
258
|
};
|
|
207
259
|
|
|
208
260
|
//#endregion
|
|
209
|
-
export { S402_HEADERS, S402_VERSION, createS402Error, decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, s402Client, s402Error, s402ErrorCode, s402Facilitator, s402ResourceServer, validateRequirementsShape };
|
|
261
|
+
export { S402_HEADERS, S402_VERSION, createS402Error, decodePaymentPayload, decodePaymentRequired, decodeSettleResponse, detectProtocol, encodePaymentPayload, encodePaymentRequired, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, s402Client, s402Error, s402ErrorCode, s402Facilitator, s402ResourceServer, validateRequirementsShape };
|
package/dist/types.d.mts
CHANGED
|
@@ -46,7 +46,15 @@ interface s402PaymentRequirements {
|
|
|
46
46
|
unlock?: s402UnlockExtra;
|
|
47
47
|
/** Extra fields for prepaid scheme */
|
|
48
48
|
prepaid?: s402PrepaidExtra;
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Arbitrary extension data (forward-compatible extensibility).
|
|
51
|
+
*
|
|
52
|
+
* D-10 (Trust boundary): extensions is an opaque bag — the s402 library
|
|
53
|
+
* passes it through without validation. Consumers MUST treat extension values
|
|
54
|
+
* as untrusted input. Do not use extensions for security-critical fields
|
|
55
|
+
* (use first-class typed fields instead). Scheme implementations should
|
|
56
|
+
* validate any extension keys they consume.
|
|
57
|
+
*/
|
|
50
58
|
extensions?: Record<string, unknown>;
|
|
51
59
|
}
|
|
52
60
|
/** Stream-specific requirements */
|
|
@@ -237,6 +245,61 @@ interface s402Discovery {
|
|
|
237
245
|
/** Address that receives the protocol fee */
|
|
238
246
|
protocolFeeAddress?: string;
|
|
239
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Tracks the lifecycle of a single payment exchange.
|
|
250
|
+
* Used by s402 clients (SDK fetch wrapper, MCP tools) to correlate
|
|
251
|
+
* the 402 response → payment creation → settlement result.
|
|
252
|
+
*/
|
|
253
|
+
interface s402PaymentSession {
|
|
254
|
+
/** Unique session ID (client-generated, e.g., crypto.randomUUID()) */
|
|
255
|
+
id: string;
|
|
256
|
+
/** When the session started (Date.now()) */
|
|
257
|
+
startedAt: number;
|
|
258
|
+
/** The payment requirements from the 402 response */
|
|
259
|
+
requirements: s402PaymentRequirements;
|
|
260
|
+
/** The payment payload sent to the facilitator/server (null until created) */
|
|
261
|
+
payload: s402PaymentPayload | null;
|
|
262
|
+
/** Settlement result (null until settled) */
|
|
263
|
+
result: s402SettleResponse | null;
|
|
264
|
+
/** Current session state */
|
|
265
|
+
state: 'pending' | 'paying' | 'settled' | 'failed';
|
|
266
|
+
/** Number of retry attempts */
|
|
267
|
+
retries: number;
|
|
268
|
+
/** Error message if state is 'failed' */
|
|
269
|
+
error?: string;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* A single s402-enabled service endpoint in a registry.
|
|
273
|
+
* Supports multi-service discovery (e.g., an API gateway advertising
|
|
274
|
+
* multiple endpoints with different payment requirements).
|
|
275
|
+
*/
|
|
276
|
+
interface s402ServiceEntry {
|
|
277
|
+
/** Service name (human-readable) */
|
|
278
|
+
name: string;
|
|
279
|
+
/** Service endpoint URL */
|
|
280
|
+
url: string;
|
|
281
|
+
/** Payment schemes this service accepts */
|
|
282
|
+
accepts: s402Scheme[];
|
|
283
|
+
/** Supported coin types */
|
|
284
|
+
assets: string[];
|
|
285
|
+
/** Base price in smallest unit (MIST for SUI, micro for USDC) */
|
|
286
|
+
baseAmount: string;
|
|
287
|
+
/** Facilitator URL (if not direct settlement) */
|
|
288
|
+
facilitatorUrl?: string;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Query parameters for service registry lookups.
|
|
292
|
+
*/
|
|
293
|
+
interface s402RegistryQuery {
|
|
294
|
+
/** Filter by supported scheme */
|
|
295
|
+
scheme?: s402Scheme;
|
|
296
|
+
/** Filter by coin type */
|
|
297
|
+
asset?: string;
|
|
298
|
+
/** Filter by network */
|
|
299
|
+
network?: string;
|
|
300
|
+
/** Maximum number of results */
|
|
301
|
+
limit?: number;
|
|
302
|
+
}
|
|
240
303
|
/**
|
|
241
304
|
* HTTP headers used by s402 (same names as x402 for compatibility).
|
|
242
305
|
* All lowercase per HTTP/2 spec (RFC 9113 §8.2.1). The Headers API
|
|
@@ -249,4 +312,4 @@ declare const S402_HEADERS: {
|
|
|
249
312
|
readonly STREAM_ID: "x-stream-id";
|
|
250
313
|
};
|
|
251
314
|
//#endregion
|
|
252
|
-
export { S402_HEADERS, S402_VERSION, s402Discovery, s402EscrowExtra, s402EscrowPayload, s402ExactPayload, s402Mandate, s402MandateRequirements, s402PaymentPayload, s402PaymentPayloadBase, s402PaymentRequirements, s402PrepaidExtra, s402PrepaidPayload, s402Scheme, s402SettleResponse, s402SettlementMode, s402StreamExtra, s402StreamPayload, s402UnlockExtra, s402UnlockPayload, s402VerifyResponse };
|
|
315
|
+
export { S402_HEADERS, S402_VERSION, s402Discovery, s402EscrowExtra, s402EscrowPayload, s402ExactPayload, s402Mandate, s402MandateRequirements, s402PaymentPayload, s402PaymentPayloadBase, s402PaymentRequirements, s402PaymentSession, s402PrepaidExtra, s402PrepaidPayload, s402RegistryQuery, s402Scheme, s402ServiceEntry, s402SettleResponse, s402SettlementMode, s402StreamExtra, s402StreamPayload, s402UnlockExtra, s402UnlockPayload, s402VerifyResponse };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s402",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "s402 — Sui-native HTTP 402 wire format. Types, HTTP encoding, and scheme registry for five payment schemes. Wire-compatible with x402. Zero runtime dependencies.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,22 +78,21 @@
|
|
|
78
78
|
"default": "./dist/errors.mjs"
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
83
|
+
"fast-check": "^4.5.3",
|
|
84
|
+
"tsdown": "^0.20.3",
|
|
85
|
+
"typescript": "^5.7.0",
|
|
86
|
+
"vitepress": "^1.6.4",
|
|
87
|
+
"vitest": "^3.0.5"
|
|
88
|
+
},
|
|
81
89
|
"scripts": {
|
|
82
90
|
"build": "tsdown",
|
|
83
91
|
"typecheck": "tsc --noEmit",
|
|
84
92
|
"test": "vitest run",
|
|
85
93
|
"test:watch": "vitest",
|
|
86
|
-
"prepublishOnly": "npm run build && npm run typecheck && npm run test",
|
|
87
94
|
"docs:dev": "vitepress dev docs",
|
|
88
95
|
"docs:build": "vitepress build docs",
|
|
89
96
|
"docs:preview": "vitepress preview docs"
|
|
90
|
-
},
|
|
91
|
-
"devDependencies": {
|
|
92
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
93
|
-
"fast-check": "^4.5.3",
|
|
94
|
-
"tsdown": "^0.20.3",
|
|
95
|
-
"typescript": "^5.7.0",
|
|
96
|
-
"vitepress": "^1.6.4",
|
|
97
|
-
"vitest": "^3.0.5"
|
|
98
97
|
}
|
|
99
|
-
}
|
|
98
|
+
}
|