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 +38 -3
- package/README.md +37 -6
- package/index.d.ts +11 -2
- package/package.json +3 -2
- package/src/authorizationClaims.mjs +143 -0
- package/src/dualGate.mjs +40 -2
- package/src/gate.mjs +7 -122
- package/src/license.mjs +4 -3
- package/src/network.mjs +74 -23
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.
|
|
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.
|
|
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.
|
|
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()`
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
104
|
-
settlementGraceSeconds
|
|
34
|
+
maxUpstreamSeconds,
|
|
35
|
+
settlementGraceSeconds,
|
|
105
36
|
} = {}) {
|
|
106
37
|
assertSellerPins(resourceId, expectedRecipient);
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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) {
|