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 +26 -1
- package/README.md +8 -4
- package/index.d.ts +2 -1
- package/package.json +4 -3
- package/src/license.mjs +4 -3
- package/src/network.mjs +74 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.10.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": ">=
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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 (
|
|
63
|
-
|
|
64
|
-
|
|
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[
|
|
76
|
-
);
|
|
126
|
+
return isPrivateIpv4([
|
|
127
|
+
(groups[6] >> 8) & 0xff, groups[6] & 0xff,
|
|
128
|
+
(groups[7] >> 8) & 0xff, groups[7] & 0xff,
|
|
129
|
+
]);
|
|
77
130
|
}
|
|
78
|
-
|
|
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
|
|
140
|
+
return isPrivateIpv4(octets);
|
|
90
141
|
}
|
|
91
142
|
|
|
92
143
|
export function parseSafePaymentUrl(raw) {
|