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