archy/neode-ui/mock-backend.js
archipelago 4d0c2d6717 feat(demo): real testnet tx links + interactive buy-files flow
- Tx/explorer links open mempool.space/testnet/tx/<id>; the backend hydrates the
  wallet's transactions with REAL recent testnet txids at startup (best-effort,
  falls back to mock hashes offline). Mempool app + demo-external apps open in a
  new tab; deep-link paths are carried through.
- Add the content.* paid-download handlers the buy flow needs (owned-list,
  preview-peer, download-peer-{paid,invoice,onchain}, request-invoice,
  invoice-status, request-onchain, onchain-status) — every path resolves to a
  success state with testnet receive addresses / bolt11 invoices so visitors can
  walk the full buy → unlock journey.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:53:05 -04:00

4228 lines
174 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Archipelago Mock Backend Server
* Pure Archipelago implementation - NO StartOS dependencies
* Supports dev modes: setup, onboarding, existing
*/
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import { WebSocketServer } from 'ws'
import http from 'http'
import { exec } from 'child_process'
import { promisify } from 'util'
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
import Docker from 'dockerode'
import { AsyncLocalStorage } from 'node:async_hooks'
import crypto from 'crypto'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const execPromise = promisify(exec)
// DEMO mode: public, multi-visitor sandbox. Each visitor gets an isolated,
// ephemeral copy of all mutable state (see per-session store below), real
// container runtimes are never touched, and idle sessions are reaped.
// When DEMO is off, behaviour is identical to the classic single-user dev mock.
const DEMO =
process.env.DEMO === '1' ||
process.env.VITE_DEMO === '1' ||
process.env.VITE_DEV_MODE === 'demo'
// Find container socket: Podman (macOS/Linux) or Docker
import { existsSync, readFileSync } from 'fs'
// Report the real app version, suffixed with -demo in the public sandbox so it's
// obviously the demo while still tracking whatever version the UI ships.
let APP_VERSION = '0.1.0'
try {
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
if (pkg.version) APP_VERSION = pkg.version
} catch { /* fall back to default */ }
if (DEMO) APP_VERSION += '-demo'
function findContainerSocket() {
// DOCKER_HOST env var (set by podman machine start)
if (process.env.DOCKER_HOST) {
const p = process.env.DOCKER_HOST.replace('unix://', '')
if (existsSync(p)) return p
}
// Podman machine socket (macOS) — check TMPDIR-based path
if (process.env.TMPDIR) {
const podmanDir = path.join(path.dirname(process.env.TMPDIR), 'podman')
const sock = path.join(podmanDir, 'podman-machine-default-api.sock')
if (existsSync(sock)) return sock
}
// Docker socket
if (existsSync('/var/run/docker.sock')) return '/var/run/docker.sock'
// Linux podman rootless
const uid = process.getuid?.() || 1000
const linuxSock = `/run/user/${uid}/podman/podman.sock`
if (existsSync(linuxSock)) return linuxSock
return null
}
// In DEMO mode we never bind to a real container runtime — the public demo must
// be host-independent and unable to touch the host's Docker/Podman.
const containerSocket = DEMO ? null : findContainerSocket()
const docker = containerSocket ? new Docker({ socketPath: containerSocket }) : null
if (DEMO) {
console.log('[Container] DEMO mode — simulation only (real runtime disabled)')
} else if (containerSocket) {
console.log(`[Container] Socket: ${containerSocket}`)
} else {
console.log('[Container] No socket found — simulation mode (no Docker/Podman)')
}
const app = express()
const PORT = 5959
// Dev mode from environment (setup, onboarding, existing, boot, or default)
const DEV_MODE = process.env.VITE_DEV_MODE || 'default'
// Boot mode: simulate server startup delay
let BOOT_START_TIME = Date.now()
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
// CORS configuration
const corsOptions = {
credentials: true,
origin: (origin, callback) => {
if (!origin) return callback(null, true)
callback(null, true)
}
}
app.use(cors(corsOptions))
// Skip JSON body parsing for filebrowser upload routes (binary file bodies)
app.use((req, res, next) => {
if (req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') {
return next()
}
express.json({ limit: '50mb' })(req, res, next)
})
app.use(cookieParser())
// DEMO: bind every request to an isolated per-visitor state store (keyed by the
// `demo_sid` cookie) for the remainder of the request. Outside DEMO this is a
// no-op and all handlers share the single default store (classic mock behaviour).
app.use((req, res, next) => {
if (!DEMO) return next()
const store = resolveSessionStore(req, res)
stateContext.run(store, () => next())
})
// Mock session storage
const sessions = new Map()
// Public demo uses a memorable shared password (shown on the login screen);
// the classic dev mock keeps password123.
const MOCK_PASSWORD = DEMO ? 'entertoexit' : 'password123'
// Mutable wallet state — faucet/send/receive modify these values.
// SEED_* objects are pristine templates; each demo session gets a deep clone
// (see makeSessionStore). `walletState` itself becomes a session-aware proxy below.
const SEED_WALLET = {
onchain_sats: 2_350_000,
channel_sats: 8_250_000,
ecash_sats: 250_000,
ecash_tokens: 12,
block_height: 892451,
transactions: [
{ tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, direction: 'incoming', num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding', total_fees: 0, dest_addresses: [] },
{ tx_hash: 'cd34ef5678901234567890abcdef1234567890ab', amount_sats: 250_000, direction: 'incoming', num_confirmations: 28, block_height: 892420, time_stamp: Math.floor(Date.now()/1000) - 7200, label: 'Faucet deposit', total_fees: 0, dest_addresses: [] },
{ tx_hash: 'ff99ee88dd7766554433221100aabbccddeeff00', amount_sats: 100_000, direction: 'incoming', num_confirmations: 0, block_height: 0, time_stamp: Math.floor(Date.now()/1000) - 600, label: 'Incoming from faucet', total_fees: 0, dest_addresses: [] },
],
}
function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') }
const SEED_BTCRELAY = {
settings: {
enabled_for_peers: true,
allow_peer_requests: true,
allow_http: false,
allow_https: true,
allow_tor: true,
selected_peer_pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04',
http_endpoint: '',
https_endpoint: 'https://shard.tx1138.com/',
tor_endpoint: 'http://btc-relay-demoabcdefghijklmnop.onion/',
},
trusted_nodes: [
{
pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04',
onion: 'trustedalphaabcdefghijklmnop.onion',
name: 'Trusted Alpha',
relay_approved: true,
},
{
pubkey: '02f6ab6c88037cd527a92f3a016a7bd18bb2ebd91d5a3efb1161481b5cf7d9ea2a',
onion: 'trustedbetabcdefghijklmnopq.onion',
name: 'Trusted Beta',
relay_approved: false,
},
],
requests: [
{
id: 'relay-demo-incoming',
direction: 'incoming',
status: 'pending',
peer_pubkey: '02f6ab6c88037cd527a92f3a016a7bd18bb2ebd91d5a3efb1161481b5cf7d9ea2a',
peer_onion: 'trustedbetabcdefghijklmnopq.onion',
peer_name: 'Trusted Beta',
message: 'Can I use your node for a Wasabi broadcast?',
created_at: new Date(Date.now() - 12 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 12 * 60 * 1000).toISOString(),
},
],
}
// User state (simulated file-based storage). Returns a fresh object per session.
// In DEMO the visitor is always treated as fully set up ("existing") so the
// onboarding WIZARD (seed/identity/backup) is never forced by the route guard.
// The welcome INTRO still shows via the frontend's per-day replay gate.
function seedUserState() {
const mode = DEMO ? 'existing' : DEV_MODE
switch (mode) {
case 'setup':
// Setup mode: user needs to set a password (simple setup, not onboarding).
return { setupComplete: false, onboardingComplete: false, passwordHash: null }
case 'onboarding':
// Password already set; visitor still needs to go through onboarding/intro.
return { setupComplete: true, onboardingComplete: false, passwordHash: MOCK_PASSWORD }
case 'existing':
// Fully set up, just needs to log in.
return { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD }
case 'boot':
// Simulate server startup delay (boot screen), then behave like onboarding.
return { setupComplete: true, onboardingComplete: false, passwordHash: MOCK_PASSWORD }
default:
// Default: fully set up (for UI development).
return { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD }
}
}
function seedMockState() {
return { analyticsEnabled: false }
}
console.log(`[Auth] Dev mode: ${DEV_MODE}${DEMO ? ' (DEMO multi-session)' : ''}`)
// Broadcast a data-update patch to the WebSocket clients of the CURRENT session
// only (so demo visitors never see each other's state). Outside a request context
// (e.g. startup) this resolves to the default store, matching single-user mode.
function broadcastUpdate(patch) {
const message = JSON.stringify({
rev: Date.now(),
patch: patch
})
currentStore().sockets.forEach(client => {
if (client.readyState === 1) { // OPEN
client.send(message)
}
})
}
// Track used ports and running containers
const usedPorts = new Set([5959, 8100])
const runningContainers = new Map()
// Predefined port mappings for known apps
const portMappings = {
'atob': 8102,
'k484': 8103,
'amin': 8104,
'filebrowser': 8083,
'bitcoin-knots': 8332,
'electrs': 50001,
'btcpay-server': 23000,
'lnd': 8080,
'mempool': 4080,
'homeassistant': 8123,
'grafana': 3000,
'searxng': 8888,
'ollama': 11434,
'nextcloud': 8082,
'vaultwarden': 8222,
'jellyfin': 8096,
'photoprism': 2342,
'immich': 2283,
'portainer': 9443,
'uptime-kuma': 3001,
'tailscale': 41641,
'fedimint': 8175,
'thunderhub': 3010,
'nostr-rs-relay': 7000,
'syncthing': 8384,
'penpot': 9001,
'nginx-proxy-manager': 8181,
'indeedhub': 8190,
'dwn': 3000,
'tor': 9050,
}
// Auto-assign port for unknown apps (start at 8200, increment)
let nextAutoPort = 8200
// Helper: Query real Docker containers
async function getDockerContainers() {
if (!docker) return {}
try {
const containers = await docker.listContainers({ all: true })
// Map of container names to app IDs
const containerMapping = {
'archy-bitcoin': 'bitcoin',
'archy-btcpay': 'btcpay-server',
'archy-homeassistant': 'homeassistant',
'archy-grafana': 'grafana',
'archy-endurain': 'endurain',
'archy-fedimint': 'fedimint',
'archy-morphos': 'morphos-server',
'archy-lnd': 'lightning-stack',
'archy-mempool-web': 'mempool',
'mempool-electrs': 'mempool-electrs',
'archy-ollama': 'ollama',
'archy-searxng': 'searxng',
'archy-penpot-frontend': 'penpot'
}
const apps = {}
for (const container of containers) {
const name = container.Names[0].replace(/^\//, '')
const appId = containerMapping[name]
if (!appId) continue
const isRunning = container.State === 'running'
const ports = container.Ports || []
const hostPort = ports.find(p => p.PublicPort)?.PublicPort || null
// Get app metadata
const appMetadata = {
'bitcoin': {
title: 'Bitcoin Core',
icon: '/assets/img/app-icons/bitcoin.svg',
description: 'Full Bitcoin node implementation'
},
'btcpay-server': {
title: 'BTCPay Server',
icon: '/assets/img/app-icons/btcpay-server.png',
description: 'Self-hosted Bitcoin payment processor'
},
'homeassistant': {
title: 'Home Assistant',
icon: '/assets/img/app-icons/homeassistant.png',
description: 'Open source home automation platform'
},
'grafana': {
title: 'Grafana',
icon: '/assets/img/grafana.png',
description: 'Analytics and monitoring platform'
},
'endurain': {
title: 'Endurain',
icon: '/assets/img/endurain.png',
description: 'Application platform'
},
'fedimint': {
title: 'Fedimint',
icon: '/assets/img/app-icons/fedimint.png',
description: 'Federated Bitcoin mint'
},
'morphos-server': {
title: 'MorphOS Server',
icon: '/assets/img/morphos.png',
description: 'Server platform'
},
'lightning-stack': {
title: 'Lightning Stack',
icon: '/assets/img/app-icons/lightning-stack.png',
description: 'Lightning Network (LND)'
},
'mempool': {
title: 'Mempool',
icon: '/assets/img/app-icons/mempool.png',
description: 'Bitcoin blockchain explorer'
},
'mempool-electrs': {
title: 'Electrs',
icon: '/assets/img/app-icons/electrs.svg',
description: 'Electrum protocol indexer for Bitcoin'
},
'ollama': {
title: 'Ollama',
icon: '/assets/img/app-icons/ollama.png',
description: 'Run large language models locally'
},
'searxng': {
title: 'SearXNG',
icon: '/assets/img/app-icons/searxng.png',
description: 'Privacy-respecting metasearch engine'
},
'penpot': {
title: 'Penpot',
icon: '/assets/img/penpot.webp',
description: 'Open-source design and prototyping'
}
}
const metadata = appMetadata[appId] || {
title: appId,
icon: '/assets/icon/pwa-192x192-v2.png',
description: `${appId} application`
}
apps[appId] = {
title: metadata.title,
version: '1.0.0',
status: isRunning ? 'running' : 'stopped',
state: isRunning ? 'running' : 'stopped',
health: isRunning ? 'healthy' : null,
'static-files': {
license: 'MIT',
instructions: metadata.description,
icon: metadata.icon
},
manifest: {
id: appId,
title: metadata.title,
version: '1.0.0',
description: {
short: metadata.description,
long: metadata.description
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '#',
'upstream-repo': '#',
'support-site': '#',
'marketing-site': '#',
'donation-url': null,
interfaces: hostPort ? {
main: {
name: 'Web Interface',
description: `${metadata.title} web interface`,
ui: true
}
} : {}
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': hostPort ? {
main: {
'tor-address': `${appId}.onion`,
'lan-address': `http://localhost:${hostPort}`
}
} : {},
status: isRunning ? 'running' : 'stopped'
}
}
}
return apps
} catch (error) {
console.error('[Docker] Error querying containers:', error.message)
return {}
}
}
// Helper: Check if Docker/Podman is available
async function isContainerRuntimeAvailable() {
try {
// Try Podman first (Archipelago's choice)
await execPromise('podman ps')
return { available: true, runtime: 'podman' }
} catch {
try {
// Fallback to Docker
await execPromise('docker ps')
return { available: true, runtime: 'docker' }
} catch {
return { available: false, runtime: null }
}
}
}
// Marketplace metadata lookup for install (title, description, icon, version)
const marketplaceMetadata = {
'bitcoin-knots': { title: 'Bitcoin Knots', shortDesc: 'Full Bitcoin node — validate and relay blocks and transactions', icon: '/assets/img/app-icons/bitcoin-knots.webp' },
'electrs': { title: 'Electrs', shortDesc: 'Electrum protocol indexer for Bitcoin', icon: '/assets/img/app-icons/electrs.svg' },
'btcpay-server': { title: 'BTCPay Server', shortDesc: 'Self-hosted Bitcoin payment processor', icon: '/assets/img/app-icons/btcpay-server.png' },
'lnd': { title: 'LND', shortDesc: 'Lightning Network Daemon', icon: '/assets/img/app-icons/lnd.svg' },
'mempool': { title: 'Mempool Explorer', shortDesc: 'Bitcoin blockchain and mempool visualizer', icon: '/assets/img/app-icons/mempool.webp' },
'homeassistant': { title: 'Home Assistant', shortDesc: 'Open-source home automation platform', icon: '/assets/img/app-icons/homeassistant.png' },
'grafana': { title: 'Grafana', shortDesc: 'Analytics and monitoring dashboards', icon: '/assets/img/app-icons/grafana.png' },
'searxng': { title: 'SearXNG', shortDesc: 'Privacy-respecting metasearch engine', icon: '/assets/img/app-icons/searxng.png' },
'ollama': { title: 'Ollama', shortDesc: 'Run large language models locally', icon: '/assets/img/app-icons/ollama.png' },
'penpot': { title: 'Penpot', shortDesc: 'Open-source design and prototyping platform', icon: '/assets/img/app-icons/penpot.webp' },
'nextcloud': { title: 'Nextcloud', shortDesc: 'Self-hosted cloud storage and collaboration', icon: '/assets/img/app-icons/nextcloud.webp' },
'vaultwarden': { title: 'Vaultwarden', shortDesc: 'Self-hosted password manager (Bitwarden-compatible)', icon: '/assets/img/app-icons/vaultwarden.webp' },
'jellyfin': { title: 'Jellyfin', shortDesc: 'Free media server for movies, music, and photos', icon: '/assets/img/app-icons/jellyfin.webp' },
'photoprism': { title: 'PhotoPrism', shortDesc: 'AI-powered photo management', icon: '/assets/img/app-icons/photoprism.svg' },
'immich': { title: 'Immich', shortDesc: 'High-performance photo and video backup', icon: '/assets/img/app-icons/immich.png' },
'filebrowser': { title: 'File Browser', shortDesc: 'Web-based file manager', icon: '/assets/img/app-icons/file-browser.webp' },
'nginx-proxy-manager': { title: 'Nginx Proxy Manager', shortDesc: 'Easy proxy management with SSL', icon: '/assets/img/app-icons/nginx.svg' },
'portainer': { title: 'Portainer', shortDesc: 'Container management UI', icon: '/assets/img/app-icons/portainer.webp' },
'uptime-kuma': { title: 'Uptime Kuma', shortDesc: 'Self-hosted monitoring tool', icon: '/assets/img/app-icons/uptime-kuma.webp' },
'tailscale': { title: 'Tailscale', shortDesc: 'Zero-config VPN for secure remote access', icon: '/assets/img/app-icons/tailscale.webp' },
'fedimint': { title: 'Fedimint', shortDesc: 'Federated Bitcoin mint with Guardian UI', icon: '/assets/img/app-icons/fedimint.png' },
'indeedhub': { title: 'Indeehub', shortDesc: 'Bitcoin documentary streaming platform', icon: '/assets/img/app-icons/indeedhub.png' },
'dwn': { title: 'Decentralized Web Node', shortDesc: 'Store and sync personal data with DID-based access', icon: '/assets/img/app-icons/dwn.svg' },
'nostr-rs-relay': { title: 'Nostr Relay', shortDesc: 'Run your own Nostr relay', icon: '/assets/img/app-icons/nostr-rs-relay.svg' },
'syncthing': { title: 'Syncthing', shortDesc: 'Peer-to-peer file synchronization', icon: '/assets/img/app-icons/syncthing.png' },
'thunderhub': { title: 'ThunderHub', shortDesc: 'Lightning node management UI with channel management and payments', icon: '/assets/img/app-icons/thunderhub.svg' },
'tor': { title: 'Tor', shortDesc: 'Anonymous communication over the Tor network', icon: '/assets/img/app-icons/tor.png' },
'amin': { title: 'Amin', shortDesc: 'Administrative interface for Archipelago', icon: '/assets/icon/pwa-192x192-v2.png' },
}
// Helper: Install package with container runtime (if available) or simulate
async function installPackage(id, manifestUrl, opts = {}) {
console.log(`[Package] 📦 Installing ${id}...`)
try {
// Check if already installed
if (mockData['package-data'][id]) {
throw new Error(`Package ${id} is already installed`)
}
const version = opts.version || '0.1.0'
const runtime = await isContainerRuntimeAvailable()
// Get package metadata from marketplace lookup, then fallback
const metadata = marketplaceMetadata[id] || {
title: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
shortDesc: `${id} application`,
icon: `/assets/img/app-icons/${id}.png`
}
// Determine port — use known mapping, or auto-assign a unique one
let assignedPort = portMappings[id]
if (!assignedPort) {
while (usedPorts.has(nextAutoPort)) nextAutoPort++
assignedPort = nextAutoPort++
}
usedPorts.add(assignedPort)
let containerMode = false
let actuallyRunning = false
// Try to run with container runtime if available
if (runtime.available) {
try {
console.log(`[Package] 🐳 ${runtime.runtime} available, attempting to run container...`)
const containerName = `${id}-archipelago`
const stopCmd = runtime.runtime === 'podman'
? `podman stop ${containerName} 2>/dev/null || true`
: `docker stop ${containerName} 2>/dev/null || true`
const rmCmd = runtime.runtime === 'podman'
? `podman rm ${containerName} 2>/dev/null || true`
: `docker rm ${containerName} 2>/dev/null || true`
// Stop and remove existing container if it exists
await execPromise(stopCmd)
await execPromise(rmCmd)
// Check if image exists
const imageCheckCmd = runtime.runtime === 'podman'
? `podman images -q ${id}:${version}`
: `docker images -q ${id}:${version}`
let { stdout } = await execPromise(imageCheckCmd)
if (stdout.trim()) {
// Image exists, start container
const runCmd = runtime.runtime === 'podman'
? `podman run -d --name ${containerName} -p ${assignedPort}:80 ${id}:${version}`
: `docker run -d --name ${containerName} -p ${assignedPort}:80 ${id}:${version}`
await execPromise(runCmd)
// Wait for container to be ready
await new Promise(resolve => setTimeout(resolve, 2000))
// Verify container is running
const statusCmd = runtime.runtime === 'podman'
? `podman ps --filter name=${containerName} --format "{{.Status}}"`
: `docker ps --filter name=${containerName} --format "{{.Status}}"`
const { stdout: containerStatus } = await execPromise(statusCmd)
if (containerStatus.includes('Up')) {
containerMode = true
actuallyRunning = true
runningContainers.set(id, {
port: assignedPort,
containerId: containerName,
runtime: runtime.runtime
})
console.log(`[Package] 🐳 ${runtime.runtime} container running on port ${assignedPort}`)
}
} else {
console.log(`[Package] Container image ${id}:${version} not found, using simulation mode`)
}
} catch (containerError) {
console.log(`[Package] ⚠️ Container error (${containerError.message}), falling back to simulation`)
}
} else {
console.log(`[Package] Container runtime not available, using simulation mode`)
}
// If container didn't work, simulate installation
if (!containerMode) {
await new Promise(resolve => setTimeout(resolve, 1500))
runningContainers.set(id, { port: assignedPort, containerId: null, runtime: null })
}
// Add to mock data using staticApp format for consistency
mockData['package-data'][id] = {
...staticApp({
id,
title: metadata.title,
version,
shortDesc: metadata.shortDesc,
longDesc: metadata.shortDesc,
state: 'running',
lanPort: assignedPort,
icon: metadata.icon,
}),
port: assignedPort,
containerMode: containerMode,
actuallyRunning: actuallyRunning,
}
// Broadcast update
broadcastUpdate([
{
op: 'add',
path: `/package-data/${id}`,
value: mockData['package-data'][id]
}
])
if (containerMode) {
console.log(`[Package] ✅ ${id} installed and RUNNING at http://localhost:${assignedPort}`)
} else {
console.log(`[Package] ✅ ${id} installed (simulated)`)
}
return { success: true, port: assignedPort, containerMode }
} catch (error) {
console.error(`[Package] ❌ Installation failed:`, error.message)
throw error
}
}
// Helper: Uninstall package
async function uninstallPackage(id) {
console.log(`[Package] 🗑️ Uninstalling ${id}...`)
try {
if (staticDevApps[id]) {
throw new Error(`${id} is a demo app and cannot be uninstalled`)
}
if (!mockData['package-data'][id]) {
throw new Error(`Package ${id} is not installed`)
}
// Stop container if it's running
const containerInfo = runningContainers.get(id)
if (containerInfo && containerInfo.containerId) {
try {
const runtime = containerInfo.runtime || 'docker'
const stopCmd = runtime === 'podman'
? `podman stop ${containerInfo.containerId} 2>/dev/null || true`
: `docker stop ${containerInfo.containerId} 2>/dev/null || true`
const rmCmd = runtime === 'podman'
? `podman rm ${containerInfo.containerId} 2>/dev/null || true`
: `docker rm ${containerInfo.containerId} 2>/dev/null || true`
console.log(`[Package] 🐳 Stopping container ${containerInfo.containerId}...`)
await execPromise(stopCmd)
await execPromise(rmCmd)
console.log(`[Package] 🐳 Container stopped`)
} catch (error) {
console.log(`[Package] ⚠️ Error stopping container: ${error.message}`)
}
}
await new Promise(resolve => setTimeout(resolve, 1000))
const port = mockData['package-data'][id].port
if (port) {
usedPorts.delete(port)
}
runningContainers.delete(id)
delete mockData['package-data'][id]
broadcastUpdate([
{
op: 'remove',
path: `/package-data/${id}`
}
])
console.log(`[Package] ✅ ${id} uninstalled successfully`)
return { success: true }
} catch (error) {
console.error(`[Package] ❌ Uninstall failed:`, error.message)
throw error
}
}
// Mock data
const SEED_MOCKDATA = {
'server-info': {
id: 'archipelago-demo',
version: APP_VERSION,
name: 'Archipelago',
pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
'status-info': {
restarting: false,
'shutting-down': false,
updated: false,
'backup-progress': null,
'update-progress': null,
},
'lan-address': 'localhost',
'tor-address': 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion',
unread: 3,
'wifi-ssids': ['Home-5G', 'Archipelago-Mesh', 'Neighbors-Open'],
'zram-enabled': true,
},
'package-data': {}, // Will be populated from Docker + static apps
ui: {
name: 'Archipelago',
'ack-welcome': '0.1.0',
marketplace: {
'selected-hosts': [],
'known-hosts': {},
},
theme: 'dark',
},
}
// Helper to build a static app entry
function staticApp({ id, title, version, shortDesc, longDesc, license, state, lanPort, torHost, icon }) {
return {
title,
version,
status: state,
state,
'static-files': {
license: license || 'MIT',
instructions: shortDesc,
icon: icon || `/assets/img/app-icons/${id}.png`,
},
manifest: {
id,
title,
version,
description: { short: shortDesc, long: longDesc || shortDesc },
'release-notes': 'Latest stable release',
license: license || 'MIT',
'wrapper-repo': '#',
'upstream-repo': '#',
'support-site': '#',
'marketing-site': '#',
'donation-url': null,
interfaces: {
main: { name: 'Web Interface', description: `${title} web interface`, ui: true },
},
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: {
'tor-address': torHost ? `${torHost}.onion` : `${id}.onion`,
'lan-address': lanPort ? `http://localhost:${lanPort}` : '',
},
},
status: state,
},
}
}
// Static dev apps (always shown in My Apps when using mock backend)
const staticDevApps = {
'bitcoin-knots': staticApp({
id: 'bitcoin-knots',
title: 'Bitcoin Knots',
version: '27.1',
shortDesc: 'Full Bitcoin node',
longDesc: 'Validate and relay Bitcoin blocks and transactions with the Archipelago custom node UI.',
state: 'running',
lanPort: 8334,
icon: '/assets/img/app-icons/bitcoin-knots.webp',
}),
bitcoin: staticApp({
id: 'bitcoin',
title: 'Bitcoin Core',
version: '27.1',
shortDesc: 'Full Bitcoin node',
longDesc: 'Validate every transaction and block. Full consensus enforcement — the bedrock of sovereignty.',
state: 'running',
lanPort: 8332,
}),
lnd: staticApp({
id: 'lnd',
title: 'LND',
version: '0.18.3',
shortDesc: 'Lightning Network Daemon',
longDesc: 'Instant Bitcoin payments with near-zero fees. Open channels, route payments, earn sats.',
state: 'running',
lanPort: 8080,
}),
electrs: staticApp({
id: 'electrs',
title: 'Electrs',
version: '0.10.6',
shortDesc: 'Electrum Server in Rust',
longDesc: 'Private blockchain indexing for wallet lookups. Connect Sparrow, BlueWallet, or any Electrum-compatible wallet.',
state: 'running',
lanPort: 50001,
}),
mempool: staticApp({
id: 'mempool',
title: 'Mempool',
version: '3.0.0',
shortDesc: 'Blockchain explorer & fee estimator',
longDesc: 'Real-time mempool visualization, transaction tracking, and fee estimation — all on your own node.',
license: 'AGPL-3.0',
state: 'running',
lanPort: 4080,
}),
lorabell: staticApp({
id: 'lorabell',
title: 'LoraBell',
version: '1.0.0',
shortDesc: 'LoRa doorbell',
longDesc: 'Receive doorbell notifications over LoRa radio — no WiFi or internet required.',
state: 'running',
lanPort: null,
}),
meshtastic: staticApp({
id: 'meshtastic',
title: 'Meshtastic',
version: '2-daily-alpine',
shortDesc: 'LoRa mesh networking',
longDesc: 'Open-source mesh networking for LoRa radios. Create decentralized communication networks.',
state: 'running',
lanPort: 4403,
icon: '/assets/img/app-icons/meshcore.svg',
}),
filebrowser: staticApp({
id: 'filebrowser',
title: 'File Browser',
version: '2.27.0',
shortDesc: 'Web-based file manager',
longDesc: 'Browse, upload, and manage files through an elegant web interface. Drag-and-drop uploads, media previews, and sharing.',
state: 'running',
lanPort: 8083,
icon: '/assets/img/app-icons/file-browser.webp',
}),
thunderhub: staticApp({
id: 'thunderhub',
title: 'ThunderHub',
version: '0.13.31',
shortDesc: 'Lightning node management UI',
longDesc: 'Full Lightning node management — channels, payments, routing fees, and node health. Connect to your LND and manage everything from one dashboard.',
state: 'running',
lanPort: 3010,
}),
fedimint: staticApp({
id: 'fedimint',
title: 'Fedimint',
version: '0.10.0',
shortDesc: 'Federated Bitcoin mint',
longDesc: 'Federated Chaumian e-cash mint with Guardian UI. Community custody, private payments, and Lightning gateways.',
state: 'running',
lanPort: 8175,
}),
}
function mergePackageData(dockerApps) {
return { ...dockerApps, ...staticDevApps }
}
// Initialize package data from Docker on startup
async function initializePackageData() {
console.log('[Docker] Querying running containers...')
const dockerApps = await getDockerContainers()
mockData['package-data'] = mergePackageData(dockerApps)
const appCount = Object.keys(mockData['package-data']).length
const runningCount = Object.values(mockData['package-data']).filter(app => app.state === 'running').length
console.log(`[Docker] Found ${appCount} containers (${runningCount} running)`)
if (appCount > 0) {
console.log('[Docker] Apps detected:')
Object.entries(mockData['package-data']).forEach(([id, app]) => {
const port = app.installed?.['interface-addresses']?.main?.['lan-address']
console.log(` - ${app.title} (${app.state})${port ? `${port}` : ''}`)
})
} else {
console.log('[Docker] No containers found. Start docker-compose to see apps.')
}
}
// Handle CORS preflight
app.options('/rpc/v1', (req, res) => {
res.status(200).end()
})
app.get('/', (_req, res) => {
const uiPort = process.env.VITE_DEV_SERVER_PORT || '8102'
res
.status(200)
.type('html')
.send(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=http://localhost:${uiPort}/">
<title>Archipelago dev backend</title>
</head>
<body>
<p>This is the mock JSON-RPC backend. Open the dashboard at
<a href="http://localhost:${uiPort}/">http://localhost:${uiPort}/</a>.
</p>
</body>
</html>`)
})
app.get('/rpc/v1', (_req, res) => {
const uiPort = process.env.VITE_DEV_SERVER_PORT || '8102'
res
.status(405)
.type('text/plain')
.send(`JSON-RPC is available at /rpc/v1 for POST requests only.\nOpen the dashboard at http://localhost:${uiPort}/.\n`)
})
// DEMO runs on a testnet (signet) so visitors can play with worthless coins.
const DEMO_CHAIN = DEMO ? 'signet' : 'main'
function mockBitcoinBlockchainInfo() {
return {
chain: DEMO_CHAIN,
blocks: 902418,
headers: 902418,
bestblockhash: randomHex(32),
difficulty: 126_984_812_384_099.3,
verificationprogress: 0.999998,
initialblockdownload: false,
size_on_disk: 678_000_000_000,
pruned: false,
chainwork: '00000000000000000000000000000000000000008d4b42d8b9b0000000000000',
}
}
function mockBitcoinNetworkInfo() {
return {
version: 270100,
subversion: '/Satoshi:27.1/Knots:20240513/',
protocolversion: 70016,
localservices: '0000000000000409',
localrelay: true,
timeoffset: 0,
networkactive: true,
connections: 18,
networks: [
{ name: 'ipv4', limited: false, reachable: true, proxy: '', proxy_randomize_credentials: false },
{ name: 'onion', limited: false, reachable: true, proxy: '127.0.0.1:9050', proxy_randomize_credentials: true },
],
}
}
function bitcoinRelayStatusPayload() {
return {
settings: bitcoinRelayMockState.settings,
trusted_nodes: bitcoinRelayMockState.trusted_nodes,
requests: bitcoinRelayMockState.requests,
local_node: {
synced: true,
blocks: 902418,
headers: 902418,
chain: DEMO_CHAIN,
status_ok: true,
status_stale: false,
error: null,
},
credentials: {
username: 'txrelay',
available: true,
password_available: true,
rpcauth_available: true,
client_env_available: true,
client_env_path: '/var/lib/archipelago/secrets/bitcoin-rpc-txrelay-client.env',
restart_hint: 'If this was just generated, restart Bitcoin Core/Knots so bitcoind loads the txrelay rpcauth whitelist.',
},
}
}
app.get('/app/bitcoin-ui/', async (_req, res) => {
try {
const html = await fs.readFile(path.join(__dirname, '..', 'docker', 'bitcoin-ui', 'index.html'), 'utf8')
res.type('html').send(html)
} catch (error) {
res.status(500).type('text/plain').send(`Unable to load Bitcoin UI mock: ${error.message}`)
}
})
// ── Mock app UIs served in the in-app iframe (DEMO) ─────────────────────────
function demoAppShell(title, accent, bodyHtml) {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0b0f1a;color:#e6e8ee;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh;padding:28px}
.wrap{max-width:860px;margin:0 auto}
.hd{display:flex;align-items:center;gap:14px;margin-bottom:22px}
.dot{width:11px;height:11px;border-radius:50%;background:${accent};box-shadow:0 0 12px ${accent}}
h1{font-size:22px;font-weight:650}
.sub{color:#8b93a7;font-size:13px;margin-top:2px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:18px}
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:16px}
.k{color:#8b93a7;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
.v{font-size:20px;font-weight:650;margin-top:6px}
.v.mono{font-family:ui-monospace,Menlo,monospace;font-size:13px;word-break:break-all;font-weight:500}
.bar{height:8px;border-radius:6px;background:rgba(255,255,255,.08);overflow:hidden;margin-top:10px}
.bar>i{display:block;height:100%;background:${accent}}
.badge{display:inline-flex;align-items:center;gap:6px;background:rgba(34,197,94,.15);color:#86efac;border:1px solid rgba(34,197,94,.3);padding:4px 10px;border-radius:999px;font-size:12px}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{text-align:left;padding:9px 6px;border-bottom:1px solid rgba(255,255,255,.06)}
th{color:#8b93a7;font-weight:500;font-size:11px;text-transform:uppercase}
.demo-tag{position:fixed;bottom:14px;right:16px;font-size:11px;color:#6b7280}
</style></head><body><div class="wrap">${bodyHtml}</div>
<div class="demo-tag">Archipelago demo · signet</div></body></html>`
}
// 12 trusted/federated nodes for the demo Federation view.
function demoFederationNodes() {
const names = [
['archy-198', 'trusted', ['bitcoin-knots', 'lnd', 'mempool', 'electrs']],
['arch-tailscale-1', 'trusted', ['bitcoin-knots', 'lnd', 'nextcloud']],
['bunker-alpha', 'trusted', ['bitcoin-knots', 'vaultwarden']],
['seaside-node', 'trusted', ['bitcoin-knots', 'lnd', 'fedimint']],
['highland-relay', 'trusted', ['bitcoin-knots', 'electrs', 'mempool']],
['orchard-12', 'trusted', ['bitcoin-knots', 'immich']],
['nimbus-vps', 'trusted', ['bitcoin-knots', 'lnd', 'thunderhub']],
['rivertown', 'trusted', ['bitcoin-knots', 'jellyfin']],
['granite-pi', 'trusted', ['bitcoin-knots', 'lnd']],
['saltmarsh', 'observer', ['bitcoin-knots']],
['dustbowl-node', 'observer', ['bitcoin-knots', 'electrs']],
['frontier-7', 'observer', ['bitcoin-knots', 'mempool']],
]
return names.map(([name, trust, apps], i) => {
const seenAgo = 60000 + i * 95000
return {
did: `did:key:z6Mk${randomHex(20)}`,
pubkey: randomHex(32),
onion: `${name.replace(/[^a-z0-9]/g, '')}${randomHex(8)}.onion`,
trust_level: trust,
added_at: new Date(Date.now() - (i + 2) * 86400000).toISOString(),
name,
last_seen: new Date(Date.now() - seenAgo).toISOString(),
last_state: {
timestamp: new Date(Date.now() - seenAgo).toISOString(),
apps: apps.map(id => ({ id, status: 'running', version: '1.0' })),
cpu_usage_percent: Math.round((6 + (i * 7) % 55) * 10) / 10,
mem_used_bytes: (2 + (i % 6)) * 1_000_000_000,
mem_total_bytes: (i % 2 ? 32 : 16) * 1_000_000_000,
disk_used_bytes: (400 + i * 90) * 1_000_000_000,
disk_total_bytes: (i % 2 ? 2000 : 1000) * 1_000_000_000,
uptime_secs: 86400 * (i + 1),
tor_active: trust === 'trusted',
},
}
})
}
app.get(['/app/electrumx/', '/app/electrs/', '/app/archy-electrs-ui/'], (_req, res) => {
res.type('html').send(demoAppShell('Electrs — Electrum Server', '#22d3ee', `
<div class="hd"><span class="dot"></span><div><h1>Electrs</h1><div class="sub">Electrum server in Rust · signet</div></div></div>
<div class="grid">
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">● Serving clients</span></div></div>
<div class="card"><div class="k">Indexed height</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
<div class="card"><div class="k">RPC port</div><div class="v mono">50002 (SSL)</div></div>
<div class="card"><div class="k">Connected wallets</div><div class="v">3</div></div>
<div class="card"><div class="k">Mempool txs</div><div class="v">12,840</div></div>
<div class="card"><div class="k">DB size</div><div class="v">58.2 GB</div></div>
</div>
<div class="card"><div class="k" style="margin-bottom:8px">Recent client subscriptions</div>
<table><thead><tr><th>Wallet</th><th>Address type</th><th>Subscribed</th></tr></thead><tbody>
<tr><td>Sparrow</td><td>P2WPKH</td><td>2 min ago</td></tr>
<tr><td>BlueWallet</td><td>P2TR</td><td>9 min ago</td></tr>
<tr><td>Electrum</td><td>P2WPKH</td><td>21 min ago</td></tr>
</tbody></table>
</div>`))
})
app.get(['/app/fedimint/', '/app/fedimintd/'], (_req, res) => {
res.type('html').send(demoAppShell('Fedimint — Guardian', '#a78bfa', `
<div class="hd"><span class="dot"></span><div><h1>Fedimint Guardian</h1><div class="sub">Archipelago Federation · 4-of-5 consensus</div></div></div>
<div class="grid">
<div class="card"><div class="k">Consensus</div><div class="v"><span class="badge">● Online</span></div></div>
<div class="card"><div class="k">Epoch</div><div class="v">48,217</div></div>
<div class="card"><div class="k">Guardians online</div><div class="v">5 / 5</div></div>
<div class="card"><div class="k">E-cash issued</div><div class="v">12,480,000 sat</div></div>
<div class="card"><div class="k">Lightning gateway</div><div class="v"><span class="badge">● Connected</span></div></div>
<div class="card"><div class="k">Block sync</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
</div>
<div class="card"><div class="k" style="margin-bottom:8px">Guardians</div>
<table><thead><tr><th>Guardian</th><th>Peer</th><th>Status</th></tr></thead><tbody>
<tr><td>Guardian Alpha</td><td>peer-0</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Beta</td><td>peer-1</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Gamma</td><td>peer-2</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Delta</td><td>peer-3</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Epsilon</td><td>peer-4</td><td><span class="badge">online</span></td></tr>
</tbody></table>
</div>`))
})
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
res.json({
ok: true,
stale: false,
updated_at_ms: Date.now(),
error: null,
blockchain_info: mockBitcoinBlockchainInfo(),
network_info: mockBitcoinNetworkInfo(),
index_info: {
txindex: { synced: true, best_block_height: 902418 },
blockfilterindex: { synced: true, best_block_height: 902418 },
},
zmq_notifications: [
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
],
})
})
app.post('/app/bitcoin-ui/bitcoin-rpc/', (req, res) => {
const { id = 'bitcoin-ui', method } = req.body || {}
const results = {
getblockchaininfo: mockBitcoinBlockchainInfo(),
getnetworkinfo: mockBitcoinNetworkInfo(),
getindexinfo: {
txindex: { synced: true, best_block_height: 902418 },
blockfilterindex: { synced: true, best_block_height: 902418 },
},
getzmqnotifications: [
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
],
getmempoolinfo: { loaded: true, size: 18452, bytes: 62400000, usage: 143000000, maxmempool: 300000000 },
getpeerinfo: Array.from({ length: 18 }, (_, index) => ({ id: index, addr: `198.51.100.${index + 10}:8333`, inbound: index % 3 === 0 })),
}
if (!Object.prototype.hasOwnProperty.call(results, method)) {
return res.json({ id, result: null, error: { code: -32601, message: `Method not found: ${method}` } })
}
res.json({ id, result: results[method], error: null })
})
app.post('/app/bitcoin-ui/rpc/v1', (req, res) => {
const { method, params = {} } = req.body || {}
const now = new Date().toISOString()
switch (method) {
case 'bitcoin.relay-status':
return res.json({ result: bitcoinRelayStatusPayload(), error: null })
case 'bitcoin.relay-update-settings':
bitcoinRelayMockState.settings = {
...bitcoinRelayMockState.settings,
...params,
}
return res.json({ result: bitcoinRelayStatusPayload(), error: null })
case 'bitcoin.relay-request-peer': {
const peer = bitcoinRelayMockState.trusted_nodes.find(p => p.pubkey === params.peer_pubkey)
if (!peer) return res.status(400).json({ error: { message: 'Peer is not in trusted nodes' } })
const request = {
id: `relay-demo-${Date.now()}`,
direction: 'outbound',
status: 'pending',
peer_pubkey: peer.pubkey,
peer_onion: peer.onion,
peer_name: peer.name,
message: params.message || '',
created_at: now,
updated_at: now,
}
bitcoinRelayMockState.requests.push(request)
return res.json({ result: { ok: true, request_id: request.id }, error: null })
}
case 'bitcoin.relay-approve-request':
case 'bitcoin.relay-reject-request': {
const requestId = params.id || params.request_id
const request = bitcoinRelayMockState.requests.find(r => r.id === requestId)
if (!request) return res.status(404).json({ error: { message: `Request not found: ${requestId}` } })
request.status = method.endsWith('approve-request') ? 'approved' : 'rejected'
request.updated_at = now
if (request.status === 'approved') {
request.approved_endpoint = bitcoinRelayMockState.settings.https_endpoint || bitcoinRelayMockState.settings.tor_endpoint || bitcoinRelayMockState.settings.http_endpoint
request.credential_secret_path = '/var/lib/archipelago/secrets/bitcoin-relay-peer-demo.env'
}
return res.json({ result: { ok: true, request_id: request.id }, error: null })
}
case 'bitcoin.relay-create-tor-service':
bitcoinRelayMockState.settings.allow_tor = true
bitcoinRelayMockState.settings.tor_endpoint = 'http://btc-relay-demoabcdefghijklmnop.onion/'
return res.json({
result: {
created: true,
name: 'bitcoin-rpc',
onion_address: 'btc-relay-demoabcdefghijklmnop.onion',
},
error: null,
})
default:
return res.status(404).json({ error: { message: `Unknown mock Bitcoin UI RPC method: ${method}` } })
}
})
// RPC endpoint
app.post('/rpc/v1', (req, res) => {
const { method, params } = req.body
console.log(`[RPC] ${method}`)
// Boot mode: return 502 during simulated startup delay
if (DEV_MODE === 'boot') {
// Reset boot timer when browser does a fresh page load (server.echo with 'boot' message)
if (method === 'server.echo' && params?.message === 'boot-reset') {
BOOT_START_TIME = Date.now()
console.log(`[Boot] Timer RESET — simulating ${BOOT_DELAY_MS / 1000}s startup`)
return res.status(502).json({ error: 'Server starting up (reset)' })
}
const elapsed = Date.now() - BOOT_START_TIME
if (elapsed < BOOT_DELAY_MS) {
const secs = Math.round(elapsed / 1000)
const total = Math.round(BOOT_DELAY_MS / 1000)
console.log(`[Boot] Server starting... ${secs}s / ${total}s`)
return res.status(502).json({ error: 'Server starting up' })
}
if (elapsed < BOOT_DELAY_MS + 2000) {
console.log(`[Boot] Server is now READY (took ${Math.round(elapsed / 1000)}s)`)
}
}
try {
switch (method) {
// Authentication endpoints
case 'auth.setup': {
const { password } = params
if (!password || password.length < 8) {
return res.json({
error: {
code: -32602,
message: 'Password must be at least 8 characters',
},
})
}
// Set up user
userState.setupComplete = true
userState.passwordHash = password // In real app, bcrypt hash
console.log(`[Auth] User setup completed`)
return res.json({ result: { success: true } })
}
case 'auth.isSetup': {
return res.json({ result: userState.setupComplete })
}
case 'auth.onboardingComplete': {
userState.onboardingComplete = true
console.log(`[Auth] Onboarding completed`)
return res.json({ result: true })
}
case 'auth.isOnboardingComplete': {
return res.json({ result: userState.onboardingComplete })
}
case 'auth.resetOnboarding': {
userState.onboardingComplete = false
console.log('[Auth] Onboarding reset')
return res.json({ result: true })
}
case 'app.filebrowser-token': {
// The Cloud/Files UI exchanges this for a filebrowser auth cookie.
// The mock filebrowser endpoints don't validate it, so any token works.
return res.json({ result: { token: `demo-fb-${Date.now().toString(36)}` } })
}
case 'node.did': {
const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
return res.json({ result: { did: mockDid, pubkey: mockPubkey } })
}
case 'node.nostr-publish': {
return res.json({ result: { event_id: 'mock-event-id', success: 2, failed: 0 } })
}
case 'node.nostr-pubkey': {
return res.json({ result: { nostr_pubkey: 'mock-nostr-pubkey-hex' } })
}
case 'node.signChallenge': {
const { challenge } = params || {}
const mockSig = Buffer.from(`mock-sig-${challenge || 'challenge'}`).toString('hex')
return res.json({ result: { signature: mockSig } })
}
case 'identity.verify': {
return res.json({ result: { valid: true } })
}
// BIP-39 seed management (mock for dev mode)
case 'seed.generate': {
const mockWords = [
'abandon', 'ability', 'able', 'about', 'above', 'absent',
'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident',
'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire',
'across', 'act', 'action', 'actor', 'actress', 'actual'
]
return res.json({ result: { words: mockWords } })
}
case 'seed.verify': {
const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
return res.json({ result: { verified: true, did: mockDid, nostr_npub: 'npub1mock...' } })
}
case 'seed.restore': {
const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
return res.json({ result: { did: mockDid, nostr_npub: 'npub1mock...', restored: true } })
}
case 'seed.save-encrypted': {
return res.json({ result: { saved: true } })
}
case 'seed.status': {
return res.json({ result: { has_seed: true, is_legacy: false, identity_count: 1, next_index: 1 } })
}
case 'node.createBackup': {
const { passphrase } = params || {}
if (!passphrase) {
return res.json({ error: { code: -32602, message: 'Missing passphrase' } })
}
const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
return res.json({
result: {
version: 1,
did: mockDid,
pubkey: mockPubkey,
kid: `${mockDid}#key-1`,
encrypted: true,
blob: Buffer.from(`mock-encrypted-backup-${passphrase}`).toString('base64'),
timestamp: new Date().toISOString(),
},
})
}
case 'auth.login': {
const { password } = params
if (!userState.setupComplete) {
return res.json({
error: {
code: -32603,
message: 'User not set up. Please complete setup first.',
},
})
}
// Simple password check (in real app, use bcrypt)
if (password !== userState.passwordHash && password !== MOCK_PASSWORD) {
return res.json({
error: {
code: -32603,
message: 'Password Incorrect',
},
})
}
const sessionId = `session-${Date.now()}`
sessions.set(sessionId, {
createdAt: new Date(),
})
res.cookie('session', sessionId, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
})
return res.json({ result: null })
}
case 'auth.logout': {
const sessionId = req.cookies?.session
if (sessionId) {
sessions.delete(sessionId)
}
res.clearCookie('session')
return res.json({ result: null })
}
case 'server.set-name': {
const name = (params?.name || '').trim()
if (!name || name.length > 64) {
return res.json({ error: { code: -1, message: 'Name must be 1-64 characters' } })
}
mockData['server-info'].name = name
broadcastUpdate()
return res.json({ result: { name } })
}
case 'server.echo': {
return res.json({ result: { message: params?.message || 'Hello from Archipelago!' } })
}
case 'server.time': {
return res.json({
result: {
now: new Date().toISOString(),
uptime: process.uptime(),
},
})
}
case 'server.metrics': {
// Slightly randomize so the dashboard feels alive
return res.json({
result: {
cpu: +(12 + Math.random() * 18).toFixed(1),
memory: +(58 + Math.random() * 8).toFixed(1),
disk: +(34 + Math.random() * 3).toFixed(1),
},
})
}
case 'marketplace.get': {
const mockApps = [
{
id: 'bitcoin',
title: 'Bitcoin Core',
description: 'Full Bitcoin node — validate transactions, enforce consensus rules, and support the network. The foundation of sovereignty.',
version: '27.1',
icon: '/assets/img/app-icons/bitcoin.png',
author: 'Bitcoin Core',
license: 'MIT',
category: 'Bitcoin',
},
{
id: 'lnd',
title: 'LND',
description: 'Lightning Network Daemon — instant, low-fee Bitcoin payments. Open channels, route payments, earn routing fees.',
version: '0.18.3',
icon: '/assets/img/app-icons/lnd.png',
author: 'Lightning Labs',
license: 'MIT',
category: 'Bitcoin',
},
{
id: 'electrs',
title: 'Electrs',
description: 'Electrum Server in Rust — index the blockchain for fast wallet lookups. Connect your hardware wallets privately.',
version: '0.10.6',
icon: '/assets/img/app-icons/electrs.png',
author: 'Roman Zeyde',
license: 'MIT',
category: 'Bitcoin',
},
{
id: 'mempool',
title: 'Mempool',
description: 'Bitcoin blockchain explorer and mempool visualizer. Monitor transactions, fees, and network activity in real time.',
version: '3.0.0',
icon: '/assets/img/app-icons/mempool.png',
author: 'Mempool Space',
license: 'AGPL-3.0',
category: 'Bitcoin',
},
{
id: 'btcpay',
title: 'BTCPay Server',
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments in your store — no fees, no middlemen, no KYC.',
version: '2.0.4',
icon: '/assets/img/app-icons/btcpay.png',
author: 'BTCPay Server',
license: 'MIT',
category: 'Commerce',
},
{
id: 'fedimint',
title: 'Fedimint',
description: 'Federated Chaumian e-cash mint — community custody, private payments, and Lightning gateways for your tribe.',
version: '0.10.0',
icon: '/assets/img/app-icons/fedimint.png',
author: 'Fedimint',
license: 'MIT',
category: 'Bitcoin',
},
{
id: 'thunderhub',
title: 'ThunderHub',
description: 'Lightning node management UI — manage channels, send and receive payments, view routing fees, and monitor your node health.',
version: '0.13.31',
icon: '/assets/img/app-icons/thunderhub.svg',
author: 'Anthony Potdevin',
license: 'MIT',
category: 'Bitcoin',
},
{
id: 'vaultwarden',
title: 'Vaultwarden',
description: 'Self-hosted password manager compatible with Bitwarden clients. Keep your credentials under your own roof.',
version: '1.32.5',
icon: '/assets/img/app-icons/vaultwarden.png',
author: 'Vaultwarden',
license: 'AGPL-3.0',
category: 'Privacy',
},
{
id: 'nextcloud',
title: 'Nextcloud',
description: 'Your personal cloud — files, calendar, contacts, and collaboration. Replace Google Drive and Dropbox entirely.',
version: '29.0.0',
icon: '/assets/img/app-icons/nextcloud.png',
author: 'Nextcloud GmbH',
license: 'AGPL-3.0',
category: 'Cloud',
},
{
id: 'nostr-relay',
title: 'Nostr Relay',
description: 'Run your own Nostr relay — sovereign social networking. Publish notes, follow friends, no censorship possible.',
version: '0.34.0',
icon: '/assets/img/app-icons/nostr-relay.png',
author: 'nostr-rs-relay',
license: 'MIT',
category: 'Social',
},
{
id: 'home-assistant',
title: 'Home Assistant',
description: 'Open-source home automation — control lights, locks, cameras, and sensors. Smart home without the cloud dependency.',
version: '2024.12.0',
icon: '/assets/img/app-icons/home-assistant.png',
author: 'Home Assistant',
license: 'Apache-2.0',
category: 'IoT',
},
{
id: 'syncthing',
title: 'Syncthing',
description: 'Continuous peer-to-peer file synchronization. Sync folders across devices without any cloud service.',
version: '1.28.1',
icon: '/assets/img/app-icons/syncthing.png',
author: 'Syncthing Foundation',
license: 'MPL-2.0',
category: 'Cloud',
},
{
id: 'tor',
title: 'Tor',
description: 'Anonymous communication — route traffic through the Tor network. Access your node from anywhere, privately.',
version: '0.4.8.13',
icon: '/assets/img/app-icons/tor.png',
author: 'Tor Project',
license: 'BSD-3',
category: 'Privacy',
},
]
return res.json({ result: mockApps })
}
case 'server.update':
case 'server.restart':
case 'server.shutdown': {
return res.json({ result: 'ok' })
}
case 'package.install': {
const { id, url, dockerImage, version } = params
installPackage(id, url, { dockerImage, version }).catch(err => {
console.error(`[RPC] Installation failed:`, err.message)
})
return res.json({ result: `job-${Date.now()}` })
}
case 'package.uninstall': {
const { id } = params
uninstallPackage(id).catch(err => {
console.error(`[RPC] Uninstall failed:`, err.message)
})
return res.json({ result: 'ok' })
}
case 'package.start':
case 'package.stop':
case 'package.restart': {
return res.json({ result: 'ok' })
}
case 'auth.totp.status': {
return res.json({ result: { enabled: false } })
}
case 'auth.totp.setup.begin': {
return res.json({
result: {
qr_svg: '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="#fff"/><text x="100" y="100" text-anchor="middle" font-size="12" fill="#333">Mock QR Code</text></svg>',
secret_base32: 'JBSWY3DPEHPK3PXP',
pending_token: 'mock-pending-token',
},
})
}
case 'auth.totp.setup.confirm': {
return res.json({
result: {
enabled: true,
backup_codes: ['ABCD-EFGH', 'JKLM-NPQR', 'STUV-WXYZ', '2345-6789', 'ABCD-2345', 'EFGH-6789', 'JKLM-STUV', 'NPQR-WXYZ'],
},
})
}
case 'auth.totp.disable': {
return res.json({ result: { disabled: true } })
}
case 'auth.login.totp':
case 'auth.login.backup': {
return res.json({ result: { success: true } })
}
// =========================================================================
// Identity & Onboarding
// =========================================================================
case 'identity.create': {
const { name, purpose } = params || {}
console.log(`[Identity] Created identity: "${name || 'Personal'}" (${purpose || 'personal'})`)
return res.json({ result: { success: true, id: `identity-${Date.now()}` } })
}
case 'identity.register-name': {
const { name } = params || {}
console.log(`[Identity] Registered name: ${name}`)
return res.json({ result: { success: true, id: `name-${Date.now()}` } })
}
case 'identity.remove-name': {
return res.json({ result: { success: true } })
}
case 'identity.set-default': {
return res.json({ result: { success: true } })
}
case 'identity.delete': {
return res.json({ result: { success: true } })
}
case 'identity.issue-credential': {
return res.json({ result: { success: true, credential_id: `cred-${Date.now()}` } })
}
case 'identity.revoke-credential': {
return res.json({ result: { success: true } })
}
case 'identity.list': {
return res.json({
result: {
identities: [
{
id: 'id-primary',
name: 'Primary',
purpose: 'Main node identity',
pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
created_at: '2026-01-10T08:00:00Z',
is_default: true,
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1mockprimaryidentitypubkeyvalue0000000000000000000000000abc',
profile: {
display_name: 'Archipelago Node',
about: 'Self-sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com',
},
},
{
id: 'id-anon',
name: 'Anonymous',
purpose: 'Privacy-focused browsing',
pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
did: 'did:key:z6MkvWkza1fMBWhKnYE3CgMgxHLKbN8NmbFRqHcECF4oGrwx',
created_at: '2026-02-14T12:30:00Z',
is_default: false,
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
profile: { display_name: 'Anon' },
},
{
id: 'id-merchant',
name: 'Merchant',
purpose: 'BTCPay & commerce',
pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp9WNhSwaxN21V',
created_at: '2026-03-01T16:00:00Z',
is_default: false,
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
},
],
},
})
}
case 'identity.get': {
const id = params?.id || 'id-primary'
return res.json({
result: {
id,
name: 'Primary',
purpose: 'Main node identity',
pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
created_at: '2026-01-10T08:00:00Z',
is_default: true,
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1mockprimaryidentitypubkeyvalue0000000000000000000000000abc',
profile: {
display_name: 'Archipelago Node',
about: 'Self-sovereign Bitcoin node',
},
},
})
}
case 'identity.export-keys': {
return res.json({
result: {
ed25519_secret_hex: 'deadbeef'.repeat(8),
nostr_secret_hex: 'cafebabe'.repeat(8),
nostr_nsec: 'nsec1mockexportedkeyvalue00000000000000000000000000000000000abc',
},
})
}
case 'identity.update-profile': {
return res.json({ result: { success: true } })
}
case 'identity.publish-profile': {
return res.json({ result: { event_id: `evt-${Date.now().toString(36)}` } })
}
case 'identity.list': {
return res.json({
result: {
identities: [
{
id: 'id-primary',
name: 'Primary',
purpose: 'personal',
pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
created_at: '2026-01-10T08:00:00Z',
is_default: true,
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
profile: {
display_name: 'Satoshi',
about: 'Running a sovereign Bitcoin node',
nip05: 'satoshi@archipelago.local',
lud16: 'satoshi@getalby.com',
},
},
{
id: 'id-business',
name: 'Business',
purpose: 'business',
pubkey: 'f6e5d4c3b2a10987654321fedcba0987654321fedcba0987654321fedcba0987',
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
created_at: '2026-02-05T12:30:00Z',
is_default: false,
nostr_pubkey: 'f6e5d4c3b2a10987654321fedcba0987654321fedcba0987654321fedcba0987',
nostr_npub: 'npub17m9mx9kk2ry8p3xfkq0070fjlph9r9l0dj0y8fn0zrlj80ekjph7jxmxfg',
profile: {
display_name: 'Archy Consulting',
about: 'Bitcoin infrastructure services',
},
},
{
id: 'id-anon',
name: 'Anonymous',
purpose: 'anonymous',
pubkey: 'deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb',
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
created_at: '2026-03-01T18:00:00Z',
is_default: false,
profile: {},
},
],
},
})
}
case 'identity.get': {
const id = params?.id || 'id-primary'
const identities = {
'id-primary': {
id: 'id-primary', name: 'Primary', purpose: 'personal',
pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
is_default: true, created_at: '2026-01-10T08:00:00Z',
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
},
}
return res.json({ result: identities[id] || identities['id-primary'] })
}
case 'identity.export-keys': {
const { password } = params || {}
if (password !== MOCK_PASSWORD) {
return res.json({ error: { code: -32603, message: 'Incorrect password' } })
}
return res.json({
result: {
ed25519_secret_hex: 'mock_ed25519_secret_' + '0'.repeat(40),
nostr_secret_hex: 'mock_nostr_secret_' + '0'.repeat(44),
nostr_nsec: 'nsec1mockkeymockkeymockkeymockkeymockkeymockkeymockkeymockkeymo',
},
})
}
case 'identity.update-profile': {
console.log(`[Identity] Updated profile for: ${params?.id}`)
return res.json({ result: { success: true } })
}
case 'identity.publish-profile': {
console.log(`[Identity] Published profile for: ${params?.id}`)
return res.json({ result: { event_id: `nostr-event-${Date.now().toString(36)}` } })
}
// =========================================================================
// Nostr
// =========================================================================
case 'nostr.add-relay': {
const { url } = params || {}
console.log(`[Nostr] Added relay: ${url}`)
return res.json({ result: { success: true } })
}
case 'nostr.remove-relay': {
return res.json({ result: { success: true } })
}
case 'nostr.toggle-relay': {
return res.json({ result: { success: true } })
}
// =========================================================================
// Content & Network
// =========================================================================
case 'content.browse-peer': {
const onion = params?.onion || ''
return res.json({
result: {
items: [
{ id: 'peer-doc-1', filename: 'Bitcoin Whitepaper.pdf', mime_type: 'application/pdf', size_bytes: 184292, description: 'The original Bitcoin whitepaper by Satoshi Nakamoto', access: 'free' },
{ id: 'peer-img-1', filename: 'node-setup-guide.png', mime_type: 'image/png', size_bytes: 524800, description: 'Visual guide for setting up a Bitcoin node', access: 'free' },
{ id: 'peer-vid-1', filename: 'Lightning Demo.mp4', mime_type: 'video/mp4', size_bytes: 15728640, description: 'Lightning Network payment channel demo', access: { paid: { price_sats: 500 } } },
],
},
})
}
case 'content.remove': {
return res.json({ result: { success: true } })
}
case 'content.set-pricing': {
return res.json({ result: { success: true } })
}
// ── Demo paid-content / "buying files" flow ───────────────────────────
// Every payment path resolves to success so visitors can experience the
// full buy → unlock journey with testnet addresses and invoices.
case 'content.owned-list': {
return res.json({ result: { items: [] } })
}
case 'content.owned-get':
case 'content.preview-peer':
case 'content.download-peer-paid':
case 'content.download-peer-invoice':
case 'content.download-peer-onchain': {
const filename = params?.filename || 'demo-content'
const body = Buffer.from(
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
`node-to-node content market. On a real node this would be the actual file you purchased.\n`,
'utf-8'
).toString('base64')
return res.json({ result: { data: body, mime_type: 'text/plain', ecash_backend: params?.method || 'cashu' } })
}
case 'content.request-invoice': {
const price = params?.price_sats || 500
return res.json({
result: {
bolt11: 'lntb' + price + '1p' + randomHex(50),
payment_hash: randomHex(32),
price_sats: price,
},
})
}
case 'content.invoice-status': {
// Settle after the first poll so the QR flow shows a realistic wait.
return res.json({ result: { paid: true } })
}
case 'content.request-onchain': {
const price = params?.price_sats || 500
return res.json({
result: {
address: 'tb1q' + randomHex(19),
amount_sats: price,
content_id: params?.content_id || '',
expires_at: new Date(Date.now() + 3600000).toISOString(),
},
})
}
case 'content.onchain-status': {
return res.json({ result: { paid: true, status: 'confirmed', confirmations: 1 } })
}
case 'network.diagnostics': {
return res.json({
result: {
wan_ip: '82.14.203.47',
nat_type: 'Full Cone',
upnp_available: true,
tor_connected: true,
wifi_count: 3,
},
})
}
case 'network.list-interfaces': {
return res.json({
result: {
interfaces: [
{ name: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.168.1.228/24'] },
{ name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.168.1.230/24'] },
{ name: 'lo', type: 'loopback', state: 'up', mac: '00:00:00:00:00:00', ipv4: ['127.0.0.1/8'] },
{ name: 'podman0', type: 'bridge', state: 'up', mac: '2e:f4:8a:11:22:33', ipv4: ['10.89.0.1/16'] },
{ name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.82.97.63/32'] },
],
},
})
}
case 'network.scan-wifi': {
return res.json({
result: {
networks: [
{ ssid: 'HomeNetwork', signal: 92, security: 'WPA2' },
{ ssid: 'Neighbor-5G', signal: 45, security: 'WPA3' },
{ ssid: 'CoffeeShop', signal: 30, security: 'Open' },
],
},
})
}
case 'network.configure-wifi': {
console.log(`[Network] Connecting to WiFi: ${params?.ssid}`)
return res.json({ result: { success: true } })
}
case 'network.dns-status': {
return res.json({
result: {
provider: 'system',
servers: ['1.1.1.1', '9.9.9.9'],
doh_enabled: false,
doh_url: null,
resolv_conf_servers: ['1.1.1.1', '9.9.9.9'],
},
})
}
case 'network.configure-dns': {
console.log(`[Network] DNS configured: ${params?.provider}`)
return res.json({ result: { success: true } })
}
case 'network.accept-request': {
return res.json({ result: { success: true } })
}
case 'network.reject-request': {
return res.json({ result: { success: true } })
}
// =========================================================================
// Server & Auth extras
// =========================================================================
case 'server.health': {
return res.json({
result: {
status: 'healthy',
uptime: Math.floor(process.uptime()),
services: {
backend: 'running',
nginx: 'running',
podman: 'running',
tor: 'running',
},
},
})
}
case 'auth.changePassword': {
const { currentPassword } = params || {}
if (currentPassword !== userState.passwordHash && currentPassword !== MOCK_PASSWORD) {
return res.json({ error: { code: -32603, message: 'Current password is incorrect' } })
}
userState.passwordHash = params.newPassword
console.log('[Auth] Password changed')
return res.json({ result: { success: true } })
}
// =========================================================================
// Router (port forwarding)
// =========================================================================
case 'router.add-forward': {
console.log(`[Router] Added forward: ${JSON.stringify(params)}`)
return res.json({ result: { success: true, id: `fwd-${Date.now()}` } })
}
case 'router.remove-forward': {
return res.json({ result: { success: true } })
}
case 'router.list-forwards': {
return res.json({
result: {
forwards: [
{ id: 'fwd-1', name: 'Bitcoin P2P', external_port: 8333, internal_port: 8333, protocol: 'tcp', enabled: true },
{ id: 'fwd-2', name: 'LND gRPC', external_port: 10009, internal_port: 10009, protocol: 'tcp', enabled: true },
],
},
})
}
// =========================================================================
// Tor & Peer Networking
// =========================================================================
case 'tor.list-services': {
return res.json({
result: {
tor_running: true,
services: [
{ name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false },
{ name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false },
{ name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false },
],
},
})
}
case 'node.tor-address': {
return res.json({
result: {
tor_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion',
},
})
}
case 'node-list-peers': {
return res.json({
result: {
peers: [
{ onion: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'a1b2c3d4e5f6', name: 'satoshi-node' },
{ onion: 'peer2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'f6e5d4c3b2a1', name: 'lightning-lab' },
{ onion: 'peer3mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion', pubkey: 'c3d4e5f6a1b2', name: 'sovereign-relay' },
],
},
})
}
case 'node-check-peer': {
return res.json({ result: { onion: params?.onion || '', reachable: Math.random() > 0.2 } })
}
case 'node-add-peer': {
console.log(`[Peers] Added peer: ${params?.onion}`)
return res.json({ result: { success: true } })
}
case 'node-send-message': {
console.log(`[Peers] Sent message to: ${params?.onion}`)
return res.json({ result: { ok: true, sent_to: params?.onion || '' } })
}
case 'node-nostr-discover': {
return res.json({
result: {
nodes: [
{ did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.168.1.50' },
{ did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.168.1.51' },
],
},
})
}
case 'node-messages-received':
case 'node.messages': {
return res.json({
result: {
messages: [
{ from_pubkey: 'a1b2c3d4e5f6', message: 'Hey, your relay is online! Nice uptime.', timestamp: new Date(Date.now() - 3600000).toISOString() },
{ from_pubkey: 'f6e5d4c3b2a1', message: 'Channel opened successfully. 500k sats capacity.', timestamp: new Date(Date.now() - 7200000).toISOString() },
{ from_pubkey: 'c3d4e5f6a1b2', message: 'Backup sync complete. All good on my end.', timestamp: new Date(Date.now() - 86400000).toISOString() },
],
},
})
}
case 'node.notifications': {
return res.json({
result: [
{
id: 'notif-1',
type: 'info',
title: 'Bitcoin Core synced',
message: 'Blockchain fully synced at block 892,451. IBD complete.',
timestamp: new Date(Date.now() - 1800000).toISOString(),
read: false,
},
{
id: 'notif-2',
type: 'success',
title: 'LND channel opened',
message: 'Channel opened with ACINQ (500,000 sats capacity). 6 confirmations received.',
timestamp: new Date(Date.now() - 7200000).toISOString(),
read: false,
},
{
id: 'notif-3',
type: 'warning',
title: 'Disk usage above 80%',
message: 'Storage at 82% capacity. Consider pruning old blockchain data or expanding storage.',
timestamp: new Date(Date.now() - 14400000).toISOString(),
read: true,
},
{
id: 'notif-4',
type: 'info',
title: 'System update available',
message: 'Archipelago v0.1.1 is available. Review changelog before updating.',
timestamp: new Date(Date.now() - 86400000).toISOString(),
read: true,
},
{
id: 'notif-5',
type: 'success',
title: 'Fedimint guardian connected',
message: 'Guardian consensus achieved. Mint is operational with 3/4 guardians online.',
timestamp: new Date(Date.now() - 43200000).toISOString(),
read: false,
},
],
})
}
// =====================================================================
// Federation (multi-node clusters)
// =====================================================================
case 'federation.list-nodes': {
return res.json({ result: { nodes: demoFederationNodes() } })
}
case 'federation.invite': {
const mockCode = 'fed1:' + Buffer.from(JSON.stringify({
did: 'did:key:z6MkTest228NodeInvite',
onion: 'self228abc2def3ghi4jkl5mno6pqr7stu8vwx.onion',
pubkey: 'aabbccdd',
token: 'mock-invite-token-' + Date.now(),
})).toString('base64url')
return res.json({
result: {
code: mockCode,
did: 'did:key:z6MkTest228NodeInvite',
onion: 'self228abc2def3ghi4jkl5mno6pqr7stu8vwx.onion',
},
})
}
case 'federation.join': {
console.log(`[Federation] Joining with code: ${params?.code?.substring(0, 20)}...`)
return res.json({
result: {
joined: true,
node: {
did: 'did:key:z6MkNewJoinedNode',
onion: 'newnode123abc456def789ghi012jkl345mno6pqr.onion',
pubkey: 'ddeeff11',
trust_level: 'trusted',
},
},
})
}
case 'federation.sync-state': {
return res.json({
result: {
synced: 3,
failed: 0,
results: [
{ did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', status: 'synced', apps: 4 },
{ did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', status: 'synced', apps: 3 },
{ did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb', status: 'synced', apps: 2 },
],
},
})
}
case 'federation.set-trust': {
console.log(`[Federation] Set trust: ${params?.did} -> ${params?.trust_level}`)
return res.json({ result: { updated: true, did: params?.did, trust_level: params?.trust_level } })
}
case 'federation.remove-node': {
console.log(`[Federation] Remove node: ${params?.did}`)
return res.json({ result: { removed: true, nodes_remaining: 2 } })
}
case 'federation.deploy-app': {
console.log(`[Federation] Deploy app: ${params?.app_id} to ${params?.target_did}`)
return res.json({ result: { deployed: true, app_id: params?.app_id } })
}
// =====================================================================
// DWN (Decentralized Web Node)
// =====================================================================
case 'dwn.status': {
return res.json({
result: {
running: true,
protocols_registered: 3,
messages_stored: 47,
peers_synced: 2,
last_sync: new Date(Date.now() - 600000).toISOString(),
protocols: [
{ uri: 'https://archipelago.dev/protocols/node-identity/v1', published: true, messages: 12 },
{ uri: 'https://archipelago.dev/protocols/federation/v1', published: false, messages: 28 },
{ uri: 'https://archipelago.dev/protocols/app-deploy/v1', published: false, messages: 7 },
],
},
})
}
case 'dwn.sync': {
console.log('[DWN] Syncing with peers...')
return res.json({ result: { synced: true, messages_pulled: 5, messages_pushed: 3 } })
}
// =====================================================================
// Mesh Networking (LoRa radio via Meshcore)
// =====================================================================
case 'mesh.status': {
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
return res.json({
result: {
enabled: true,
device_type: 'Meshcore',
device_path: '/dev/ttyUSB0',
device_connected: true,
firmware_version: '2.3.1',
self_node_id: 42,
self_advert_name: 'archy-228',
peer_count: 4,
channel_name: 'archipelago',
messages_sent: 23,
messages_received: 47,
detected_devices: ['/dev/ttyUSB0'],
announce_block_headers: globalThis.__meshHeaders.announce_block_headers,
receive_block_headers: globalThis.__meshHeaders.receive_block_headers,
},
})
}
case 'mesh.peers': {
return res.json({
result: {
peers: [
{
contact_id: 1,
advert_name: 'archy-198',
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
rssi: -67,
snr: 9.5,
last_heard: new Date(Date.now() - 30000).toISOString(),
hops: 0,
},
{
contact_id: 2,
advert_name: 'satoshi-relay',
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
rssi: -82,
snr: 4.2,
last_heard: new Date(Date.now() - 120000).toISOString(),
hops: 1,
},
{
contact_id: 3,
advert_name: 'mountain-node',
did: null,
pubkey_hex: null,
rssi: -95,
snr: 1.8,
last_heard: new Date(Date.now() - 600000).toISOString(),
hops: 2,
},
{
contact_id: 4,
advert_name: 'bunker-alpha',
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
rssi: -74,
snr: 7.1,
last_heard: new Date(Date.now() - 45000).toISOString(),
hops: 0,
},
],
count: 4,
},
})
}
case 'mesh.messages': {
const limit = params?.limit || 100
const now = Date.now()
const allMessages = [
{ id: 1, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Node online. Bitcoin Knots synced to tip.', timestamp: new Date(now - 3600000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 2, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Good. Electrs index at 98%. Channel capacity 2.5M sats.', timestamp: new Date(now - 3540000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 3, direction: 'received', peer_contact_id: 2, peer_name: 'satoshi-relay', plaintext: 'Block #890,413 relayed. Fees avg 12 sat/vB.', timestamp: new Date(now - 3000000).toISOString(), delivered: true, encrypted: true, message_type: 'block_header', typed_payload: { alert_type: 'block_header', message: 'Block #890,413 — 2,847 txs, 12 sat/vB avg fee', signed: true } },
{ id: 4, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice: 50,000 sats — Channel opening fee', timestamp: new Date(now - 1800000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: false } },
{ id: 5, direction: 'sent', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Running mesh-only mode. No internet for 48h. All good.', timestamp: new Date(now - 900000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 6, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Copy. Block height 890,412 via compact headers.', timestamp: new Date(now - 840000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 7, direction: 'received', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'EMERGENCY: Solar array failure. Running on battery reserve.', timestamp: new Date(now - 600000).toISOString(), delivered: true, encrypted: false, message_type: 'alert', typed_payload: { alert_type: 'emergency', message: 'Solar array failure. Running on battery reserve. ETA 4h before shutdown.', coordinate: { lat: 39507400, lng: -106042800, label: 'Mountain relay site' }, signed: true } },
{ id: 8, direction: 'sent', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Opening 1M sat channel to your node. Approve?', timestamp: new Date(now - 300000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 9, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Approved. Waiting for funding tx confirmation.', timestamp: new Date(now - 240000).toISOString(), delivered: true, encrypted: true, message_type: 'text' },
{ id: 10, direction: 'sent', peer_contact_id: 3, peer_name: 'mountain-node', plaintext: 'Location shared', timestamp: new Date(now - 120000).toISOString(), delivered: true, encrypted: false, message_type: 'coordinate', typed_payload: { lat: 30267200, lng: -97743100, label: 'Supply drop point' } },
{ id: 11, direction: 'received', peer_contact_id: 4, peer_name: 'bunker-alpha', plaintext: 'Dead man switch check-in. All systems nominal. Battery 78%.', timestamp: new Date(now - 60000).toISOString(), delivered: true, encrypted: true, message_type: 'alert', typed_payload: { alert_type: 'status', message: 'All systems nominal. Battery 78%. Mesh uptime 14d.', signed: true } },
{ id: 12, direction: 'received', peer_contact_id: 1, peer_name: 'archy-198', plaintext: 'Invoice paid: 50,000 sats', timestamp: new Date(now - 30000).toISOString(), delivered: true, encrypted: true, message_type: 'invoice', typed_payload: { bolt11: 'lnbc500000n1pjmesh...truncated...', amount_sats: 50000, memo: 'Channel opening fee', paid: true, payment_hash: 'a1b2c3d4e5f6...' } },
]
return res.json({
result: {
messages: allMessages.slice(0, limit),
count: allMessages.length,
},
})
}
case 'mesh.send': {
const contactId = params?.contact_id
const message = params?.message || ''
const peer = [
{ id: 1, name: 'archy-198', encrypted: true },
{ id: 2, name: 'satoshi-relay', encrypted: true },
{ id: 3, name: 'mountain-node', encrypted: false },
{ id: 4, name: 'bunker-alpha', encrypted: true },
].find(p => p.id === contactId)
console.log(`[Mesh] Send to ${peer?.name || contactId}: ${message}`)
return res.json({
result: {
sent: true,
message_id: Math.floor(Math.random() * 10000) + 100,
encrypted: peer?.encrypted ?? false,
},
})
}
case 'mesh.broadcast': {
console.log('[Mesh] Broadcasting identity over LoRa')
return res.json({ result: { broadcast: true } })
}
case 'mesh.configure': {
console.log(`[Mesh] Configure:`, params)
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
if (params && typeof params.announce_block_headers === 'boolean') globalThis.__meshHeaders.announce_block_headers = params.announce_block_headers
if (params && typeof params.receive_block_headers === 'boolean') globalThis.__meshHeaders.receive_block_headers = params.receive_block_headers
return res.json({ result: { configured: true, ...globalThis.__meshHeaders } })
}
case 'mesh.send-invoice': {
console.log(`[Mesh] Send invoice: ${params?.amount_sats} sats to contact ${params?.contact_id}`)
return res.json({
result: {
sent: true,
message_id: Math.floor(Math.random() * 10000) + 200,
amount_sats: params?.amount_sats,
bolt11: `lnbc${params?.amount_sats}n1pjmesh...`,
},
})
}
case 'mesh.send-coordinate': {
console.log(`[Mesh] Send coordinate: ${params?.lat}, ${params?.lng} to contact ${params?.contact_id}`)
return res.json({
result: {
sent: true,
message_id: Math.floor(Math.random() * 10000) + 300,
lat: Math.round((params?.lat || 0) * 1000000),
lng: Math.round((params?.lng || 0) * 1000000),
},
})
}
case 'mesh.send-alert': {
console.log(`[Mesh] Send alert: ${params?.alert_type}${params?.message}`)
return res.json({
result: {
sent: true,
alert_type: params?.alert_type || 'status',
signed: true,
},
})
}
case 'mesh.outbox': {
return res.json({
result: {
messages: [
{
id: 1,
dest_did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
from_did: 'did:key:z6MkSelf',
created_at: new Date(Date.now() - 1800000).toISOString(),
ttl_secs: 86400,
retry_count: 3,
relay_hops: 0,
expired: false,
},
{
id: 2,
dest_did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp7NQD5EjEREWh',
from_did: 'did:key:z6MkSelf',
created_at: new Date(Date.now() - 7200000).toISOString(),
ttl_secs: 86400,
retry_count: 8,
relay_hops: 1,
expired: false,
},
],
count: 2,
},
})
}
case 'mesh.session-status': {
const hasSess = (params?.contact_id === 1 || params?.contact_id === 4)
return res.json({
result: {
has_session: hasSess,
forward_secrecy: hasSess,
message_count: hasSess ? 23 : 0,
ratchet_generation: hasSess ? 7 : 0,
peer_did: hasSess ? 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9' : null,
},
})
}
case 'mesh.rotate-prekeys': {
console.log('[Mesh] Rotating prekeys...')
return res.json({
result: {
rotated: true,
signed_prekey_id: Math.floor(Math.random() * 1000000),
one_time_prekeys: 10,
},
})
}
case 'mesh.deadman-status': {
return res.json({
result: {
dead_man_enabled: false,
dead_man_interval_secs: 21600,
time_remaining_secs: 21600,
triggered: false,
has_gps: false,
emergency_contacts: 2,
},
})
}
case 'mesh.deadman-configure': {
const { enabled, interval_secs } = params || {}
console.log(`[Mesh] Deadman configured: enabled=${enabled}, interval=${interval_secs}`)
return res.json({
result: {
dead_man_enabled: enabled ?? false,
dead_man_interval_secs: interval_secs ?? 21600,
time_remaining_secs: interval_secs ?? 21600,
triggered: false,
has_gps: false,
emergency_contacts: 2,
},
})
}
case 'mesh.deadman-checkin': {
return res.json({
result: {
checked_in: true,
time_remaining_secs: 21600,
},
})
}
case 'mesh.block-headers': {
return res.json({
result: {
headers: [
{ height: 893421, hash: '0000000000000000000234a6b12dc03e5c4f7e891d2f34b5a678cd9012345678', timestamp: new Date(Date.now() - 600000).toISOString() },
{ height: 893420, hash: '00000000000000000001bc7d89ef01234567890abcdef1234567890abcdef12', timestamp: new Date(Date.now() - 1200000).toISOString() },
{ height: 893419, hash: '00000000000000000003ef4a56789012bcdef34567890abcdef1234567890ab', timestamp: new Date(Date.now() - 1800000).toISOString() },
],
latest_height: 893421,
count: 3,
},
})
}
case 'mesh.relay-tx': {
return res.json({
result: {
request_id: Math.floor(Math.random() * 10000),
queued: true,
tx_hex_len: (params?.tx_hex || '').length,
},
})
}
case 'mesh.relay-status': {
return res.json({
result: {
relayed: true,
pending_count: 0,
status: 'confirmed',
txid: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
},
})
}
case 'mesh.relay-lightning': {
return res.json({
result: {
request_id: Math.floor(Math.random() * 10000),
queued: true,
amount_sats: params?.amount_sats || 0,
},
})
}
// =====================================================================
// Transport Layer (unified routing: mesh > lan > tor)
// =====================================================================
case 'transport.status': {
return res.json({
result: {
transports: [
{ kind: 'mesh', available: true },
{ kind: 'lan', available: true },
{ kind: 'tor', available: true },
],
mesh_only: false,
peer_count: 5,
},
})
}
case 'transport.peers': {
return res.json({
result: {
peers: [
{
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
name: 'archy-198',
trust_level: 'trusted',
mesh_contact_id: 1,
lan_address: '192.168.1.198:5678',
onion_address: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion',
preferred_transport: 'lan',
available_transports: ['mesh', 'lan', 'tor'],
last_seen: new Date(Date.now() - 30000).toISOString(),
},
{
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
name: 'satoshi-relay',
trust_level: 'trusted',
mesh_contact_id: 2,
lan_address: null,
onion_address: 'peer2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion',
preferred_transport: 'mesh',
available_transports: ['mesh', 'tor'],
last_seen: new Date(Date.now() - 120000).toISOString(),
},
{
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
name: 'bunker-alpha',
trust_level: 'observer',
mesh_contact_id: 4,
lan_address: null,
onion_address: null,
preferred_transport: 'mesh',
available_transports: ['mesh'],
last_seen: new Date(Date.now() - 45000).toISOString(),
},
{
did: 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG',
pubkey_hex: 'd4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5',
name: 'office-node',
trust_level: 'trusted',
mesh_contact_id: null,
lan_address: '192.168.1.42:5678',
onion_address: 'peer4mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion',
preferred_transport: 'lan',
available_transports: ['lan', 'tor'],
last_seen: new Date(Date.now() - 60000).toISOString(),
},
{
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp7NQD5EjEREWh',
pubkey_hex: 'e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6',
name: 'remote-cabin',
trust_level: 'trusted',
mesh_contact_id: null,
lan_address: null,
onion_address: 'peer5xyz9abc2def3ghi4jkl5mno6pqr7stu8vw.onion',
preferred_transport: 'tor',
available_transports: ['tor'],
last_seen: new Date(Date.now() - 300000).toISOString(),
},
],
},
})
}
case 'transport.send': {
const targetDid = params?.did
console.log(`[Transport] Send to ${targetDid} via best transport`)
return res.json({
result: {
sent: true,
transport_used: 'mesh',
did: targetDid,
},
})
}
case 'transport.set-mode': {
const meshOnly = params?.mesh_only ?? false
console.log(`[Transport] Set mesh_only mode: ${meshOnly}`)
return res.json({ result: { mesh_only: meshOnly, configured: true } })
}
// =====================================================================
// LND / Lightning
// =====================================================================
case 'lnd.getinfo': {
return res.json({
result: {
alias: 'archy-signet',
color: '#f7931a',
num_active_channels: 4,
num_inactive_channels: 1,
num_pending_channels: 1,
block_height: walletState.block_height,
synced_to_chain: true,
synced_to_graph: true,
version: '0.17.4-beta',
identity_pubkey: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
chains: [{ chain: 'bitcoin', network: 'signet' }],
balance_sats: walletState.onchain_sats,
channel_balance_sats: walletState.channel_sats,
},
})
}
case 'lnd.gettransactions': {
const pending = walletState.transactions.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3).length
return res.json({
result: {
transactions: walletState.transactions,
incoming_pending_count: pending,
},
})
}
case 'lnd.channelbalance': {
return res.json({
result: {
local_balance: { sat: walletState.channel_sats },
remote_balance: { sat: 11750000 },
pending_open_local_balance: { sat: 500000 },
},
})
}
case 'lnd.walletbalance': {
return res.json({
result: {
total_balance: walletState.onchain_sats + 100000,
confirmed_balance: walletState.onchain_sats,
unconfirmed_balance: 100000,
},
})
}
case 'lnd.listchannels': {
return res.json({
result: {
channels: [
{ chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, peer_alias: 'WalletOfSatoshi' },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, peer_alias: 'Voltage' },
{ chan_id: '840921088114691', remote_pubkey: '03456789', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: true, peer_alias: 'Kraken' },
],
},
})
}
case 'lnd.newaddress': {
const addrType = params?.type || 'p2wkh'
const mockAddr = addrType === 'p2tr'
? 'tb1p' + randomHex(29)
: 'tb1q' + randomHex(19)
return res.json({ result: { address: mockAddr } })
}
case 'lnd.addinvoice':
case 'lnd.createinvoice': {
const amt = params?.amt || params?.value || params?.amount_sats || 1000
const rHash = randomHex(32)
return res.json({
result: {
r_hash: rHash,
payment_request: `lnsb${amt}n1pjmock${Date.now().toString(36)}qqqxqyz5vqsp5mock${rHash.slice(0,20)}`,
add_index: Date.now(),
payment_addr: randomHex(32),
},
})
}
case 'lnd.payinvoice':
case 'lnd.sendpayment': {
const amt = params?.amt || params?.amount_sats || 1000
const fee = Math.floor(Math.random() * 10) + 1
walletState.channel_sats = Math.max(0, walletState.channel_sats - amt - fee)
return res.json({
result: {
payment_hash: randomHex(32),
payment_preimage: randomHex(32),
status: 'SUCCEEDED',
fee_sat: fee,
value_sat: amt,
},
})
}
case 'lnd.sendcoins': {
const amt = params?.amount || params?.amt || 50000
walletState.onchain_sats = Math.max(0, walletState.onchain_sats - amt)
const txid = randomHex(32)
walletState.transactions.unshift({
tx_hash: txid, amount_sats: -amt, direction: 'outgoing', num_confirmations: 0,
block_height: 0, time_stamp: Math.floor(Date.now()/1000), label: 'Sent on-chain',
total_fees: 250, dest_addresses: [params?.addr || ''],
})
return res.json({
result: { txid, amount: amt },
})
}
case 'lnd.decodepayreq': {
return res.json({
result: {
destination: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
num_satoshis: params?.pay_req?.match(/lnsb(\d+)/)?.[1] || '1000',
description: 'Mock decoded invoice',
expiry: 3600,
timestamp: Math.floor(Date.now() / 1000),
},
})
}
case 'lnd.openchannel': {
return res.json({
result: {
funding_txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
output_index: 0,
},
})
}
case 'lnd.closechannel': {
return res.json({
result: {
closing_txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
},
})
}
case 'lnd.listinvoices': {
return res.json({ result: { invoices: MOCK_LND_DATA.invoices } })
}
case 'lnd.listpayments': {
return res.json({ result: { payments: MOCK_LND_DATA.payments } })
}
case 'lnd.create-psbt':
case 'lnd.finalize-psbt':
case 'lnd.create-raw-tx': {
return res.json({
result: {
psbt: 'cHNidP8BAH0CAAAA...mockPSBT',
txid: Array.from({length: 32}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join(''),
},
})
}
// =====================================================================
// Wallet / Ecash (Fedimint)
// =====================================================================
case 'wallet.ecash-balance': {
return res.json({
result: {
balance_sats: walletState.ecash_sats,
balance_msat: walletState.ecash_sats * 1000,
token_count: walletState.ecash_tokens,
federations: [
{ federation_id: 'fed1-demo', name: 'Archy Signet Mint', balance_msat: walletState.ecash_sats * 1000, gateway_active: true },
],
},
})
}
case 'wallet.ecash-send': {
const amt = params?.amount_sats || 1000
walletState.ecash_sats = Math.max(0, walletState.ecash_sats - amt)
walletState.ecash_tokens = Math.max(0, walletState.ecash_tokens - 1)
return res.json({
result: {
token: `cashuSend_mock_${amt}_${Date.now().toString(36)}`,
amount_sats: amt,
},
})
}
case 'wallet.ecash-receive': {
const amt = 5000
walletState.ecash_sats += amt
walletState.ecash_tokens += 1
return res.json({
result: {
amount_sats: amt,
federation_id: 'fed1-demo',
},
})
}
case 'wallet.ecash-history': {
return res.json({
result: {
transactions: [
{ type: 'receive', amount_sats: 50000, timestamp: new Date(Date.now() - 86400000).toISOString(), note: 'Minted from Lightning' },
{ type: 'send', amount_sats: 5000, timestamp: new Date(Date.now() - 43200000).toISOString(), note: 'Sent ecash token' },
{ type: 'receive', amount_sats: 10000, timestamp: new Date(Date.now() - 3600000).toISOString(), note: 'Redeemed token' },
],
},
})
}
case 'wallet.networking-profits': {
return res.json({
result: {
total_earned_sats: 42,
total_forwarded_sats: 35025,
forward_count: 3,
period_days: 30,
daily_avg_sats: 1.4,
},
})
}
case 'dev.faucet': {
const amount = params?.amount_sats || 1_000_000
const ecashAmt = Math.floor(amount / 10)
walletState.onchain_sats += amount
walletState.channel_sats += amount
walletState.ecash_sats += ecashAmt
walletState.ecash_tokens += 1
const txid = randomHex(32)
walletState.transactions.unshift({
tx_hash: txid, amount_sats: amount, direction: 'incoming', num_confirmations: 0,
block_height: 0, time_stamp: Math.floor(Date.now()/1000), label: 'Dev faucet',
total_fees: 0, dest_addresses: [],
})
console.log(`[Dev Faucet] +${amount} on-chain, +${amount} Lightning, +${ecashAmt} ecash`)
return res.json({
result: {
onchain: { txid, amount_sats: amount },
lightning: { payment_hash: randomHex(32), amount_sats: amount },
ecash: { token: `cashuSend_faucet_${amount}_${Date.now().toString(36)}`, amount_sats: ecashAmt },
message: `Added ${amount} sats on-chain, ${amount} sats Lightning, ${ecashAmt} sats ecash`,
},
})
}
case 'bitcoin.getinfo': {
return res.json({
result: {
chain: 'signet',
blocks: 892451,
headers: 892451,
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
difficulty: 0.001126515290698186,
mediantime: Math.floor(Date.now() / 1000) - 300,
verificationprogress: 1.0,
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
size_on_disk: 210_000_000,
pruned: false,
network: 'signet',
},
})
}
// =====================================================================
// Analytics / Telemetry
// =====================================================================
case 'analytics.get-status': {
return res.json({ result: { enabled: mockState.analyticsEnabled || false, description: 'Anonymous aggregate statistics. No personal data collected.' } })
}
case 'analytics.enable': {
mockState.analyticsEnabled = true
return res.json({ result: { enabled: true } })
}
case 'analytics.disable': {
mockState.analyticsEnabled = false
return res.json({ result: { enabled: false } })
}
case 'telemetry.fleet-status': {
return res.json({
result: {
nodes: [
{
node_id: 'archy-228',
version: '0.1.0',
uptime_secs: 604800,
cpu_cores: 4,
cpu_pct: +(15 + Math.random() * 20).toFixed(1),
mem_pct: +(38 + Math.random() * 10).toFixed(1),
disk_pct: 34.2,
container_count: 12,
running_count: 10,
federation_peers: 4,
recent_alerts: [],
containers: [
{ id: 'bitcoin', state: 'running', version: '27.0' },
{ id: 'lnd', state: 'running', version: '0.18.0' },
{ id: 'electrs', state: 'running', version: '0.10.6' },
{ id: 'mempool', state: 'running', version: '3.0.0' },
],
reported_at: new Date().toISOString(),
},
{
node_id: 'archy-198',
version: '0.1.0',
uptime_secs: 259200,
cpu_cores: 4,
cpu_pct: +(8 + Math.random() * 12).toFixed(1),
mem_pct: +(25 + Math.random() * 8).toFixed(1),
disk_pct: 22.7,
container_count: 8,
running_count: 7,
federation_peers: 4,
recent_alerts: [{ rule: 'container_crash', message: 'electrs restarted 2x in 1h', timestamp: new Date(Date.now() - 7200000).toISOString() }],
containers: [
{ id: 'bitcoin', state: 'running', version: '27.0' },
{ id: 'lnd', state: 'running', version: '0.18.0' },
{ id: 'electrs', state: 'running', version: '0.10.6' },
],
reported_at: new Date(Date.now() - 60000).toISOString(),
},
{
node_id: 'arch-1',
version: '0.1.0',
uptime_secs: 172800,
cpu_cores: 2,
cpu_pct: +(5 + Math.random() * 10).toFixed(1),
mem_pct: +(45 + Math.random() * 15).toFixed(1),
disk_pct: 61.3,
container_count: 6,
running_count: 6,
federation_peers: 4,
recent_alerts: [],
containers: [
{ id: 'bitcoin', state: 'running', version: '27.0' },
{ id: 'lnd', state: 'running', version: '0.18.0' },
],
reported_at: new Date(Date.now() - 120000).toISOString(),
},
{
node_id: 'arch-2',
version: '0.1.0',
uptime_secs: 86400,
cpu_cores: 2,
cpu_pct: +(3 + Math.random() * 8).toFixed(1),
mem_pct: +(30 + Math.random() * 10).toFixed(1),
disk_pct: 18.9,
container_count: 5,
running_count: 5,
federation_peers: 4,
recent_alerts: [],
containers: [
{ id: 'bitcoin', state: 'running', version: '27.0' },
],
reported_at: new Date(Date.now() - 300000).toISOString(),
},
{
node_id: 'arch-3',
version: '0.1.0',
uptime_secs: 43200,
cpu_cores: 4,
cpu_pct: +(20 + Math.random() * 15).toFixed(1),
mem_pct: +(55 + Math.random() * 10).toFixed(1),
disk_pct: 47.5,
container_count: 10,
running_count: 9,
federation_peers: 4,
recent_alerts: [{ rule: 'disk_warning', message: 'Disk usage approaching 50%', timestamp: new Date(Date.now() - 3600000).toISOString() }],
containers: [
{ id: 'bitcoin', state: 'running', version: '27.0' },
{ id: 'lnd', state: 'running', version: '0.18.0' },
{ id: 'electrs', state: 'running', version: '0.10.6' },
{ id: 'mempool', state: 'running', version: '3.0.0' },
],
reported_at: new Date(Date.now() - 30000).toISOString(),
},
],
},
})
}
case 'telemetry.fleet-alerts': {
return res.json({
result: {
alerts: [
{ node_id: 'archy-198', rule: 'container_crash', message: 'electrs restarted 2x in 1h', timestamp: new Date(Date.now() - 7200000).toISOString() },
{ node_id: 'arch-3', rule: 'disk_warning', message: 'Disk usage approaching 50%', timestamp: new Date(Date.now() - 3600000).toISOString() },
{ node_id: 'arch-1', rule: 'mem_high', message: 'Memory usage above 60%', timestamp: new Date(Date.now() - 86400000).toISOString() },
],
},
})
}
case 'telemetry.fleet-node-history': {
const nodeId = params?.node_id || 'archy-228'
const now = Date.now()
const history = Array.from({ length: 24 }, (_, i) => ({
timestamp: new Date(now - (23 - i) * 3600000).toISOString(),
cpu_pct: +(10 + Math.random() * 30).toFixed(1),
mem_pct: +(30 + Math.random() * 20).toFixed(1),
disk_pct: +(30 + Math.random() * 5).toFixed(1),
}))
return res.json({ result: { history } })
}
case 'analytics.get-snapshot':
case 'telemetry.report': {
return res.json({ result: {
node_id: 'mock-dev-node',
version: '1.2.0-alpha',
uptime_secs: 86400,
cpu_cores: 4,
ram_mb: 16384,
container_count: 12,
running_count: 10,
federation_peers: 2,
recent_alerts: [],
reported_at: new Date().toISOString(),
}})
}
// System / Network / Updates
// =====================================================================
case 'system.stats': {
return res.json({
result: {
cpu_usage_percent: +(12 + Math.random() * 18).toFixed(1),
mem_used_bytes: 6_200_000_000 + Math.floor(Math.random() * 500_000_000),
mem_total_bytes: 16_000_000_000,
disk_used_bytes: 620_000_000_000 + Math.floor(Math.random() * 10_000_000_000),
disk_total_bytes: 1_800_000_000_000,
uptime_secs: Math.floor(process.uptime()) + 604800,
load_avg: [+(0.5 + Math.random() * 1.5).toFixed(2), +(0.8 + Math.random()).toFixed(2), +(0.6 + Math.random()).toFixed(2)],
net_rx_bytes: 12_400_000_000,
net_tx_bytes: 8_900_000_000,
},
})
}
case 'update.status': {
return res.json({
result: {
current_version: APP_VERSION,
latest_version: APP_VERSION,
update_available: false,
release_notes: 'You are running the latest demo build.',
channel: 'stable',
},
})
}
case 'seed.reveal': {
if (!params || !params.password) {
return res.json({ error: { code: -32000, message: 'Password is required to reveal the recovery phrase' } })
}
// Demo gate: accept any non-empty password; reject "wrong" to exercise the error path.
if (params.password === 'wrong') {
return res.json({ error: { code: -32000, message: 'Incorrect password' } })
}
const demo = 'legal winner thank year wave sausage worth useful legal winner thank yellow able cabin dad debris during dose talent layer crater proud drift movie'.split(' ')
return res.json({ result: { words: demo, word_count: demo.length } })
}
case 'update.list-mirrors': {
globalThis.__mockMirrors ||= [
{ url: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json', label: 'Origin (vps2)' },
]
return res.json({ result: { mirrors: globalThis.__mockMirrors } })
}
case 'update.get-source': {
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
return res.json({
result: {
source: globalThis.__swarmPrefs.source,
provide_dht: globalThis.__swarmPrefs.provide_dht,
swarm_available: false, // default build has no iroh-swarm feature
swarm_enabled: false,
},
})
}
case 'update.set-source': {
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
if (params && (params.source === 'origin' || params.source === 'swarm')) {
globalThis.__swarmPrefs.source = params.source
}
if (params && typeof params.provide === 'boolean') {
globalThis.__swarmPrefs.provide_dht = params.provide
}
return res.json({
result: {
source: globalThis.__swarmPrefs.source,
provide_dht: globalThis.__swarmPrefs.provide_dht,
swarm_available: false,
swarm_enabled: false,
},
})
}
case 'network.list-requests': {
return res.json({
result: {
requests: [
{ id: 'req-1', from_did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', from_name: 'archy-198', type: 'federation-join', status: 'pending', created_at: new Date(Date.now() - 3600000).toISOString() },
],
},
})
}
// ── Monitoring ──────────────────────────────────────────────
case 'monitoring.current': {
return res.json({
result: {
system: {
cpu_percent: +(12 + Math.random() * 18).toFixed(1),
load_avg_1: +(0.5 + Math.random() * 1.5).toFixed(2),
load_avg_5: +(0.8 + Math.random()).toFixed(2),
load_avg_15: +(0.6 + Math.random()).toFixed(2),
mem_used_bytes: 6_200_000_000 + Math.floor(Math.random() * 500_000_000),
mem_total_bytes: 16_000_000_000,
disk_used_bytes: 620_000_000_000,
disk_total_bytes: 1_800_000_000_000,
net_rx_bytes: 12_400_000_000 + Math.floor(Math.random() * 100_000_000),
net_tx_bytes: 8_900_000_000 + Math.floor(Math.random() * 50_000_000),
uptime_secs: Math.floor(process.uptime()) + 604800,
},
rpc: { avg_latency_ms: +(2 + Math.random() * 8).toFixed(1), requests_per_minute: Math.floor(30 + Math.random() * 40) },
},
})
}
case 'monitoring.history': {
const points = 60
const history = []
for (let i = 0; i < points; i++) {
history.push({
timestamp: new Date(Date.now() - (points - i) * 60000).toISOString(),
system: {
cpu_percent: +(10 + Math.random() * 25).toFixed(1),
mem_used_bytes: 5_800_000_000 + Math.floor(Math.random() * 1_000_000_000),
mem_total_bytes: 16_000_000_000,
net_rx_bytes: Math.floor(Math.random() * 5_000_000),
net_tx_bytes: Math.floor(Math.random() * 3_000_000),
},
rpc: { avg_latency_ms: +(1 + Math.random() * 10).toFixed(1) },
})
}
return res.json({ result: { history } })
}
case 'monitoring.alerts': {
return res.json({
result: {
alerts: [
{ id: 'a1', kind: 'cpu_high', message: 'CPU usage exceeded 80% for 5 minutes', timestamp: new Date(Date.now() - 7200000).toISOString(), acknowledged: false },
{ id: 'a2', kind: 'disk_high', message: 'Disk usage at 85%', timestamp: new Date(Date.now() - 86400000).toISOString(), acknowledged: true },
],
},
})
}
case 'monitoring.alert-rules': {
return res.json({
result: {
rules: [
{ kind: 'cpu_high', enabled: true, threshold: 80, description: 'Alert when CPU usage exceeds threshold' },
{ kind: 'mem_high', enabled: true, threshold: 85, description: 'Alert when memory usage exceeds threshold' },
{ kind: 'disk_high', enabled: true, threshold: 90, description: 'Alert when disk usage exceeds threshold' },
{ kind: 'backend_error_spike', enabled: false, threshold: 500, description: 'Alert when RPC latency exceeds threshold' },
],
},
})
}
case 'monitoring.configure-alert':
case 'monitoring.acknowledge-alert': {
return res.json({ result: { success: true } })
}
case 'monitoring.export': {
return res.json({ result: { data: 'timestamp,cpu,mem\n2026-04-10T12:00:00Z,15.2,38.5\n' } })
}
case 'monitoring.container-metrics': {
return res.json({
result: {
containers: [
{ name: 'bitcoin-knots', cpu_percent: 8.2, mem_used_bytes: 1_200_000_000, net_rx_bytes: 5_000_000, net_tx_bytes: 3_200_000 },
{ name: 'lnd', cpu_percent: 3.1, mem_used_bytes: 480_000_000, net_rx_bytes: 2_100_000, net_tx_bytes: 1_800_000 },
{ name: 'electrs', cpu_percent: 12.4, mem_used_bytes: 890_000_000, net_rx_bytes: 800_000, net_tx_bytes: 600_000 },
{ name: 'mempool', cpu_percent: 5.6, mem_used_bytes: 320_000_000, net_rx_bytes: 1_500_000, net_tx_bytes: 900_000 },
{ name: 'filebrowser', cpu_percent: 0.8, mem_used_bytes: 45_000_000, net_rx_bytes: 200_000, net_tx_bytes: 150_000 },
],
},
})
}
// ── Fleet / Telemetry ─────────────────────────────────────
case 'telemetry.fleet-status': {
return res.json({
result: {
nodes: [
{
id: 'node-1', name: 'archy-main', did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
status: 'online', last_seen: new Date().toISOString(),
version: '1.3.0', uptime_secs: 604800,
system: { cpu_percent: 15.2, mem_used_bytes: 6_200_000_000, mem_total_bytes: 16_000_000_000, disk_used_bytes: 620_000_000_000, disk_total_bytes: 1_800_000_000_000 },
apps: ['bitcoin-knots', 'lnd', 'electrs', 'mempool', 'filebrowser'],
tor_connected: true,
},
{
id: 'node-2', name: 'archy-198', did: 'did:key:z6Mkp2z3PJbJHbQ95fGk3CqYVNEPE3VNnFGA7yUYkQjXoZTL',
status: 'online', last_seen: new Date(Date.now() - 120000).toISOString(),
version: '1.2.1', uptime_secs: 259200,
system: { cpu_percent: 8.7, mem_used_bytes: 3_100_000_000, mem_total_bytes: 8_000_000_000, disk_used_bytes: 180_000_000_000, disk_total_bytes: 500_000_000_000 },
apps: ['bitcoin-knots', 'lnd', 'mempool'],
tor_connected: true,
},
{
id: 'node-3', name: 'archy-vps', did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5ER7eLYwBqPR4NkDhCUD7Li',
status: 'offline', last_seen: new Date(Date.now() - 3600000).toISOString(),
version: '1.3.0', uptime_secs: 0,
system: { cpu_percent: 0, mem_used_bytes: 0, mem_total_bytes: 4_000_000_000, disk_used_bytes: 45_000_000_000, disk_total_bytes: 80_000_000_000 },
apps: ['lnd'],
tor_connected: false,
},
],
},
})
}
case 'telemetry.fleet-alerts': {
return res.json({
result: {
alerts: [
{ id: 'fa1', node_id: 'node-3', node_name: 'archy-vps', kind: 'node_offline', message: 'Node went offline', timestamp: new Date(Date.now() - 3600000).toISOString(), acknowledged: false },
{ id: 'fa2', node_id: 'node-2', node_name: 'archy-198', kind: 'disk_high', message: 'Disk usage at 36%', timestamp: new Date(Date.now() - 86400000).toISOString(), acknowledged: true },
],
},
})
}
case 'telemetry.fleet-node-history': {
const nodeId = params?.node_id || 'node-1'
const points = 60
const history = []
for (let i = 0; i < points; i++) {
history.push({
timestamp: new Date(Date.now() - (points - i) * 60000).toISOString(),
cpu_percent: +(8 + Math.random() * 20).toFixed(1),
mem_used_bytes: 5_000_000_000 + Math.floor(Math.random() * 2_000_000_000),
})
}
return res.json({ result: { node_id: nodeId, history } })
}
default: {
console.log(`[RPC] Unknown method: ${method}`)
return res.json({
error: {
code: -32601,
message: `Method not found: ${method}`,
},
})
}
}
} catch (error) {
console.error('[RPC Error]', error)
return res.json({
error: {
code: -32603,
message: error.message,
},
})
}
})
// =============================================================================
// Mock FileBrowser API (for Cloud page in demo/Docker deployments)
// =============================================================================
const SEED_FILES = {
'/': [
{ name: 'Music', path: '/Music', size: 0, modified: '2025-03-01T10:00:00Z', isDir: true, type: '' },
{ name: 'Documents', path: '/Documents', size: 0, modified: '2025-02-28T14:30:00Z', isDir: true, type: '' },
{ name: 'Photos', path: '/Photos', size: 0, modified: '2025-02-20T09:15:00Z', isDir: true, type: '' },
{ name: 'Videos', path: '/Videos', size: 0, modified: '2025-01-15T18:00:00Z', isDir: true, type: '' },
],
'/Music': [
{ name: 'Bad Actors Reveal.mp3', path: '/Music/Bad Actors Reveal.mp3', size: 8_400_000, modified: '2025-01-10T12:00:00Z', isDir: false, type: 'audio' },
{ name: 'Architects of Tomorrow.wav', path: '/Music/Architects of Tomorrow.wav', size: 42_000_000, modified: '2025-01-08T15:00:00Z', isDir: false, type: 'audio' },
{ name: 'Sats or Shackles.mp3', path: '/Music/Sats or Shackles.mp3', size: 6_200_000, modified: '2024-12-20T10:00:00Z', isDir: false, type: 'audio' },
{ name: 'The Four Horseman of Technocracy.mp3', path: '/Music/The Four Horseman of Technocracy.mp3', size: 7_800_000, modified: '2024-12-15T11:00:00Z', isDir: false, type: 'audio' },
{ name: 'Inverse Dylan (Remix).mp3', path: '/Music/Inverse Dylan (Remix).mp3', size: 5_600_000, modified: '2024-12-10T16:00:00Z', isDir: false, type: 'audio' },
{ name: 'Hootcoiner.mp3', path: '/Music/Hootcoiner.mp3', size: 4_200_000, modified: '2024-11-28T09:00:00Z', isDir: false, type: 'audio' },
{ name: 'decentrealisation.mp3', path: '/Music/decentrealisation.mp3', size: 5_100_000, modified: '2024-11-20T14:00:00Z', isDir: false, type: 'audio' },
{ name: 'neo-morality.mp3', path: '/Music/neo-morality.mp3', size: 6_800_000, modified: '2024-11-15T11:00:00Z', isDir: false, type: 'audio' },
{ name: 'death is a gift.mp3', path: '/Music/death is a gift.mp3', size: 4_500_000, modified: '2024-11-10T08:00:00Z', isDir: false, type: 'audio' },
{ name: 'Wash the fucking dishes.mp3', path: '/Music/Wash the fucking dishes.mp3', size: 3_900_000, modified: '2024-11-05T13:00:00Z', isDir: false, type: 'audio' },
{ name: 'All the leaves are brown.mp3', path: '/Music/All the leaves are brown.mp3', size: 5_300_000, modified: '2024-10-28T10:00:00Z', isDir: false, type: 'audio' },
{ name: 'Builders not talkers.mp3', path: '/Music/Builders not talkers.mp3', size: 4_700_000, modified: '2024-10-20T15:00:00Z', isDir: false, type: 'audio' },
{ name: 'SMRI.mp3', path: '/Music/SMRI.mp3', size: 5_900_000, modified: '2024-10-15T12:00:00Z', isDir: false, type: 'audio' },
{ name: 'Shadrap.mp3', path: '/Music/Shadrap.mp3', size: 3_400_000, modified: '2024-10-10T09:00:00Z', isDir: false, type: 'audio' },
{ name: 'The Wehrman.mp3', path: '/Music/The Wehrman.mp3', size: 6_100_000, modified: '2024-10-05T14:00:00Z', isDir: false, type: 'audio' },
{ name: 'An Exploited Substrate.mp3', path: '/Music/An Exploited Substrate.mp3', size: 4_800_000, modified: '2024-09-28T11:00:00Z', isDir: false, type: 'audio' },
{ name: 'Govcucks.wav', path: '/Music/Govcucks.wav', size: 38_000_000, modified: '2024-09-20T16:00:00Z', isDir: false, type: 'audio' },
],
'/Documents': [
{ name: 'bitcoin-whitepaper-notes.md', path: '/Documents/bitcoin-whitepaper-notes.md', size: 820, modified: '2025-02-28T14:30:00Z', isDir: false, type: 'text' },
{ name: 'node-setup-checklist.md', path: '/Documents/node-setup-checklist.md', size: 950, modified: '2025-02-25T10:00:00Z', isDir: false, type: 'text' },
{ name: 'lightning-channels.csv', path: '/Documents/lightning-channels.csv', size: 680, modified: '2025-02-20T16:00:00Z', isDir: false, type: 'text' },
{ name: 'sovereignty-manifesto.txt', path: '/Documents/sovereignty-manifesto.txt', size: 1100, modified: '2025-02-15T12:00:00Z', isDir: false, type: 'text' },
{ name: 'backup-log.json', path: '/Documents/backup-log.json', size: 1450, modified: '2025-03-01T02:00:00Z', isDir: false, type: 'text' },
],
'/Photos': [
{ name: 'node-rack-setup.jpg', path: '/Photos/node-rack-setup.jpg', size: 2_400_000, modified: '2025-02-20T09:15:00Z', isDir: false, type: 'image' },
{ name: 'bitcoin-conference-2024.jpg', path: '/Photos/bitcoin-conference-2024.jpg', size: 3_100_000, modified: '2024-12-15T14:30:00Z', isDir: false, type: 'image' },
{ name: 'lightning-network-visualization.png', path: '/Photos/lightning-network-visualization.png', size: 1_800_000, modified: '2025-01-10T11:00:00Z', isDir: false, type: 'image' },
{ name: 'home-server-build.jpg', path: '/Photos/home-server-build.jpg', size: 2_900_000, modified: '2024-11-20T16:45:00Z', isDir: false, type: 'image' },
{ name: 'sunset-from-balcony.jpg', path: '/Photos/sunset-from-balcony.jpg', size: 4_200_000, modified: '2025-02-14T18:30:00Z', isDir: false, type: 'image' },
],
'/Videos': [
{ name: 'node-unboxing-timelapse.mp4', path: '/Videos/node-unboxing-timelapse.mp4', size: 85_000_000, modified: '2024-11-01T10:00:00Z', isDir: false, type: 'video' },
{ name: 'bitcoin-explained-5min.mp4', path: '/Videos/bitcoin-explained-5min.mp4', size: 42_000_000, modified: '2024-10-15T14:00:00Z', isDir: false, type: 'video' },
{ name: 'lightning-payment-demo.mp4', path: '/Videos/lightning-payment-demo.mp4', size: 28_000_000, modified: '2025-01-20T12:00:00Z', isDir: false, type: 'video' },
],
}
const SEED_FILE_CONTENTS = {
'/Documents/bitcoin-whitepaper-notes.md': `# Bitcoin Whitepaper Notes\n\n## Key Concepts\n\n### Peer-to-Peer Electronic Cash\n- No trusted third party needed\n- Double-spending solved via proof-of-work\n- Longest chain = truth\n\n### Proof of Work\n- SHA-256 based hashing\n- Difficulty adjusts every 2016 blocks (~2 weeks)\n- Incentive: block reward + transaction fees\n\n## My Thoughts\n- The 21M supply cap is genius - digital scarcity\n- Lightning Network solves the scaling concern\n- Self-custody is the whole point`,
'/Documents/node-setup-checklist.md': `# Archipelago Node Setup Checklist\n\n## Hardware\n- [x] Intel NUC / Mini PC (16GB RAM minimum)\n- [x] 2TB NVMe SSD\n- [x] USB drive for installer\n- [x] Ethernet cable\n\n## Core Apps\n- [x] Bitcoin Knots\n- [x] LND\n- [x] Mempool Explorer\n- [ ] BTCPay Server\n- [ ] Fedimint`,
'/Documents/lightning-channels.csv': `channel_id,peer_alias,capacity_sats,local_balance,remote_balance,status\nch_001,ACINQ,5000000,2450000,2550000,active\nch_002,WalletOfSatoshi,2000000,1200000,800000,active\nch_003,Voltage,10000000,4500000,5500000,active\nch_004,Kraken,3000000,1800000,1200000,active`,
'/Documents/sovereignty-manifesto.txt': `THE SOVEREIGNTY MANIFESTO\n=========================\n\nWe hold these truths to be self-evident:\n\n1. Your data belongs to you.\n2. Your money should be uncensorable.\n3. Your communications should be private.\n4. Your compute should be sovereign.\n5. Your identity should be self-issued.\n\nRun your own node. Hold your own keys. Own your own data. Be sovereign.`,
'/Documents/backup-log.json': JSON.stringify({ backups: [{ id: 'bkp-2025-03-01', timestamp: '2025-03-01T02:00:00Z', type: 'full', apps: ['bitcoin-knots', 'lnd', 'mempool'], size_mb: 2340, status: 'success' }] }, null, 2),
}
// FileBrowser UI (demo placeholder when launched directly)
app.get('/app/filebrowser/', (req, res) => {
res.type('html').send(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>File Browser</title><style>*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;color:#e0e0e0;font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:rgba(0,0,0,0.4);border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:48px;text-align:center;max-width:400px;backdrop-filter:blur(20px)}
h1{font-size:24px;margin-bottom:12px}p{color:rgba(255,255,255,0.6);font-size:14px;line-height:1.6}</style></head>
<body><div class="card"><h1>File Browser</h1><p>File Browser is running. Use the Cloud page in Archipelago to manage your files.</p></div></body></html>`)
})
// FileBrowser login - return mock JWT
app.post('/app/filebrowser/api/login', (req, res) => {
res.send('"mock-filebrowser-token-demo"')
})
// ── Per-session file store helpers ──────────────────────────────────────────
// store().files = { tree: { '<dir>': [entries] }, contents: { '<path>': string|Buffer }, bytes }
const FB_QUOTA_BYTES = Number(process.env.DEMO_FILE_QUOTA_BYTES) || 50 * 1024 * 1024
function fbNormalize(raw) {
// → leading slash, no trailing slash (root stays '/')
const p = '/' + decodeURIComponent(raw || '').split('/').filter(Boolean).join('/')
return p === '/' ? '/' : p.replace(/\/+$/, '')
}
function fbParent(p) {
const i = p.lastIndexOf('/')
return i <= 0 ? '/' : p.slice(0, i)
}
function fbBase(p) { return p.slice(p.lastIndexOf('/') + 1) }
function fbType(name) {
const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase()
if (['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac'].includes(ext)) return 'audio'
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image'
if (['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext)) return 'video'
if (['txt', 'md', 'json', 'csv', 'log', 'yaml', 'yml', 'xml', 'conf', 'ini'].includes(ext)) return 'text'
return ''
}
function fbContentType(name) {
const t = fbType(name)
const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase()
if (t === 'audio') return ext === 'wav' ? 'audio/wav' : 'audio/mpeg'
if (t === 'image') return ext === 'png' ? 'image/png' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg'
if (t === 'video') return 'video/mp4'
return 'text/plain; charset=utf-8'
}
function fbListResponse(res, items) {
res.json({
items,
numDirs: items.filter(i => i.isDir).length,
numFiles: items.filter(i => !i.isDir).length,
sorting: { by: 'name', asc: true },
})
}
// FileBrowser list resources (root: /api/resources or /api/resources/)
app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => {
const dir = fbNormalize(req.params[0] || '')
const items = currentStore().files.tree[dir] || []
fbListResponse(res, items)
})
// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder)
app.post('/app/filebrowser/api/resources/*', (req, res) => {
const store = currentStore()
const { tree, contents } = store.files
const isFolder = (req.params[0] || '').endsWith('/')
const full = fbNormalize(req.params[0] || '')
const parent = fbParent(full)
const name = fbBase(full)
if (!name) return res.sendStatus(400)
if (!tree[parent]) tree[parent] = []
if (isFolder) {
if (!tree[parent].some(e => e.name === name && e.isDir)) {
tree[parent].push({ name, path: full, size: 0, modified: new Date().toISOString(), isDir: true, type: '' })
if (!tree[full]) tree[full] = []
}
return res.sendStatus(200)
}
// File upload — collect body with a quota guard.
const chunks = []
let size = 0
let aborted = false
req.on('data', (c) => {
size += c.length
if (store.files.bytes + size > FB_QUOTA_BYTES) {
aborted = true
req.destroy()
return
}
chunks.push(c)
})
req.on('end', () => {
if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)')
const buf = Buffer.concat(chunks)
// Replace existing entry of the same name (override=true).
const existing = tree[parent].find(e => e.name === name && !e.isDir)
if (existing) store.files.bytes -= existing.size
tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir))
const type = fbType(name)
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type })
contents[full] = type === 'text' ? buf.toString('utf-8') : buf
store.files.bytes += buf.length
res.sendStatus(200)
})
req.on('error', () => { if (!res.headersSent) res.sendStatus(400) })
})
// FileBrowser delete (file or folder + its subtree)
app.delete('/app/filebrowser/api/resources/*', (req, res) => {
const store = currentStore()
const { tree, contents } = store.files
const full = fbNormalize(req.params[0] || '')
const parent = fbParent(full)
if (tree[parent]) {
const entry = tree[parent].find(e => e.path === full)
if (entry && !entry.isDir) store.files.bytes -= entry.size || 0
tree[parent] = tree[parent].filter(e => e.path !== full)
}
// Recursively drop a directory's children.
if (tree[full]) {
const stack = [full]
while (stack.length) {
const d = stack.pop()
for (const e of tree[d] || []) {
if (e.isDir) stack.push(e.path)
else { store.files.bytes -= e.size || 0; delete contents[e.path] }
}
delete tree[d]
}
}
delete contents[full]
res.sendStatus(200)
})
// FileBrowser rename/move (PATCH with { destination })
app.patch('/app/filebrowser/api/resources/*', (req, res) => {
const store = currentStore()
const { tree, contents } = store.files
const full = fbNormalize(req.params[0] || '')
const dest = fbNormalize((req.body && req.body.destination) || '')
if (!dest || dest === '/') return res.sendStatus(400)
const parent = fbParent(full)
const entry = (tree[parent] || []).find(e => e.path === full)
if (!entry) return res.sendStatus(404)
const newName = fbBase(dest)
entry.name = newName
entry.path = dest
entry.modified = new Date().toISOString()
entry.type = entry.isDir ? '' : fbType(newName)
if (contents[full] !== undefined) { contents[dest] = contents[full]; delete contents[full] }
res.sendStatus(200)
})
// FileBrowser raw file content (text reads, blob/stream fetches)
app.get('/app/filebrowser/api/raw/*', (req, res) => {
const full = fbNormalize(req.params[0] || '')
const content = currentStore().files.contents[full]
if (content === undefined) return res.status(404).send('File not found')
res.type(fbContentType(fbBase(full)))
res.send(Buffer.isBuffer(content) ? content : String(content))
})
// Claude API Proxy (reads ANTHROPIC_API_KEY from environment)
// Uses fetch (Node 22+) for reliable DNS resolution and streaming in Docker/Alpine
// =============================================================================
app.post('/aiui/api/claude/*', async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
return res.status(500).json({
type: 'error',
error: { type: 'configuration_error', message: 'ANTHROPIC_API_KEY not configured on server' }
})
}
const apiPath = '/' + req.params[0]
// Clean request body for Anthropic API
const body = req.body
if (body) {
if (!body.max_tokens) body.max_tokens = 4096
// Fix model IDs — AIUI may send short names
if (body.model && !body.model.includes('-2')) {
const modelMap = {
'claude-haiku-4.5': 'claude-haiku-4-5-20251001',
'claude-haiku-4-5': 'claude-haiku-4-5-20251001',
'claude-sonnet-4-5': 'claude-sonnet-4-5-20250514',
'claude-sonnet-4.5': 'claude-sonnet-4-5-20250514',
}
if (modelMap[body.model]) body.model = modelMap[body.model]
}
// Strip AIUI-specific fields that Anthropic API rejects
delete body.webSearch
delete body.webResults
delete body.context
}
const bodyStr = JSON.stringify(body)
const url = `https://api.anthropic.com${apiPath}`
console.log(`[Claude Proxy] → POST ${url} (${bodyStr.length} bytes, model: ${body?.model || 'unknown'})`)
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 60000)
const apiRes = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: bodyStr,
})
clearTimeout(timeout)
console.log(`[Claude Proxy] ← ${apiRes.status}`)
// Forward status and headers
res.status(apiRes.status)
for (const [key, value] of apiRes.headers.entries()) {
// Skip hop-by-hop headers
if (!['transfer-encoding', 'connection', 'keep-alive'].includes(key.toLowerCase())) {
res.setHeader(key, value)
}
}
// Stream the response body
if (apiRes.body) {
const reader = apiRes.body.getReader()
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) { res.end(); return }
if (!res.writableEnded) res.write(value)
}
}
pump().catch((err) => {
console.error('[Claude Proxy] Stream error:', err.message)
if (!res.writableEnded) res.end()
})
} else {
res.end()
}
} catch (err) {
const msg = err.name === 'AbortError' ? 'Request timed out (60s)' : (err.message || 'Unknown error')
console.error(`[Claude Proxy] Error: ${msg}`)
if (!res.headersSent) {
res.status(502).json({
type: 'error',
error: { type: 'proxy_error', message: msg }
})
}
}
})
// Ollama (local AI) proxy — forwards to localhost:11434
app.all('/aiui/api/ollama/*', (req, res) => {
const ollamaPath = '/' + req.params[0]
const bodyStr = JSON.stringify(req.body)
const options = {
hostname: '127.0.0.1',
port: 11434,
path: ollamaPath,
method: req.method,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
}
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers)
proxyRes.pipe(res)
})
proxyReq.on('error', (err) => {
const msg = err.message || err.code || 'Ollama not available'
console.error('[Ollama Proxy] Error:', msg)
if (!res.headersSent) {
res.status(502).json({ error: msg })
}
})
if (req.method !== 'GET' && req.method !== 'HEAD') {
proxyReq.write(bodyStr)
}
proxyReq.end()
})
// =============================================================================
// Ollama Local AI Proxy (forwards to Ollama on localhost:11434)
// =============================================================================
app.all('/api/ollama/*', (req, res) => {
const ollamaPath = '/' + req.params[0]
const isPost = req.method === 'POST'
const bodyStr = isPost ? JSON.stringify(req.body) : null
const options = {
hostname: '127.0.0.1',
port: 11434,
path: ollamaPath,
method: req.method,
headers: { 'Content-Type': 'application/json' },
}
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers)
proxyRes.pipe(res)
})
proxyReq.on('error', (err) => {
const msg = err.message || err.code || 'Ollama not available'
console.error('[Ollama Proxy] Error:', msg)
if (!res.headersSent) {
res.status(502).json({ error: msg })
}
})
if (bodyStr) proxyReq.write(bodyStr)
proxyReq.end()
})
// Web search stub (no search engine configured in demo)
app.get('/api/web-search', (req, res) => {
res.json({ results: [] })
})
// TMDB API stub (no TMDB key in demo)
app.get('/api/tmdb/*', (req, res) => {
res.json({ results: [] })
})
// Catch-all for unimplemented API endpoints (return JSON, not HTML)
app.all('/api/*', (req, res) => {
res.status(404).json({ error: 'Not available in demo mode' })
})
app.all('/aiui/api/*', (req, res) => {
res.status(404).json({ error: 'Not available in demo mode' })
})
// =============================================================================
// Mock ThunderHub UI + Lightning API (no Docker required)
// =============================================================================
const MOCK_LND_DATA = {
info: {
alias: 'archy-signet',
color: '#f7931a',
public_key: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
num_active_channels: 4,
num_inactive_channels: 1,
num_pending_channels: 1,
block_height: 892451,
synced_to_chain: true,
synced_to_graph: true,
version: '0.17.4-beta commit=v0.17.4-beta',
chains: [{ chain: 'bitcoin', network: 'signet' }],
uris: ['03a1b2c3@archy-signet.onion:9735'],
},
balance: {
total_balance: 2_450_000,
confirmed_balance: 2_350_000,
unconfirmed_balance: 100_000,
},
channelBalance: {
local_balance: { sat: 8_250_000 },
remote_balance: { sat: 11_750_000 },
pending_open_local_balance: { sat: 500_000 },
pending_open_remote_balance: { sat: 0 },
},
channels: [
{ chan_id: '840921088114688', remote_pubkey: '02778f4a4e...acinq', capacity: 5_000_000, local_balance: 2_450_000, remote_balance: 2_550_000, active: true, peer_alias: 'ACINQ Signet', total_satoshis_sent: 850_000, total_satoshis_received: 1_200_000, uptime: 604800, lifetime: 2592000 },
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12...wos', capacity: 2_000_000, local_balance: 1_200_000, remote_balance: 800_000, active: true, peer_alias: 'WalletOfSatoshi', total_satoshis_sent: 350_000, total_satoshis_received: 500_000, uptime: 259200, lifetime: 1296000 },
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98...voltage', capacity: 10_000_000, local_balance: 4_500_000, remote_balance: 5_500_000, active: true, peer_alias: 'Voltage', total_satoshis_sent: 2_100_000, total_satoshis_received: 1_800_000, uptime: 518400, lifetime: 2592000 },
{ chan_id: '840921088114691', remote_pubkey: '03456789ab...kraken', capacity: 3_000_000, local_balance: 100_000, remote_balance: 2_900_000, active: true, peer_alias: 'Kraken', total_satoshis_sent: 50_000, total_satoshis_received: 120_000, uptime: 86400, lifetime: 604800 },
{ chan_id: '840921088114692', remote_pubkey: '02112233aa...old', capacity: 1_000_000, local_balance: 0, remote_balance: 1_000_000, active: false, peer_alias: 'OldPeer-Offline', total_satoshis_sent: 0, total_satoshis_received: 0, uptime: 0, lifetime: 5184000 },
],
pendingChannels: {
pending_open_channels: [
{ channel: { remote_node_pub: '03ffeeddcc...newpeer', capacity: 500_000, local_balance: 500_000, remote_balance: 0 }, confirmation_height: 892452 },
],
pending_closing_channels: [],
pending_force_closing_channels: [],
waiting_close_channels: [],
},
invoices: [
{ memo: 'Channel opening fee', value: 50_000, settled: true, creation_date: Math.floor(Date.now()/1000) - 86400, settle_date: Math.floor(Date.now()/1000) - 85800, payment_request: 'lnsb500000n1pjtest...truncated', state: 'SETTLED', amt_paid_sat: 50_000, r_hash: Buffer.from('aabbccdd01').toString('hex') },
{ memo: 'Test payment', value: 1_000, settled: true, creation_date: Math.floor(Date.now()/1000) - 7200, settle_date: Math.floor(Date.now()/1000) - 7100, payment_request: 'lnsb10000n1pjtest2...truncated', state: 'SETTLED', amt_paid_sat: 1_000, r_hash: Buffer.from('aabbccdd02').toString('hex') },
{ memo: 'Coffee payment', value: 5_000, settled: true, creation_date: Math.floor(Date.now()/1000) - 3600, settle_date: Math.floor(Date.now()/1000) - 3500, payment_request: 'lnsb50000n1pjtest3...truncated', state: 'SETTLED', amt_paid_sat: 5_000, r_hash: Buffer.from('aabbccdd03').toString('hex') },
{ memo: 'Donation', value: 21_000, settled: false, creation_date: Math.floor(Date.now()/1000) - 600, settle_date: 0, payment_request: 'lnsb210000n1pjtest4...truncated', state: 'OPEN', amt_paid_sat: 0, r_hash: Buffer.from('aabbccdd04').toString('hex') },
],
payments: [
{ payment_hash: 'ff00112233', value_sat: 10_000, fee_sat: 3, status: 'SUCCEEDED', creation_date: Math.floor(Date.now()/1000) - 43200, payment_request: 'lnsb100000n1pjpay1...', failure_reason: 'FAILURE_REASON_NONE' },
{ payment_hash: 'ff00112234', value_sat: 100_000, fee_sat: 12, status: 'SUCCEEDED', creation_date: Math.floor(Date.now()/1000) - 21600, payment_request: 'lnsb1000000n1pjpay2...', failure_reason: 'FAILURE_REASON_NONE' },
{ payment_hash: 'ff00112235', value_sat: 500, fee_sat: 1, status: 'SUCCEEDED', creation_date: Math.floor(Date.now()/1000) - 1800, payment_request: 'lnsb5000n1pjpay3...', failure_reason: 'FAILURE_REASON_NONE' },
],
forwarding: [
{ chan_id_in: '840921088114688', chan_id_out: '840921088114690', amt_in: 10_012, amt_out: 10_000, fee: 12, timestamp_ns: (Date.now() - 7200000) * 1e6 },
{ chan_id_in: '840921088114690', chan_id_out: '840921088114689', amt_in: 5_005, amt_out: 5_000, fee: 5, timestamp_ns: (Date.now() - 3600000) * 1e6 },
{ chan_id_in: '840921088114689', chan_id_out: '840921088114691', amt_in: 25_025, amt_out: 25_000, fee: 25, timestamp_ns: (Date.now() - 1200000) * 1e6 },
],
}
// ThunderHub mock web UI
app.get('/app/thunderhub/', (req, res) => {
const d = MOCK_LND_DATA
const totalCap = d.channels.reduce((s, c) => s + c.capacity, 0)
const totalLocal = d.channels.reduce((s, c) => s + c.local_balance, 0)
const totalRemote = d.channels.reduce((s, c) => s + c.remote_balance, 0)
const totalFees = d.forwarding.reduce((s, f) => s + f.fee, 0)
const channelRows = d.channels.map(c => `
<tr>
<td>${c.peer_alias}</td>
<td>${(c.capacity/1e6).toFixed(1)}M</td>
<td><div style="display:flex;gap:4px;align-items:center"><div style="background:#4ade80;height:8px;width:${Math.round(c.local_balance/c.capacity*100)}%;border-radius:4px"></div><span style="font-size:11px;opacity:.6">${(c.local_balance/1e3).toFixed(0)}k</span></div></td>
<td><div style="display:flex;gap:4px;align-items:center"><div style="background:#3b82f6;height:8px;width:${Math.round(c.remote_balance/c.capacity*100)}%;border-radius:4px"></div><span style="font-size:11px;opacity:.6">${(c.remote_balance/1e3).toFixed(0)}k</span></div></td>
<td><span style="color:${c.active ? '#4ade80' : '#ef4444'}">${c.active ? 'Active' : 'Offline'}</span></td>
</tr>`).join('')
const invoiceRows = d.invoices.slice().reverse().map(inv => `
<tr>
<td>${inv.memo}</td>
<td>${inv.value.toLocaleString()} sats</td>
<td><span style="color:${inv.settled ? '#4ade80' : '#fb923c'}">${inv.settled ? 'Settled' : 'Open'}</span></td>
<td>${new Date(inv.creation_date * 1000).toLocaleString()}</td>
</tr>`).join('')
const paymentRows = d.payments.map(p => `
<tr>
<td>${p.payment_hash.slice(0,12)}...</td>
<td>${p.value_sat.toLocaleString()} sats</td>
<td>${p.fee_sat} sats</td>
<td style="color:#4ade80">Succeeded</td>
</tr>`).join('')
res.type('html').send(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ThunderHub — archy-signet</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0f0f1a;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif;padding:24px}
h1{font-size:22px;margin-bottom:4px;color:#fb923c}
.subtitle{color:rgba(255,255,255,.5);font-size:13px;margin-bottom:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:32px}
.stat{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:16px}
.stat .label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:rgba(255,255,255,.4);margin-bottom:4px}
.stat .value{font-size:24px;font-weight:600}
.stat .value.green{color:#4ade80}.stat .value.orange{color:#fb923c}.stat .value.blue{color:#3b82f6}
h2{font-size:16px;margin:24px 0 12px;color:rgba(255,255,255,.8)}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.1);color:rgba(255,255,255,.4);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.05)}
tr:hover td{background:rgba(255,255,255,.02)}
.section{background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:20px;margin-bottom:20px}
.badge{display:inline-block;background:rgba(247,147,26,.15);color:#fb923c;padding:2px 8px;border-radius:6px;font-size:11px;margin-left:8px}
</style></head><body>
<h1>ThunderHub<span class="badge">signet</span></h1>
<div class="subtitle">${d.info.alias} &mdash; block ${d.info.block_height.toLocaleString()} &mdash; ${d.info.num_active_channels} active channels</div>
<div class="grid">
<div class="stat"><div class="label">On-chain Balance</div><div class="value orange">${(d.balance.confirmed_balance/1e6).toFixed(2)}M sats</div></div>
<div class="stat"><div class="label">Channel Capacity</div><div class="value">${(totalCap/1e6).toFixed(1)}M sats</div></div>
<div class="stat"><div class="label">Local Balance</div><div class="value green">${(totalLocal/1e6).toFixed(1)}M sats</div></div>
<div class="stat"><div class="label">Remote Balance</div><div class="value blue">${(totalRemote/1e6).toFixed(1)}M sats</div></div>
<div class="stat"><div class="label">Routing Fees Earned</div><div class="value green">${totalFees} sats</div></div>
<div class="stat"><div class="label">Payments Sent</div><div class="value">${d.payments.length}</div></div>
</div>
<div class="section">
<h2>Channels (${d.channels.length})</h2>
<table><thead><tr><th>Peer</th><th>Capacity</th><th>Local</th><th>Remote</th><th>Status</th></tr></thead><tbody>${channelRows}</tbody></table>
</div>
<div class="section">
<h2>Recent Invoices</h2>
<table><thead><tr><th>Memo</th><th>Amount</th><th>Status</th><th>Created</th></tr></thead><tbody>${invoiceRows}</tbody></table>
</div>
<div class="section">
<h2>Recent Payments</h2>
<table><thead><tr><th>Hash</th><th>Amount</th><th>Fee</th><th>Status</th></tr></thead><tbody>${paymentRows}</tbody></table>
</div>
<div class="section">
<h2>Forwarding History</h2>
<table><thead><tr><th>In Channel</th><th>Out Channel</th><th>Amount</th><th>Fee</th><th>Time</th></tr></thead><tbody>
${d.forwarding.map(f => {
const inPeer = d.channels.find(c => c.chan_id === f.chan_id_in)?.peer_alias || f.chan_id_in
const outPeer = d.channels.find(c => c.chan_id === f.chan_id_out)?.peer_alias || f.chan_id_out
return `<tr><td>${inPeer}</td><td>${outPeer}</td><td>${f.amt_in.toLocaleString()} sats</td><td>${f.fee} sats</td><td>${new Date(f.timestamp_ns/1e6).toLocaleString()}</td></tr>`
}).join('')}
</tbody></table>
</div>
<p style="text-align:center;color:rgba(255,255,255,.25);font-size:12px;margin-top:32px">Mock ThunderHub &mdash; Archipelago Dev Mode &mdash; No Docker Required</p>
</body></html>`)
})
// ThunderHub API stubs (for any programmatic access)
app.get('/app/thunderhub/api/info', (req, res) => res.json(MOCK_LND_DATA.info))
app.get('/app/thunderhub/api/balance', (req, res) => res.json(MOCK_LND_DATA.balance))
app.get('/app/thunderhub/api/channels', (req, res) => res.json(MOCK_LND_DATA.channels))
app.get('/app/thunderhub/api/invoices', (req, res) => res.json(MOCK_LND_DATA.invoices))
app.get('/app/thunderhub/api/payments', (req, res) => res.json(MOCK_LND_DATA.payments))
app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding))
// Generic app shell for any launched app without a dedicated mock UI — a clean
// "not interactive in the demo" notice with the app's icon. Registered after all
// specific /app/... routes so those win; only bare /app/<id>[/] reaches here.
app.get(['/app/:id', '/app/:id/'], (req, res) => {
const id = String(req.params.id || '').replace(/[^a-z0-9._-]/gi, '')
const title = id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
res.type('html').send(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
<style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}
body{background:#0b0f1a;color:#e6e8ee;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:flex;align-items:center;justify-content:center}
.card{text-align:center;padding:48px 40px;max-width:380px}
img{width:84px;height:84px;border-radius:20px;object-fit:cover;margin-bottom:20px;background:rgba(255,255,255,.05)}
h1{font-size:22px;font-weight:650;margin-bottom:8px}
p{color:#8b93a7;font-size:14px;line-height:1.5}
.tag{margin-top:18px;display:inline-block;font-size:12px;color:#fbbf24;border:1px solid rgba(251,191,36,.3);background:rgba(251,191,36,.1);padding:5px 12px;border-radius:999px}
</style></head><body><div class="card">
<img src="/assets/img/app-icons/${id}.png" onerror="this.onerror=null;this.src='/assets/icon/favico-black.svg'" alt="">
<h1>${title}</h1>
<p>This app isn't interactive in the demo, but it runs fully on a real Archipelago node.</p>
<div class="tag">Demo preview</div>
</div></body></html>`)
})
// Health check
app.get('/health', (req, res) => {
res.status(200).send('healthy')
})
// ───────────────────────────────────────────────────────────────────────────
// Per-session state isolation (DEMO multi-visitor sandbox)
//
// Every mutable global (mockData, walletState, userState, mockState,
// bitcoinRelayMockState) and the filebrowser file store is partitioned per
// visitor. Handlers keep referring to those names unchanged — the names are
// Proxies that forward to the current request's store, resolved via
// AsyncLocalStorage. Outside DEMO (or outside a request, e.g. at startup) they
// resolve to a single shared `defaultStore`, so classic single-user behaviour
// is byte-for-byte preserved.
// ───────────────────────────────────────────────────────────────────────────
const stateContext = new AsyncLocalStorage()
// Build a fresh, fully-isolated state bundle from the pristine seeds.
function makeSessionStore() {
const md = structuredClone(SEED_MOCKDATA)
// No real runtime in DEMO → package list is the curated static app set.
md['package-data'] = structuredClone(staticDevApps)
return {
mockData: md,
walletState: structuredClone(SEED_WALLET),
userState: seedUserState(),
mockState: seedMockState(),
bitcoinRelayMockState: structuredClone(SEED_BTCRELAY),
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 },
sockets: new Set(),
lastSeen: Date.now(),
}
}
// The shared store used in single-user mode and for any work outside a request.
const defaultStore = makeSessionStore()
function currentStore() {
return stateContext.getStore() || defaultStore
}
// A Proxy whose every operation is delegated to currentStore()[bucket], so the
// existing handler code (`mockData['package-data']`, `walletState.x += n`, …)
// transparently reads/writes the right visitor's state.
function sessionBucketProxy(bucket) {
const target = () => currentStore()[bucket]
return new Proxy(Object.create(null), {
get: (_t, k) => target()[k],
set: (_t, k, v) => { target()[k] = v; return true },
has: (_t, k) => k in target(),
deleteProperty: (_t, k) => { delete target()[k]; return true },
ownKeys: () => Reflect.ownKeys(target()),
getOwnPropertyDescriptor: (_t, k) => {
const d = Object.getOwnPropertyDescriptor(target(), k)
if (d) d.configurable = true
return d
},
defineProperty: (_t, k, d) => { Object.defineProperty(target(), k, d); return true },
getPrototypeOf: () => Object.prototype,
})
}
const mockData = sessionBucketProxy('mockData')
const walletState = sessionBucketProxy('walletState')
const userState = sessionBucketProxy('userState')
const mockState = sessionBucketProxy('mockState')
const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState')
// Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped.
const demoSessions = new Map() // sid -> store
const DEMO_SESSION_TTL_MS = Number(process.env.DEMO_SESSION_TTL_MS) || 45 * 60 * 1000
const DEMO_MAX_SESSIONS = Number(process.env.DEMO_MAX_SESSIONS) || 500
function resolveSessionStore(req, res) {
let sid = req.cookies?.demo_sid
if (!sid || !demoSessions.has(sid)) {
// Cap concurrent sessions: evict the oldest if we're at the limit.
if (demoSessions.size >= DEMO_MAX_SESSIONS) {
let oldestSid = null, oldest = Infinity
for (const [k, s] of demoSessions) if (s.lastSeen < oldest) { oldest = s.lastSeen; oldestSid = k }
if (oldestSid) { reapSession(oldestSid) }
}
sid = crypto.randomUUID()
demoSessions.set(sid, makeSessionStore())
res.cookie('demo_sid', sid, { httpOnly: true, sameSite: 'lax', maxAge: DEMO_SESSION_TTL_MS })
}
const store = demoSessions.get(sid)
store.lastSeen = Date.now()
store.sid = sid
return store
}
// Resolve the session store for a WebSocket upgrade request. The HTTP layer has
// already issued the `demo_sid` cookie by the time the socket connects; if it is
// somehow absent we fall back to a fresh ephemeral store (no cookie to set here).
function wsStoreForRequest(req) {
const raw = req.headers?.cookie || ''
const m = raw.match(/(?:^|;\s*)demo_sid=([^;]+)/)
const sid = m && m[1]
if (sid && demoSessions.has(sid)) {
const store = demoSessions.get(sid)
store.lastSeen = Date.now()
return store
}
const store = makeSessionStore()
if (sid) demoSessions.set(sid, store)
return store
}
function reapSession(sid) {
const store = demoSessions.get(sid)
if (!store) return
for (const ws of store.sockets) { try { ws.close(4000, 'session expired') } catch { /* ignore */ } }
demoSessions.delete(sid)
}
if (DEMO) {
setInterval(() => {
const now = Date.now()
for (const [sid, store] of demoSessions) {
if (now - store.lastSeen > DEMO_SESSION_TTL_MS) reapSession(sid)
}
}, 60 * 1000).unref?.()
}
// WebSocket endpoint
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws/db' })
wss.on('connection', (ws, req) => {
console.log('[WebSocket] Client connected from', req.socket.remoteAddress)
// Attach this socket to the visitor's session store so broadcasts only reach
// that visitor. In non-DEMO mode every socket joins the single default store.
const wsStore = DEMO ? wsStoreForRequest(req) : defaultStore
wsStore.sockets.add(ws)
// Set up ping/pong to keep connection alive
const pingInterval = setInterval(() => {
if (ws.readyState === 1) { // OPEN
try {
ws.ping()
} catch (err) {
console.error('[WebSocket] Ping error:', err)
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
}
} else {
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
}
}, 30000) // Ping every 30 seconds
// Send periodic heartbeat data so clients don't think the connection is dead
const heartbeatInterval = setInterval(() => {
if (ws.readyState === 1) {
try {
ws.send(JSON.stringify({ type: 'heartbeat', rev: Date.now() }))
} catch { /* ignore */ }
}
}, 45000) // Every 45s (client expects data within 60s)
// Send initial data immediately (this visitor's store, not the global proxy —
// there is no request context inside the WS connection handler).
try {
ws.send(JSON.stringify({
type: 'initial',
data: wsStore.mockData,
}))
console.log('[WebSocket] Initial data sent')
} catch (err) {
console.error('[WebSocket] Error sending initial data:', err)
}
ws.on('pong', () => {
// Client responded to ping, connection is alive
})
ws.on('message', (message) => {
// Handle incoming messages if needed
try {
const data = JSON.parse(message.toString())
console.log('[WebSocket] Received message:', data)
} catch (err) {
console.error('[WebSocket] Error parsing message:', err)
}
})
ws.on('close', (code, reason) => {
console.log('[WebSocket] Client disconnected', { code, reason: reason.toString() })
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
wsStore.sockets.delete(ws)
})
ws.on('error', (error) => {
console.error('[WebSocket Error]', error)
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
wsStore.sockets.delete(ws)
})
})
// Best-effort: pull a few REAL recent testnet txids so the wallet's transactions
// deep-link to live mempool.space/testnet pages. Falls back to the mock hashes
// (already set) if offline. Patches the pristine seed so every session inherits them.
async function hydrateRealTestnetTxids() {
if (!DEMO) return
try {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 4000)
const r = await fetch('https://mempool.space/testnet/api/mempool/recent', { signal: ctrl.signal })
clearTimeout(t)
if (!r.ok) return
const recent = await r.json()
const txids = (Array.isArray(recent) ? recent : []).map(x => x.txid).filter(Boolean)
if (!txids.length) return
SEED_WALLET.transactions.forEach((tx, i) => { if (txids[i]) tx.tx_hash = txids[i] })
// Also patch the already-built default store (sessions clone from SEED at creation).
defaultStore.walletState.transactions.forEach((tx, i) => { if (txids[i]) tx.tx_hash = txids[i] })
console.log(`[Demo] Hydrated ${Math.min(txids.length, SEED_WALLET.transactions.length)} real testnet txids`)
} catch { /* offline — keep mock hashes */ }
}
server.listen(PORT, '0.0.0.0', async () => {
const runtime = await isContainerRuntimeAvailable()
// Initialize package data from Docker
await initializePackageData()
await hydrateRealTestnetTxids()
console.log(`
╔════════════════════════════════════════════════════════════╗
║ ║
║ 🚀 Archipelago Mock Backend Server ║
║ ║
║ RPC: http://localhost:${PORT}/rpc/v1 ║
║ WebSocket: ws://localhost:${PORT}/ws/db ║
║ ║
║ Dev Mode: ${DEV_MODE.padEnd(47)}
║ Setup: ${userState.setupComplete ? '✅ Complete' : '❌ Not done'.padEnd(47)}
║ Onboarding: ${userState.onboardingComplete ? '✅ Complete' : '❌ Not done'.padEnd(46)}
║ ║
║ Mock Password: ${MOCK_PASSWORD.padEnd(40)}
║ ║
║ Container Runtime: ${runtime.available ? `${runtime.runtime}`.padEnd(40) : '⏭️ Simulation mode'.padEnd(40)}
║ Claude API Key: ${process.env.ANTHROPIC_API_KEY ? '✅ Set (' + process.env.ANTHROPIC_API_KEY.slice(0, 12) + '...)' : '❌ Not set (chat disabled)'.padEnd(40)}
║ ║
╚════════════════════════════════════════════════════════════╝
`)
console.log('Mock backend is running. Press Ctrl+C to stop.\n')
// Pre-check Anthropic API connectivity
if (process.env.ANTHROPIC_API_KEY) {
try {
const dns = await import('dns')
dns.lookup('api.anthropic.com', (err, address) => {
if (err) {
console.error('[Claude Proxy] ⚠ DNS lookup failed for api.anthropic.com:', err.message)
console.error('[Claude Proxy] Chat will fail. Check container DNS settings.')
} else {
console.log('[Claude Proxy] ✅ api.anthropic.com resolves to', address)
}
})
} catch { /* ignore */ }
}
// Periodically update package data from Docker (merge with static dev apps)
// Only poll if container runtime is available (avoids log spam in demo/Docker deployments)
if (runtime.available) {
setInterval(async () => {
const dockerApps = await getDockerContainers()
mockData['package-data'] = mergePackageData(dockerApps)
// Broadcast update to connected clients
broadcastUpdate([
{
op: 'replace',
path: '/package-data',
value: mockData['package-data']
}
])
}, 5000) // Update every 5 seconds
}
})
process.on('SIGINT', () => {
console.log('\n\nShutting down mock backend...')
server.close(() => {
console.log('Server stopped.')
process.exit(0)
})
})