@relay-federation/bridge 0.1.2 → 0.3.1

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.
@@ -1,4 +1,11 @@
1
1
  import { createServer } from 'node:http'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync } from 'node:fs'
4
+ import { join, dirname } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import https from 'node:https'
7
+ import { parseTx } from './output-parser.js'
8
+ import { scanAddress } from './address-scanner.js'
2
9
 
3
10
  /**
4
11
  * StatusServer — localhost-only HTTP server exposing bridge status.
@@ -7,8 +14,12 @@ import { createServer } from 'node:http'
7
14
  * Binds to 127.0.0.1 only — not accessible from outside the machine.
8
15
  *
9
16
  * Endpoints:
17
+ * GET / — HTML dashboard (auto-refreshes every 5s)
10
18
  * GET /status — JSON object with bridge state
11
19
  */
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url))
22
+ const DASHBOARD_HTML = readFileSync(join(__dirname, '..', 'dashboard', 'index.html'), 'utf8')
12
23
  export class StatusServer {
13
24
  /**
14
25
  * @param {object} opts
@@ -17,6 +28,8 @@ export class StatusServer {
17
28
  * @param {import('./header-relay.js').HeaderRelay} [opts.headerRelay]
18
29
  * @param {import('./tx-relay.js').TxRelay} [opts.txRelay]
19
30
  * @param {object} [opts.config] — Bridge config (pubkeyHex, endpoint, meshId)
31
+ * @param {object} [opts.bsvNodeClient] — BSV P2P node client (2.26)
32
+ * @param {object} [opts.store] — PersistentStore for wallet balance (2.27)
20
33
  */
21
34
  constructor (opts = {}) {
22
35
  this._port = opts.port || 9333
@@ -24,30 +37,83 @@ export class StatusServer {
24
37
  this._headerRelay = opts.headerRelay || null
25
38
  this._txRelay = opts.txRelay || null
26
39
  this._config = opts.config || {}
40
+ this._scorer = opts.scorer || null
41
+ this._peerHealth = opts.peerHealth || null
42
+ this._bsvNodeClient = opts.bsvNodeClient || null
43
+ this._store = opts.store || null
44
+ this._performOutboundHandshake = opts.performOutboundHandshake || null
45
+ this._registeredPubkeys = opts.registeredPubkeys || null
46
+ this._gossipManager = opts.gossipManager || null
27
47
  this._startedAt = Date.now()
28
48
  this._server = null
49
+
50
+ // Job system for async actions (register, deregister)
51
+ this._jobs = new Map()
52
+ this._jobCounter = 0
53
+
54
+ // Log ring buffer — max 500 entries
55
+ this._logs = []
56
+ this._logListeners = new Set()
57
+ this._maxLogs = 500
58
+
59
+ // App monitoring state
60
+ this._appChecks = new Map()
61
+ this._requestTracker = new Map()
62
+ this._appSSLCache = new Map()
63
+ this._appBridgeDomains = new Set()
64
+ this._appCheckInterval = null
65
+ this._addressCache = new Map()
66
+ if (this._config.apps) {
67
+ for (const app of this._config.apps) {
68
+ this._appChecks.set(app.url, { checks: [], lastError: null })
69
+ if (app.bridgeDomain) {
70
+ this._appBridgeDomains.add(app.bridgeDomain)
71
+ this._requestTracker.set(app.bridgeDomain, { total: 0, endpoints: {}, lastSeen: null })
72
+ }
73
+ try { this._appBridgeDomains.add(new URL(app.url).hostname) } catch {}
74
+ }
75
+ }
29
76
  }
30
77
 
31
78
  /**
32
79
  * Build the status object from current bridge state.
33
- * @returns {object}
80
+ * @param {object} [opts]
81
+ * @param {boolean} [opts.authenticated=false] — Include operator-only fields
82
+ * @returns {Promise<object>}
34
83
  */
35
- getStatus () {
84
+ async getStatus ({ authenticated = false } = {}) {
36
85
  const peers = []
37
86
  if (this._peerManager) {
38
87
  for (const [pubkeyHex, conn] of this._peerManager.peers) {
39
- peers.push({
88
+ const entry = {
40
89
  pubkeyHex,
41
90
  endpoint: conn.endpoint,
42
91
  connected: !!conn.connected
43
- })
92
+ }
93
+ if (this._scorer) {
94
+ entry.score = Math.round(this._scorer.getScore(pubkeyHex) * 100) / 100
95
+ const metrics = this._scorer.getMetrics(pubkeyHex)
96
+ if (metrics) {
97
+ entry.scoreBreakdown = {
98
+ uptime: Math.round(metrics.uptime * 100) / 100,
99
+ responseTime: Math.round(metrics.responseTime * 100) / 100,
100
+ dataAccuracy: Math.round(metrics.dataAccuracy * 100) / 100,
101
+ stakeAge: Math.round(metrics.stakeAge * 100) / 100,
102
+ raw: metrics.raw
103
+ }
104
+ }
105
+ }
106
+ if (this._peerHealth) {
107
+ entry.health = this._peerHealth.getStatus(pubkeyHex)
108
+ }
109
+ peers.push(entry)
44
110
  }
45
111
  }
46
112
 
47
- return {
113
+ const status = {
48
114
  bridge: {
115
+ name: this._config.name || null,
49
116
  pubkeyHex: this._config.pubkeyHex || null,
50
- endpoint: this._config.endpoint || null,
51
117
  meshId: this._config.meshId || null,
52
118
  uptimeSeconds: Math.floor((Date.now() - this._startedAt) / 1000)
53
119
  },
@@ -64,8 +130,191 @@ export class StatusServer {
64
130
  txs: {
65
131
  mempool: this._txRelay ? this._txRelay.mempool.size : 0,
66
132
  seen: this._txRelay ? this._txRelay.seen.size : 0
133
+ },
134
+ bsvNode: {
135
+ connected: this._bsvNodeClient ? this._bsvNodeClient.connectedCount > 0 : false,
136
+ peers: this._bsvNodeClient ? this._bsvNodeClient.connectedCount : 0,
137
+ height: this._bsvNodeClient ? this._bsvNodeClient.bestHeight : null
138
+ }
139
+ }
140
+
141
+ // Operator-only fields
142
+ if (authenticated) {
143
+ status.operator = true
144
+ status.bridge.endpoint = this._config.endpoint || null
145
+ status.bridge.domains = this._config.domains || []
146
+ try {
147
+ const { PrivateKey } = await import('@bsv/sdk')
148
+ status.bridge.address = PrivateKey.fromWif(this._config.wif).toPublicKey().toAddress()
149
+ } catch {
150
+ status.bridge.address = this._config.address || null
151
+ }
152
+ status.wallet = { balanceSats: null, utxoCount: 0 }
153
+ if (this._store) {
154
+ try { status.wallet.balanceSats = await this._store.getBalance() } catch {}
155
+ try { status.wallet.utxoCount = (await this._store.getUnspentUtxos()).length } catch {}
156
+ }
157
+ }
158
+
159
+ return status
160
+ }
161
+
162
+ /**
163
+ * Check if a request is authenticated via statusSecret.
164
+ * @param {import('node:http').IncomingMessage} req
165
+ * @returns {boolean}
166
+ */
167
+ _checkAuth (req) {
168
+ const secret = this._config.statusSecret
169
+ if (!secret) return false
170
+
171
+ // Check ?auth= query param
172
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
173
+ const authParam = url.searchParams.get('auth')
174
+ if (authParam === secret) return true
175
+
176
+ // Check Authorization: Bearer header
177
+ const authHeader = req.headers.authorization
178
+ if (authHeader && authHeader.startsWith('Bearer ') && authHeader.slice(7) === secret) return true
179
+
180
+ return false
181
+ }
182
+
183
+ /**
184
+ * Add a log entry to the ring buffer and notify SSE listeners.
185
+ * @param {string} message
186
+ */
187
+ addLog (message) {
188
+ const entry = { timestamp: Date.now(), message }
189
+ this._logs.push(entry)
190
+ if (this._logs.length > this._maxLogs) {
191
+ this._logs.shift()
192
+ }
193
+ // Notify SSE listeners
194
+ for (const listener of this._logListeners) {
195
+ listener(entry)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Create a job for tracking async actions.
201
+ * @returns {{ jobId: string, log: function }}
202
+ */
203
+ _createJob () {
204
+ const jobId = `job_${++this._jobCounter}_${Date.now()}`
205
+ const job = { status: 'running', events: [], done: false, listeners: new Set() }
206
+ this._jobs.set(jobId, job)
207
+
208
+ // Auto-cleanup after 5 minutes
209
+ setTimeout(() => this._jobs.delete(jobId), 5 * 60 * 1000)
210
+
211
+ const log = (type, message, data) => {
212
+ const event = { type, message, data, timestamp: Date.now() }
213
+ job.events.push(event)
214
+ if (type === 'done' || type === 'error') {
215
+ job.status = type === 'error' ? 'failed' : 'completed'
216
+ job.done = true
217
+ }
218
+ // Notify SSE listeners
219
+ for (const listener of job.listeners) {
220
+ listener(event)
67
221
  }
68
222
  }
223
+
224
+ return { jobId, log }
225
+ }
226
+
227
+ /**
228
+ * Read the full JSON body from a request.
229
+ * @param {import('node:http').IncomingMessage} req
230
+ * @returns {Promise<object>}
231
+ */
232
+ _readBody (req) {
233
+ return new Promise((resolve, reject) => {
234
+ let body = ''
235
+ req.on('data', chunk => { body += chunk })
236
+ req.on('end', () => {
237
+ try { resolve(body ? JSON.parse(body) : {}) } catch (e) { reject(e) }
238
+ })
239
+ req.on('error', reject)
240
+ })
241
+ }
242
+
243
+ /**
244
+ * Check SSL certificate for a hostname.
245
+ */
246
+ _checkSSL (hostname) {
247
+ return new Promise((resolve) => {
248
+ const req = https.request({ hostname, port: 443, method: 'HEAD', rejectUnauthorized: false, timeout: 5000 }, (res) => {
249
+ const cert = res.socket.getPeerCertificate()
250
+ if (!cert || !cert.valid_to) { resolve(null); req.destroy(); return }
251
+ resolve({
252
+ valid: res.socket.authorized,
253
+ issuer: cert.issuer?.O || cert.issuer?.CN || 'Unknown',
254
+ expiresAt: new Date(cert.valid_to).toISOString(),
255
+ daysRemaining: Math.floor((new Date(cert.valid_to) - Date.now()) / 86400000)
256
+ })
257
+ req.destroy()
258
+ })
259
+ req.on('error', () => resolve(null))
260
+ req.setTimeout(5000, () => { req.destroy(); resolve(null) })
261
+ req.end()
262
+ })
263
+ }
264
+
265
+ /**
266
+ * Health-check a single app.
267
+ */
268
+ async _checkApp (app) {
269
+ const entry = this._appChecks.get(app.url)
270
+ if (!entry) return
271
+ const start = Date.now()
272
+ let statusCode = 0
273
+ let up = false
274
+ let errorMsg = null
275
+ try {
276
+ const controller = new AbortController()
277
+ const timeout = setTimeout(() => controller.abort(), 5000)
278
+ const res = await fetch(app.healthUrl || app.url, { method: app.healthUrl ? 'GET' : 'HEAD', signal: controller.signal, redirect: 'follow' })
279
+ clearTimeout(timeout)
280
+ statusCode = res.status
281
+ up = statusCode >= 200 && statusCode < 400
282
+ } catch (err) {
283
+ errorMsg = err.message || 'Request failed'
284
+ }
285
+ const check = { timestamp: new Date().toISOString(), up, statusCode, responseTimeMs: Date.now() - start }
286
+ entry.checks.push(check)
287
+ if (entry.checks.length > 100) entry.checks.shift()
288
+ if (!up) entry.lastError = { message: errorMsg || `HTTP ${statusCode}`, timestamp: check.timestamp }
289
+ }
290
+
291
+ /**
292
+ * Run health checks on all configured apps.
293
+ */
294
+ async _checkAllApps () {
295
+ if (!this._config.apps) return
296
+ for (const app of this._config.apps) {
297
+ await this._checkApp(app)
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Start background app health monitoring (30s interval).
303
+ */
304
+ startAppMonitoring () {
305
+ if (!this._config.apps || this._config.apps.length === 0) return
306
+ this._checkAllApps()
307
+ this._appCheckInterval = setInterval(() => this._checkAllApps(), 30000)
308
+ }
309
+
310
+ /**
311
+ * Stop background app health monitoring.
312
+ */
313
+ stopAppMonitoring () {
314
+ if (this._appCheckInterval) {
315
+ clearInterval(this._appCheckInterval)
316
+ this._appCheckInterval = null
317
+ }
69
318
  }
70
319
 
71
320
  /**
@@ -75,26 +324,728 @@ export class StatusServer {
75
324
  start () {
76
325
  return new Promise((resolve, reject) => {
77
326
  this._server = createServer((req, res) => {
78
- if (req.method === 'GET' && req.url === '/status') {
79
- const status = this.getStatus()
80
- res.writeHead(200, { 'Content-Type': 'application/json' })
81
- res.end(JSON.stringify(status))
82
- } else {
83
- res.writeHead(404)
84
- res.end('Not Found')
327
+ // CORS headers for federation dashboard
328
+ res.setHeader('Access-Control-Allow-Origin', '*')
329
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
330
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
331
+
332
+ if (req.method === 'OPTIONS') {
333
+ res.writeHead(204)
334
+ res.end()
335
+ return
85
336
  }
337
+
338
+ this._handleRequest(req, res).catch(() => {
339
+ res.writeHead(500)
340
+ res.end('Internal Server Error')
341
+ })
86
342
  })
87
343
 
88
- this._server.listen(this._port, '127.0.0.1', () => resolve())
344
+ this._server.listen(this._port, '0.0.0.0', () => resolve())
89
345
  this._server.on('error', reject)
90
346
  })
91
347
  }
92
348
 
349
+ /**
350
+ * Route incoming HTTP requests.
351
+ * @param {import('node:http').IncomingMessage} req
352
+ * @param {import('node:http').ServerResponse} res
353
+ */
354
+ async _handleRequest (req, res) {
355
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
356
+ const path = url.pathname
357
+ const authenticated = this._checkAuth(req)
358
+
359
+ // Track requests from known app domains
360
+ const origin = req.headers.origin || req.headers.referer || ''
361
+ const host = (req.headers.host || '').split(':')[0]
362
+ let trackDomain = null
363
+ if (origin) { try { trackDomain = new URL(origin).hostname } catch {} }
364
+ if (!trackDomain && host && this._appBridgeDomains.has(host)) trackDomain = host
365
+ if (trackDomain && this._appBridgeDomains.has(trackDomain)) {
366
+ let bridgeDomain = trackDomain
367
+ if (this._config.apps) {
368
+ for (const app of this._config.apps) {
369
+ try { if (trackDomain === new URL(app.url).hostname) { bridgeDomain = app.bridgeDomain; break } } catch {}
370
+ }
371
+ }
372
+ const data = this._requestTracker.get(bridgeDomain)
373
+ if (data) {
374
+ data.total++
375
+ let ep = path
376
+ if (path.startsWith('/tx/')) ep = '/tx/:txid'
377
+ else if (path.startsWith('/inscription/')) ep = '/inscription/:content'
378
+ else if (path.startsWith('/jobs/')) ep = '/jobs/:id'
379
+ data.endpoints[ep] = (data.endpoints[ep] || 0) + 1
380
+ data.lastSeen = new Date().toISOString()
381
+ }
382
+ }
383
+
384
+ // GET /status — public or operator status
385
+ if (req.method === 'GET' && path === '/status') {
386
+ const status = await this.getStatus({ authenticated })
387
+ res.writeHead(200, { 'Content-Type': 'application/json' })
388
+ res.end(JSON.stringify(status))
389
+ return
390
+ }
391
+
392
+ // GET /mempool — public decoded mempool transactions
393
+ if (req.method === 'GET' && path === '/mempool') {
394
+ const txs = []
395
+ if (this._txRelay) {
396
+ for (const [txid, rawHex] of this._txRelay.mempool) {
397
+ try {
398
+ const parsed = parseTx(rawHex)
399
+ txs.push({
400
+ txid,
401
+ size: rawHex.length / 2,
402
+ inputs: parsed.inputs,
403
+ outputs: parsed.outputs.map(o => ({
404
+ vout: o.vout,
405
+ satoshis: o.satoshis,
406
+ isP2PKH: o.isP2PKH,
407
+ hash160: o.hash160,
408
+ type: o.type,
409
+ data: o.data,
410
+ protocol: o.protocol,
411
+ parsed: o.parsed
412
+ }))
413
+ })
414
+ } catch {
415
+ txs.push({ txid, size: rawHex.length / 2, inputs: [], outputs: [], error: 'decode failed' })
416
+ }
417
+ }
418
+ }
419
+ res.writeHead(200, { 'Content-Type': 'application/json' })
420
+ res.end(JSON.stringify({ count: txs.length, txs }))
421
+ return
422
+ }
423
+
424
+ // GET /discover — public list of all known bridges in the mesh
425
+ if (req.method === 'GET' && path === '/discover') {
426
+ const bridges = []
427
+ // Add self
428
+ bridges.push({
429
+ name: this._config.name || null,
430
+ pubkeyHex: this._config.pubkeyHex || null,
431
+ endpoint: this._config.endpoint || null,
432
+ meshId: this._config.meshId || null,
433
+ statusUrl: 'http://' + (req.headers.host || '127.0.0.1:' + this._port) + '/status'
434
+ })
435
+ // Add gossip directory (all known peers)
436
+ if (this._gossipManager) {
437
+ for (const peer of this._gossipManager.getDirectory()) {
438
+ // Derive statusUrl from ws endpoint: ws://host:8333 → http://host:9333
439
+ let statusUrl = null
440
+ try {
441
+ const u = new URL(peer.endpoint)
442
+ const statusPort = parseInt(u.port, 10) + 1000
443
+ statusUrl = 'http://' + u.hostname + ':' + statusPort + '/status'
444
+ } catch {}
445
+ bridges.push({
446
+ pubkeyHex: peer.pubkeyHex,
447
+ endpoint: peer.endpoint,
448
+ meshId: peer.meshId || null,
449
+ statusUrl
450
+ })
451
+ }
452
+ }
453
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
454
+ res.end(JSON.stringify({ count: bridges.length, bridges }))
455
+ return
456
+ }
457
+
458
+ // GET / or /dashboard — built-in HTML dashboard
459
+ if (req.method === 'GET' && (path === '/' || path === '/dashboard')) {
460
+ res.writeHead(200, { 'Content-Type': 'text/html' })
461
+ res.end(DASHBOARD_HTML)
462
+ return
463
+ }
464
+
465
+ // POST /broadcast — relay a raw tx to peers
466
+ if (req.method === 'POST' && path === '/broadcast') {
467
+ const body = await this._readBody(req)
468
+ const { rawHex } = body
469
+ if (!rawHex || typeof rawHex !== 'string') {
470
+ res.writeHead(400, { 'Content-Type': 'application/json' })
471
+ res.end(JSON.stringify({ error: 'rawHex required' }))
472
+ return
473
+ }
474
+ const buf = Buffer.from(rawHex, 'hex')
475
+ const hash = createHash('sha256').update(createHash('sha256').update(buf).digest()).digest()
476
+ const txid = Buffer.from(hash).reverse().toString('hex')
477
+ const sent = this._txRelay ? this._txRelay.broadcastTx(txid, rawHex) : 0
478
+ res.writeHead(200, { 'Content-Type': 'application/json' })
479
+ res.end(JSON.stringify({ txid, peers: sent }))
480
+ return
481
+ }
482
+
483
+ // GET /tx/:txid — fetch and parse transaction with full protocol support
484
+ if (req.method === 'GET' && path.startsWith('/tx/')) {
485
+ const txid = path.slice(4)
486
+ if (!txid || txid.length !== 64) {
487
+ res.writeHead(400, { 'Content-Type': 'application/json' })
488
+ res.end(JSON.stringify({ error: 'Invalid txid' }))
489
+ return
490
+ }
491
+
492
+ let rawHex = null
493
+ let source = null
494
+
495
+ // Check mempool first
496
+ if (this._txRelay && this._txRelay.mempool.has(txid)) {
497
+ rawHex = this._txRelay.mempool.get(txid)
498
+ source = 'mempool'
499
+ }
500
+
501
+ // Try P2P
502
+ if (!rawHex && this._bsvNodeClient) {
503
+ try {
504
+ const result = await this._bsvNodeClient.getTx(txid, 5000)
505
+ rawHex = result.rawHex
506
+ source = 'p2p'
507
+ } catch {}
508
+ }
509
+
510
+ // Fall back to WoC
511
+ if (!rawHex) {
512
+ try {
513
+ const resp = await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`)
514
+ if (!resp.ok) throw new Error(`WoC ${resp.status}`)
515
+ rawHex = await resp.text()
516
+ source = 'woc'
517
+ } catch (err) {
518
+ res.writeHead(404, { 'Content-Type': 'application/json' })
519
+ res.end(JSON.stringify({ error: `tx not found: ${err.message}` }))
520
+ return
521
+ }
522
+ }
523
+
524
+ // Parse with full protocol support
525
+ try {
526
+ const parsed = parseTx(rawHex)
527
+ res.writeHead(200, { 'Content-Type': 'application/json' })
528
+ res.end(JSON.stringify({
529
+ txid: parsed.txid,
530
+ source,
531
+ size: rawHex.length / 2,
532
+ inputs: parsed.inputs,
533
+ outputs: parsed.outputs
534
+ }))
535
+ } catch (err) {
536
+ res.writeHead(200, { 'Content-Type': 'application/json' })
537
+ res.end(JSON.stringify({ txid, source, size: rawHex.length / 2, error: 'parse failed: ' + err.message }))
538
+ }
539
+ return
540
+ }
541
+
542
+ // POST /register — operator: start async registration
543
+ if (req.method === 'POST' && path === '/register') {
544
+ if (!authenticated) {
545
+ res.writeHead(401, { 'Content-Type': 'application/json' })
546
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
547
+ return
548
+ }
549
+ const { runRegister } = await import('./actions.js')
550
+ const { jobId, log } = this._createJob()
551
+ res.writeHead(202, { 'Content-Type': 'application/json' })
552
+ res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
553
+ // Run async — don't await
554
+ runRegister({ config: this._config, store: this._store, log }).catch(err => {
555
+ log('error', err.message)
556
+ })
557
+ return
558
+ }
559
+
560
+ // POST /deregister — operator: start async deregistration
561
+ if (req.method === 'POST' && path === '/deregister') {
562
+ if (!authenticated) {
563
+ res.writeHead(401, { 'Content-Type': 'application/json' })
564
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
565
+ return
566
+ }
567
+ const { runDeregister } = await import('./actions.js')
568
+ const body = await this._readBody(req)
569
+ const { jobId, log } = this._createJob()
570
+ res.writeHead(202, { 'Content-Type': 'application/json' })
571
+ res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
572
+ runDeregister({ config: this._config, store: this._store, reason: body.reason || 'shutdown', log }).catch(err => {
573
+ log('error', err.message)
574
+ })
575
+ return
576
+ }
577
+
578
+ // POST /fund — operator: store a funding tx (synchronous)
579
+ if (req.method === 'POST' && path === '/fund') {
580
+ if (!authenticated) {
581
+ res.writeHead(401, { 'Content-Type': 'application/json' })
582
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
583
+ return
584
+ }
585
+ const { runFund } = await import('./actions.js')
586
+ const body = await this._readBody(req)
587
+ if (!body.rawHex) {
588
+ res.writeHead(400, { 'Content-Type': 'application/json' })
589
+ res.end(JSON.stringify({ error: 'rawHex required' }))
590
+ return
591
+ }
592
+ try {
593
+ const result = await runFund({ config: this._config, store: this._store, rawHex: body.rawHex, log: () => {} })
594
+ res.writeHead(200, { 'Content-Type': 'application/json' })
595
+ res.end(JSON.stringify(result))
596
+ } catch (err) {
597
+ res.writeHead(400, { 'Content-Type': 'application/json' })
598
+ res.end(JSON.stringify({ error: err.message }))
599
+ }
600
+ return
601
+ }
602
+
603
+ // POST /connect — operator: connect to a peer endpoint
604
+ if (req.method === 'POST' && path === '/connect') {
605
+ if (!authenticated) {
606
+ res.writeHead(401, { 'Content-Type': 'application/json' })
607
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
608
+ return
609
+ }
610
+ const body = await this._readBody(req)
611
+ if (!body.endpoint) {
612
+ res.writeHead(400, { 'Content-Type': 'application/json' })
613
+ res.end(JSON.stringify({ error: 'endpoint required (e.g. ws://host:port)' }))
614
+ return
615
+ }
616
+ if (!this._peerManager || !this._performOutboundHandshake) {
617
+ res.writeHead(500, { 'Content-Type': 'application/json' })
618
+ res.end(JSON.stringify({ error: 'Bridge not running — peer manager unavailable' }))
619
+ return
620
+ }
621
+ try {
622
+ const conn = this._peerManager.connectToPeer({ endpoint: body.endpoint })
623
+ if (conn) {
624
+ conn.on('open', () => this._performOutboundHandshake(conn))
625
+ res.writeHead(200, { 'Content-Type': 'application/json' })
626
+ res.end(JSON.stringify({ endpoint: body.endpoint, status: 'connecting' }))
627
+ } else {
628
+ res.writeHead(200, { 'Content-Type': 'application/json' })
629
+ res.end(JSON.stringify({ endpoint: body.endpoint, status: 'already_connected_or_failed' }))
630
+ }
631
+ } catch (err) {
632
+ res.writeHead(400, { 'Content-Type': 'application/json' })
633
+ res.end(JSON.stringify({ error: err.message }))
634
+ }
635
+ return
636
+ }
637
+
638
+ // POST /send — operator: send BSV from bridge wallet
639
+ if (req.method === 'POST' && path === '/send') {
640
+ if (!authenticated) {
641
+ res.writeHead(401, { 'Content-Type': 'application/json' })
642
+ res.end(JSON.stringify({ error: 'Unauthorized. Provide statusSecret via ?auth= or Authorization header.' }))
643
+ return
644
+ }
645
+ const { runSend } = await import('./actions.js')
646
+ const body = await this._readBody(req)
647
+ if (!body.toAddress || !body.amount) {
648
+ res.writeHead(400, { 'Content-Type': 'application/json' })
649
+ res.end(JSON.stringify({ error: 'toAddress and amount required' }))
650
+ return
651
+ }
652
+ const { jobId, log } = this._createJob()
653
+ res.writeHead(202, { 'Content-Type': 'application/json' })
654
+ res.end(JSON.stringify({ jobId, stream: `/jobs/${jobId}` }))
655
+ runSend({ config: this._config, store: this._store, toAddress: body.toAddress, amount: Number(body.amount), log }).catch(err => {
656
+ log('error', err.message)
657
+ })
658
+ return
659
+ }
660
+
661
+ // GET /jobs/:id — SSE stream for job progress
662
+ if (req.method === 'GET' && path.startsWith('/jobs/')) {
663
+ const jobId = path.slice(6)
664
+ const job = this._jobs.get(jobId)
665
+ if (!job) {
666
+ res.writeHead(404, { 'Content-Type': 'application/json' })
667
+ res.end(JSON.stringify({ error: 'Job not found' }))
668
+ return
669
+ }
670
+ res.writeHead(200, {
671
+ 'Content-Type': 'text/event-stream',
672
+ 'Cache-Control': 'no-cache',
673
+ Connection: 'keep-alive'
674
+ })
675
+ // Replay past events
676
+ for (const event of job.events) {
677
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
678
+ }
679
+ if (job.done) {
680
+ res.write(`data: ${JSON.stringify({ type: 'end', status: job.status })}\n\n`)
681
+ res.end()
682
+ return
683
+ }
684
+ // Stream new events
685
+ const listener = (event) => {
686
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
687
+ if (event.type === 'done' || event.type === 'error') {
688
+ res.write(`data: ${JSON.stringify({ type: 'end', status: event.type === 'error' ? 'failed' : 'completed' })}\n\n`)
689
+ res.end()
690
+ job.listeners.delete(listener)
691
+ }
692
+ }
693
+ job.listeners.add(listener)
694
+ req.on('close', () => job.listeners.delete(listener))
695
+ return
696
+ }
697
+
698
+ // GET /logs — SSE stream of live bridge logs
699
+ if (req.method === 'GET' && path === '/logs') {
700
+ res.writeHead(200, {
701
+ 'Content-Type': 'text/event-stream',
702
+ 'Cache-Control': 'no-cache',
703
+ Connection: 'keep-alive'
704
+ })
705
+ // Replay buffer
706
+ for (const entry of this._logs) {
707
+ res.write(`data: ${JSON.stringify(entry)}\n\n`)
708
+ }
709
+ // Stream new
710
+ const listener = (entry) => {
711
+ res.write(`data: ${JSON.stringify(entry)}\n\n`)
712
+ }
713
+ this._logListeners.add(listener)
714
+ req.on('close', () => this._logListeners.delete(listener))
715
+ return
716
+ }
717
+
718
+ // GET /inscriptions — query indexed inscriptions
719
+ if (req.method === 'GET' && path === '/inscriptions') {
720
+ if (!this._store) {
721
+ res.writeHead(500, { 'Content-Type': 'application/json' })
722
+ res.end(JSON.stringify({ error: 'Store not available' }))
723
+ return
724
+ }
725
+ const mime = url.searchParams.get('mime')
726
+ const address = url.searchParams.get('address')
727
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10) || 50, 200)
728
+ try {
729
+ const inscriptions = await this._store.getInscriptions({ mime, address, limit })
730
+ const total = await this._store.getInscriptionCount()
731
+ res.writeHead(200, { 'Content-Type': 'application/json' })
732
+ res.end(JSON.stringify({ total, count: inscriptions.length, inscriptions, filters: { mime: mime || null, address: address || null } }))
733
+ } catch (err) {
734
+ res.writeHead(500, { 'Content-Type': 'application/json' })
735
+ res.end(JSON.stringify({ error: err.message }))
736
+ }
737
+ return
738
+ }
739
+
740
+ // GET /address/:addr/history — transaction history for an address (via WoC)
741
+ const addrMatch = path.match(/^\/address\/([13][a-km-zA-HJ-NP-Z1-9]{24,33})\/history$/)
742
+ if (req.method === 'GET' && addrMatch) {
743
+ const addr = addrMatch[1]
744
+ const cached = this._addressCache.get(addr)
745
+ if (cached && Date.now() - cached.time < 60000) {
746
+ res.writeHead(200, { 'Content-Type': 'application/json' })
747
+ res.end(JSON.stringify({ address: addr, history: cached.data, cached: true }))
748
+ return
749
+ }
750
+ try {
751
+ const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/address/' + addr + '/history', { signal: AbortSignal.timeout(10000) })
752
+ if (!resp.ok) throw new Error('WoC returned ' + resp.status)
753
+ const history = await resp.json()
754
+ this._addressCache.set(addr, { data: history, time: Date.now() })
755
+ // Prune cache if it grows too large
756
+ if (this._addressCache.size > 100) {
757
+ const oldest = this._addressCache.keys().next().value
758
+ this._addressCache.delete(oldest)
759
+ }
760
+ res.writeHead(200, { 'Content-Type': 'application/json' })
761
+ res.end(JSON.stringify({ address: addr, history, cached: false }))
762
+ } catch (err) {
763
+ res.writeHead(502, { 'Content-Type': 'application/json' })
764
+ res.end(JSON.stringify({ error: 'Failed to fetch address history: ' + err.message }))
765
+ }
766
+ return
767
+ }
768
+
769
+ // GET /price — cached BSV/USD exchange rate
770
+ if (req.method === 'GET' && path === '/price') {
771
+ const now = Date.now()
772
+ if (!this._priceCache || now - this._priceCache.timestamp > 60000) {
773
+ try {
774
+ const resp = await fetch('https://api.whatsonchain.com/v1/bsv/main/exchangerate')
775
+ if (resp.ok) {
776
+ const data = await resp.json()
777
+ this._priceCache = { data, timestamp: now }
778
+ }
779
+ } catch {}
780
+ }
781
+ if (this._priceCache) {
782
+ res.writeHead(200, { 'Content-Type': 'application/json' })
783
+ res.end(JSON.stringify({
784
+ usd: this._priceCache.data.rate || this._priceCache.data.USD,
785
+ currency: 'USD',
786
+ source: 'whatsonchain',
787
+ cached: this._priceCache.timestamp,
788
+ ttl: 60000
789
+ }))
790
+ return
791
+ }
792
+ res.writeHead(503, { 'Content-Type': 'application/json' })
793
+ res.end(JSON.stringify({ error: 'Price unavailable' }))
794
+ return
795
+ }
796
+
797
+ // GET /tokens — list all deployed tokens
798
+ if (req.method === 'GET' && path === '/tokens') {
799
+ if (!this._store) {
800
+ res.writeHead(500, { 'Content-Type': 'application/json' })
801
+ res.end(JSON.stringify({ error: 'Store not available' }))
802
+ return
803
+ }
804
+ const tokens = await this._store.listTokens()
805
+ res.writeHead(200, { 'Content-Type': 'application/json' })
806
+ res.end(JSON.stringify({ tokens }))
807
+ return
808
+ }
809
+
810
+ // GET /token/:tick — token deploy info
811
+ const tokenMatch = path.match(/^\/token\/([^/]+)$/)
812
+ if (req.method === 'GET' && tokenMatch) {
813
+ if (!this._store) {
814
+ res.writeHead(500, { 'Content-Type': 'application/json' })
815
+ res.end(JSON.stringify({ error: 'Store not available' }))
816
+ return
817
+ }
818
+ const token = await this._store.getToken(decodeURIComponent(tokenMatch[1]))
819
+ if (!token) {
820
+ res.writeHead(404, { 'Content-Type': 'application/json' })
821
+ res.end(JSON.stringify({ error: 'Token not found' }))
822
+ return
823
+ }
824
+ res.writeHead(200, { 'Content-Type': 'application/json' })
825
+ res.end(JSON.stringify(token))
826
+ return
827
+ }
828
+
829
+ // GET /token/:tick/balance/:scriptHash — token balance for owner
830
+ const balMatch = path.match(/^\/token\/([^/]+)\/balance\/([0-9a-f]{64})$/)
831
+ if (req.method === 'GET' && balMatch) {
832
+ if (!this._store) {
833
+ res.writeHead(500, { 'Content-Type': 'application/json' })
834
+ res.end(JSON.stringify({ error: 'Store not available' }))
835
+ return
836
+ }
837
+ const tick = decodeURIComponent(balMatch[1])
838
+ const ownerScriptHash = balMatch[2]
839
+ const balance = await this._store.getTokenBalance(tick, ownerScriptHash)
840
+ res.writeHead(200, { 'Content-Type': 'application/json' })
841
+ res.end(JSON.stringify({ tick, ownerScriptHash, balance }))
842
+ return
843
+ }
844
+
845
+ // GET /tx/:txid/status — tx lifecycle state
846
+ const statusMatch = path.match(/^\/tx\/([0-9a-f]{64})\/status$/)
847
+ if (req.method === 'GET' && statusMatch) {
848
+ if (!this._store) {
849
+ res.writeHead(500, { 'Content-Type': 'application/json' })
850
+ res.end(JSON.stringify({ error: 'Store not available' }))
851
+ return
852
+ }
853
+ const txid = statusMatch[1]
854
+ const status = await this._store.getTxStatus(txid)
855
+ const block = await this._store.getTxBlock(txid)
856
+ if (!status) {
857
+ res.writeHead(404, { 'Content-Type': 'application/json' })
858
+ res.end(JSON.stringify({ error: 'Transaction not found' }))
859
+ return
860
+ }
861
+ res.writeHead(200, { 'Content-Type': 'application/json' })
862
+ res.end(JSON.stringify({ txid, ...status, block: block || undefined }))
863
+ return
864
+ }
865
+
866
+ // GET /proof/:txid — merkle proof for confirmed tx
867
+ const proofMatch = path.match(/^\/proof\/([0-9a-f]{64})$/)
868
+ if (req.method === 'GET' && proofMatch) {
869
+ if (!this._store) {
870
+ res.writeHead(500, { 'Content-Type': 'application/json' })
871
+ res.end(JSON.stringify({ error: 'Store not available' }))
872
+ return
873
+ }
874
+ const txid = proofMatch[1]
875
+ const block = await this._store.getTxBlock(txid)
876
+ if (!block || !block.proof) {
877
+ res.writeHead(404, { 'Content-Type': 'application/json' })
878
+ res.end(JSON.stringify({ error: 'Proof not available' }))
879
+ return
880
+ }
881
+ res.writeHead(200, { 'Content-Type': 'application/json' })
882
+ res.end(JSON.stringify({ txid, blockHash: block.blockHash, height: block.height, proof: block.proof }))
883
+ return
884
+ }
885
+
886
+ // GET /inscription/:txid/:vout/content — serve raw inscription content
887
+ const inscMatch = path.match(/^\/inscription\/([0-9a-f]{64})\/(\d+)\/content$/)
888
+ if (req.method === 'GET' && inscMatch) {
889
+ if (!this._store) {
890
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
891
+ res.end('Store not available')
892
+ return
893
+ }
894
+ try {
895
+ const record = await this._store.getInscription(inscMatch[1], parseInt(inscMatch[2], 10))
896
+ if (!record) {
897
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
898
+ res.end('Not found')
899
+ return
900
+ }
901
+ // Resolve content: inline hex first, then CAS fallback
902
+ let buf = record.content ? Buffer.from(record.content, 'hex') : null
903
+ if (!buf && record.contentHash) {
904
+ buf = await this._store.getContentBytes(record.contentHash)
905
+ }
906
+ if (!buf) {
907
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
908
+ res.end('Content not available')
909
+ return
910
+ }
911
+ res.writeHead(200, {
912
+ 'Content-Type': record.contentType || 'application/octet-stream',
913
+ 'Content-Length': buf.length,
914
+ 'Cache-Control': 'public, max-age=31536000, immutable'
915
+ })
916
+ res.end(buf)
917
+ } catch (err) {
918
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
919
+ res.end(err.message)
920
+ }
921
+ return
922
+ }
923
+
924
+ // POST /scan-address — scan an address for inscriptions via WhatsOnChain
925
+ if (req.method === 'POST' && path === '/scan-address') {
926
+ if (!this._store) {
927
+ res.writeHead(500, { 'Content-Type': 'application/json' })
928
+ res.end(JSON.stringify({ error: 'Store not available' }))
929
+ return
930
+ }
931
+ let body = ''
932
+ req.on('data', chunk => { body += chunk })
933
+ req.on('end', async () => {
934
+ try {
935
+ const { address } = JSON.parse(body)
936
+ if (!address || typeof address !== 'string' || address.length < 25 || address.length > 35) {
937
+ res.writeHead(400, { 'Content-Type': 'application/json' })
938
+ res.end(JSON.stringify({ error: 'Invalid address' }))
939
+ return
940
+ }
941
+
942
+ // Stream progress via SSE
943
+ res.writeHead(200, {
944
+ 'Content-Type': 'text/event-stream',
945
+ 'Cache-Control': 'no-cache',
946
+ 'Connection': 'keep-alive',
947
+ 'Access-Control-Allow-Origin': '*'
948
+ })
949
+
950
+ const result = await scanAddress(address, this._store, (progress) => {
951
+ res.write('data: ' + JSON.stringify(progress) + '\n\n')
952
+ })
953
+
954
+ res.write('data: ' + JSON.stringify({ phase: 'complete', result }) + '\n\n')
955
+ res.end()
956
+ } catch (err) {
957
+ if (!res.headersSent) {
958
+ res.writeHead(500, { 'Content-Type': 'application/json' })
959
+ res.end(JSON.stringify({ error: err.message }))
960
+ } else {
961
+ res.write('data: ' + JSON.stringify({ phase: 'error', error: err.message }) + '\n\n')
962
+ res.end()
963
+ }
964
+ }
965
+ })
966
+ return
967
+ }
968
+
969
+ // POST /rebuild-inscription-index — deduplicate and rebuild secondary indexes
970
+ if (req.method === 'POST' && path === '/rebuild-inscription-index') {
971
+ if (!this._store) {
972
+ res.writeHead(500, { 'Content-Type': 'application/json' })
973
+ res.end(JSON.stringify({ error: 'Store not available' }))
974
+ return
975
+ }
976
+ try {
977
+ const count = await this._store.rebuildInscriptionIndex()
978
+ res.writeHead(200, { 'Content-Type': 'application/json' })
979
+ res.end(JSON.stringify({ rebuilt: count }))
980
+ } catch (err) {
981
+ res.writeHead(500, { 'Content-Type': 'application/json' })
982
+ res.end(JSON.stringify({ error: err.message }))
983
+ }
984
+ return
985
+ }
986
+
987
+ // GET /apps — app health, SSL, and usage data
988
+ if (req.method === 'GET' && path === '/apps') {
989
+ const apps = []
990
+ if (this._config.apps) {
991
+ for (const app of this._config.apps) {
992
+ const entry = this._appChecks.get(app.url) || { checks: [], lastError: null }
993
+ const checks = entry.checks
994
+ const checksUp = checks.filter(c => c.up).length
995
+ const latest = checks.length > 0 ? checks[checks.length - 1] : null
996
+
997
+ let ssl = null
998
+ try {
999
+ const hostname = new URL(app.url).hostname
1000
+ const cached = this._appSSLCache.get(hostname)
1001
+ if (cached && cached.data && Date.now() - cached.checkedAt < 3600000) {
1002
+ ssl = cached.data
1003
+ } else {
1004
+ ssl = await this._checkSSL(hostname)
1005
+ this._appSSLCache.set(hostname, { data: ssl, checkedAt: Date.now() })
1006
+ }
1007
+ } catch {}
1008
+
1009
+ const usage = this._requestTracker.get(app.bridgeDomain) || { total: 0, endpoints: {}, lastSeen: null }
1010
+
1011
+ apps.push({
1012
+ name: app.name,
1013
+ url: app.url,
1014
+ bridgeDomain: app.bridgeDomain,
1015
+ health: {
1016
+ status: latest ? (latest.up ? 'online' : 'offline') : 'unknown',
1017
+ statusCode: latest ? latest.statusCode : 0,
1018
+ responseTimeMs: latest ? latest.responseTimeMs : 0,
1019
+ lastCheck: latest ? latest.timestamp : null,
1020
+ lastError: entry.lastError,
1021
+ uptimePercent: checks.length > 0 ? Math.round((checksUp / checks.length) * 1000) / 10 : 0,
1022
+ checksTotal: checks.length,
1023
+ checksUp
1024
+ },
1025
+ ssl,
1026
+ usage: {
1027
+ totalRequests: usage.total,
1028
+ endpoints: { ...usage.endpoints },
1029
+ lastSeen: usage.lastSeen
1030
+ }
1031
+ })
1032
+ }
1033
+ }
1034
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1035
+ res.end(JSON.stringify({ apps }))
1036
+ return
1037
+ }
1038
+
1039
+ res.writeHead(404)
1040
+ res.end('Not Found')
1041
+ }
1042
+
93
1043
  /**
94
1044
  * Stop the HTTP server.
95
1045
  * @returns {Promise<void>}
96
1046
  */
97
1047
  stop () {
1048
+ this.stopAppMonitoring()
98
1049
  return new Promise((resolve) => {
99
1050
  if (this._server) {
100
1051
  this._server.close(() => resolve())