openpay-x402-sdk 0.10.1 → 0.10.3

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
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.3 — 2026-10-10
4
+
5
+ - Widen `isPrivatePaymentHost` / `parseSafePaymentUrl` to the same IANA
6
+ special-purpose table the OpenPay server uses: NAT64 (`64:ff9b::/96`,
7
+ `64:ff9b:1::/48`), 6to4 (`2002::/16`), Teredo (`2001::/32`), the deprecated
8
+ 6to4 relay (`192.88.99.0/24`), IPv4-translated (`::ffff:0:0:0/96`),
9
+ documentation, benchmarking, IETF-assignment, discard-only, segment-routing,
10
+ site-local and reserved ranges are now rejected before DNS, before a custom
11
+ transport runs and at connect time. Mapped IPv4 is judged against the full
12
+ IPv4 table. A `::` that stands for no group is treated as malformed (rejected).
13
+ Public addresses next to each range stay allowed.
14
+ - `resolveLicense` accepts `productUrl: null` (the seller released their last
15
+ @handle). The descriptor keeps the license identity, so gates keep working;
16
+ only the store page link is absent. Any non-null `productUrl` is validated as
17
+ before. `LicenseDescriptor.productUrl` is now `string | null`. 0.10.2 and
18
+ earlier reject such a descriptor with `invalid_response`.
19
+ - No dependency changes.
20
+
21
+ ## 0.10.2 — 2026-09-24
22
+
23
+ - Fix `createDualGate` USDC authorization reuse before settlement. Reuse JPYC's
24
+ local claim manager for v1/v2 payments, keyed by pinned chain, asset, payer and
25
+ nonce; share the USDC ledger across gate instances and endpoints in one process
26
+ to reject duplicates before relay verification and upstream execution.
27
+ - Enforce the configured upstream plus settlement validity margin before USDC
28
+ verify and again before granting upstream work. Reject unclaimable payloads.
29
+ Release tentative claims on verify rejection/failure; retain claims after
30
+ verification through upstream abandonment, settlement success, rejection and
31
+ ambiguous settlement until authorization expiry. Margins remain specific to each
32
+ calling gate. JPYC behavior/wire is unchanged.
33
+ - Clarify README/types: USDC claims are process-local, are lost on restart, and
34
+ provide no coordination across processes/isolates. Holding after definitive
35
+ settlement rejection is deliberate; retrying upstream requires a fresh nonce
36
+ and signature. Claims do not reserve funds or guarantee settlement.
37
+ No dependencies added.
38
+
3
39
  ## 0.10.1 — 2026-09-23
4
40
 
5
41
  - Create spend-store directories with mode 0700, including compatibility saves,
@@ -9,7 +45,7 @@
9
45
  Mode 0700 also applies to newly created intermediate directories at custom
10
46
  spend-store paths; pre-existing shared/group directories keep their permissions.
11
47
  - Preserve spend reservations, locks, accounting and wallet 0600/0700 checks.
12
- No dependencies added. Unpublished; rule-15 human review is required before adoption.
48
+ No dependencies added.
13
49
 
14
50
  ## 0.10.0 — 2026-09-23
15
51
 
@@ -27,8 +63,7 @@
27
63
  `SellerPinError` and still stop both rails.
28
64
  - Update seller types, examples and generated snippets. Existing sellers must
29
65
  configure pins and redeploy the SDK/snippet; deploy the matching relay update
30
- first. No dependencies added. Unpublished; rule-15 human review is required
31
- before adoption.
66
+ first. No dependencies added.
32
67
 
33
68
  ## 0.9.0 — 2026-09-21
34
69
 
package/README.md CHANGED
@@ -113,13 +113,13 @@ before a 402, verification or settlement; map these errors to an HTTP 500 respon
113
113
  Each payment retains its validated requirements through settlement. Pass
114
114
  `openpayOrigin` to use an origin other than `https://open-pay.jp`.
115
115
 
116
- Upgrade existing seller deployments to 0.10.0 and configure the required pins,
116
+ Upgrade existing seller deployments to 0.10.2 and configure the required pins,
117
117
  or regenerate and redeploy your snippet from your own dashboard. Old installed
118
118
  SDKs and pasted snippets remain vulnerable until replaced; a package release does
119
119
  not update them. Register the listing, then retrieve its pinned snippet from the
120
120
  dashboard before serving payments.
121
121
 
122
- Before `verify()` contacts the facilitator, each gate instance claims a canonical
122
+ Before `verify()` contacts the facilitator, each JPYC gate instance claims a canonical
123
123
  authorization identity until its `validBefore` time. Set `maxUpstreamSeconds`
124
124
  (default `60`) to the seller's worst-case upstream duration; the gate also
