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 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
- - **Subject line:** `[s402 security] <brief description>`
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 (valid for Sui MIST amounts).
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 (valid for Sui MIST amounts).
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 a finite number between 0 and 10000, got ${record.protocolFeeBps}`);
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
- return scheme.settle(payload, requirements);
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
- /** Arbitrary extension data (forward-compatible extensibility) */
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.0",
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
+ }