s402 0.2.0 → 0.2.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 -1
- package/README.md +26 -0
- package/dist/compat.d.mts +14 -0
- package/dist/compat.mjs +15 -0
- package/dist/errors.d.mts +26 -0
- package/dist/errors.mjs +26 -0
- package/dist/http.d.mts +86 -4
- package/dist/http.mjs +110 -5
- package/dist/index.d.mts +86 -4
- package/dist/index.mjs +94 -6
- package/dist/receipts.mjs +49 -11
- package/package.json +18 -12
- package/test/conformance/README.md +172 -0
- package/test/conformance/vectors/body-transport.json +142 -0
- package/test/conformance/vectors/compat-normalize.json +259 -0
- package/test/conformance/vectors/payload-decode.json +110 -0
- package/test/conformance/vectors/payload-encode.json +127 -0
- package/test/conformance/vectors/receipt-format.json +668 -0
- package/test/conformance/vectors/receipt-parse.json +450 -0
- package/test/conformance/vectors/requirements-decode.json +377 -0
- package/test/conformance/vectors/requirements-encode.json +338 -0
- package/test/conformance/vectors/roundtrip.json +156 -0
- package/test/conformance/vectors/settle-decode.json +76 -0
- package/test/conformance/vectors/settle-encode.json +65 -0
- package/test/conformance/vectors/validation-reject.json +317 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ 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.2.1] - 2026-03-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Conformance test vectors ship in npm package** — 133 machine-readable JSON test vectors across 12 files now included via `test/conformance/vectors`. Cross-language implementors (Go, Python, Rust) can `npm pack s402` to get the vectors without cloning the repo.
|
|
13
|
+
- **API stability declaration** — `API-STABILITY.md` classifies all 83 exports as stable, experimental, or internal.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Barrel export JSDoc updated to chain-agnostic wording (was "Sui-native").
|
|
18
|
+
|
|
8
19
|
## [0.2.0] - 2026-03-01
|
|
9
20
|
|
|
10
21
|
### Added
|
|
@@ -27,7 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
27
38
|
|
|
28
39
|
- **BREAKING**: `s402RouteConfig.asset` is now required (was optional).
|
|
29
40
|
- JSDoc on `s402PaymentRequirements` updated to chain-agnostic wording (network, asset, amount fields).
|
|
30
|
-
-
|
|
41
|
+
- **Conformance test suite** — 133 machine-readable JSON test vectors across 12 files for cross-language implementation verification. Covers encode/decode, body transport, compat normalization, receipt format/parse, validation rejection, key-stripping, and roundtrip identity. Vectors ship in the npm package.
|
|
42
|
+
- **API stability declaration** — `API-STABILITY.md` classifies all 83 exports as stable/experimental/internal.
|
|
43
|
+
- 405 tests across 12 suites (was 207 at v0.1.0).
|
|
31
44
|
|
|
32
45
|
## [0.1.8] - 2026-02-27
|
|
33
46
|
|
|
@@ -116,6 +129,7 @@ _Version bump for npm publish after license change._
|
|
|
116
129
|
- Property-based fuzz testing via fast-check
|
|
117
130
|
- 207 tests, zero runtime dependencies
|
|
118
131
|
|
|
132
|
+
[0.2.1]: https://github.com/s402-protocol/core/compare/v0.2.0...v0.2.1
|
|
119
133
|
[0.2.0]: https://github.com/s402-protocol/core/compare/v0.1.8...v0.2.0
|
|
120
134
|
[0.1.8]: https://github.com/s402-protocol/core/compare/v0.1.7...v0.1.8
|
|
121
135
|
[0.1.7]: https://github.com/s402-protocol/core/compare/v0.1.6...v0.1.7
|
package/README.md
CHANGED
|
@@ -36,6 +36,18 @@ HTTP 402 ("Payment Required") has been reserved since 1999 — waiting for a pay
|
|
|
36
36
|
|
|
37
37
|
**s402 is Sui-native by design.** These advantages come from Sui's object model, PTBs, and sub-second finality. They can't be replicated on EVM — and they don't need to be. x402 already handles EVM well. s402 handles Sui better.
|
|
38
38
|
|
|
39
|
+
## Which Scheme Should I Use?
|
|
40
|
+
|
|
41
|
+
| Your situation | Scheme | Gas per 1K calls | Latency |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| One-time API call, simplest path | **Exact** | $7.00 | ~400ms |
|
|
44
|
+
| High-frequency API (10+ calls) | **Prepaid** | $0.014 | ~0ms per call |
|
|
45
|
+
| Buyer needs dispute protection | **Escrow** | $7.00 | ~400ms |
|
|
46
|
+
| Selling encrypted content | **Unlock** | $7.00 | ~400ms |
|
|
47
|
+
| Real-time billing (per-second) | **Stream** | variable | ~400ms setup |
|
|
48
|
+
|
|
49
|
+
**Quick decision:** Use **Prepaid** for AI agents making repeated API calls. Use **Exact** for everything else (it's the x402-compatible default). See the [full guide](https://s402-protocol.org/guide/which-scheme) for details.
|
|
50
|
+
|
|
39
51
|
## Architecture
|
|
40
52
|
|
|
41
53
|
```
|
|
@@ -325,6 +337,20 @@ const requirements: s402PaymentRequirements = {
|
|
|
325
337
|
|
|
326
338
|
5. **Errors tell you what to do.** Every error code includes `retryable` (can the client try again?) and `suggestedAction` (what should it do?). Agents can self-recover.
|
|
327
339
|
|
|
340
|
+
## Conformance Testing
|
|
341
|
+
|
|
342
|
+
s402 ships 133 machine-readable JSON test vectors for cross-language conformance. If you're implementing s402 in Go, Python, Rust, or any other language, use these vectors to verify your implementation matches the spec.
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
# Vectors are in the npm package
|
|
346
|
+
ls node_modules/s402/test/conformance/vectors/
|
|
347
|
+
|
|
348
|
+
# Or clone the repo
|
|
349
|
+
ls test/conformance/vectors/
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
See [`test/conformance/README.md`](./test/conformance/README.md) for the vector format, encoding scheme, and implementation guide.
|
|
353
|
+
|
|
328
354
|
## Related
|
|
329
355
|
|
|
330
356
|
- **[SweeFi](https://github.com/sweeinc/sweefi)** — Open-source payment SDK built on s402. 10 packages including PTB builders, MCP tools, CLI, and UI components.
|
package/dist/compat.d.mts
CHANGED
|
@@ -108,6 +108,20 @@ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope): s402Pa
|
|
|
108
108
|
* Validates required fields to catch malformed/malicious payloads at the trust boundary.
|
|
109
109
|
*
|
|
110
110
|
* Returns a clean object with only known s402 fields — unknown top-level keys are stripped.
|
|
111
|
+
*
|
|
112
|
+
* @param obj - Raw decoded JSON (could be s402, x402 V1, or x402 V2 envelope)
|
|
113
|
+
* @returns Validated s402PaymentRequirements
|
|
114
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the format is unrecognized or malformed
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* import { normalizeRequirements } from 's402/compat';
|
|
119
|
+
*
|
|
120
|
+
* // Works with any format — auto-detects s402 vs x402
|
|
121
|
+
* const rawJson = JSON.parse(atob(header));
|
|
122
|
+
* const requirements = normalizeRequirements(rawJson);
|
|
123
|
+
* // Always returns s402PaymentRequirements regardless of input format
|
|
124
|
+
* ```
|
|
111
125
|
*/
|
|
112
126
|
declare function normalizeRequirements(obj: Record<string, unknown>): s402PaymentRequirements;
|
|
113
127
|
//#endregion
|
package/dist/compat.mjs
CHANGED
|
@@ -127,8 +127,23 @@ function fromX402Envelope(envelope) {
|
|
|
127
127
|
* Validates required fields to catch malformed/malicious payloads at the trust boundary.
|
|
128
128
|
*
|
|
129
129
|
* Returns a clean object with only known s402 fields — unknown top-level keys are stripped.
|
|
130
|
+
*
|
|
131
|
+
* @param obj - Raw decoded JSON (could be s402, x402 V1, or x402 V2 envelope)
|
|
132
|
+
* @returns Validated s402PaymentRequirements
|
|
133
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if the format is unrecognized or malformed
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* import { normalizeRequirements } from 's402/compat';
|
|
138
|
+
*
|
|
139
|
+
* // Works with any format — auto-detects s402 vs x402
|
|
140
|
+
* const rawJson = JSON.parse(atob(header));
|
|
141
|
+
* const requirements = normalizeRequirements(rawJson);
|
|
142
|
+
* // Always returns s402PaymentRequirements regardless of input format
|
|
143
|
+
* ```
|
|
130
144
|
*/
|
|
131
145
|
function normalizeRequirements(obj) {
|
|
146
|
+
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}`);
|
|
132
147
|
if (isS402(obj)) {
|
|
133
148
|
validateRequirementsShape(obj);
|
|
134
149
|
return pickRequirementsFields(obj);
|
package/dist/errors.d.mts
CHANGED
|
@@ -33,10 +33,36 @@ interface s402ErrorInfo {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Create a typed s402 error with recovery hints.
|
|
36
|
+
*
|
|
37
|
+
* @param code - One of the 15 s402 error codes (e.g. 'SETTLEMENT_FAILED')
|
|
38
|
+
* @param message - Optional human-readable message (defaults to the code)
|
|
39
|
+
* @returns Error info object with code, message, retryable flag, and suggestedAction
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const info = createS402Error('INSUFFICIENT_BALANCE');
|
|
44
|
+
* // { code: 'INSUFFICIENT_BALANCE', message: 'INSUFFICIENT_BALANCE',
|
|
45
|
+
* // retryable: false, suggestedAction: 'Top up wallet balance...' }
|
|
46
|
+
* ```
|
|
36
47
|
*/
|
|
37
48
|
declare function createS402Error(code: s402ErrorCodeType, message?: string): s402ErrorInfo;
|
|
38
49
|
/**
|
|
39
50
|
* s402 error class for throwing in code.
|
|
51
|
+
* Every instance includes a machine-readable `code`, `retryable` flag, and `suggestedAction`.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* try {
|
|
56
|
+
* await facilitator.process(payload, requirements);
|
|
57
|
+
* } catch (e) {
|
|
58
|
+
* if (e instanceof s402Error) {
|
|
59
|
+
* console.log(e.code); // 'SETTLEMENT_FAILED'
|
|
60
|
+
* console.log(e.retryable); // true
|
|
61
|
+
* console.log(e.suggestedAction); // 'Transient RPC failure...'
|
|
62
|
+
* if (e.retryable) { } // retry with exponential backoff
|
|
63
|
+
* }
|
|
64
|
+
* }
|
|
65
|
+
* ```
|
|
40
66
|
*/
|
|
41
67
|
declare class s402Error extends Error {
|
|
42
68
|
readonly code: s402ErrorCodeType;
|
package/dist/errors.mjs
CHANGED
|
@@ -89,6 +89,17 @@ const ERROR_HINTS = {
|
|
|
89
89
|
};
|
|
90
90
|
/**
|
|
91
91
|
* Create a typed s402 error with recovery hints.
|
|
92
|
+
*
|
|
93
|
+
* @param code - One of the 15 s402 error codes (e.g. 'SETTLEMENT_FAILED')
|
|
94
|
+
* @param message - Optional human-readable message (defaults to the code)
|
|
95
|
+
* @returns Error info object with code, message, retryable flag, and suggestedAction
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const info = createS402Error('INSUFFICIENT_BALANCE');
|
|
100
|
+
* // { code: 'INSUFFICIENT_BALANCE', message: 'INSUFFICIENT_BALANCE',
|
|
101
|
+
* // retryable: false, suggestedAction: 'Top up wallet balance...' }
|
|
102
|
+
* ```
|
|
92
103
|
*/
|
|
93
104
|
function createS402Error(code, message) {
|
|
94
105
|
const hints = ERROR_HINTS[code];
|
|
@@ -101,6 +112,21 @@ function createS402Error(code, message) {
|
|
|
101
112
|
}
|
|
102
113
|
/**
|
|
103
114
|
* s402 error class for throwing in code.
|
|
115
|
+
* Every instance includes a machine-readable `code`, `retryable` flag, and `suggestedAction`.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* try {
|
|
120
|
+
* await facilitator.process(payload, requirements);
|
|
121
|
+
* } catch (e) {
|
|
122
|
+
* if (e instanceof s402Error) {
|
|
123
|
+
* console.log(e.code); // 'SETTLEMENT_FAILED'
|
|
124
|
+
* console.log(e.retryable); // true
|
|
125
|
+
* console.log(e.suggestedAction); // 'Transient RPC failure...'
|
|
126
|
+
* if (e.retryable) { } // retry with exponential backoff
|
|
127
|
+
* }
|
|
128
|
+
* }
|
|
129
|
+
* ```
|
|
104
130
|
*/
|
|
105
131
|
var s402Error = class extends Error {
|
|
106
132
|
code;
|
package/dist/http.d.mts
CHANGED
|
@@ -1,19 +1,85 @@
|
|
|
1
1
|
import { s402PaymentPayload, s402PaymentRequirements, s402SettleResponse } from "./types.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/http.d.ts
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Encode payment requirements for the `payment-required` header.
|
|
6
|
+
*
|
|
7
|
+
* @param requirements - Typed s402 payment requirements
|
|
8
|
+
* @returns Base64-encoded JSON string for the HTTP header
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { encodePaymentRequired } from 's402/http';
|
|
13
|
+
*
|
|
14
|
+
* const header = encodePaymentRequired({
|
|
15
|
+
* s402Version: '1',
|
|
16
|
+
* accepts: ['exact'],
|
|
17
|
+
* network: 'sui:mainnet',
|
|
18
|
+
* asset: '0x2::sui::SUI',
|
|
19
|
+
* amount: '1000000',
|
|
20
|
+
* payTo: '0xYOUR_ADDRESS',
|
|
21
|
+
* });
|
|
22
|
+
* response.headers.set('payment-required', header);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
5
25
|
declare function encodePaymentRequired(requirements: s402PaymentRequirements): string;
|
|
6
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Encode payment payload for the `x-payment` header.
|
|
28
|
+
*
|
|
29
|
+
* @param payload - Typed s402 payment payload (any scheme)
|
|
30
|
+
* @returns Base64-encoded JSON string for the HTTP header
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { encodePaymentPayload, S402_HEADERS } from 's402/http';
|
|
35
|
+
*
|
|
36
|
+
* const header = encodePaymentPayload(payload);
|
|
37
|
+
* fetch(url, { headers: { [S402_HEADERS.PAYMENT]: header } });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
7
40
|
declare function encodePaymentPayload(payload: s402PaymentPayload): string;
|
|
8
41
|
/** Encode settlement response for the `payment-response` header */
|
|
9
42
|
declare function encodeSettleResponse(response: s402SettleResponse): string;
|
|
10
43
|
/** Return a clean object with only known s402 requirement fields. */
|
|
11
44
|
declare function pickRequirementsFields(obj: Record<string, unknown>): s402PaymentRequirements;
|
|
12
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Decode payment requirements from the `payment-required` header.
|
|
47
|
+
* Validates shape, strips unknown keys, enforces size limit (64KB).
|
|
48
|
+
*
|
|
49
|
+
* @param header - Base64-encoded JSON string from the HTTP header
|
|
50
|
+
* @returns Validated s402 payment requirements
|
|
51
|
+
* @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* import { decodePaymentRequired } from 's402/http';
|
|
56
|
+
*
|
|
57
|
+
* const header = response.headers.get('payment-required')!;
|
|
58
|
+
* const requirements = decodePaymentRequired(header);
|
|
59
|
+
* console.log(requirements.accepts); // ['exact', 'prepaid']
|
|
60
|
+
* console.log(requirements.amount); // '1000000'
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
13
63
|
declare function decodePaymentRequired(header: string): s402PaymentRequirements;
|
|
14
64
|
/** Return a clean payload object with only known s402 payload fields. */
|
|
15
65
|
declare function pickPayloadFields(obj: Record<string, unknown>): s402PaymentPayload;
|
|
16
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Decode payment payload from the `x-payment` header.
|
|
68
|
+
* Validates shape, strips unknown keys, enforces size limit (64KB).
|
|
69
|
+
*
|
|
70
|
+
* @param header - Base64-encoded JSON string from the HTTP header
|
|
71
|
+
* @returns Validated s402 payment payload
|
|
72
|
+
* @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* import { decodePaymentPayload, S402_HEADERS } from 's402/http';
|
|
77
|
+
*
|
|
78
|
+
* const header = request.headers.get(S402_HEADERS.PAYMENT)!;
|
|
79
|
+
* const payload = decodePaymentPayload(header);
|
|
80
|
+
* console.log(payload.scheme); // 'exact'
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
17
83
|
declare function decodePaymentPayload(header: string): s402PaymentPayload;
|
|
18
84
|
/** Return a clean settle response with only known s402 fields. */
|
|
19
85
|
declare function pickSettleResponseFields(obj: Record<string, unknown>): s402SettleResponse;
|
|
@@ -110,6 +176,22 @@ declare function detectProtocol(headers: Headers): 's402' | 'x402' | 'unknown';
|
|
|
110
176
|
* Extract s402 payment requirements from a 402 Response.
|
|
111
177
|
* Returns null if the header is missing, malformed, or not s402 format.
|
|
112
178
|
* For x402 responses, decode the header manually and use normalizeRequirements().
|
|
179
|
+
*
|
|
180
|
+
* @param response - Fetch API Response object (status should be 402)
|
|
181
|
+
* @returns Parsed requirements, or null if not an s402 response
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import { extractRequirementsFromResponse } from 's402/http';
|
|
186
|
+
*
|
|
187
|
+
* const res = await fetch(url);
|
|
188
|
+
* if (res.status === 402) {
|
|
189
|
+
* const requirements = extractRequirementsFromResponse(res);
|
|
190
|
+
* if (requirements) {
|
|
191
|
+
* // Build and send payment
|
|
192
|
+
* }
|
|
193
|
+
* }
|
|
194
|
+
* ```
|
|
113
195
|
*/
|
|
114
196
|
declare function extractRequirementsFromResponse(response: Response): s402PaymentRequirements | null;
|
|
115
197
|
//#endregion
|
package/dist/http.mjs
CHANGED
|
@@ -13,11 +13,44 @@ function fromBase64(b64) {
|
|
|
13
13
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
14
14
|
return new TextDecoder().decode(bytes);
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Encode payment requirements for the `payment-required` header.
|
|
18
|
+
*
|
|
19
|
+
* @param requirements - Typed s402 payment requirements
|
|
20
|
+
* @returns Base64-encoded JSON string for the HTTP header
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { encodePaymentRequired } from 's402/http';
|
|
25
|
+
*
|
|
26
|
+
* const header = encodePaymentRequired({
|
|
27
|
+
* s402Version: '1',
|
|
28
|
+
* accepts: ['exact'],
|
|
29
|
+
* network: 'sui:mainnet',
|
|
30
|
+
* asset: '0x2::sui::SUI',
|
|
31
|
+
* amount: '1000000',
|
|
32
|
+
* payTo: '0xYOUR_ADDRESS',
|
|
33
|
+
* });
|
|
34
|
+
* response.headers.set('payment-required', header);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
17
37
|
function encodePaymentRequired(requirements) {
|
|
18
38
|
return toBase64(JSON.stringify(requirements));
|
|
19
39
|
}
|
|
20
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Encode payment payload for the `x-payment` header.
|
|
42
|
+
*
|
|
43
|
+
* @param payload - Typed s402 payment payload (any scheme)
|
|
44
|
+
* @returns Base64-encoded JSON string for the HTTP header
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { encodePaymentPayload, S402_HEADERS } from 's402/http';
|
|
49
|
+
*
|
|
50
|
+
* const header = encodePaymentPayload(payload);
|
|
51
|
+
* fetch(url, { headers: { [S402_HEADERS.PAYMENT]: header } });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
21
54
|
function encodePaymentPayload(payload) {
|
|
22
55
|
return toBase64(JSON.stringify(payload));
|
|
23
56
|
}
|
|
@@ -104,7 +137,24 @@ function pickRequirementsFields(obj) {
|
|
|
104
137
|
else result[key] = obj[key];
|
|
105
138
|
return result;
|
|
106
139
|
}
|
|
107
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Decode payment requirements from the `payment-required` header.
|
|
142
|
+
* Validates shape, strips unknown keys, enforces size limit (64KB).
|
|
143
|
+
*
|
|
144
|
+
* @param header - Base64-encoded JSON string from the HTTP header
|
|
145
|
+
* @returns Validated s402 payment requirements
|
|
146
|
+
* @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```ts
|
|
150
|
+
* import { decodePaymentRequired } from 's402/http';
|
|
151
|
+
*
|
|
152
|
+
* const header = response.headers.get('payment-required')!;
|
|
153
|
+
* const requirements = decodePaymentRequired(header);
|
|
154
|
+
* console.log(requirements.accepts); // ['exact', 'prepaid']
|
|
155
|
+
* console.log(requirements.amount); // '1000000'
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
108
158
|
function decodePaymentRequired(header) {
|
|
109
159
|
if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `payment-required header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
|
|
110
160
|
let parsed;
|
|
@@ -160,7 +210,23 @@ function pickPayloadFields(obj) {
|
|
|
160
210
|
}
|
|
161
211
|
return result;
|
|
162
212
|
}
|
|
163
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Decode payment payload from the `x-payment` header.
|
|
215
|
+
* Validates shape, strips unknown keys, enforces size limit (64KB).
|
|
216
|
+
*
|
|
217
|
+
* @param header - Base64-encoded JSON string from the HTTP header
|
|
218
|
+
* @returns Validated s402 payment payload
|
|
219
|
+
* @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* import { decodePaymentPayload, S402_HEADERS } from 's402/http';
|
|
224
|
+
*
|
|
225
|
+
* const header = request.headers.get(S402_HEADERS.PAYMENT)!;
|
|
226
|
+
* const payload = decodePaymentPayload(header);
|
|
227
|
+
* console.log(payload.scheme); // 'exact'
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
164
230
|
function decodePaymentPayload(header) {
|
|
165
231
|
if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `x-payment header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
|
|
166
232
|
let parsed;
|
|
@@ -374,6 +440,19 @@ function validateRequirementsShape(obj) {
|
|
|
374
440
|
if (record.facilitatorUrl !== void 0) {
|
|
375
441
|
if (typeof record.facilitatorUrl !== "string") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must be a string, got ${typeof record.facilitatorUrl}`);
|
|
376
442
|
if (/[\x00-\x1f\x7f]/.test(record.facilitatorUrl)) throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl contains control characters (potential header injection)");
|
|
443
|
+
try {
|
|
444
|
+
const url = new URL(record.facilitatorUrl);
|
|
445
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must use https:// or http://, got "${url.protocol}"`);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (e instanceof s402Error) throw e;
|
|
448
|
+
throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (record.settlementMode !== void 0) {
|
|
452
|
+
if (record.settlementMode !== "facilitator" && record.settlementMode !== "direct") throw new s402Error("INVALID_PAYLOAD", `settlementMode must be "facilitator" or "direct", got ${JSON.stringify(record.settlementMode)}`);
|
|
453
|
+
}
|
|
454
|
+
if (record.receiptRequired !== void 0) {
|
|
455
|
+
if (typeof record.receiptRequired !== "boolean") throw new s402Error("INVALID_PAYLOAD", `receiptRequired must be a boolean, got ${typeof record.receiptRequired}`);
|
|
377
456
|
}
|
|
378
457
|
validateSubObjects(record);
|
|
379
458
|
}
|
|
@@ -392,11 +471,21 @@ function validatePayloadShape(obj) {
|
|
|
392
471
|
if (typeof inner.signature !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.signature must be a string, got ${typeof inner.signature}`);
|
|
393
472
|
if (record.scheme === "unlock" && typeof inner.encryptionId !== "string") throw new s402Error("INVALID_PAYLOAD", `unlock payload requires encryptionId (string), got ${typeof inner.encryptionId}`);
|
|
394
473
|
if (record.scheme === "prepaid" && typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
|
|
474
|
+
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}`);
|
|
395
475
|
}
|
|
396
476
|
/** Validate that a decoded settle response has the required shape. */
|
|
397
477
|
function validateSettleShape(obj) {
|
|
398
478
|
if (obj == null || typeof obj !== "object") throw new s402Error("INVALID_PAYLOAD", "Settle response is not an object");
|
|
399
|
-
|
|
479
|
+
const record = obj;
|
|
480
|
+
if (typeof record.success !== "boolean") throw new s402Error("INVALID_PAYLOAD", "Malformed settle response: missing or invalid \"success\" (boolean)");
|
|
481
|
+
if (record.txDigest !== void 0 && typeof record.txDigest !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: txDigest must be a string, got ${typeof record.txDigest}`);
|
|
482
|
+
if (record.receiptId !== void 0 && typeof record.receiptId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: receiptId must be a string, got ${typeof record.receiptId}`);
|
|
483
|
+
if (record.finalityMs !== void 0 && (typeof record.finalityMs !== "number" || !Number.isFinite(record.finalityMs))) throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: finalityMs must be a finite number, got ${typeof record.finalityMs}`);
|
|
484
|
+
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}`);
|
|
485
|
+
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}`);
|
|
486
|
+
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}`);
|
|
487
|
+
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}`);
|
|
488
|
+
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}`);
|
|
400
489
|
}
|
|
401
490
|
/** Content type for s402 JSON body transport */
|
|
402
491
|
const S402_CONTENT_TYPE = "application/s402+json";
|
|
@@ -481,6 +570,22 @@ function detectProtocol(headers) {
|
|
|
481
570
|
* Extract s402 payment requirements from a 402 Response.
|
|
482
571
|
* Returns null if the header is missing, malformed, or not s402 format.
|
|
483
572
|
* For x402 responses, decode the header manually and use normalizeRequirements().
|
|
573
|
+
*
|
|
574
|
+
* @param response - Fetch API Response object (status should be 402)
|
|
575
|
+
* @returns Parsed requirements, or null if not an s402 response
|
|
576
|
+
*
|
|
577
|
+
* @example
|
|
578
|
+
* ```ts
|
|
579
|
+
* import { extractRequirementsFromResponse } from 's402/http';
|
|
580
|
+
*
|
|
581
|
+
* const res = await fetch(url);
|
|
582
|
+
* if (res.status === 402) {
|
|
583
|
+
* const requirements = extractRequirementsFromResponse(res);
|
|
584
|
+
* if (requirements) {
|
|
585
|
+
* // Build and send payment
|
|
586
|
+
* }
|
|
587
|
+
* }
|
|
588
|
+
* ```
|
|
484
589
|
*/
|
|
485
590
|
function extractRequirementsFromResponse(response) {
|
|
486
591
|
const header = response.headers.get(S402_HEADERS.PAYMENT_REQUIRED);
|
package/dist/index.d.mts
CHANGED
|
@@ -102,8 +102,19 @@ declare class s402Client {
|
|
|
102
102
|
/**
|
|
103
103
|
* Register a scheme implementation for a network.
|
|
104
104
|
*
|
|
105
|
-
* @param network -
|
|
106
|
-
* @param scheme - Scheme implementation
|
|
105
|
+
* @param network - Network identifier (e.g., "sui:testnet", "sui:mainnet")
|
|
106
|
+
* @param scheme - Scheme implementation (from @sweefi/sui or your own)
|
|
107
|
+
* @returns `this` for chaining
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* import { s402Client } from 's402';
|
|
112
|
+
*
|
|
113
|
+
* const client = new s402Client();
|
|
114
|
+
* client
|
|
115
|
+
* .register('sui:mainnet', exactScheme)
|
|
116
|
+
* .register('sui:mainnet', prepaidScheme);
|
|
117
|
+
* ```
|
|
107
118
|
*/
|
|
108
119
|
register(network: string, scheme: s402ClientScheme): this;
|
|
109
120
|
/**
|
|
@@ -114,6 +125,23 @@ declare class s402Client {
|
|
|
114
125
|
*
|
|
115
126
|
* Accepts typed s402PaymentRequirements only. For x402 input, normalize
|
|
116
127
|
* first via `normalizeRequirements()` from 's402/compat'.
|
|
128
|
+
*
|
|
129
|
+
* @param requirements - Server's payment requirements (from a 402 response)
|
|
130
|
+
* @returns Payment payload ready to send in the `x-payment` header
|
|
131
|
+
* @throws {s402Error} `NETWORK_MISMATCH` if no schemes registered for the network
|
|
132
|
+
* @throws {s402Error} `SCHEME_NOT_SUPPORTED` if no registered scheme matches server's accepts
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* import { s402Client, decodePaymentRequired, encodePaymentPayload, S402_HEADERS } from 's402';
|
|
137
|
+
*
|
|
138
|
+
* const client = new s402Client();
|
|
139
|
+
* client.register('sui:mainnet', exactScheme);
|
|
140
|
+
*
|
|
141
|
+
* const requirements = decodePaymentRequired(res.headers.get('payment-required')!);
|
|
142
|
+
* const payload = await client.createPayment(requirements);
|
|
143
|
+
* fetch(url, { headers: { [S402_HEADERS.PAYMENT]: encodePaymentPayload(payload) } });
|
|
144
|
+
* ```
|
|
117
145
|
*/
|
|
118
146
|
createPayment(requirements: s402PaymentRequirements): Promise<s402PaymentPayload>;
|
|
119
147
|
/**
|
|
@@ -141,12 +169,33 @@ declare class s402Facilitator {
|
|
|
141
169
|
*/
|
|
142
170
|
settle(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
143
171
|
/**
|
|
144
|
-
* Expiration-guarded verify + settle in one call.
|
|
172
|
+
* Expiration-guarded verify + settle in one call. **This is the recommended path.**
|
|
145
173
|
* Rejects expired requirements, verifies the payload, then settles.
|
|
174
|
+
* Includes deduplication (prevents concurrent identical requests) and timeouts
|
|
175
|
+
* (5s verify, 15s settle).
|
|
146
176
|
*
|
|
147
177
|
* Note: True atomicity comes from Sui's PTBs in the scheme implementation,
|
|
148
178
|
* not from this method. This method provides the expiration guard and
|
|
149
|
-
* sequential verify
|
|
179
|
+
* sequential verify-then-settle orchestration.
|
|
180
|
+
*
|
|
181
|
+
* @param payload - Client's payment payload
|
|
182
|
+
* @param requirements - Server's payment requirements
|
|
183
|
+
* @returns Settlement result (check `result.success` and `result.errorCode`)
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```ts
|
|
187
|
+
* import { s402Facilitator } from 's402';
|
|
188
|
+
*
|
|
189
|
+
* const facilitator = new s402Facilitator();
|
|
190
|
+
* facilitator.register('sui:mainnet', exactFacilitatorScheme);
|
|
191
|
+
*
|
|
192
|
+
* const result = await facilitator.process(payload, requirements);
|
|
193
|
+
* if (result.success) {
|
|
194
|
+
* console.log(result.txDigest); // Sui transaction digest
|
|
195
|
+
* } else {
|
|
196
|
+
* console.log(result.errorCode); // e.g. 'VERIFICATION_FAILED'
|
|
197
|
+
* }
|
|
198
|
+
* ```
|
|
150
199
|
*/
|
|
151
200
|
process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
152
201
|
/**
|
|
@@ -176,6 +225,25 @@ declare class s402ResourceServer {
|
|
|
176
225
|
* Build payment requirements for a route.
|
|
177
226
|
* Uses the first scheme in the config's schemes array.
|
|
178
227
|
* Always includes "exact" in accepts for x402 compat.
|
|
228
|
+
*
|
|
229
|
+
* @param config - Per-route payment configuration (schemes, price, network, payTo, asset)
|
|
230
|
+
* @returns Payment requirements ready to encode for the 402 response
|
|
231
|
+
* @throws {s402Error} `INVALID_PAYLOAD` if price is not a valid non-negative integer string
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* import { s402ResourceServer, encodePaymentRequired } from 's402';
|
|
236
|
+
*
|
|
237
|
+
* const server = new s402ResourceServer();
|
|
238
|
+
* const requirements = server.buildRequirements({
|
|
239
|
+
* schemes: ['exact'],
|
|
240
|
+
* price: '1000000',
|
|
241
|
+
* network: 'sui:mainnet',
|
|
242
|
+
* payTo: '0xYOUR_ADDRESS',
|
|
243
|
+
* asset: '0x2::sui::SUI',
|
|
244
|
+
* });
|
|
245
|
+
* res.status(402).setHeader('payment-required', encodePaymentRequired(requirements));
|
|
246
|
+
* ```
|
|
179
247
|
*/
|
|
180
248
|
buildRequirements(config: s402RouteConfig): s402PaymentRequirements;
|
|
181
249
|
/**
|
|
@@ -191,6 +259,20 @@ declare class s402ResourceServer {
|
|
|
191
259
|
* Expiration-guarded verify + settle. This is the recommended path.
|
|
192
260
|
* Rejects expired requirements, verifies the payload, then settles.
|
|
193
261
|
* True atomicity comes from Sui PTBs in the scheme implementation.
|
|
262
|
+
*
|
|
263
|
+
* @param payload - Client's payment payload (from the `x-payment` header)
|
|
264
|
+
* @param requirements - The requirements this server originally sent
|
|
265
|
+
* @returns Settlement result with txDigest on success
|
|
266
|
+
* @throws {s402Error} `FACILITATOR_UNAVAILABLE` if no facilitator is configured
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```ts
|
|
270
|
+
* const result = await server.process(payload, requirements);
|
|
271
|
+
* if (result.success) {
|
|
272
|
+
* // Serve the protected resource
|
|
273
|
+
* res.status(200).json({ data: 'paid content' });
|
|
274
|
+
* }
|
|
275
|
+
* ```
|
|
194
276
|
*/
|
|
195
277
|
process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
196
278
|
}
|