openpay-x402-sdk 0.5.0 → 0.6.0
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 +48 -0
- package/README.md +70 -6
- package/index.d.ts +125 -0
- package/package.json +1 -1
- package/src/dualGate.mjs +196 -0
- package/src/executor.mjs +38 -20
- package/src/guards.mjs +14 -3
- package/src/index.mjs +2 -0
- package/src/listing.mjs +173 -0
- package/src/network.mjs +19 -0
- package/src/spendStore.mjs +166 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Add `createDualGate` — a dual-rail seller gate that serves both JPYC (Polygon,
|
|
6
|
+
OpenPay facilitator) and USDC (Base, standard x402 relayed via OpenPay to the
|
|
7
|
+
CDP facilitator). USDC payments settle directly to the seller wallet with 0%
|
|
8
|
+
OpenPay fee; if the USDC face cannot be fetched, the gate degrades to
|
|
9
|
+
JPYC-only and never blocks JPYC payments.
|
|
10
|
+
- Add `createListingClient` — programmatic marketplace listing (register, list,
|
|
11
|
+
update, deactivate) with built-in SIWE sign-in, so sellers and agents can
|
|
12
|
+
publish listings without the web form. `register` requires an explicit
|
|
13
|
+
`attested: true` (the SDK never attests on your behalf); set `usdc` to also
|
|
14
|
+
appear on the x402 Bazaar after the first settled purchase.
|
|
15
|
+
- Return an explicit `settlement` field from `pay()`, because HTTP `200` only
|
|
16
|
+
means the seller returned a body and is not evidence that the payment settled.
|
|
17
|
+
`verified` means a receipt header was present and the facilitator signature
|
|
18
|
+
bound it to this payment, `unverified` means a header was present but
|
|
19
|
+
unsigned, malformed, forged, or mismatched, and `receipt_unavailable` means no
|
|
20
|
+
header was returned or the facilitator signer could not be resolved. Treat
|
|
21
|
+
anything but `verified` as not proven paid. `receipt` keeps its previous
|
|
22
|
+
meaning and an unlocked response body is still never discarded.
|
|
23
|
+
- Take over a spend lock left behind by a killed process instead of failing
|
|
24
|
+
budgeted payments forever. A lock whose last modification is older than
|
|
25
|
+
`SPEND_LOCK_STALE_MS` (60s, now exported) is moved aside with an atomic
|
|
26
|
+
`rename` — never `unlink` — and the mover re-inspects the moved file to prove
|
|
27
|
+
it took the very lock it measured, so two processes that observe the same
|
|
28
|
+
stale lock cannot both enter the critical section. A lock younger than the
|
|
29
|
+
window is left alone, so a live holder is never displaced.
|
|
30
|
+
- Record the owning `pid` and `createdAt` in the lock file, name the lock path in
|
|
31
|
+
a new `detail` on `{ ok: false, reason: 'unavailable' }`, and treat an
|
|
32
|
+
already-absent lock at release time as a completed release. A custom `fsImpl`
|
|
33
|
+
without `stat`/`rename` keeps the previous fail-closed behavior and now warns
|
|
34
|
+
once instead of disabling the takeover silently.
|
|
35
|
+
- Resolve the target hostname before calling an injected custom `fetchImpl`, so a
|
|
36
|
+
public name pointing at a private or link-local address is rejected before the
|
|
37
|
+
custom transport runs. Connection-time rebinding protection still requires
|
|
38
|
+
supplying `lookup`; a resolver failure does not block, since only the transport
|
|
39
|
+
that opens the socket can re-validate the address it connects to.
|
|
40
|
+
- Require `DISCOVERY_URL` to be `https`, with plaintext `http` allowed only for
|
|
41
|
+
`localhost` / `127.0.0.1`. The discovery origin is the authority for catalog
|
|
42
|
+
trust — URLs it lists are payable without an `ALLOWED_HOSTS` entry — so a
|
|
43
|
+
substitutable plaintext catalog could pass an attacker's resource off as
|
|
44
|
+
reviewed. Both `readRuntimeConfig` and `parseClientOptions` enforce it.
|
|
45
|
+
- Declare the new surface in `index.d.ts` (`SETTLEMENT`, `SettlementStatus`,
|
|
46
|
+
`PaymentResult.settlement`, `SPEND_LOCK_STALE_MS`, `SpendReservationResult.detail`,
|
|
47
|
+
`fsImpl.stat`, `createDualGate`, `createListingClient` and their inputs) and
|
|
48
|
+
document dual-rail selling, code-side listing, settlement truth, stale-lock
|
|
49
|
+
takeover, and the custom-transport boundary in the README.
|
|
50
|
+
|
|
3
51
|
## 0.5.0
|
|
4
52
|
|
|
5
53
|
- Reserve session and daily capacity immediately before exposing a signed
|
package/README.md
CHANGED
|
@@ -121,6 +121,54 @@ ledger.
|
|
|
121
121
|
The copy-paste paywall snippet generated by OpenPay provides the same one-shot
|
|
122
122
|
gate; `createJpycGate` is its importable SDK counterpart with split settlement.
|
|
123
123
|
|
|
124
|
+
### Dual-rail: also sell in USDC (Base) and appear on the x402 Bazaar
|
|
125
|
+
|
|
126
|
+
If your listing has the USDC face enabled, use `createDualGate` with the listing
|
|
127
|
+
id (shown as `MY_RESOURCE_ID` in the generated snippet). The 402 then carries
|
|
128
|
+
both JPYC and USDC `accepts` plus a `PAYMENT-REQUIRED` header; USDC payments are
|
|
129
|
+
relayed by OpenPay to the CDP facilitator and settle directly to your Base
|
|
130
|
+
address with 0% OpenPay fee. If the USDC face cannot be fetched (relay off or
|
|
131
|
+
unavailable), the gate degrades to JPYC-only — USDC never blocks JPYC payments.
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
import { createDualGate } from 'openpay-x402-sdk';
|
|
135
|
+
|
|
136
|
+
const gate = createDualGate({
|
|
137
|
+
resourceUrl: process.env.MY_RESOURCE_URL,
|
|
138
|
+
resourceId: process.env.MY_RESOURCE_ID,
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`handle()` / `verify()` work exactly like `createJpycGate`, on both rails.
|
|
143
|
+
|
|
144
|
+
### Register listings without the web form
|
|
145
|
+
|
|
146
|
+
`createListingClient` signs in with SIWE and manages your marketplace listings
|
|
147
|
+
programmatically — so a seller (or an agent) can go from nothing to a dual-rail
|
|
148
|
+
listing entirely in code:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
import { createListingClient } from 'openpay-x402-sdk';
|
|
152
|
+
|
|
153
|
+
const listings = createListingClient({ privateKey: process.env.SELLER_PRIVATE_KEY });
|
|
154
|
+
const { resource, paywallSnippet } = await listings.register({
|
|
155
|
+
url: 'https://api.example.com/paid/report',
|
|
156
|
+
description: 'What the purchase completes, in one paragraph.',
|
|
157
|
+
priceJpyc: '100',
|
|
158
|
+
category: 'api',
|
|
159
|
+
usdc: { priceUsd: '0.01', serviceName: 'Example Report API' }, // optional USDC face
|
|
160
|
+
attested: true, // your personal attestation — the SDK never sets this for you
|
|
161
|
+
});
|
|
162
|
+
// resource.id → pass to createDualGate; paywallSnippet → or paste the snippet instead
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`register` refuses to run without an explicit `attested: true`: you must
|
|
166
|
+
personally affirm that you have the right to provide and charge for the
|
|
167
|
+
resource and that it is payment-gated (HTTP 402). `list()`, `update(id, input)`,
|
|
168
|
+
and `deactivate(id)` complete the lifecycle; `update` without `usdc` removes the
|
|
169
|
+
USDC face, so pass the previous value to keep it. The private key signs locally
|
|
170
|
+
and is never transmitted.
|
|
171
|
+
|
|
124
172
|
## Money guards
|
|
125
173
|
|
|
126
174
|
| Option | Default | Guard |
|
|
@@ -159,9 +207,12 @@ atomic `reserve(key, amount, limit, reservation)` method; legacy
|
|
|
159
207
|
config readers.
|
|
160
208
|
|
|
161
209
|
An abrupt process or machine stop can leave `spend.json.lock`; reservations then
|
|
162
|
-
fail closed instead of paying without a limit.
|
|
163
|
-
|
|
164
|
-
|
|
210
|
+
fail closed instead of paying without a limit. The lock file records the owning
|
|
211
|
+
`pid` and `createdAt`, and a lock whose last modification is older than 60
|
|
212
|
+
seconds is taken over automatically on the next reservation, so a killed process
|
|
213
|
+
no longer blocks budgeted payments forever. A lock younger than that is left
|
|
214
|
+
alone and the rejection names the lock path in its `detail`. Stop every process
|
|
215
|
+
that uses the same store before removing a lock by hand.
|
|
165
216
|
|
|
166
217
|
The client also rejects non-JPYC metadata, unsupported networks or schemes,
|
|
167
218
|
non-canonical JPYC contracts or EIP-712 domains, seller timeouts above
|
|
@@ -172,9 +223,14 @@ signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
|
|
|
172
223
|
environment config readers. Target host/catalog admission and private-address
|
|
173
224
|
checks run before buyer target requests. Those requests require HTTPS, do not
|
|
174
225
|
follow redirects, and have a 15-second timeout. The default Node transport also
|
|
175
|
-
validates DNS before and during connection to block rebinding
|
|
176
|
-
|
|
177
|
-
|
|
226
|
+
validates DNS before and during connection to block rebinding. A custom
|
|
227
|
+
`fetchImpl` still gets the pre-connection resolution check — a hostname that
|
|
228
|
+
resolves to a private or link-local address is rejected before the injected
|
|
229
|
+
transport is called — but connection-time rebinding protection requires also
|
|
230
|
+
supplying `lookup`, because only the transport that opens the socket can
|
|
231
|
+
re-validate the address it actually connects to. A custom `fetchImpl` without
|
|
232
|
+
`lookup` therefore remains a trusted transport boundary for connect-time
|
|
233
|
+
enforcement.
|
|
178
234
|
|
|
179
235
|
`pay()` returns a non-null `receipt` only when the facilitator signer advertised
|
|
180
236
|
by `/api/facilitator/supported` signed it and the transaction, payer, network,
|
|
@@ -182,6 +238,14 @@ asset, merchant amount, fee, chain, and authorization nonce all match this
|
|
|
182
238
|
payment. A missing, malformed, forged, or mismatched seller response header
|
|
183
239
|
becomes `receipt: null` without discarding the unlocked response body.
|
|
184
240
|
|
|
241
|
+
`pay()` also returns an explicit `settlement` field, because an HTTP `200` only
|
|
242
|
+
means the seller returned a body and is not evidence that the payment settled.
|
|
243
|
+
`verified` means a receipt header was present and its facilitator signature was
|
|
244
|
+
bound to this payment; `unverified` means a header was present but unsigned,
|
|
245
|
+
malformed, forged, or mismatched; `receipt_unavailable` means no header was
|
|
246
|
+
returned or the facilitator signer could not be resolved. Treat `unverified` and
|
|
247
|
+
`receipt_unavailable` as not proven paid.
|
|
248
|
+
|
|
185
249
|
## Signers
|
|
186
250
|
|
|
187
251
|
Choose exactly one of `privateKey`, `steward`, or `signer`. Supplying more than
|
package/index.d.ts
CHANGED
|
@@ -75,6 +75,11 @@ export type SpendReservationResult =
|
|
|
75
75
|
ok: false;
|
|
76
76
|
reason: 'limit_exceeded' | 'unavailable';
|
|
77
77
|
totalAtomic?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Operator-facing hint for `unavailable`, present only when the block came from the
|
|
80
|
+
* lock file (it names the lock path). Never parse it; it is diagnostic text.
|
|
81
|
+
*/
|
|
82
|
+
detail?: string;
|
|
78
83
|
};
|
|
79
84
|
|
|
80
85
|
export type PaymentLookup = (
|
|
@@ -97,9 +102,21 @@ export interface FileSpendStoreOptions {
|
|
|
97
102
|
): Promise<{ close(): Promise<unknown> }>;
|
|
98
103
|
rename?(from: string, to: string): Promise<unknown>;
|
|
99
104
|
unlink?(path: string): Promise<unknown>;
|
|
105
|
+
/**
|
|
106
|
+
* Required (together with `rename`) for taking over a lock left behind by a killed
|
|
107
|
+
* process. When either is missing the takeover is disabled and the SDK warns once —
|
|
108
|
+
* a stale lock then blocks budgeted payments until it is removed by hand.
|
|
109
|
+
*/
|
|
110
|
+
stat?(path: string): Promise<{ mtimeMs?: number; mtime?: Date | number }>;
|
|
100
111
|
};
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A spend lock older than this cannot belong to a live holder, so it may be taken over.
|
|
116
|
+
* Exported so operators and tests can reason about the same window as the store.
|
|
117
|
+
*/
|
|
118
|
+
export const SPEND_LOCK_STALE_MS: number;
|
|
119
|
+
|
|
103
120
|
interface ClientCommonOptions {
|
|
104
121
|
maxPerCallJpyc?: JpycAmount;
|
|
105
122
|
maxSessionJpyc?: JpycAmount;
|
|
@@ -243,10 +260,30 @@ export interface InvalidChallengeQuote {
|
|
|
243
260
|
|
|
244
261
|
export type QuoteResult = GuardedQuote | InvalidChallengeQuote;
|
|
245
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Settlement truth for one paid call. `status: 200` alone is not evidence of settlement.
|
|
265
|
+
* - `verified`: a receipt header was present and the facilitator signature bound it to this payment.
|
|
266
|
+
* - `unverified`: a receipt header was present but unsigned, malformed, forged, or mismatched.
|
|
267
|
+
* - `receipt_unavailable`: no receipt header, or the facilitator signer could not be resolved.
|
|
268
|
+
*/
|
|
269
|
+
export type SettlementStatus = 'verified' | 'unverified' | 'receipt_unavailable';
|
|
270
|
+
|
|
271
|
+
/** The `SettlementStatus` values as a runtime object (`unavailable` = `receipt_unavailable`). */
|
|
272
|
+
export const SETTLEMENT: {
|
|
273
|
+
readonly verified: 'verified';
|
|
274
|
+
readonly unverified: 'unverified';
|
|
275
|
+
readonly unavailable: 'receipt_unavailable';
|
|
276
|
+
};
|
|
277
|
+
|
|
246
278
|
export interface PaymentResult {
|
|
247
279
|
status: number;
|
|
248
280
|
body: unknown;
|
|
249
281
|
receipt: unknown;
|
|
282
|
+
/**
|
|
283
|
+
* Optional so objects built before this field existed stay source-compatible. Absent must be
|
|
284
|
+
* read the same way as `receipt_unavailable`: no settlement was proven.
|
|
285
|
+
*/
|
|
286
|
+
settlement?: SettlementStatus;
|
|
250
287
|
}
|
|
251
288
|
|
|
252
289
|
export interface OpenPayClient {
|
|
@@ -296,6 +333,94 @@ export interface JpycGate {
|
|
|
296
333
|
|
|
297
334
|
export function createJpycGate(options: JpycGateOptions): JpycGate;
|
|
298
335
|
|
|
336
|
+
export interface DualGateOptions extends JpycGateOptions {
|
|
337
|
+
/** OpenPay listing id (MY_RESOURCE_ID in the generated snippet). Enables the USDC (Base) rail. */
|
|
338
|
+
resourceId: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Dual-rail seller gate: JPYC (Polygon, OpenPay facilitator) plus USDC (Base, standard x402
|
|
343
|
+
* relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
|
|
344
|
+
* or unavailable), the gate degrades to JPYC-only — the USDC side never blocks JPYC payments.
|
|
345
|
+
*/
|
|
346
|
+
export function createDualGate(options: DualGateOptions): JpycGate;
|
|
347
|
+
|
|
348
|
+
export interface ListingUsdcInput {
|
|
349
|
+
/** USD price without a $ sign, up to 6 decimals (e.g. "0.005"). */
|
|
350
|
+
priceUsd: string;
|
|
351
|
+
/** USDC (Base) receiving address. Defaults to the JPYC payTo. */
|
|
352
|
+
payTo?: string;
|
|
353
|
+
/** Display name for x402 Bazaar search (max 60 chars). */
|
|
354
|
+
serviceName?: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface ListingInput {
|
|
358
|
+
url: string;
|
|
359
|
+
description: string;
|
|
360
|
+
/** Integer JPYC price as a string (e.g. "100"). */
|
|
361
|
+
priceJpyc: string;
|
|
362
|
+
category: string;
|
|
363
|
+
docsUrl?: string;
|
|
364
|
+
license?: string;
|
|
365
|
+
/** JPYC receiving address. Defaults to the signed-in wallet. */
|
|
366
|
+
payTo?: string;
|
|
367
|
+
/** Enable the USDC (Base) face — also lists on the x402 Bazaar after the first settle. */
|
|
368
|
+
usdc?: ListingUsdcInput;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface RegisterListingInput extends ListingInput {
|
|
372
|
+
/**
|
|
373
|
+
* Required, must be literally true: your personal attestation that you have the right to
|
|
374
|
+
* provide and charge for this resource and that it is payment-gated (HTTP 402).
|
|
375
|
+
* The SDK never sets this for you.
|
|
376
|
+
*/
|
|
377
|
+
attested: true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface ListingRecord {
|
|
381
|
+
id: string;
|
|
382
|
+
url: string;
|
|
383
|
+
description: string;
|
|
384
|
+
priceJpyc: string;
|
|
385
|
+
category: string;
|
|
386
|
+
payTo: string;
|
|
387
|
+
docsUrl?: string;
|
|
388
|
+
license?: string;
|
|
389
|
+
usdc?: { payTo: string; priceUsd: string; serviceName?: string };
|
|
390
|
+
paywallSnippet?: string;
|
|
391
|
+
hidden?: boolean;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface RegisterListingResult {
|
|
395
|
+
resource: ListingRecord;
|
|
396
|
+
/** Copy-paste 402 gate for your server (dual-rail x402Gate when usdc is set). */
|
|
397
|
+
paywallSnippet: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface ListingClient {
|
|
401
|
+
/** The seller wallet address that signs in via SIWE (checksummed). */
|
|
402
|
+
address: Address;
|
|
403
|
+
register(input: RegisterListingInput): Promise<RegisterListingResult>;
|
|
404
|
+
list(): Promise<ListingRecord[]>;
|
|
405
|
+
update(id: string, input: ListingInput): Promise<{ resource: ListingRecord }>;
|
|
406
|
+
deactivate(id: string): Promise<boolean>;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Programmatic listing client — register, list, update, and deactivate OpenPay marketplace
|
|
411
|
+
* listings without the web form. Signs in with SIWE using the given private key on first use;
|
|
412
|
+
* the key is only used to sign locally and is never transmitted.
|
|
413
|
+
*/
|
|
414
|
+
export function createListingClient(options: {
|
|
415
|
+
privateKey: string;
|
|
416
|
+
openpayOrigin?: string;
|
|
417
|
+
fetchImpl?: typeof globalThis.fetch;
|
|
418
|
+
/** SIWE chainId (default 137 = Polygon). */
|
|
419
|
+
chainId?: number;
|
|
420
|
+
statement?: string;
|
|
421
|
+
now?: () => number;
|
|
422
|
+
}): ListingClient;
|
|
423
|
+
|
|
299
424
|
export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
|
|
300
425
|
ReceiveWithAuthorization: Array<{ name: string; type: string }>;
|
|
301
426
|
};
|
package/package.json
CHANGED
package/src/dualGate.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// dual-rail 出品者ゲート: createJpycGate (JPYC・Polygon) に USDC (Base・標準 x402) の面を重ねる。
|
|
2
|
+
//
|
|
3
|
+
// USDC 面は OpenPay のリレー (/api/x402/relay/*) から取得・中継する:
|
|
4
|
+
// - requirements: 402 に並記する完成形 (v1Accepts + PAYMENT-REQUIRED ヘッダ) を配布
|
|
5
|
+
// - verify/settle: CDP facilitator への中継 (支払いは購入者 → 出品者 payTo へ直接)
|
|
6
|
+
//
|
|
7
|
+
// 隔離 (最重要): USDC 面の取得失敗 (リレー未点灯・障害) は null に落とし、JPYC ゲートだけで
|
|
8
|
+
// 継続する — 付帯面 (USDC) の障害が決済本体 (JPYC) を止めない。
|
|
9
|
+
//
|
|
10
|
+
// レール振り分け: PAYMENT-SIGNATURE ヘッダ (v2 = USDC クライアント)、または x-payment (v1) の
|
|
11
|
+
// network が USDC 面と一致するときだけ USDC レール。その他は従来の JPYC ゲートへ委譲する。
|
|
12
|
+
// JPYC レールの 402 には USDC accepts を追記 (decorate) して両面を常に見せる。
|
|
13
|
+
|
|
14
|
+
import { createJpycGate } from './gate.mjs';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
17
|
+
const USDC_FACE_CACHE_MS = 5 * 60_000;
|
|
18
|
+
|
|
19
|
+
function decodeBase64Json(value) {
|
|
20
|
+
const binary = atob(value);
|
|
21
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
22
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function encodeBase64Json(value) {
|
|
26
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
27
|
+
let binary = '';
|
|
28
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
29
|
+
return btoa(binary);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createDualGate({
|
|
33
|
+
resourceUrl,
|
|
34
|
+
resourceId,
|
|
35
|
+
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
36
|
+
fetchImpl = globalThis.fetch,
|
|
37
|
+
now = Date.now,
|
|
38
|
+
maxUpstreamSeconds,
|
|
39
|
+
settlementGraceSeconds,
|
|
40
|
+
}) {
|
|
41
|
+
if (typeof resourceId !== 'string' || resourceId.length === 0) {
|
|
42
|
+
throw new Error('resourceId is required (shown as MY_RESOURCE_ID in your OpenPay listing)');
|
|
43
|
+
}
|
|
44
|
+
const jpyc = createJpycGate({
|
|
45
|
+
resourceUrl,
|
|
46
|
+
openpayOrigin,
|
|
47
|
+
fetchImpl,
|
|
48
|
+
now,
|
|
49
|
+
...(maxUpstreamSeconds === undefined ? {} : { maxUpstreamSeconds }),
|
|
50
|
+
...(settlementGraceSeconds === undefined ? {} : { settlementGraceSeconds }),
|
|
51
|
+
});
|
|
52
|
+
const origin = openpayOrigin.replace(/\/+$/, '');
|
|
53
|
+
|
|
54
|
+
let usdcCache = null;
|
|
55
|
+
let usdcCachedAt = 0;
|
|
56
|
+
async function usdcFace() {
|
|
57
|
+
if (usdcCache !== null && now() - usdcCachedAt < USDC_FACE_CACHE_MS) {
|
|
58
|
+
return usdcCache;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetchImpl(
|
|
62
|
+
`${origin}/api/x402/relay/requirements?resourceId=${encodeURIComponent(resourceId)}`,
|
|
63
|
+
);
|
|
64
|
+
if (!response.ok) return null;
|
|
65
|
+
const face = await response.json();
|
|
66
|
+
if (!face || typeof face !== 'object' || !face.v1Accepts) return null;
|
|
67
|
+
usdcCache = face;
|
|
68
|
+
usdcCachedAt = now();
|
|
69
|
+
return face;
|
|
70
|
+
} catch {
|
|
71
|
+
// リレー未点灯/障害 → USDC 面なしで継続 (JPYC 本体を止めない)。キャッシュしない
|
|
72
|
+
// (復旧したら次のリクエストで拾う)。
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// JPYC ゲートが返した 402 に USDC 面 (accepts + PAYMENT-REQUIRED ヘッダ) を追記する。
|
|
78
|
+
// 402 以外・USDC 面なし・body が読めない場合はそのまま返す (壊さない)。
|
|
79
|
+
async function decorate402(response, usdc) {
|
|
80
|
+
if (!usdc || !(response instanceof Response) || response.status !== 402) {
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
let body;
|
|
84
|
+
try {
|
|
85
|
+
body = await response.clone().json();
|
|
86
|
+
} catch {
|
|
87
|
+
return response;
|
|
88
|
+
}
|
|
89
|
+
if (!body || !Array.isArray(body.accepts)) return response;
|
|
90
|
+
const headers = new Headers(response.headers);
|
|
91
|
+
headers.set('content-type', 'application/json');
|
|
92
|
+
if (typeof usdc.paymentRequiredHeader === 'string') {
|
|
93
|
+
headers.set('PAYMENT-REQUIRED', usdc.paymentRequiredHeader);
|
|
94
|
+
}
|
|
95
|
+
return new Response(
|
|
96
|
+
JSON.stringify({ ...body, accepts: [...body.accepts, usdc.v1Accepts] }),
|
|
97
|
+
{ status: 402, headers },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// USDC レールの 402。JPYC accepts の取得失敗は握って USDC 面だけで返す
|
|
102
|
+
// (challenge の失敗で支払いエラーの伝達自体を落とさない)。
|
|
103
|
+
async function usdcChallenge(usdc, error) {
|
|
104
|
+
let jpycAccepts = [];
|
|
105
|
+
try {
|
|
106
|
+
const headerless = { url: resourceUrl, headers: { get: () => null } };
|
|
107
|
+
const challenge = await jpyc.verify(headerless);
|
|
108
|
+
if (challenge instanceof Response && challenge.status === 402) {
|
|
109
|
+
const body = await challenge.json();
|
|
110
|
+
if (Array.isArray(body.accepts)) jpycAccepts = body.accepts;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* JPYC カタログ未掲載などは USDC のみで継続 */
|
|
114
|
+
}
|
|
115
|
+
const headers = { 'content-type': 'application/json' };
|
|
116
|
+
if (typeof usdc.paymentRequiredHeader === 'string') {
|
|
117
|
+
headers['PAYMENT-REQUIRED'] = usdc.paymentRequiredHeader;
|
|
118
|
+
}
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
x402Version: 1,
|
|
122
|
+
accepts: [...jpycAccepts, usdc.v1Accepts],
|
|
123
|
+
error,
|
|
124
|
+
}),
|
|
125
|
+
{ status: 402, headers },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function verify(request) {
|
|
130
|
+
const usdc = await usdcFace();
|
|
131
|
+
const signatureHeader = request.headers.get('payment-signature');
|
|
132
|
+
const v1Header = request.headers.get('x-payment');
|
|
133
|
+
let v1Network = null;
|
|
134
|
+
if (v1Header) {
|
|
135
|
+
try {
|
|
136
|
+
const decoded = decodeBase64Json(v1Header);
|
|
137
|
+
if (decoded && typeof decoded === 'object') v1Network = decoded.network;
|
|
138
|
+
} catch {
|
|
139
|
+
/* 不正 header は下のレール判定で JPYC 側に流し、そこで 402 になる */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const usdcRail =
|
|
144
|
+
usdc !== null &&
|
|
145
|
+
(Boolean(signatureHeader) ||
|
|
146
|
+
(typeof v1Network === 'string' && v1Network === usdc.v1Accepts.network));
|
|
147
|
+
|
|
148
|
+
if (usdcRail) {
|
|
149
|
+
const relay = async (path) => {
|
|
150
|
+
const response = await fetchImpl(`${origin}/api/x402/relay/${path}`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'content-type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
resourceId,
|
|
155
|
+
...(signatureHeader
|
|
156
|
+
? { paymentSignatureHeader: signatureHeader }
|
|
157
|
+
: { paymentHeader: v1Header }),
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
return response.json();
|
|
161
|
+
};
|
|
162
|
+
const verification = await relay('verify');
|
|
163
|
+
if (verification.isValid !== true) {
|
|
164
|
+
return usdcChallenge(usdc, verification.invalidReason || 'payment_invalid');
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
async settle() {
|
|
168
|
+
const settlement = await relay('settle');
|
|
169
|
+
if (settlement.success !== true) {
|
|
170
|
+
return usdcChallenge(usdc, settlement.errorReason || 'settlement_failed');
|
|
171
|
+
}
|
|
172
|
+
return { paymentResponseHeader: encodeBase64Json(settlement) };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const result = await jpyc.verify(request);
|
|
178
|
+
if (result instanceof Response) return decorate402(result, usdc);
|
|
179
|
+
return {
|
|
180
|
+
async settle() {
|
|
181
|
+
const settlement = await result.settle();
|
|
182
|
+
return settlement instanceof Response
|
|
183
|
+
? decorate402(settlement, usdc)
|
|
184
|
+
: settlement;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handle(request) {
|
|
190
|
+
const verification = await verify(request);
|
|
191
|
+
if (verification instanceof Response) return verification;
|
|
192
|
+
return verification.settle();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { handle, verify };
|
|
196
|
+
}
|
package/src/executor.mjs
CHANGED
|
@@ -127,36 +127,53 @@ function utcDate(value) {
|
|
|
127
127
|
return instant.toISOString().slice(0, 10);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Settlement truth for one paid call. HTTP 200 only means the seller returned a body; it is not
|
|
131
|
+
// evidence that the facilitator settled on chain, and an LLM reading `status: 200` will assume it is.
|
|
132
|
+
// 'verified' — a receipt header was present and the facilitator signature bound it to this payment
|
|
133
|
+
// 'unverified' — a receipt header was present but unsigned, malformed, forged, or mismatched
|
|
134
|
+
// 'receipt_unavailable' — no receipt header, or the facilitator signer could not be resolved
|
|
135
|
+
export const SETTLEMENT = Object.freeze({
|
|
136
|
+
verified: 'verified',
|
|
137
|
+
unverified: 'unverified',
|
|
138
|
+
unavailable: 'receipt_unavailable',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function settlementOutcome(
|
|
131
142
|
raw,
|
|
132
143
|
reservation,
|
|
133
144
|
normalizedAccept,
|
|
134
145
|
resolveReceiptSigner,
|
|
135
146
|
) {
|
|
147
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
148
|
+
return { receipt: null, settlement: SETTLEMENT.unavailable };
|
|
149
|
+
}
|
|
136
150
|
try {
|
|
137
151
|
const decoded = decodePaymentResponse(raw);
|
|
138
|
-
|
|
152
|
+
// A header that cannot be decoded is a seller claim we could not check, not an absent claim.
|
|
153
|
+
if (decoded === null) {
|
|
154
|
+
return { receipt: null, settlement: SETTLEMENT.unverified };
|
|
155
|
+
}
|
|
139
156
|
const expectedSigner = await resolveReceiptSigner();
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
!(await verifyBoundPaymentResponse(decoded, {
|
|
143
|
-
expectedSigner,
|
|
144
|
-
payer: reservation.payer,
|
|
145
|
-
network: reservation.network,
|
|
146
|
-
asset: reservation.asset,
|
|
147
|
-
chainId: normalizedAccept.chainId,
|
|
148
|
-
merchant: normalizedAccept.extra.openpay.merchant,
|
|
149
|
-
merchantValue: normalizedAccept.extra.openpay.merchantValue,
|
|
150
|
-
feeValue: normalizedAccept.extra.openpay.feeValue,
|
|
151
|
-
nonce: reservation.id,
|
|
152
|
-
}))
|
|
153
|
-
) {
|
|
154
|
-
return null;
|
|
157
|
+
if (expectedSigner === null) {
|
|
158
|
+
return { receipt: null, settlement: SETTLEMENT.unavailable };
|
|
155
159
|
}
|
|
156
|
-
|
|
160
|
+
const bound = await verifyBoundPaymentResponse(decoded, {
|
|
161
|
+
expectedSigner,
|
|
162
|
+
payer: reservation.payer,
|
|
163
|
+
network: reservation.network,
|
|
164
|
+
asset: reservation.asset,
|
|
165
|
+
chainId: normalizedAccept.chainId,
|
|
166
|
+
merchant: normalizedAccept.extra.openpay.merchant,
|
|
167
|
+
merchantValue: normalizedAccept.extra.openpay.merchantValue,
|
|
168
|
+
feeValue: normalizedAccept.extra.openpay.feeValue,
|
|
169
|
+
nonce: reservation.id,
|
|
170
|
+
});
|
|
171
|
+
return bound
|
|
172
|
+
? { receipt: decoded, settlement: SETTLEMENT.verified }
|
|
173
|
+
: { receipt: null, settlement: SETTLEMENT.unverified };
|
|
157
174
|
} catch {
|
|
158
175
|
// A seller-controlled receipt header must not erase an unlocked body and invite a duplicate payment.
|
|
159
|
-
return null;
|
|
176
|
+
return { receipt: null, settlement: SETTLEMENT.unverified };
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -533,7 +550,7 @@ export function createPaymentExecutor({
|
|
|
533
550
|
await confirmDailyReservation(reservation.id);
|
|
534
551
|
}
|
|
535
552
|
}
|
|
536
|
-
const receipt = await
|
|
553
|
+
const { receipt, settlement } = await settlementOutcome(
|
|
537
554
|
unlocked.headers.get('x-payment-response'),
|
|
538
555
|
reservation,
|
|
539
556
|
normalizedAccept,
|
|
@@ -543,6 +560,7 @@ export function createPaymentExecutor({
|
|
|
543
560
|
status: unlocked.status,
|
|
544
561
|
body: unlockedBody,
|
|
545
562
|
receipt,
|
|
563
|
+
settlement,
|
|
546
564
|
};
|
|
547
565
|
}
|
|
548
566
|
|
package/src/guards.mjs
CHANGED
|
@@ -167,9 +167,20 @@ function parseHttpUrl(raw, label) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
// DISCOVERY_URL は catalog trust の権威 (ここに載る URL は ALLOWED_HOSTS への手動追加なしで
|
|
171
|
+
// 支払える)。平文 http はネットワーク上で差し替え可能なので、攻撃者のカタログが「審査済み」に
|
|
172
|
+
// 化けて支払い先を乗っ取る波及を断つため https に限る。例外はローカル開発 (localhost /
|
|
173
|
+
// 127.0.0.1) のみ — 自機の facilitator/カタログを立てて配線を確かめる用途。
|
|
174
|
+
function requireDiscoveryUrl(raw, label) {
|
|
171
175
|
const url = parseHttpUrl(raw, label);
|
|
172
176
|
if (url === null) throw new Error(`${label} must be an http(s) URL`);
|
|
177
|
+
if (
|
|
178
|
+
url.protocol === 'http:' &&
|
|
179
|
+
url.hostname !== 'localhost' &&
|
|
180
|
+
url.hostname !== '127.0.0.1'
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(`${label} must use https (http is allowed only for localhost)`);
|
|
183
|
+
}
|
|
173
184
|
return url.toString();
|
|
174
185
|
}
|
|
175
186
|
|
|
@@ -206,7 +217,7 @@ export function readMoneyConfig(env = process.env) {
|
|
|
206
217
|
export function readRuntimeConfig(env = process.env) {
|
|
207
218
|
return {
|
|
208
219
|
...readMoneyConfig(env),
|
|
209
|
-
discoveryUrl:
|
|
220
|
+
discoveryUrl: requireDiscoveryUrl(
|
|
210
221
|
nonEmpty(env.DISCOVERY_URL) ?? DEFAULT_DISCOVERY_URL,
|
|
211
222
|
'DISCOVERY_URL',
|
|
212
223
|
),
|
|
@@ -264,7 +275,7 @@ export function parseClientOptions(options = {}) {
|
|
|
264
275
|
: parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
|
|
265
276
|
allowedHosts: parseAllowedHosts(options.allowedHosts),
|
|
266
277
|
catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
|
|
267
|
-
discoveryUrl:
|
|
278
|
+
discoveryUrl: requireDiscoveryUrl(
|
|
268
279
|
nonEmpty(options.discoveryUrl) ?? DEFAULT_DISCOVERY_URL,
|
|
269
280
|
'DISCOVERY_URL',
|
|
270
281
|
),
|
package/src/index.mjs
CHANGED
package/src/listing.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// コード出品クライアント: 出品フォームを開かずに、SIWE サインイン込みで AI ストアの
|
|
2
|
+
// 出品を登録・一覧・更新・無効化する (POST/GET/PATCH/DELETE /api/facilitator/resources)。
|
|
3
|
+
//
|
|
4
|
+
// 法的に重要: register には **attested: true の明示が必須** (自動付与しない)。これは
|
|
5
|
+
// 「登録するリソースを提供・課金する正当な権利を有し、支払い (HTTP 402 等) でゲートして
|
|
6
|
+
// いる」という出品者本人の表明であり、SDK が代わりに宣言してよいものではない。
|
|
7
|
+
//
|
|
8
|
+
// セッション: 初回操作時に SIWE (EIP-4361) でサインインし cookie を保持・再利用する。
|
|
9
|
+
// 鍵はメモリ内でのみ使用し、どこにも送信しない (署名のみ)。
|
|
10
|
+
|
|
11
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
12
|
+
import { createSiweMessage } from 'viem/siwe';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
15
|
+
const SIWE_MESSAGE_TTL_MS = 5 * 60_000;
|
|
16
|
+
const DEFAULT_SIWE_CHAIN_ID = 137; // Polygon (OpenPay の常時サポートチェーン)
|
|
17
|
+
const DEFAULT_STATEMENT = 'Sign in to OpenPay to manage your x402 listings.';
|
|
18
|
+
|
|
19
|
+
async function readJson(response) {
|
|
20
|
+
try {
|
|
21
|
+
return await response.json();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createListingClient({
|
|
28
|
+
privateKey,
|
|
29
|
+
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
30
|
+
fetchImpl = globalThis.fetch,
|
|
31
|
+
chainId = DEFAULT_SIWE_CHAIN_ID,
|
|
32
|
+
statement = DEFAULT_STATEMENT,
|
|
33
|
+
now = Date.now,
|
|
34
|
+
}) {
|
|
35
|
+
if (typeof privateKey !== 'string' || !privateKey.startsWith('0x')) {
|
|
36
|
+
throw new Error('privateKey (0x...) is required');
|
|
37
|
+
}
|
|
38
|
+
const account = privateKeyToAccount(privateKey);
|
|
39
|
+
const origin = openpayOrigin.replace(/\/+$/, '');
|
|
40
|
+
let cookie = null;
|
|
41
|
+
|
|
42
|
+
async function signIn() {
|
|
43
|
+
const nonceResponse = await fetchImpl(`${origin}/api/auth/siwe/nonce`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
});
|
|
46
|
+
const nonceBody = await readJson(nonceResponse);
|
|
47
|
+
if (!nonceResponse.ok || typeof nonceBody?.nonce !== 'string') {
|
|
48
|
+
throw new Error(`siwe nonce failed: HTTP ${nonceResponse.status}`);
|
|
49
|
+
}
|
|
50
|
+
const issuedAt = new Date(now());
|
|
51
|
+
const message = createSiweMessage({
|
|
52
|
+
domain: new URL(origin).host,
|
|
53
|
+
address: account.address,
|
|
54
|
+
statement,
|
|
55
|
+
uri: origin,
|
|
56
|
+
version: '1',
|
|
57
|
+
chainId,
|
|
58
|
+
nonce: nonceBody.nonce,
|
|
59
|
+
issuedAt,
|
|
60
|
+
expirationTime: new Date(issuedAt.getTime() + SIWE_MESSAGE_TTL_MS),
|
|
61
|
+
});
|
|
62
|
+
const signature = await account.signMessage({ message });
|
|
63
|
+
const verifyResponse = await fetchImpl(`${origin}/api/auth/siwe/verify`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ message, signature }),
|
|
67
|
+
});
|
|
68
|
+
if (!verifyResponse.ok) {
|
|
69
|
+
const body = await readJson(verifyResponse);
|
|
70
|
+
throw new Error(`siwe verify failed: ${body?.error ?? `HTTP ${verifyResponse.status}`}`);
|
|
71
|
+
}
|
|
72
|
+
const setCookie = verifyResponse.headers.get('set-cookie') ?? '';
|
|
73
|
+
const sessionCookie = setCookie.split(';')[0];
|
|
74
|
+
if (!sessionCookie.includes('=')) {
|
|
75
|
+
throw new Error('siwe verify returned no session cookie');
|
|
76
|
+
}
|
|
77
|
+
cookie = sessionCookie;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function authedFetch(path, init = {}, retried = false) {
|
|
81
|
+
if (cookie === null) await signIn();
|
|
82
|
+
const response = await fetchImpl(`${origin}${path}`, {
|
|
83
|
+
...init,
|
|
84
|
+
headers: { ...(init.headers ?? {}), cookie },
|
|
85
|
+
});
|
|
86
|
+
// セッション失効 (401) は一度だけサインインし直して再試行する。
|
|
87
|
+
if (response.status === 401 && !retried) {
|
|
88
|
+
cookie = null;
|
|
89
|
+
return authedFetch(path, init, true);
|
|
90
|
+
}
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildBody(input) {
|
|
95
|
+
const { url, description, priceJpyc, category, docsUrl, license, payTo, usdc } = input;
|
|
96
|
+
return {
|
|
97
|
+
url,
|
|
98
|
+
description,
|
|
99
|
+
priceJpyc,
|
|
100
|
+
category,
|
|
101
|
+
...(docsUrl ? { docsUrl } : {}),
|
|
102
|
+
...(license ? { license } : {}),
|
|
103
|
+
...(payTo ? { payTo } : {}),
|
|
104
|
+
...(usdc ? { usdc } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function expectOk(response, okStatus) {
|
|
109
|
+
const body = await readJson(response);
|
|
110
|
+
if (response.status !== okStatus) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`openpay listing API failed: HTTP ${response.status} ${body?.error ?? ''}`.trim(),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
/** SIWE でサインインする出品者アドレス (checksum)。 */
|
|
120
|
+
address: account.address,
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 出品を登録する。attested: true の明示が必須 —
|
|
124
|
+
* 「このリソースを提供・課金する正当な権利があり、402 等の支払いゲートを実装している」
|
|
125
|
+
* という出品者本人の表明で、SDK は代行しない。
|
|
126
|
+
* 戻り値: { resource, paywallSnippet } (usdc 面つきなら dual-rail スニペット)。
|
|
127
|
+
*/
|
|
128
|
+
async register(input) {
|
|
129
|
+
if (input?.attested !== true) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'attested: true is required — you must personally affirm that you have the right ' +
|
|
132
|
+
'to provide and charge for this resource and that it is payment-gated (HTTP 402).',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const response = await authedFetch('/api/facilitator/resources', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'content-type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ ...buildBody(input), attested: true }),
|
|
139
|
+
});
|
|
140
|
+
return expectOk(response, 201);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** 自分の出品一覧 (paywallSnippet つき)。 */
|
|
144
|
+
async list() {
|
|
145
|
+
const response = await authedFetch('/api/facilitator/resources');
|
|
146
|
+
const body = await expectOk(response, 200);
|
|
147
|
+
return body.resources ?? [];
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/** 出品を編集する (usdc を省略すると USDC 面は外れる — 現状維持は前回の値を渡す)。 */
|
|
151
|
+
async update(id, input) {
|
|
152
|
+
const response = await authedFetch(
|
|
153
|
+
`/api/facilitator/resources/${encodeURIComponent(id)}`,
|
|
154
|
+
{
|
|
155
|
+
method: 'PATCH',
|
|
156
|
+
headers: { 'content-type': 'application/json' },
|
|
157
|
+
body: JSON.stringify(buildBody(input)),
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
return expectOk(response, 200);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/** 出品を無効化する (公開カタログから外す・履歴は残る)。 */
|
|
164
|
+
async deactivate(id) {
|
|
165
|
+
const response = await authedFetch(
|
|
166
|
+
`/api/facilitator/resources/${encodeURIComponent(id)}`,
|
|
167
|
+
{ method: 'DELETE' },
|
|
168
|
+
);
|
|
169
|
+
await expectOk(response, 200);
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/network.mjs
CHANGED
|
@@ -240,6 +240,25 @@ export async function fetchPaymentTarget(
|
|
|
240
240
|
normalizedAddresses(
|
|
241
241
|
await lookupWithSignal(resolver, parsed.hostname, signal),
|
|
242
242
|
);
|
|
243
|
+
} else {
|
|
244
|
+
// A custom fetchImpl used to skip pre-resolution entirely, so a public hostname pointing at
|
|
245
|
+
// 169.254.169.254 or another private target reached the injected transport unchecked. Resolve
|
|
246
|
+
// with the default resolver and reject a proven-private target before the transport runs.
|
|
247
|
+
// A resolver failure is not itself proof of a private target and the custom transport resolves
|
|
248
|
+
// independently, so it does not block here; connection-time rebinding protection still
|
|
249
|
+
// requires supplying `lookup` (documented in the README).
|
|
250
|
+
let addresses = null;
|
|
251
|
+
try {
|
|
252
|
+
addresses = await lookupWithSignal(resolver, parsed.hostname, signal);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// An aborted pre-resolution is the caller's timeout, not a DNS verdict; continuing would
|
|
255
|
+
// hand the transport a dead signal.
|
|
256
|
+
if (signal.aborted) throw error;
|
|
257
|
+
addresses = null;
|
|
258
|
+
}
|
|
259
|
+
if (Array.isArray(addresses) && addresses.length > 0) {
|
|
260
|
+
normalizedAddresses(addresses);
|
|
261
|
+
}
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
const init = {
|
package/src/spendStore.mjs
CHANGED
|
@@ -3,6 +3,12 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
const RESERVATIONS_KEY = '__openpayReservations';
|
|
4
4
|
const LOCK_RETRY_COUNT = 40;
|
|
5
5
|
const LOCK_RETRY_MS = 25;
|
|
6
|
+
// A lock older than this cannot belong to a live holder: the retry loop above waits only
|
|
7
|
+
// LOCK_RETRY_COUNT * LOCK_RETRY_MS (1s) and every critical section is a small read plus an
|
|
8
|
+
// atomic replace, so a live process either finishes or dies far inside this window. Without the
|
|
9
|
+
// takeover a SIGKILL leaves spend.json.lock behind and every budgeted payment fails forever.
|
|
10
|
+
export const SPEND_LOCK_STALE_MS = 60_000;
|
|
11
|
+
const LOCK_ERROR_CODE = 'ESPENDLOCK';
|
|
6
12
|
|
|
7
13
|
function isObject(value) {
|
|
8
14
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -42,6 +48,14 @@ function reservationMatches(existing, reservation) {
|
|
|
42
48
|
);
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
// Only a lock error carries a path we raised ourselves; arbitrary errors stay opaque so a
|
|
52
|
+
// filesystem message cannot leak into a caller-visible field.
|
|
53
|
+
function unavailableDetail(error) {
|
|
54
|
+
return isObject(error) && error.code === LOCK_ERROR_CODE
|
|
55
|
+
? { detail: error.message }
|
|
56
|
+
: {};
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
function supportsAtomicFileUpdates(fileSystem) {
|
|
46
60
|
return (
|
|
47
61
|
typeof fileSystem.open === 'function' &&
|
|
@@ -50,6 +64,26 @@ function supportsAtomicFileUpdates(fileSystem) {
|
|
|
50
64
|
);
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
// stale takeover は「古さの判定 (stat)」と「奪取の原子化 (rename)」の両方を要する。
|
|
68
|
+
function supportsStaleTakeover(fileSystem) {
|
|
69
|
+
return (
|
|
70
|
+
typeof fileSystem.stat === 'function' && typeof fileSystem.rename === 'function'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let staleTakeoverWarned = false;
|
|
75
|
+
|
|
76
|
+
// 掟13: takeover が無効な fsImpl を **黙って** 素通りさせない。無効なら「SIGKILL で残った
|
|
77
|
+
// ロックが予算つき支払いを永久に止める」状態に戻るので、運用者が気づけるよう一度だけ警告する。
|
|
78
|
+
function warnStaleTakeoverUnavailable() {
|
|
79
|
+
if (staleTakeoverWarned) return;
|
|
80
|
+
staleTakeoverWarned = true;
|
|
81
|
+
console.warn(
|
|
82
|
+
'openpay-x402-sdk: fsImpl lacks stat/rename — stale spend-lock takeover is disabled; ' +
|
|
83
|
+
'a lock left behind by a killed process will block budgeted payments until it is removed.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
54
88
|
let runtimePromise;
|
|
55
89
|
|
|
@@ -87,17 +121,134 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
87
121
|
return document;
|
|
88
122
|
}
|
|
89
123
|
|
|
124
|
+
function lockUnavailable(lockPath, detail) {
|
|
125
|
+
return Object.assign(
|
|
126
|
+
new Error(
|
|
127
|
+
`spend store lock unavailable: ${lockPath}${detail === undefined ? '' : ` (${detail})`}`,
|
|
128
|
+
),
|
|
129
|
+
{ code: LOCK_ERROR_CODE, lockPath },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Record who holds the lock so an operator inspecting a stuck store can tell whether the pid
|
|
134
|
+
// is still alive. Failure to annotate must not release a lock we successfully created.
|
|
135
|
+
async function annotateLock(handle, lockPath) {
|
|
136
|
+
if (typeof handle?.writeFile !== 'function') return;
|
|
137
|
+
try {
|
|
138
|
+
await handle.writeFile(
|
|
139
|
+
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
|
|
140
|
+
'utf8',
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
// The lock is held either way; the owner note is diagnostic only.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 「そのロックファイルが何者か」を age と同一性 (inode + mtime) の両方で捉える。takeover は
|
|
148
|
+
// 「stat した stale そのものを奪えたか」を後段で照合する必要があるため、age だけでは足りない。
|
|
149
|
+
async function lockIdentity(fileSystem, lockPath) {
|
|
150
|
+
try {
|
|
151
|
+
const stats = await fileSystem.stat(lockPath);
|
|
152
|
+
const modified = Number(stats?.mtimeMs ?? stats?.mtime);
|
|
153
|
+
if (!Number.isFinite(modified)) return null;
|
|
154
|
+
return { ageMs: Date.now() - modified, modified, ino: stats?.ino };
|
|
155
|
+
} catch {
|
|
156
|
+
// A lock that vanished or cannot be inspected is not provably stale.
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// inode が取れる実 fs では inode 一致が同一ファイルの証明。inode を返さない fsImpl では
|
|
162
|
+
// mtime 一致で代替する (takeover 直後の新しいロックは必ず mtime が今なので判別できる)。
|
|
163
|
+
function isSameLockFile(observed, moved) {
|
|
164
|
+
if (observed === null || moved === null) return false;
|
|
165
|
+
if (observed.ino !== undefined && moved.ino !== undefined) {
|
|
166
|
+
return observed.ino === moved.ino && observed.modified === moved.modified;
|
|
167
|
+
}
|
|
168
|
+
return observed.modified === moved.modified;
|
|
169
|
+
}
|
|
170
|
+
|
|
90
171
|
async function acquireLock(fileSystem, lockPath) {
|
|
91
172
|
for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt += 1) {
|
|
92
173
|
try {
|
|
93
|
-
|
|
174
|
+
const handle = await fileSystem.open(lockPath, 'wx');
|
|
175
|
+
await annotateLock(handle, lockPath);
|
|
176
|
+
return handle;
|
|
94
177
|
} catch (error) {
|
|
95
178
|
if (!isLockHeld(error)) throw error;
|
|
96
|
-
if (attempt === LOCK_RETRY_COUNT - 1)
|
|
179
|
+
if (attempt === LOCK_RETRY_COUNT - 1) break;
|
|
97
180
|
await wait(LOCK_RETRY_MS);
|
|
98
181
|
}
|
|
99
182
|
}
|
|
100
|
-
|
|
183
|
+
|
|
184
|
+
// Stale takeover. A fresh lock is left alone, so a live holder is never displaced; only a lock
|
|
185
|
+
// that outlived the 1s contention window is taken over.
|
|
186
|
+
//
|
|
187
|
+
// ⚠️ The takeover MUST NOT unlink lockPath. `unlink(lockPath)` then `open(lockPath,'wx')` lets
|
|
188
|
+
// two processes that both saw the same stale lock interleave as
|
|
189
|
+
// A: unlink → open (holds the lock)
|
|
190
|
+
// B: unlink (deletes A's *fresh* lock) → open (succeeds)
|
|
191
|
+
// so both run the critical section at once and one reservation is lost. `rename` is atomic and
|
|
192
|
+
// exactly one process can move the stale file away, so ownership of the takeover is proven
|
|
193
|
+
// before lockPath is recreated.
|
|
194
|
+
//
|
|
195
|
+
// ⚠️ rename が原子的なのは「移動」だけで、「stat した stale を移動した」ことまでは保証しない。
|
|
196
|
+
// stat と rename の間に相手が takeover を終えていると、掴むのは相手の **新しい** ロックになる
|
|
197
|
+
// (A: rename → open(保持) / B: rename で A のロックを退避 → open 成功 = 両者が臨界区間)。
|
|
198
|
+
// だから移動先を stat し直し、奪ったのが stat した stale 本人であることを照合する。
|
|
199
|
+
if (!supportsStaleTakeover(fileSystem)) {
|
|
200
|
+
warnStaleTakeoverUnavailable();
|
|
201
|
+
throw lockUnavailable(lockPath, 'held (stale takeover unavailable)');
|
|
202
|
+
}
|
|
203
|
+
const observed = await lockIdentity(fileSystem, lockPath);
|
|
204
|
+
if (observed === null || observed.ageMs < SPEND_LOCK_STALE_MS) {
|
|
205
|
+
throw lockUnavailable(
|
|
206
|
+
lockPath,
|
|
207
|
+
observed === null
|
|
208
|
+
? 'held'
|
|
209
|
+
: `held for ${Math.round(observed.ageMs / 1000)}s`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const stalePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
|
|
213
|
+
try {
|
|
214
|
+
await fileSystem.rename(lockPath, stalePath);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// ENOENT = another process won the same takeover and its lock is now fresh. Report the
|
|
217
|
+
// contention instead of guessing; the caller retries from the normal acquisition loop.
|
|
218
|
+
throw lockUnavailable(
|
|
219
|
+
lockPath,
|
|
220
|
+
isMissingFile(error)
|
|
221
|
+
? 'reacquired by another process'
|
|
222
|
+
: 'stale, takeover failed',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
// 退避したのが stat した stale 本人でなければ、生きた保持者のロックを奪ってしまっている。
|
|
226
|
+
// 元に戻してから撤退する (戻さないと保持者の解放が空振りし、次の到着者が素通りで入れてしまう)。
|
|
227
|
+
if (!isSameLockFile(observed, await lockIdentity(fileSystem, stalePath))) {
|
|
228
|
+
try {
|
|
229
|
+
await fileSystem.rename(stalePath, lockPath);
|
|
230
|
+
} catch {
|
|
231
|
+
// 復元できないケースまで握るのは危険だが、ここで投げても保持者のロックは戻らない。
|
|
232
|
+
// 呼び出し側には下の unavailable で「入れなかった」と伝わる方が安全側。
|
|
233
|
+
}
|
|
234
|
+
throw lockUnavailable(lockPath, 'reacquired by another process');
|
|
235
|
+
}
|
|
236
|
+
let handle;
|
|
237
|
+
try {
|
|
238
|
+
handle = await fileSystem.open(lockPath, 'wx');
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!isLockHeld(error)) throw error;
|
|
241
|
+
throw lockUnavailable(lockPath, 'reacquired by another process');
|
|
242
|
+
} finally {
|
|
243
|
+
try {
|
|
244
|
+
await fileSystem.unlink(stalePath);
|
|
245
|
+
} catch {
|
|
246
|
+
// The stale file is already out of the lock path, so a leftover copy cannot hand the
|
|
247
|
+
// critical section to anyone; only disk tidiness suffers.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await annotateLock(handle, lockPath);
|
|
251
|
+
return handle;
|
|
101
252
|
}
|
|
102
253
|
|
|
103
254
|
async function withLock(operation) {
|
|
@@ -113,7 +264,14 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
113
264
|
try {
|
|
114
265
|
await handle.close();
|
|
115
266
|
} finally {
|
|
116
|
-
|
|
267
|
+
try {
|
|
268
|
+
await runtime.fileSystem.unlink(runtime.lockPath);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// ENOENT = ロックは既に無い (takeover を試みた相手が一瞬退避した等) = 解放の目的は達成済み。
|
|
271
|
+
// 解放の空振りで、書き込みまで終えた予約を unavailable に化けさせない。ENOENT 以外は
|
|
272
|
+
// 「ロックが残る」ので握りつぶさず投げる。
|
|
273
|
+
if (!isMissingFile(error)) throw error;
|
|
274
|
+
}
|
|
117
275
|
}
|
|
118
276
|
}
|
|
119
277
|
}
|
|
@@ -237,8 +395,10 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
237
395
|
await writeDocument(runtime, current);
|
|
238
396
|
return { ok: true, totalAtomic: next.toString() };
|
|
239
397
|
});
|
|
240
|
-
} catch {
|
|
241
|
-
|
|
398
|
+
} catch (error) {
|
|
399
|
+
// Name the lock file when the block came from it. A bare "unavailable" sends operators
|
|
400
|
+
// hunting through the payment path for a failure that is one leftover file.
|
|
401
|
+
return { ok: false, reason: 'unavailable', ...unavailableDetail(error) };
|
|
242
402
|
}
|
|
243
403
|
}
|
|
244
404
|
|