openpay-x402-sdk 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -2
- package/README.md +31 -5
- package/index.d.ts +9 -1
- package/package.json +1 -1
- package/src/authorizationClaims.mjs +143 -0
- package/src/dualGate.mjs +40 -2
- package/src/gate.mjs +7 -122
- package/src/spendStore.mjs +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.2 — unreleased
|
|
4
|
+
|
|
5
|
+
- Fix `createDualGate` USDC authorization reuse before settlement. Reuse JPYC's
|
|
6
|
+
local claim manager for v1/v2 payments, keyed by pinned chain, asset, payer and
|
|
7
|
+
nonce; share the USDC ledger across gate instances and endpoints in one process
|
|
8
|
+
to reject duplicates before relay verification and upstream execution.
|
|
9
|
+
- Enforce the configured upstream plus settlement validity margin before USDC
|
|
10
|
+
verify and again before granting upstream work. Reject unclaimable payloads.
|
|
11
|
+
Release tentative claims on verify rejection/failure; retain claims after
|
|
12
|
+
verification through upstream abandonment, settlement success, rejection and
|
|
13
|
+
ambiguous settlement until authorization expiry. Margins remain specific to each
|
|
14
|
+
calling gate. JPYC behavior/wire is unchanged.
|
|
15
|
+
- Clarify README/types: USDC claims are process-local, are lost on restart, and
|
|
16
|
+
provide no coordination across processes/isolates. Holding after definitive
|
|
17
|
+
settlement rejection is deliberate; retrying upstream requires a fresh nonce
|
|
18
|
+
and signature. Claims do not reserve funds or guarantee settlement.
|
|
19
|
+
No dependencies added.
|
|
20
|
+
|
|
21
|
+
## 0.10.1 — 2026-09-23
|
|
22
|
+
|
|
23
|
+
- Create spend-store directories with mode 0700, including compatibility saves,
|
|
24
|
+
so an env-key user's spend directory can later hold an MCP keystore without
|
|
25
|
+
failing its permission checks. Existing directories are never chmod-ed; inspect
|
|
26
|
+
and manually repair legacy 0755 directories before switching to keystore mode.
|
|
27
|
+
Mode 0700 also applies to newly created intermediate directories at custom
|
|
28
|
+
spend-store paths; pre-existing shared/group directories keep their permissions.
|
|
29
|
+
- Preserve spend reservations, locks, accounting and wallet 0600/0700 checks.
|
|
30
|
+
No dependencies added.
|
|
31
|
+
|
|
3
32
|
## 0.10.0 — 2026-09-23
|
|
4
33
|
|
|
5
34
|
- Breaking: seller gates require a trusted `resourceId` and `expectedRecipient`;
|
|
@@ -16,8 +45,7 @@
|
|
|
16
45
|
`SellerPinError` and still stop both rails.
|
|
17
46
|
- Update seller types, examples and generated snippets. Existing sellers must
|
|
18
47
|
configure pins and redeploy the SDK/snippet; deploy the matching relay update
|
|
19
|
-
first. No dependencies added.
|
|
20
|
-
before adoption.
|
|
48
|
+
first. No dependencies added.
|
|
21
49
|
|
|
22
50
|
## 0.9.0 — 2026-09-21
|
|
23
51
|
|
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
|
|
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;
|
|
@@ -515,6 +515,14 @@ export interface DualGateOptions extends JpycGateOptions {
|
|
|
515
515
|
* relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
|
|
516
516
|
* or unavailable), the gate degrades to JPYC-only. Identity/recipient mismatches throw and log
|
|
517
517
|
* before any 402 or payment call; they never trigger a rail fallback.
|
|
518
|
+
* USDC authorizations are not resource-bound: all gate instances in one process share a pinned
|
|
519
|
+
* chain/asset + payer/nonce ledger, lost on restart and not shared across processes/isolates.
|
|
520
|
+
* JPYC claims remain per instance. USDC v1/v2 require the calling gate's upstream + settlement
|
|
521
|
+
* validity margin before relay verify and before granting upstream work. Tentative verify failures
|
|
522
|
+
* release the claim; after upstream is granted, it remains until validBefore even if upstream is
|
|
523
|
+
* abandoned or settlement fails/has an unknown outcome. Holding after definitive rejection is
|
|
524
|
+
* deliberate: retrying upstream requires a fresh nonce/signature. Claims do not reserve buyer
|
|
525
|
+
* funds or guarantee settlement. USDC has no JPYC reservation token.
|
|
518
526
|
*/
|
|
519
527
|
export function createDualGate(options: DualGateOptions): JpycGate;
|
|
520
528
|
|
package/package.json
CHANGED
|
@@ -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/spendStore.mjs
CHANGED
|
@@ -256,7 +256,9 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
256
256
|
if (!supportsAtomicFileUpdates(runtime.fileSystem)) {
|
|
257
257
|
throw new Error('atomic spend store file operations are unavailable');
|
|
258
258
|
}
|
|
259
|
-
|
|
259
|
+
// Spend storage shares the default wallet directory: public directory bits would block
|
|
260
|
+
// a later keystore migration. Keep new directories private without chmod-ing existing ones.
|
|
261
|
+
await runtime.fileSystem.mkdir(runtime.directory, { recursive: true, mode: 0o700 });
|
|
260
262
|
const handle = await acquireLock(runtime.fileSystem, runtime.lockPath);
|
|
261
263
|
try {
|
|
262
264
|
return await operation(runtime);
|
|
@@ -438,7 +440,8 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
438
440
|
}
|
|
439
441
|
const current = documentForDate(document, key.slice(-10));
|
|
440
442
|
current[key] = atomicString;
|
|
441
|
-
|
|
443
|
+
// Compatibility saves must not create a shared directory the keystore would reject.
|
|
444
|
+
await runtime.fileSystem.mkdir(runtime.directory, { recursive: true, mode: 0o700 });
|
|
442
445
|
await runtime.fileSystem.writeFile(
|
|
443
446
|
runtime.targetPath,
|
|
444
447
|
`${JSON.stringify(current, null, 2)}\n`,
|