openpay-x402-sdk 0.10.2 → 0.10.4

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,6 +1,31 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.2 — unreleased
3
+ ## 0.10.4 — 2026-10-22
4
+
5
+ - Raise `engines.node` from `>=20` to `>=22`. Node.js 20 reached end-of-life and
6
+ does not receive security releases (for example the October 2026 release that
7
+ fixes the Brotli decoder used when `fetch` decodes a `br` response). The code
8
+ is unchanged.
9
+
10
+ ## 0.10.3 — 2026-10-10
11
+
12
+ - Widen `isPrivatePaymentHost` / `parseSafePaymentUrl` to the same IANA
13
+ special-purpose table the OpenPay server uses: NAT64 (`64:ff9b::/96`,
14
+ `64:ff9b:1::/48`), 6to4 (`2002::/16`), Teredo (`2001::/32`), the deprecated
15
+ 6to4 relay (`192.88.99.0/24`), IPv4-translated (`::ffff:0:0:0/96`),
16
+ documentation, benchmarking, IETF-assignment, discard-only, segment-routing,
17
+ site-local and reserved ranges are now rejected before DNS, before a custom
18
+ transport runs and at connect time. Mapped IPv4 is judged against the full
19
+ IPv4 table. A `::` that stands for no group is treated as malformed (rejected).
20
+ Public addresses next to each range stay allowed.
21
+ - `resolveLicense` accepts `productUrl: null` (the seller released their last
22
+ @handle). The descriptor keeps the license identity, so gates keep working;
23
+ only the store page link is absent. Any non-null `productUrl` is validated as
24
+ before. `LicenseDescriptor.productUrl` is now `string | null`. 0.10.2 and
25
+ earlier reject such a descriptor with `invalid_response`.
26
+ - No dependency changes.
27
+
28
+ ## 0.10.2 — 2026-09-24
4
29
 
5
30
  - Fix `createDualGate` USDC authorization reuse before settlement. Reuse JPYC's
6
31
  local claim manager for v1/v2 payments, keyed by pinned chain, asset, payer and
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # openpay-x402-sdk
2
2
 
3
- Node.js 20+ SDK for discovering, quoting, and buying OpenPay x402 resources priced
3
+ Node.js 22+ SDK for discovering, quoting, and buying OpenPay x402 resources priced
4
4
  in JPYC. It ships as plain ESM and has no build step.
5
5
 
6
6
  Wire compatibility: x402 v1 transport (JSON 402 body with `x402Version: 1`, plus the
@@ -525,8 +525,7 @@ issuance does not revoke an attacker's signing ability or recall downloaded byte
525
525
 
526
526
  | Runtime | Delivery subpath requirement / acceptance |
527
527
  | --- | --- |
528
- | Node 20.19+ | Global WebCrypto with standard Ed25519; package engine remains Node >=20. |
529
- | Node 22.13+ | Same Web API entry point. |
528
+ | Node 22.13+ | Global WebCrypto with standard Ed25519; the package engine is Node >=22. |
530
529
  | Node 24 | Same Web API entry point. |
531
530
  | Cloudflare Workers | Standard `Ed25519`, no `nodejs_compat`. Verified end-to-end on a real Worker + private R2 deployment with SDK 0.8.1 (2026-09-10); re-run the smoke when you change the compatibility date. |
532
531
 
@@ -611,7 +610,12 @@ splits, amount inconsistencies,
611
610
  resource URL mismatches, and catalog bait-and-switches before requesting a
612
611
  signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
613
612
  environment config readers. Target host/catalog admission and private-address
614
- checks run before buyer target requests. Those requests require HTTPS, do not
613
+ checks run before buyer target requests. The private-address check uses the
614
+ same IANA special-purpose table as the OpenPay server: loopback, private,
615
+ CGNAT, link-local, documentation, benchmarking, multicast and reserved IPv4;
616
+ IPv4-mapped/translated, NAT64, 6to4, Teredo, ULA, site-local and multicast
617
+ IPv6; plus `localhost`, `.localhost`, `.local` and `.internal` names. Those
618
+ requests require HTTPS, do not
615
619
  follow redirects, and have a 15-second timeout. The default Node transport also
616
620
  validates DNS before and during connection to block rebinding. A custom
617
621
  `fetchImpl` still gets the pre-connection resolution check — a hostname that
package/index.d.ts CHANGED
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -24,10 +24,11 @@
24
24
  "examples"
25
25
  ],
26
26
  "engines": {
27
- "node": ">=20"
27
+ "node": ">=22"
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"
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) {