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.
- package/CHANGELOG.md +66 -0
- package/README.md +226 -6
- package/index.d.ts +247 -1
- package/package.json +4 -1
- package/src/dualGate.mjs +196 -0
- package/src/executor.mjs +38 -20
- package/src/guards.mjs +14 -3
- package/src/index.mjs +4 -0
- package/src/license.mjs +123 -0
- package/src/licenseCommon.mjs +53 -0
- package/src/licenseGate.mjs +150 -0
- package/src/listing.mjs +173 -0
- package/src/network.mjs +19 -0
- package/src/spendStore.mjs +166 -6
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
|
|