openpay-x402-sdk 0.5.0 → 0.7.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.
@@ -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 = {
@@ -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
- return await fileSystem.open(lockPath, 'wx');
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) throw error;
179
+ if (attempt === LOCK_RETRY_COUNT - 1) break;
97
180
  await wait(LOCK_RETRY_MS);
98
181
  }
99
182
  }
100
- throw new Error('spend store lock unavailable');
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
- await runtime.fileSystem.unlink(runtime.lockPath);
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
- return { ok: false, reason: 'unavailable' };
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