indelible-mcp 4.1.6 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indelible-mcp",
3
- "version": "4.1.6",
3
+ "version": "4.2.0",
4
4
  "description": "Blockchain-backed memory and code storage for Claude Code. Save AI conversations and source code permanently on BSV.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -31,6 +31,7 @@ import { updateVaultIndex } from './tools/update_vault_index.js'
31
31
  import { diaryConnect } from './tools/diary_connect.js'
32
32
  import { diaryChat } from './tools/diary_chat.js'
33
33
  import { diarySave } from './tools/diary_save.js'
34
+ import { x402Fetch } from './tools/x402_fetch.js'
34
35
  import * as spv from './lib/spv.js'
35
36
 
36
37
  const CONTEXT_FILE = join(homedir(), '.indelible', 'indelible-context.jsonl')
@@ -269,7 +270,7 @@ Commands:
269
270
 
270
271
  function printHelp() {
271
272
  console.log(`
272
- Indelible MCP — Blockchain memory for Claude Code (v4.1.6)
273
+ Indelible MCP — Blockchain memory for Claude Code (v4.2.0)
273
274
 
274
275
  Setup:
275
276
  indelible-mcp setup --wif=KEY --pin=PIN Import and encrypt your private key
@@ -297,6 +298,9 @@ Diary AI (Codex/ChatGPT companion):
297
298
  indelible-mcp diary chat "message" Ask the AI companion
298
299
  indelible-mcp diary save Save exchange to blockchain
299
300
 
301
+ Payments:
302
+ x402_fetch (MCP only) Fetch URL with automatic x402 payment
303
+
300
304
  Hooks (auto-called by Claude Code):
301
305
  indelible-mcp hook pre-compact Auto-save before compaction
302
306
  indelible-mcp hook post-compact Auto-restore after compaction
@@ -481,7 +485,7 @@ function readStdin() {
481
485
 
482
486
  const SERVER_INFO = {
483
487
  name: 'indelible',
484
- version: '4.1.6',
488
+ version: '4.2.0',
485
489
  description: 'Blockchain-backed memory and code storage for Claude Code'
486
490
  }
487
491
 
@@ -643,6 +647,21 @@ const TOOLS = [
643
647
  },
644
648
  required: ['messages']
645
649
  }
650
+ },
651
+ {
652
+ name: 'x402_fetch',
653
+ description: 'Fetch a URL with automatic x402 payment. If the endpoint returns 402, the tool pays the required sats from your Indelible wallet, then re-requests with proof. Returns content and payment details.',
654
+ inputSchema: {
655
+ type: 'object',
656
+ properties: {
657
+ url: { type: 'string', description: 'URL to fetch (the x402-protected endpoint)' },
658
+ method: { type: 'string', description: 'HTTP method (default: GET)' },
659
+ headers: { type: 'object', description: 'Extra headers to include' },
660
+ body: { type: 'string', description: 'Request body (for POST requests)' },
661
+ maxSats: { type: 'number', description: 'Maximum sats to pay per request (default: 10000, safety cap)' }
662
+ },
663
+ required: ['url']
664
+ }
646
665
  }
647
666
  ]
648
667
 
@@ -708,6 +727,9 @@ async function handleMcpRequest(request) {
708
727
  case 'diary_save':
709
728
  result = await diarySave({ messages: args?.messages, summary: args?.summary })
710
729
  break
730
+ case 'x402_fetch':
731
+ result = await x402Fetch({ url: args?.url, method: args?.method, headers: args?.headers, body: args?.body, maxSats: args?.maxSats })
732
+ break
711
733
  default:
712
734
  throw new Error(`Unknown tool: ${name}`)
713
735
  }
@@ -1,293 +1,293 @@
1
- /**
2
- * API client for Indelible CLI
3
- * Handles registration, session indexing, and status checks.
4
- * Blockchain operations go through SPV bridge (see spv.js).
5
- */
6
-
7
- import { Transaction } from '@bsv/sdk'
8
- import { loadConfig } from './config.js'
9
- import * as spv from './spv.js'
10
-
11
- const DEFAULT_API_URL = 'https://indelible.one'
12
-
13
- function getApiUrl() {
14
- const config = loadConfig()
15
- return config?.api_url || DEFAULT_API_URL
16
- }
17
-
18
- function getHeaders(config) {
19
- const headers = { 'Content-Type': 'application/json' }
20
- if (config?.api_key) {
21
- headers['Authorization'] = `Bearer ${config.api_key}`
22
- }
23
- return headers
24
- }
25
-
26
- /**
27
- * Check tier/usage with Indelible server before saving.
28
- * Returns { ok, tier, usage, limit } or throws on rate limit.
29
- */
30
- export async function checkTier(apiKey) {
31
- if (!apiKey) return { ok: true, tier: 'unknown', usage: 0, limit: null } // no key = legacy, allow
32
- const apiUrl = getApiUrl()
33
- try {
34
- const res = await fetch(`${apiUrl}/api/cli/status`, {
35
- headers: { 'Authorization': `Bearer ${apiKey}` },
36
- signal: AbortSignal.timeout(5000)
37
- })
38
- if (res.status === 429) {
39
- const data = await res.json().catch(() => ({}))
40
- throw new Error(`Monthly save limit reached (${data.limit || 50}). Upgrade at indelible.one/pricing`)
41
- }
42
- if (res.ok) {
43
- const data = await res.json()
44
- return { ok: true, tier: data.tier, usage: data.saves_this_month, limit: data.saves_limit }
45
- }
46
- } catch (err) {
47
- if (err.message.includes('save limit')) throw err
48
- // Server unreachable — allow save (offline-first)
49
- }
50
- return { ok: true, tier: 'unknown', usage: 0, limit: null }
51
- }
52
-
53
- /**
54
- * Register API key with server (one-time, for SPV bridge access + web app)
55
- */
56
- export async function register(address, apiKey) {
57
- const res = await fetch(`${getApiUrl()}/api/cli/register`, {
58
- method: 'POST',
59
- headers: { 'Content-Type': 'application/json' },
60
- body: JSON.stringify({ address, api_key: apiKey }),
61
- signal: AbortSignal.timeout(10000)
62
- })
63
- if (!res.ok) {
64
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
65
- throw new Error(err.error || `Registration failed: ${res.status}`)
66
- }
67
- return res.json()
68
- }
69
-
70
- /**
71
- * Index a session on server for web app retrieval.
72
- * Called after successful broadcast — best effort, doesn't block save.
73
- */
74
- export async function indexSession(sessionData, config) {
75
- const res = await fetch(`${getApiUrl()}/api/cli/index-session`, {
76
- method: 'POST',
77
- headers: getHeaders(config),
78
- body: JSON.stringify(sessionData),
79
- signal: AbortSignal.timeout(10000)
80
- })
81
- if (!res.ok) {
82
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
83
- throw new Error(err.error || `Indexing failed: ${res.status}`)
84
- }
85
- return res.json()
86
- }
87
-
88
- /**
89
- * Fetch session index from server (faster than scanning blockchain)
90
- */
91
- export async function getSessions(address, limit, config) {
92
- const res = await fetch(`${getApiUrl()}/api/cli/sessions`, {
93
- method: 'POST',
94
- headers: getHeaders(config),
95
- body: JSON.stringify({ address, limit }),
96
- signal: AbortSignal.timeout(10000)
97
- })
98
- if (!res.ok) {
99
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
100
- throw new Error(err.error || `Fetch sessions failed: ${res.status}`)
101
- }
102
- return res.json()
103
- }
104
-
105
- /**
106
- * Get account status
107
- */
108
- export async function getStatus(config) {
109
- const res = await fetch(`${getApiUrl()}/api/cli/status`, {
110
- method: 'GET',
111
- headers: getHeaders(config),
112
- signal: AbortSignal.timeout(10000)
113
- })
114
- if (!res.ok) {
115
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
116
- throw new Error(err.error || `Status check failed: ${res.status}`)
117
- }
118
- return res.json()
119
- }
120
-
121
- /**
122
- * Commit a session to the blockchain
123
- * Checks tier limits first, then builds tx locally and broadcasts via SPV bridge.
124
- * WIF never leaves the machine — no remote fallback.
125
- * @param {Object} session - Session data to commit (must include address, encrypted)
126
- * @param {string} wif - WIF for signing/encryption
127
- * @returns {Promise<{success: boolean, txId?: string, error?: string}>}
128
- */
129
- export async function commitSession(session, wif) {
130
- const config = loadConfig()
131
- const apiKey = config?.api_key || null
132
-
133
- // Check tier/usage before broadcasting
134
- await checkTier(apiKey)
135
-
136
- // Build tx locally and broadcast via SPV bridge
137
- try {
138
- const utxos = await spv.getUtxos(session.address)
139
- if (utxos && utxos.length > 0) {
140
- const payload = {
141
- protocol: 'indelible.claude-code',
142
- encrypted: session.encrypted
143
- }
144
- const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
145
- await spv.broadcastTx(txHex)
146
-
147
- // Index session for usage tracking
148
- await indexSession({
149
- txId,
150
- address: session.address,
151
- encrypted: session.encrypted,
152
- session_id: session.session_id,
153
- prev_session_id: session.prev_session_id,
154
- summary: session.summary,
155
- message_count: session.message_count,
156
- save_type: session.type || 'full',
157
- timestamp: new Date().toISOString()
158
- }, config)
159
-
160
- return { success: true, txId, changeUtxos }
161
- }
162
- } catch (err) {
163
- if (err.message.includes('save limit')) throw err
164
- throw new Error('Gateway unreachable, save failed. Check your SPV bridge connection.')
165
- }
166
-
167
- throw new Error('No UTXOs available. Fund your wallet or check gateway connection.')
168
- }
169
-
170
- /**
171
- * Get latest sessions for an address
172
- * Scans address history via SPV bridge — no remote fallback
173
- * @param {string} address - BSV address
174
- * @param {string} wif - WIF for decryption (local only)
175
- * @param {number} limit - Number of sessions to fetch
176
- * @returns {Promise<{sessions: Array}>}
177
- */
178
- export async function getLatestSessions(address, wif, limit = 3) {
179
- try {
180
- const history = await spv.getAddressHistory(address)
181
- if (history && history.length > 0) {
182
- // Sort by height descending (most recent first), unconfirmed (height 0 or -1) first
183
- history.sort((a, b) => (b.height > 0 ? b.height : Infinity) - (a.height > 0 ? a.height : Infinity))
184
-
185
- const sessions = []
186
- for (const txInfo of history) {
187
- if (sessions.length >= limit) break
188
- try {
189
- const rawHex = await spv.getRawTx(txInfo.tx_hash)
190
- const tx = Transaction.fromHex(rawHex)
191
- let encrypted = null
192
- for (const output of tx.outputs) {
193
- if (output.satoshis === 0) {
194
- const hex = output.lockingScript.toHex()
195
- const str = Buffer.from(hex, 'hex').toString('utf8')
196
- const match = str.match(/([A-Za-z0-9+/=]{12,}):([A-Za-z0-9+/=]{20,}):([A-Za-z0-9+/=]{20,})/)
197
- if (match) { encrypted = match[0]; break }
198
- }
199
- }
200
- if (encrypted) {
201
- sessions.push({
202
- txId: txInfo.tx_hash,
203
- encrypted,
204
- timestamp: null,
205
- summary: null,
206
- message_count: null
207
- })
208
- }
209
- } catch {
210
- // Skip txs we can't fetch
211
- }
212
- }
213
-
214
- if (sessions.length > 0) {
215
- return { sessions }
216
- }
217
- }
218
- } catch {
219
- // SPV bridge failed — no fallback, WIF never leaves the machine
220
- }
221
-
222
- return { sessions: [] }
223
- }
224
-
225
- /**
226
- * Check if user has Pro or Developer tier (required for Diary AI tools)
227
- * @param {string} address - User's BSV address
228
- * @returns {Promise<{ok: boolean, plan: string, error?: string}>}
229
- */
230
- export async function checkProTier(address) {
231
- try {
232
- const res = await fetch(`${getApiUrl()}/api/stripe/subscription-status-wif`, {
233
- method: 'POST',
234
- headers: { 'Content-Type': 'application/json' },
235
- body: JSON.stringify({ address }),
236
- signal: AbortSignal.timeout(5000)
237
- })
238
- if (res.ok) {
239
- const data = await res.json()
240
- if (data.plan === 'admin' || data.plan === 'pro' || data.plan === 'developer') {
241
- return { ok: true, plan: data.plan }
242
- }
243
- return { ok: false, plan: data.plan || 'free', error: 'Pro tier required for Diary AI companion. Upgrade at indelible.one/pricing' }
244
- }
245
- } catch {
246
- // Server unreachable — allow (offline-first)
247
- return { ok: true, plan: 'unknown' }
248
- }
249
- return { ok: true, plan: 'unknown' }
250
- }
251
-
252
- /**
253
- * Auto-generate relay API key for SPV bridge access
254
- * Logs in with address, then calls generate-key endpoint
255
- * @param {string} address - BSV address
256
- * @param {string} apiUrl - Optional API URL override
257
- * @returns {Promise<{key: string, tier: string}|null>}
258
- */
259
- export async function ensureRelayKey(address, apiUrl) {
260
- if (!apiUrl) apiUrl = getApiUrl()
261
- try {
262
- const authRes = await fetch(`${apiUrl}/api/auth/wif`, {
263
- method: 'POST',
264
- headers: { 'Content-Type': 'application/json' },
265
- body: JSON.stringify({ address }),
266
- signal: AbortSignal.timeout(10000)
267
- })
268
- if (!authRes.ok) return null
269
- const { token } = await authRes.json()
270
- if (!token) return null
271
- const keyRes = await fetch(`${apiUrl}/api/relay/generate-key`, {
272
- method: 'POST',
273
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
274
- body: JSON.stringify({}),
275
- signal: AbortSignal.timeout(10000)
276
- })
277
- if (!keyRes.ok) return null
278
- const keyData = await keyRes.json()
279
- return { key: keyData.key, tier: keyData.tier }
280
- } catch { return null }
281
- }
282
-
283
- /**
284
- * Check if server is reachable
285
- */
286
- export async function checkConnection() {
287
- try {
288
- const res = await fetch(`${getApiUrl()}/api/health`, { signal: AbortSignal.timeout(5000) })
289
- return res.ok
290
- } catch {
291
- return false
292
- }
293
- }
1
+ /**
2
+ * API client for Indelible CLI
3
+ * Handles registration, session indexing, and status checks.
4
+ * Blockchain operations go through SPV bridge (see spv.js).
5
+ */
6
+
7
+ import { Transaction } from '@bsv/sdk'
8
+ import { loadConfig } from './config.js'
9
+ import * as spv from './spv.js'
10
+
11
+ const DEFAULT_API_URL = 'https://indelible.one'
12
+
13
+ function getApiUrl() {
14
+ const config = loadConfig()
15
+ return config?.api_url || DEFAULT_API_URL
16
+ }
17
+
18
+ function getHeaders(config) {
19
+ const headers = { 'Content-Type': 'application/json' }
20
+ if (config?.api_key) {
21
+ headers['Authorization'] = `Bearer ${config.api_key}`
22
+ }
23
+ return headers
24
+ }
25
+
26
+ /**
27
+ * Check tier/usage with Indelible server before saving.
28
+ * Returns { ok, tier, usage, limit } or throws on rate limit.
29
+ */
30
+ export async function checkTier(apiKey) {
31
+ if (!apiKey) return { ok: true, tier: 'unknown', usage: 0, limit: null } // no key = legacy, allow
32
+ const apiUrl = getApiUrl()
33
+ try {
34
+ const res = await fetch(`${apiUrl}/api/cli/status`, {
35
+ headers: { 'Authorization': `Bearer ${apiKey}` },
36
+ signal: AbortSignal.timeout(5000)
37
+ })
38
+ if (res.status === 429) {
39
+ const data = await res.json().catch(() => ({}))
40
+ throw new Error(`Monthly save limit reached (${data.limit || 50}). Upgrade at indelible.one/pricing`)
41
+ }
42
+ if (res.ok) {
43
+ const data = await res.json()
44
+ return { ok: true, tier: data.tier, usage: data.saves_this_month, limit: data.saves_limit }
45
+ }
46
+ } catch (err) {
47
+ if (err.message.includes('save limit')) throw err
48
+ // Server unreachable — allow save (offline-first)
49
+ }
50
+ return { ok: true, tier: 'unknown', usage: 0, limit: null }
51
+ }
52
+
53
+ /**
54
+ * Register API key with server (one-time, for SPV bridge access + web app)
55
+ */
56
+ export async function register(address, apiKey) {
57
+ const res = await fetch(`${getApiUrl()}/api/cli/register`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ address, api_key: apiKey }),
61
+ signal: AbortSignal.timeout(10000)
62
+ })
63
+ if (!res.ok) {
64
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
65
+ throw new Error(err.error || `Registration failed: ${res.status}`)
66
+ }
67
+ return res.json()
68
+ }
69
+
70
+ /**
71
+ * Index a session on server for web app retrieval.
72
+ * Called after successful broadcast — best effort, doesn't block save.
73
+ */
74
+ export async function indexSession(sessionData, config) {
75
+ const res = await fetch(`${getApiUrl()}/api/cli/index-session`, {
76
+ method: 'POST',
77
+ headers: getHeaders(config),
78
+ body: JSON.stringify(sessionData),
79
+ signal: AbortSignal.timeout(10000)
80
+ })
81
+ if (!res.ok) {
82
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
83
+ throw new Error(err.error || `Indexing failed: ${res.status}`)
84
+ }
85
+ return res.json()
86
+ }
87
+
88
+ /**
89
+ * Fetch session index from server (faster than scanning blockchain)
90
+ */
91
+ export async function getSessions(address, limit, config) {
92
+ const res = await fetch(`${getApiUrl()}/api/cli/sessions`, {
93
+ method: 'POST',
94
+ headers: getHeaders(config),
95
+ body: JSON.stringify({ address, limit }),
96
+ signal: AbortSignal.timeout(10000)
97
+ })
98
+ if (!res.ok) {
99
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
100
+ throw new Error(err.error || `Fetch sessions failed: ${res.status}`)
101
+ }
102
+ return res.json()
103
+ }
104
+
105
+ /**
106
+ * Get account status
107
+ */
108
+ export async function getStatus(config) {
109
+ const res = await fetch(`${getApiUrl()}/api/cli/status`, {
110
+ method: 'GET',
111
+ headers: getHeaders(config),
112
+ signal: AbortSignal.timeout(10000)
113
+ })
114
+ if (!res.ok) {
115
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
116
+ throw new Error(err.error || `Status check failed: ${res.status}`)
117
+ }
118
+ return res.json()
119
+ }
120
+
121
+ /**
122
+ * Commit a session to the blockchain
123
+ * Checks tier limits first, then builds tx locally and broadcasts via SPV bridge.
124
+ * WIF never leaves the machine — no remote fallback.
125
+ * @param {Object} session - Session data to commit (must include address, encrypted)
126
+ * @param {string} wif - WIF for signing/encryption
127
+ * @returns {Promise<{success: boolean, txId?: string, error?: string}>}
128
+ */
129
+ export async function commitSession(session, wif) {
130
+ const config = loadConfig()
131
+ const apiKey = config?.api_key || null
132
+
133
+ // Check tier/usage before broadcasting
134
+ await checkTier(apiKey)
135
+
136
+ // Build tx locally and broadcast via SPV bridge
137
+ try {
138
+ const utxos = await spv.getUtxos(session.address)
139
+ if (utxos && utxos.length > 0) {
140
+ const payload = {
141
+ protocol: 'indelible.claude-code',
142
+ encrypted: session.encrypted
143
+ }
144
+ const { txHex, txId, changeUtxos } = await spv.buildOpReturnTxWithChange(wif, utxos, JSON.stringify(payload))
145
+ await spv.broadcastTx(txHex)
146
+
147
+ // Index session for usage tracking
148
+ await indexSession({
149
+ txId,
150
+ address: session.address,
151
+ encrypted: session.encrypted,
152
+ session_id: session.session_id,
153
+ prev_session_id: session.prev_session_id,
154
+ summary: session.summary,
155
+ message_count: session.message_count,
156
+ save_type: session.type || 'full',
157
+ timestamp: new Date().toISOString()
158
+ }, config)
159
+
160
+ return { success: true, txId, changeUtxos }
161
+ }
162
+ } catch (err) {
163
+ if (err.message.includes('save limit')) throw err
164
+ throw new Error('Gateway unreachable, save failed. Check your SPV bridge connection.')
165
+ }
166
+
167
+ throw new Error('No UTXOs available. Fund your wallet or check gateway connection.')
168
+ }
169
+
170
+ /**
171
+ * Get latest sessions for an address
172
+ * Scans address history via SPV bridge — no remote fallback
173
+ * @param {string} address - BSV address
174
+ * @param {string} wif - WIF for decryption (local only)
175
+ * @param {number} limit - Number of sessions to fetch
176
+ * @returns {Promise<{sessions: Array}>}
177
+ */
178
+ export async function getLatestSessions(address, wif, limit = 3) {
179
+ try {
180
+ const history = await spv.getAddressHistory(address)
181
+ if (history && history.length > 0) {
182
+ // Sort by height descending (most recent first), unconfirmed (height 0 or -1) first
183
+ history.sort((a, b) => (b.height > 0 ? b.height : Infinity) - (a.height > 0 ? a.height : Infinity))
184
+
185
+ const sessions = []
186
+ for (const txInfo of history) {
187
+ if (sessions.length >= limit) break
188
+ try {
189
+ const rawHex = await spv.getRawTx(txInfo.tx_hash)
190
+ const tx = Transaction.fromHex(rawHex)
191
+ let encrypted = null
192
+ for (const output of tx.outputs) {
193
+ if (output.satoshis === 0) {
194
+ const hex = output.lockingScript.toHex()
195
+ const str = Buffer.from(hex, 'hex').toString('utf8')
196
+ const match = str.match(/([A-Za-z0-9+/=]{12,}):([A-Za-z0-9+/=]{20,}):([A-Za-z0-9+/=]{20,})/)
197
+ if (match) { encrypted = match[0]; break }
198
+ }
199
+ }
200
+ if (encrypted) {
201
+ sessions.push({
202
+ txId: txInfo.tx_hash,
203
+ encrypted,
204
+ timestamp: null,
205
+ summary: null,
206
+ message_count: null
207
+ })
208
+ }
209
+ } catch {
210
+ // Skip txs we can't fetch
211
+ }
212
+ }
213
+
214
+ if (sessions.length > 0) {
215
+ return { sessions }
216
+ }
217
+ }
218
+ } catch {
219
+ // SPV bridge failed — no fallback, WIF never leaves the machine
220
+ }
221
+
222
+ return { sessions: [] }
223
+ }
224
+
225
+ /**
226
+ * Check if user has Pro or Developer tier (required for Diary AI tools)
227
+ * @param {string} address - User's BSV address
228
+ * @returns {Promise<{ok: boolean, plan: string, error?: string}>}
229
+ */
230
+ export async function checkProTier(address) {
231
+ try {
232
+ const res = await fetch(`${getApiUrl()}/api/stripe/subscription-status-wif`, {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify({ address }),
236
+ signal: AbortSignal.timeout(5000)
237
+ })
238
+ if (res.ok) {
239
+ const data = await res.json()
240
+ if (data.plan === 'admin' || data.plan === 'pro' || data.plan === 'developer') {
241
+ return { ok: true, plan: data.plan }
242
+ }
243
+ return { ok: false, plan: data.plan || 'free', error: 'Pro tier required for Diary AI companion. Upgrade at indelible.one/pricing' }
244
+ }
245
+ } catch {
246
+ // Server unreachable — allow (offline-first)
247
+ return { ok: true, plan: 'unknown' }
248
+ }
249
+ return { ok: true, plan: 'unknown' }
250
+ }
251
+
252
+ /**
253
+ * Auto-generate relay API key for SPV bridge access
254
+ * Logs in with address, then calls generate-key endpoint
255
+ * @param {string} address - BSV address
256
+ * @param {string} apiUrl - Optional API URL override
257
+ * @returns {Promise<{key: string, tier: string}|null>}
258
+ */
259
+ export async function ensureRelayKey(address, apiUrl) {
260
+ if (!apiUrl) apiUrl = getApiUrl()
261
+ try {
262
+ const authRes = await fetch(`${apiUrl}/api/auth/wif`, {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ address }),
266
+ signal: AbortSignal.timeout(10000)
267
+ })
268
+ if (!authRes.ok) return null
269
+ const { token } = await authRes.json()
270
+ if (!token) return null
271
+ const keyRes = await fetch(`${apiUrl}/api/relay/generate-key`, {
272
+ method: 'POST',
273
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
274
+ body: JSON.stringify({}),
275
+ signal: AbortSignal.timeout(10000)
276
+ })
277
+ if (!keyRes.ok) return null
278
+ const keyData = await keyRes.json()
279
+ return { key: keyData.key, tier: keyData.tier }
280
+ } catch { return null }
281
+ }
282
+
283
+ /**
284
+ * Check if server is reachable
285
+ */
286
+ export async function checkConnection() {
287
+ try {
288
+ const res = await fetch(`${getApiUrl()}/api/health`, { signal: AbortSignal.timeout(5000) })
289
+ return res.ok
290
+ } catch {
291
+ return false
292
+ }
293
+ }