#!/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' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const execPromise = promisify(exec) const docker = new Docker() const app = express() const PORT = 5959 // Dev mode from environment (setup, onboarding, existing, or default) const DEV_MODE = process.env.VITE_DEV_MODE || 'default' // CORS configuration const corsOptions = { credentials: true, origin: (origin, callback) => { if (!origin) return callback(null, true) callback(null, true) } } app.use(cors(corsOptions)) app.use(express.json()) app.use(cookieParser()) // Mock session storage const sessions = new Map() const MOCK_PASSWORD = 'password123' // User state (simulated file-based storage) let userState = { setupComplete: false, onboardingComplete: false, passwordHash: null, // In real app, this would be bcrypt hash } // Initialize user state based on dev mode function initializeUserState() { switch (DEV_MODE) { case 'setup': // Setup mode: Original StartOS node setup - user needs to set password // This is the simple password setup, NOT the experimental onboarding userState = { setupComplete: false, // User hasn't set password yet onboardingComplete: false, // Onboarding not relevant for setup mode passwordHash: null, } break case 'onboarding': // Onboarding mode: Experimental onboarding flow // User has set password (via setup) but needs to go through experimental onboarding userState = { setupComplete: true, // Password already set onboardingComplete: false, // Needs experimental onboarding passwordHash: MOCK_PASSWORD, } break case 'existing': // Existing user: Fully set up, just needs to login userState = { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD, } break default: // Default: Fully set up (for UI development) userState = { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD, } } console.log(`[Auth] Dev mode: ${DEV_MODE}`) console.log(`[Auth] Setup: ${userState.setupComplete}, Onboarding: ${userState.onboardingComplete}`) } initializeUserState() // WebSocket clients for broadcasting updates const wsClients = new Set() // Helper: Broadcast data update to all WebSocket clients function broadcastUpdate(patch) { const message = JSON.stringify({ rev: Date.now(), patch: patch }) wsClients.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 } // Helper: Query real Docker containers async function getDockerContainers() { 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-onlyoffice': 'onlyoffice', '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' }, 'onlyoffice': { title: 'OnlyOffice', icon: '/assets/img/onlyoffice.webp', description: 'Office suite and document collaboration' }, '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', '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 } } } } // Helper: Install package with container runtime (if available) or simulate async function installPackage(id, manifestUrl) { 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 = '0.1.0' const runtime = await isContainerRuntimeAvailable() // Get package metadata const packageMetadata = { 'atob': { title: 'A to B Bitcoin', shortDesc: 'Bitcoin tools and services for seamless transactions', longDesc: 'A to B Bitcoin provides tools and services for Bitcoin transactions.', icon: '/assets/img/atob.png' }, 'k484': { title: 'K484', shortDesc: 'Point of Sale and Admin system', longDesc: 'K484 provides a complete POS and administration system.', icon: '/assets/img/k484.png' }, 'amin': { title: 'Amin', shortDesc: 'Administrative interface for Archipelago', longDesc: 'Amin provides administrative tools and monitoring.', icon: '/assets/icon/pwa-192x192-v2.png' } } const metadata = packageMetadata[id] || { title: id.charAt(0).toUpperCase() + id.slice(1), shortDesc: `${id} application`, longDesc: `${id} application for Archipelago`, icon: '/assets/icon/pwa-192x192-v2.png' } // Determine port const assignedPort = portMappings[id] || 8105 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 mockData['package-data'][id] = { title: metadata.title, version: version, status: 'running', state: 'running', port: assignedPort, containerMode: containerMode, actuallyRunning: actuallyRunning, manifest: { id: id, title: metadata.title, version: version, description: { short: metadata.shortDesc, long: metadata.longDesc }, icon: metadata.icon, interfaces: { main: { name: 'Web Interface', description: `${metadata.title} web interface`, ui: true, }, }, }, } // 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 mockData = { 'server-info': { id: 'archipelago-dev', version: '0.1.0', name: 'Archipelago Dev Server', pubkey: 'mock-pubkey', 'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null, }, 'lan-address': '192.168.1.100', unread: 0, 'wifi-ssids': [], 'zram-enabled': false, }, 'package-data': {}, // Will be populated from Docker ui: { name: 'Archipelago', 'ack-welcome': '0.1.0', marketplace: { 'selected-hosts': [], 'known-hosts': {}, }, theme: 'dark', }, } // Static dev apps (always shown in My Apps when using mock backend) const staticDevApps = { 'lorabell': { title: 'LoraBell', version: '1.0.0', status: 'running', state: 'running', 'static-files': { license: 'MIT', instructions: 'A LoRa based doorbell', icon: '/assets/img/app-icons/lorabell.png' }, manifest: { id: 'lorabell', title: 'LoraBell', version: '1.0.0', description: { short: 'A LoRa based doorbell', long: 'A LoRa based doorbell - receive doorbell notifications over LoRa radio.' }, 'release-notes': 'Initial release', license: 'MIT', 'wrapper-repo': '#', 'upstream-repo': '#', 'support-site': '#', 'marketing-site': '#', 'donation-url': null, interfaces: { main: { name: 'Web Interface', description: 'LoraBell web interface', ui: true } } }, installed: { 'current-dependents': {}, 'current-dependencies': {}, 'last-backup': null, 'interface-addresses': { main: { 'tor-address': 'lorabell.onion', 'lan-address': 'http://192.168.1.166' } }, status: 'running' } } } 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() }) // RPC endpoint app.post('/rpc/v1', (req, res) => { const { method, params } = req.body console.log(`[RPC] ${method}`) 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 '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 '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.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': { return res.json({ result: { cpu: 45.2, memory: 62.8, disk: 38.1, }, }) } case 'marketplace.get': { const mockApps = [ { id: 'bitcoin', title: 'Bitcoin Core', description: 'A full Bitcoin node.', version: '25.0.0', icon: '/assets/img/bitcoin.svg', author: 'Bitcoin Core Team', license: 'MIT', }, { id: 'lightning', title: 'Core Lightning', description: 'Lightning Network implementation.', version: '23.08', icon: '/assets/img/c-lightning.png', author: 'Blockstream', license: 'MIT', }, ] 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 } = params installPackage(id, url).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: 'Mock QR Code', 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 } }) } case 'node-messages-received': case 'node.messages': case 'node.notifications': case 'node-list-peers': { return res.json({ result: [] }) } 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 MOCK_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 MOCK_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 login - return mock JWT app.post('/app/filebrowser/api/login', (req, res) => { res.send('"mock-filebrowser-token-demo"') }) // FileBrowser list resources app.get('/app/filebrowser/api/resources/*', (req, res) => { const reqPath = decodeURIComponent(req.params[0] || '/').replace(/\/+$/, '') || '/' const items = MOCK_FILES[reqPath] || [] res.json({ items, numDirs: items.filter(i => i.isDir).length, numFiles: items.filter(i => !i.isDir).length, sorting: { by: 'name', asc: true }, }) }) app.get('/app/filebrowser/api/resources', (req, res) => { const items = MOCK_FILES['/'] || [] res.json({ items, numDirs: items.filter(i => i.isDir).length, numFiles: items.filter(i => !i.isDir).length, sorting: { by: 'name', asc: true }, }) }) // FileBrowser raw file content (for text file reading) app.get('/app/filebrowser/api/raw/*', (req, res) => { const reqPath = '/' + decodeURIComponent(req.params[0] || '') const content = MOCK_FILE_CONTENTS[reqPath] if (content) { res.type('text/plain').send(content) } else { res.status(404).send('File not found') } }) // Claude API Proxy (reads ANTHROPIC_API_KEY from environment) // ============================================================================= import https from 'https' app.post('/aiui/api/claude/*', (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 options = { hostname: 'api.anthropic.com', port: 443, path: apiPath, method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(bodyStr), }, } const proxyReq = https.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers) proxyRes.pipe(res) }) proxyReq.on('error', (err) => { const msg = err.message || err.code || JSON.stringify(err) || 'Unknown proxy error' console.error('[Claude Proxy] Error:', msg) if (!res.headersSent) { res.status(502).json({ type: 'error', error: { type: 'proxy_error', message: msg } }) } }) 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' }) }) // Health check app.get('/health', (req, res) => { res.status(200).send('healthy') }) // 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) wsClients.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 try { ws.send(JSON.stringify({ type: 'initial', data: 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) wsClients.delete(ws) }) ws.on('error', (error) => { console.error('[WebSocket Error]', error) clearInterval(pingInterval) clearInterval(heartbeatInterval) wsClients.delete(ws) }) }) server.listen(PORT, '0.0.0.0', async () => { const runtime = await isContainerRuntimeAvailable() // Initialize package data from Docker await initializePackageData() 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) : '❌ Not available'.padEnd(40)}║ ║ Docker API: ✅ Connected ║ ║ ║ ╚════════════════════════════════════════════════════════════╝ `) console.log('Mock backend is running. Press Ctrl+C to stop.\n') // 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) }) })