125
125
  retains `settlementGraceSeconds` (default `30`) and rejects a duplicate before
@@ -127,9 +127,9 @@ upstream work begins. A failed facilitator verification releases the tentative
127
127
  claim, while a successful verification keeps it through settlement so an
128
128
  unknown settlement result cannot expose the same authorization twice.
129
129
 
130
- The facilitator reservation is an optional additional defense. The gate forwards
130
+ The JPYC facilitator reservation is an optional additional defense. The gate forwards
131
131
  its token when one is returned and continues on the established token-less wire
132
- when it is not. The local claim covers concurrent requests sharing one gate
132
+ when it is not. The JPYC local claim covers concurrent requests sharing one gate
133
133
  instance; separate processes or serverless isolates do not share its in-memory
134
134
  ledger.
135
135
 
@@ -167,7 +167,33 @@ const gate = createDualGate({
167
167
  });
168
168
  ```
169
169
 
170
- `handle()` / `verify()` work exactly like `createJpycGate`, on both rails.
170
+ `handle()` / `verify()` support the same verify → upstream → settle pattern on
171
+ both rails. Starting in 0.10.2, USDC v1 and v2 requests share a claim keyed by the
172
+ pinned chain and asset plus the authorization's payer and nonce. Header formatting,
173
+ address/nonce casing, request URL and other envelope metadata do not create new
174
+ claims. USDC authorizations are not bound to a resource, so the USDC ledger is
175
+ shared across gate instances and endpoints in the same process. It is lost on
176
+ restart; separate processes and serverless isolates do not coordinate. JPYC
177
+ claims remain per gate instance.
178
+
179
+ USDC rejects duplicate authorizations with `authorization_reserved` and requires
180
+ at least `maxUpstreamSeconds + settlementGraceSeconds` remaining before relay
181
+ verification and again before returning the upstream capability (defaults: 60 + 30
182
+ seconds). Each call uses its gate's configured margin even though USDC claims
183
+ are shared. Insufficient time returns `insufficient_validity_window`; an unreadable
184
+ authorization identity or expiry returns `invalid_payment_payload`.
185
+
186
+ Verify rejection or failure releases the tentative USDC claim because upstream
187
+ work has not been authorized and verify cannot settle. After verification grants
188
+ upstream work, the claim remains until `validBefore`, including when upstream is
189
+ abandoned, settlement succeeds, settlement rejects (including a relay 409), or its
190
+ outcome is unknown due to transport/JSON/503 errors. Holding after a definitive
191
+ settle rejection is deliberate: it does not undo upstream work. Even after a
192
+ balance top-up, that signature cannot start new work before `validBefore`; a retry
193
+ requires a newly signed authorization with a fresh nonce. Claims do not reserve
194
+ buyer funds: different nonces may all verify against the same balance and later
195
+ fail to settle. This local claim does not guarantee settlement or replace
196
+ facilitator/on-chain verification, and USDC does not use JPYC's reservation token.
171
197
 
172
198
  ### Register listings without the web form
173
199
 
@@ -585,7 +611,12 @@ splits, amount inconsistencies,
585
611
  resource URL mismatches, and catalog bait-and-switches before requesting a
586
612
  signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
587
613
  environment config readers. Target host/catalog admission and private-address
588
- checks run before buyer target requests. Those requests require HTTPS, do not
614
+ checks run before buyer target requests. The private-address check uses the
615
+ same IANA special-purpose table as the OpenPay server: loopback, private,
616
+ CGNAT, link-local, documentation, benchmarking, multicast and reserved IPv4;
617
+ IPv4-mapped/translated, NAT64, 6to4, Teredo, ULA, site-local and multicast
618
+ IPv6; plus `localhost`, `.localhost`, `.local` and `.internal` names. Those
619
+ requests require HTTPS, do not
589
620
  follow redirects, and have a 15-second timeout. The default Node transport also
590
621
  validates DNS before and during connection to block rebinding. A custom
591
622
  `fetchImpl` still gets the pre-connection resolution check — a hostname that
package/index.d.ts CHANGED
@@ -322,7 +322,7 @@ export interface JpycGateOptions {
322
322
  openpayOrigin?: string;
323
323
  fetchImpl?: typeof globalThis.fetch;
324
324
  now?: () => number;
325
- /** Worst-case seller upstream duration protected by the local claim. Default: 60. */
325
+ /** Worst-case seller upstream duration; applies to both rails in createDualGate. Default: 60. */
326
326
  maxUpstreamSeconds?: number;
327
327
  /** Extra validity retained for settlement after upstream work. Default: 30. */
328
328
  settlementGraceSeconds?: number;
@@ -382,7 +382,8 @@ export interface LicenseDescriptor {
382
382
  remaining: number | null;
383
383
  saleActive: boolean;
384
384
  registered: boolean;
385
- productUrl: string;
385
+ /** Store page link; null when the seller no longer has a public @handle (the license identity stays valid). */
386
+ productUrl: string | null;
386
387
  /** Append the wallet address query parameter to check rights. */
387
388
  verifyUrl: string;
388
389
  sellerRole: 'operator' | 'third_party';
@@ -515,6 +516,14 @@ export interface DualGateOptions extends JpycGateOptions {
515
516
  * relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
516
517
  * or unavailable), the gate degrades to JPYC-only. Identity/recipient mismatches throw and log
517
518
  * before any 402 or payment call; they never trigger a rail fallback.
519
+ * USDC authorizations are not resource-bound: all gate instances in one process share a pinned
520
+ * chain/asset + payer/nonce ledger, lost on restart and not shared across processes/isolates.
521
+ * JPYC claims remain per instance. USDC v1/v2 require the calling gate's upstream + settlement
522
+ * validity margin before relay verify and before granting upstream work. Tentative verify failures
523
+ * release the claim; after upstream is granted, it remains until validBefore even if upstream is
524
+ * abandoned or settlement fails/has an unknown outcome. Holding after definitive rejection is
525
+ * deliberate: retrying upstream requires a fresh nonce/signature. Claims do not reserve buyer
526
+ * funds or guarantee settlement. USDC has no JPYC reservation token.
518
527
  */
519
528
  export function createDualGate(options: DualGateOptions): JpycGate;
520
529
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -27,7 +27,8 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "scripts": {
30
- "test": "node --test tests/*.test.mjs"
30
+ "test": "node --test tests/*.test.mjs",
31
+ "prepublishOnly": "npm test"
31
32
  },
32
33
  "dependencies": {
33
34
  "viem": "^2.45.0"
@@ -0,0 +1,143 @@
1
+ // Process-local claims prevent one authorization from starting upstream work twice.
2
+ const DEFAULT_MAX_UPSTREAM_SECONDS = 60;
3
+ const DEFAULT_SETTLEMENT_GRACE_SECONDS = 30;
4
+
5
+ function isObject(value) {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ function canonicalAddress(value) {
10
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
11
+ ? value.toLowerCase()
12
+ : null;
13
+ }
14
+
15
+ function canonicalBytes32(value) {
16
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)
17
+ ? value.toLowerCase()
18
+ : null;
19
+ }
20
+
21
+ function canonicalDecimal(value) {
22
+ return typeof value === 'string' && /^[0-9]+$/.test(value)
23
+ ? BigInt(value).toString()
24
+ : null;
25
+ }
26
+
27
+ function canonicalNetwork(value) {
28
+ if (typeof value !== 'string') return null;
29
+ const match = /^eip155:([0-9]+)$/.exec(value);
30
+ return match ? `eip155:${BigInt(match[1]).toString()}` : null;
31
+ }
32
+
33
+ export function jpycAuthorizationClaim(paymentPayload, paymentRequirements) {
34
+ if (!isObject(paymentPayload) || !isObject(paymentRequirements)) return null;
35
+ const inner = paymentPayload.payload;
36
+ const extra = paymentRequirements.extra;
37
+ if (!isObject(inner) || !isObject(extra)) return null;
38
+ const authorization = inner.authorization;
39
+ const openpay = extra.openpay;
40
+ if (!isObject(authorization) || !isObject(openpay)) return null;
41
+
42
+ const network = canonicalNetwork(paymentPayload.network);
43
+ const asset = canonicalAddress(paymentRequirements.asset);
44
+ const from = canonicalAddress(authorization.from);
45
+ const forwarder = canonicalAddress(openpay.forwarder);
46
+ const merchant = canonicalAddress(openpay.merchant);
47
+ const merchantValue = canonicalDecimal(openpay.merchantValue);
48
+ const feeReceiver = canonicalAddress(openpay.feeReceiver);
49
+ const feeValue = canonicalDecimal(openpay.feeValue);
50
+ const validAfter = canonicalDecimal(authorization.validAfter);
51
+ const validBefore = canonicalDecimal(authorization.validBefore);
52
+ const intentSalt = canonicalBytes32(authorization.intentSalt);
53
+ const parts = [
54
+ network,
55
+ asset,
56
+ from,
57
+ forwarder,
58
+ merchant,
59
+ merchantValue,
60
+ feeReceiver,
61
+ feeValue,
62
+ validAfter,
63
+ validBefore,
64
+ intentSalt,
65
+ ];
66
+ if (parts.some((part) => part === null)) return null;
67
+ return {
68
+ key: JSON.stringify(parts),
69
+ validBefore: BigInt(validBefore),
70
+ };
71
+ }
72
+
73
+ export function usdcAuthorizationClaim(paymentPayload, paymentRequirements) {
74
+ const authorization = paymentPayload?.payload?.authorization;
75
+ if (!isObject(authorization)) return null;
76
+ // The relay rebuilds the signed domain from these pinned requirements. Untrusted
77
+ // envelope metadata must not split one on-chain nonce into separate upstream claims.
78
+ const network = canonicalNetwork(paymentRequirements.network);
79
+ const asset = canonicalAddress(paymentRequirements.asset);
80
+ const from = canonicalAddress(authorization.from);
81
+ const nonce = canonicalBytes32(authorization.nonce);
82
+ const validBefore = canonicalDecimal(authorization.validBefore);
83
+ const parts = [network, asset, from, nonce];
84
+ if (parts.some((part) => part === null) || validBefore === null) return null;
85
+ return { key: JSON.stringify(parts), validBefore: BigInt(validBefore) };
86
+ }
87
+
88
+ export function createAuthorizationClaims({
89
+ now = Date.now,
90
+ maxUpstreamSeconds = DEFAULT_MAX_UPSTREAM_SECONDS,
91
+ settlementGraceSeconds = DEFAULT_SETTLEMENT_GRACE_SECONDS,
92
+ } = {}, authorizationClaims = new Map()) {
93
+ if (!Number.isSafeInteger(maxUpstreamSeconds) || maxUpstreamSeconds < 0) {
94
+ throw new Error('maxUpstreamSeconds must be a non-negative integer');
95
+ }
96
+ if (
97
+ !Number.isSafeInteger(settlementGraceSeconds) ||
98
+ settlementGraceSeconds <= 0
99
+ ) {
100
+ throw new Error('settlementGraceSeconds must be a positive integer');
101
+ }
102
+ if (!Number.isSafeInteger(maxUpstreamSeconds + settlementGraceSeconds)) {
103
+ throw new Error('reservation validity window must be a safe integer');
104
+ }
105
+ function claimAuthorization(claim) {
106
+ if (claim === null) return { ok: true, claim: null };
107
+
108
+ const nowSec = BigInt(Math.ceil(now() / 1000));
109
+ for (const [key, existing] of authorizationClaims) {
110
+ if (existing.validBefore <= nowSec) authorizationClaims.delete(key);
111
+ }
112
+
113
+ if (!hasValidityWindow(claim, nowSec)) {
114
+ return { ok: false, reason: 'insufficient_validity_window' };
115
+ }
116
+ if (authorizationClaims.has(claim.key)) {
117
+ return { ok: false, reason: 'authorization_reserved' };
118
+ }
119
+
120
+ const owner = Symbol();
121
+ authorizationClaims.set(claim.key, {
122
+ owner,
123
+ validBefore: claim.validBefore,
124
+ });
125
+ return { ok: true, claim: { key: claim.key, owner } };
126
+ }
127
+
128
+ function releaseAuthorization(claim) {
129
+ // An expired verify attempt must not release a newer owner's claim for the same identity.
130
+ if (
131
+ claim !== null &&
132
+ authorizationClaims.get(claim.key)?.owner === claim.owner
133
+ ) {
134
+ authorizationClaims.delete(claim.key);
135
+ }
136
+ }
137
+
138
+ function hasValidityWindow(claim, nowSec = BigInt(Math.ceil(now() / 1000))) {
139
+ return claim.validBefore - nowSec >= BigInt(maxUpstreamSeconds + settlementGraceSeconds);
140
+ }
141
+
142
+ return { claimAuthorization, releaseAuthorization, hasValidityWindow };
143
+ }
package/src/dualGate.mjs CHANGED
@@ -12,10 +12,14 @@
12
12
  // JPYC レールの 402 には USDC accepts を追記 (decorate) して両面を常に見せる。
13
13
 
14
14
  import { createJpycGate } from './gate.mjs';
15
+ import { createAuthorizationClaims, usdcAuthorizationClaim } from './authorizationClaims.mjs';
15
16
  import { assertSellerPins, SellerPinError, validateUsdcFace } from './sellerPins.mjs';
16
17
 
17
18
  const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
18
19
  const USDC_FACE_CACHE_MS = 5 * 60_000;
20
+ // USDC authorizations are not resource-bound: share claims so another endpoint's gate
21
+ // cannot start duplicate upstream work with the same chain/asset/payer/nonce.
22
+ const usdcAuthorizationClaims = new Map();
19
23
 
20
24
  function decodeBase64Json(value) {
21
25
  const binary = atob(value);
@@ -54,6 +58,9 @@ export function createDualGate({
54
58
  ...(settlementGraceSeconds === undefined ? {} : { settlementGraceSeconds }),
55
59
  });
56
60
  const origin = openpayOrigin.replace(/\/+$/, '');
61
+ const { claimAuthorization, releaseAuthorization, hasValidityWindow } = createAuthorizationClaims({
62
+ now, maxUpstreamSeconds, settlementGraceSeconds,
63
+ }, usdcAuthorizationClaims);
57
64
 
58
65
  let usdcCache = null;
59
66
  let usdcCachedAt = 0;
@@ -152,6 +159,19 @@ export function createDualGate({
152
159
 
153
160
  if (usdcRail) {
154
161
  const jpycAccepts = await availableJpycAccepts(request);
162
+ let paymentPayload;
163
+ try {
164
+ paymentPayload = decodeBase64Json(signatureHeader || v1Header);
165
+ } catch {
166
+ // A malformed preferred header must not bypass the claim via the other rail/header.
167
+ return usdcChallenge(usdc, jpycAccepts, 'invalid_payment_payload');
168
+ }
169
+ const authorization = usdcAuthorizationClaim(paymentPayload, usdc.v2Accept);
170
+ // Without a usable identity/expiry, verification cannot safely grant upstream work.
171
+ if (authorization === null) return usdcChallenge(usdc, jpycAccepts, 'invalid_payment_payload');
172
+ const claimed = claimAuthorization(authorization);
173
+ if (!claimed.ok) return usdcChallenge(usdc, jpycAccepts, claimed.reason);
174
+
155
175
  const relay = async (path) => {
156
176
  const response = await fetchImpl(`${origin}/api/x402/relay/${path}`, {
157
177
  method: 'POST',
@@ -174,11 +194,29 @@ export function createDualGate({
174
194
  }
175
195
  return response.json();
176
196
  };
177
- const verification = await relay('verify');
178
- if (verification instanceof Response) return verification;
197
+ let verification;
198
+ try {
199
+ verification = await relay('verify');
200
+ } catch (error) {
201
+ // Verify cannot settle or grant upstream work on failure; release only this tentative owner.
202
+ releaseAuthorization(claimed.claim);
203
+ throw error;
204
+ }
205
+ if (verification instanceof Response) {
206
+ releaseAuthorization(claimed.claim);
207
+ return verification;
208
+ }
179
209
  if (verification.isValid !== true) {
210
+ releaseAuthorization(claimed.claim);
180
211
  return usdcChallenge(usdc, jpycAccepts, verification.invalidReason || 'payment_invalid');
181
212
  }
213
+ // A slow verify must not consume the margin promised to the seller's upstream work.
214
+ if (!hasValidityWindow(authorization)) {
215
+ return usdcChallenge(usdc, jpycAccepts, 'insufficient_validity_window');
216
+ }
217
+ // As in JPYC, keep the claim until validBefore once upstream can run. Settlement
218
+ // errors (including ambiguous transport/JSON/503 and 409) must not repeat that work;
219
+ // even a definitive settle rejection cannot undo an already executed upstream call.
182
220
  return {
183
221
  async settle() {
184
222
  const settlement = await relay('settle');
package/src/gate.mjs CHANGED
@@ -1,9 +1,8 @@
1
+ import { createAuthorizationClaims, jpycAuthorizationClaim } from './authorizationClaims.mjs';
1
2
  import { assertSellerPins, validateJpycListing } from './sellerPins.mjs';
2
3
 
3
4
  const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
4
5
  const ACCEPTS_CACHE_MS = 5 * 60_000;
5
- const DEFAULT_MAX_UPSTREAM_SECONDS = 60;
6
- const DEFAULT_SETTLEMENT_GRACE_SECONDS = 30;
7
6
 
8
7
  function json402(accepts, error) {
9
8
  return new Response(JSON.stringify({ x402Version: 1, accepts, error }), {
@@ -25,74 +24,6 @@ function encodeBase64Json(value) {
25
24
  return btoa(binary);
26
25
  }
27
26
 
28
- function isObject(value) {
29
- return typeof value === 'object' && value !== null && !Array.isArray(value);
30
- }
31
-
32
- function canonicalAddress(value) {
33
- return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
34
- ? value.toLowerCase()
35
- : null;
36
- }
37
-
38
- function canonicalBytes32(value) {
39
- return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)
40
- ? value.toLowerCase()
41
- : null;
42
- }
43
-
44
- function canonicalDecimal(value) {
45
- return typeof value === 'string' && /^[0-9]+$/.test(value)
46
- ? BigInt(value).toString()
47
- : null;
48
- }
49
-
50
- function canonicalNetwork(value) {
51
- if (typeof value !== 'string') return null;
52
- const match = /^eip155:([0-9]+)$/.exec(value);
53
- return match ? `eip155:${BigInt(match[1]).toString()}` : null;
54
- }
55
-
56
- function authorizationClaim(paymentPayload, paymentRequirements) {
57
- if (!isObject(paymentPayload) || !isObject(paymentRequirements)) return null;
58
- const inner = paymentPayload.payload;
59
- const extra = paymentRequirements.extra;
60
- if (!isObject(inner) || !isObject(extra)) return null;
61
- const authorization = inner.authorization;
62
- const openpay = extra.openpay;
63
- if (!isObject(authorization) || !isObject(openpay)) return null;
64
-
65
- const network = canonicalNetwork(paymentPayload.network);
66
- const asset = canonicalAddress(paymentRequirements.asset);
67
- const from = canonicalAddress(authorization.from);
68
- const forwarder = canonicalAddress(openpay.forwarder);
69
- const merchant = canonicalAddress(openpay.merchant);
70
- const merchantValue = canonicalDecimal(openpay.merchantValue);
71
- const feeReceiver = canonicalAddress(openpay.feeReceiver);
72
- const feeValue = canonicalDecimal(openpay.feeValue);
73
- const validAfter = canonicalDecimal(authorization.validAfter);
74
- const validBefore = canonicalDecimal(authorization.validBefore);
75
- const intentSalt = canonicalBytes32(authorization.intentSalt);
76
- const parts = [
77
- network,
78
- asset,
79
- from,
80
- forwarder,
81
- merchant,
82
- merchantValue,
83
- feeReceiver,
84
- feeValue,
85
- validAfter,
86
- validBefore,
87
- intentSalt,
88
- ];
89
- if (parts.some((part) => part === null)) return null;
90
- return {
91
- key: JSON.stringify(parts),
92
- validBefore: BigInt(validBefore),
93
- };
94
- }
95
-
96
27
  export function createJpycGate({
97
28
  resourceUrl,
98
29
  resourceId,
@@ -100,62 +31,16 @@ export function createJpycGate({
100
31
  openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
101
32
  fetchImpl = globalThis.fetch,
102
33
  now = Date.now,
103
- maxUpstreamSeconds = DEFAULT_MAX_UPSTREAM_SECONDS,
104
- settlementGraceSeconds = DEFAULT_SETTLEMENT_GRACE_SECONDS,
34
+ maxUpstreamSeconds,
35
+ settlementGraceSeconds,
105
36
  } = {}) {
106
37
  assertSellerPins(resourceId, expectedRecipient);
107
- if (!Number.isSafeInteger(maxUpstreamSeconds) || maxUpstreamSeconds < 0) {
108
- throw new Error('maxUpstreamSeconds must be a non-negative integer');
109
- }
110
- if (
111
- !Number.isSafeInteger(settlementGraceSeconds) ||
112
- settlementGraceSeconds <= 0
113
- ) {
114
- throw new Error('settlementGraceSeconds must be a positive integer');
115
- }
116
- if (!Number.isSafeInteger(maxUpstreamSeconds + settlementGraceSeconds)) {
117
- throw new Error('reservation validity window must be a safe integer');
118
- }
38
+ const { claimAuthorization, releaseAuthorization } = createAuthorizationClaims({
39
+ now, maxUpstreamSeconds, settlementGraceSeconds,
40
+ });
119
41
  const origin = openpayOrigin.replace(/\/+$/, '');
120
42
  let acceptsCache = null;
121
43
  let acceptsCachedAt = 0;
122
- const authorizationClaims = new Map();
123
-
124
- function claimAuthorization(paymentPayload, paymentRequirements) {
125
- const claim = authorizationClaim(paymentPayload, paymentRequirements);
126
- if (claim === null) return { ok: true, claim: null };
127
-
128
- const nowSec = BigInt(Math.ceil(now() / 1000));
129
- for (const [key, existing] of authorizationClaims) {
130
- if (existing.validBefore <= nowSec) authorizationClaims.delete(key);
131
- }
132
-
133
- const requiredValidity = BigInt(
134
- maxUpstreamSeconds + settlementGraceSeconds,
135
- );
136
- if (claim.validBefore - nowSec < requiredValidity) {
137
- return { ok: false, reason: 'insufficient_validity_window' };
138
- }
139
- if (authorizationClaims.has(claim.key)) {
140
- return { ok: false, reason: 'authorization_reserved' };
141
- }
142
-
143
- const owner = Symbol();
144
- authorizationClaims.set(claim.key, {
145
- owner,
146
- validBefore: claim.validBefore,
147
- });
148
- return { ok: true, claim: { key: claim.key, owner } };
149
- }
150
-
151
- function releaseAuthorization(claim) {
152
- if (
153
- claim !== null &&
154
- authorizationClaims.get(claim.key)?.owner === claim.owner
155
- ) {
156
- authorizationClaims.delete(claim.key);
157
- }
158
- }
159
44
 
160
45
  async function catalogAccepts() {
161
46
  if (
@@ -211,7 +96,7 @@ export function createJpycGate({
211
96
  }
212
97
 
213
98
  const paymentRequirements = accepts[0];
214
- const claimed = claimAuthorization(paymentPayload, paymentRequirements);
99
+ const claimed = claimAuthorization(jpycAuthorizationClaim(paymentPayload, paymentRequirements));
215
100
  if (!claimed.ok) return json402(accepts, claimed.reason);
216
101
 
217
102
  let verification;
package/src/license.mjs CHANGED
@@ -146,10 +146,11 @@ export async function resolveLicense({ product, origin = DEFAULT_LICENSE_ORIGIN,
146
146
  (body.remaining !== null && (!Number.isSafeInteger(body.remaining) || body.remaining < 0 || body.remaining > body.supply)) ||
147
147
  !['operator', 'third_party'].includes(body.sellerRole)) throw new Error('Invalid descriptor fields');
148
148
  httpsUrl(body.termsUrl);
149
- const productUrl = httpsUrl(body.productUrl);
149
+ // productUrl is null when the seller no longer has a public @handle; the license identity stays valid.
150
+ const productUrl = body.productUrl === null ? null : httpsUrl(body.productUrl);
150
151
  const verifyUrl = httpsUrl(body.verifyUrl);
151
- if (productUrl.origin !== DEFAULT_LICENSE_ORIGIN || !/^\/@[^/]+$/.test(productUrl.pathname) || productUrl.hash ||
152
- productUrl.search !== `?product=${product}` || verifyUrl.origin !== DEFAULT_LICENSE_ORIGIN ||
152
+ if ((productUrl && (productUrl.origin !== DEFAULT_LICENSE_ORIGIN || !/^\/@[^/]+$/.test(productUrl.pathname) || productUrl.hash ||
153
+ productUrl.search !== `?product=${product}`)) || verifyUrl.origin !== DEFAULT_LICENSE_ORIGIN ||
153
154
  verifyUrl.pathname !== '/api/license/verify' || verifyUrl.search !== `?product=${product}` || verifyUrl.hash) {
154
155
  throw new Error('Invalid descriptor links');
155
156
  }
package/src/network.mjs CHANGED
@@ -6,13 +6,69 @@ function normalizeHost(hostname) {
6
6
  return hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '').toLowerCase();
7
7
  }
8
8
 
9
- function isPrivateIpv4(a, b) {
10
- if (a === 0 || a === 10 || a === 127) return true;
11
- if (a === 100 && b >= 64 && b <= 127) return true;
12
- if (a === 169 && b === 254) return true;
13
- if (a === 172 && b >= 16 && b <= 31) return true;
14
- if (a === 192 && b === 168) return true;
15
- return a >= 224;
9
+ // SSRF boundary shared with the OpenPay server (lib/net/privateHost.ts): private,
10
+ // non-unicast, documentation and transition ranges from the IANA special-purpose
11
+ // registries (checked 2026-09-23). Translation/tunnel ranges (NAT64, 6to4, Teredo)
12
+ // are rejected as a whole even where IANA lists globally reachable exceptions, so a
13
+ // DNS answer like 64:ff9b::a9fe:a9fe (NAT64 to 169.254.169.254) or 2002:c0a8:101::
14
+ // (6to4 to 192.168.1.1) never carries a signed X-PAYMENT into the buyer's own network.
15
+ // Keep both tables identical; tests/packages/x402-sdk-network.test.ts compares them.
16
+ const IPV4_CIDRS = [
17
+ ['0.0.0.0', 8], // this network
18
+ ['10.0.0.0', 8], // private
19
+ ['100.64.0.0', 10], // CGNAT
20
+ ['127.0.0.0', 8], // loopback
21
+ ['169.254.0.0', 16], // link-local
22
+ ['172.16.0.0', 12], // private
23
+ ['192.0.0.0', 24], // IETF protocol assignments
24
+ ['192.0.2.0', 24], // documentation
25
+ ['192.88.99.0', 24], // deprecated 6to4 relay anycast
26
+ ['192.168.0.0', 16], // private
27
+ ['198.18.0.0', 15], // benchmarking
28
+ ['198.51.100.0', 24], // documentation
29
+ ['203.0.113.0', 24], // documentation
30
+ ['224.0.0.0', 4], // multicast
31
+ ['240.0.0.0', 4], // reserved (includes limited broadcast)
32
+ ];
33
+
34
+ const IPV6_CIDRS = [
35
+ ['::', 128], // unspecified
36
+ ['::1', 128], // loopback
37
+ ['::ffff:0:0:0', 96], // IPv4-translated (RFC 6145)
38
+ ['64:ff9b::', 96], // NAT64 well-known prefix
39
+ ['64:ff9b:1::', 48], // local-use NAT64
40
+ ['100::', 64], // discard-only
41
+ ['100:0:0:1::', 64], // dummy prefix
42
+ ['2001::', 32], // Teredo
43
+ ['2001:2::', 48], // benchmarking
44
+ ['2001:db8::', 32], // documentation
45
+ ['2002::', 16], // 6to4
46
+ ['3fff::', 20], // documentation
47
+ ['5f00::', 16], // segment routing SIDs
48
+ ['fc00::', 7], // ULA
49
+ ['fe80::', 10], // link-local
50
+ ['fec0::', 10], // deprecated site-local
51
+ ['ff00::', 8], // multicast
52
+ ];
53
+
54
+ const IPV4_PREFIXES = IPV4_CIDRS.map(([base, bits]) => ({
55
+ groups: base.split('.').map(Number), bits,
56
+ }));
57
+ const IPV6_PREFIXES = IPV6_CIDRS.map(([base, bits]) => ({
58
+ groups: expandIpv6(base), bits,
59
+ }));
60
+
61
+ // Compare only the bits inside the prefix, including a CIDR that ends mid-group.
62
+ function matchesPrefix(address, prefix, groupBits) {
63
+ for (let i = 0; i * groupBits < prefix.bits; i += 1) {
64
+ const shift = Math.max(0, (i + 1) * groupBits - prefix.bits);
65
+ if (address[i] >>> shift !== prefix.groups[i] >>> shift) return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ function isPrivateIpv4(octets) {
71
+ return IPV4_PREFIXES.some((prefix) => matchesPrefix(octets, prefix, 8));
16
72
  }
17
73
 
18
74
  function expandIpv6(hostname) {
@@ -35,7 +91,8 @@ function expandIpv6(hostname) {
35
91
  groups = head;
36
92
  } else {
37
93
  const fill = 8 - head.length - tail.length;
38
- if (fill < 0) return null;
94
+ // `::` must stand for at least one zero group (same rule as the server).
95
+ if (fill < 1) return null;
39
96
  groups = [...head, ...Array(fill).fill('0'), ...tail];
40
97
  }
41
98
  if (groups.length !== 8) return null;
@@ -59,25 +116,19 @@ export function isPrivatePaymentHost(hostname) {
59
116
  if (host.includes(':')) {
60
117
  const groups = expandIpv6(host);
61
118
  if (groups === null) return true;
62
- if (groups.every((group) => group === 0)) return true;
63
- if (
64
- groups.slice(0, 7).every((group) => group === 0) &&
65
- groups[7] === 1
66
- ) {
67
- return true;
68
- }
119
+ if (IPV6_PREFIXES.some((prefix) => matchesPrefix(groups, prefix, 16))) return true;
120
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d) literals are the
121
+ // socket's IPv4 form; judge them by the full IPv4 table.
69
122
  if (
70
123
  groups.slice(0, 5).every((group) => group === 0) &&
71
124
  (groups[5] === 0xffff || groups[5] === 0)
72
125
  ) {
73
- return isPrivateIpv4(
74
- (groups[6] >> 8) & 0xff,
75
- groups[6] & 0xff,
76
- );
126
+ return isPrivateIpv4([
127
+ (groups[6] >> 8) & 0xff, groups[6] & 0xff,
128
+ (groups[7] >> 8) & 0xff, groups[7] & 0xff,
129
+ ]);
77
130
  }
78
- if (groups[0] >= 0xfc00 && groups[0] <= 0xfdff) return true;
79
- if (groups[0] >= 0xfe80 && groups[0] <= 0xfebf) return true;
80
- return groups[0] >= 0xff00;
131
+ return false;
81
132
  }
82
133
 
83
134
  const ipv4 = host.match(
@@ -86,7 +137,7 @@ export function isPrivatePaymentHost(hostname) {
86
137
  if (!ipv4) return false;
87
138
  const octets = ipv4.slice(1).map(Number);
88
139
  if (octets.some((octet) => octet > 255)) return true;
89
- return isPrivateIpv4(octets[0], octets[1]);
140
+ return isPrivateIpv4(octets);
90
141
  }
91
142
 
92
143
  export function parseSafePaymentUrl(raw) {