archy/neode-ui/mock-backend.js

1222 lines
42 KiB
JavaScript
Raw Normal View History

2026-01-24 22:59:20 +00:00
#!/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'
2026-01-24 22:59:20 +00:00
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const execPromise = promisify(exec)
const docker = new Docker()
2026-01-24 22:59:20 +00:00
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 {}
}
}
2026-01-24 22:59:20 +00:00
// 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'
2026-01-24 22:59:20 +00:00
}
}
2026-01-24 22:59:20 +00:00
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'
2026-01-24 22:59:20 +00:00
}
// 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`)
}
2026-01-24 22:59:20 +00:00
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
2026-01-24 22:59:20 +00:00
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.')
}
}
2026-01-24 22:59:20 +00:00
// 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 })
2026-01-24 22:59:20 +00:00
}
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(),
},
})
}
2026-01-24 22:59:20 +00:00
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: '<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 } })
}
case 'node-messages-received':
case 'node.messages':
case 'node.notifications': {
return res.json({ result: [] })
}
2026-01-24 22:59:20 +00:00
default: {
console.log(`[RPC] Unknown method: ${method}`)
2026-01-24 22:59:20 +00:00
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')
}
})
// =============================================================================
// AIUI Web Search stub (demo mode — no real search backend)
// =============================================================================
app.get('/aiui/api/web-search', (req, res) => {
res.json({ results: [] })
})
// 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]
// Ensure max_tokens is set — Claude API requires it but AIUI may omit it
const body = req.body
if (body && !body.max_tokens) {
body.max_tokens = 4096
}
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) => {
console.error('[Claude Proxy] Error:', err.message)
if (!res.headersSent) {
res.status(502).json({
type: 'error',
error: { type: 'proxy_error', message: err.message }
})
}
})
proxyReq.write(bodyStr)
proxyReq.end()
})
// Web search stub (no search engine configured in demo)
app.get('/api/web-search', (req, res) => {
res.json({ results: [], error: 'Web search not available in demo mode' })
})
// AIUI API catch-all for unimplemented endpoints (return JSON instead of HTML)
app.all('/aiui/api/*', (req, res) => {
res.status(404).json({ error: 'Not found' })
})
2026-01-24 22:59:20 +00:00
// 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)
2026-01-24 22:59:20 +00:00
}
} else {
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
2026-01-24 22:59:20 +00:00
}
}, 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)
2026-01-24 22:59:20 +00:00
// 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)
2026-01-24 22:59:20 +00:00
wsClients.delete(ws)
})
ws.on('error', (error) => {
console.error('[WebSocket Error]', error)
clearInterval(pingInterval)
clearInterval(heartbeatInterval)
2026-01-24 22:59:20 +00:00
wsClients.delete(ws)
})
})
server.listen(PORT, '0.0.0.0', async () => {
const runtime = await isContainerRuntimeAvailable()
// Initialize package data from Docker
await initializePackageData()
2026-01-24 22:59:20 +00:00
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
2026-01-24 22:59:20 +00:00
`)
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
}
2026-01-24 22:59:20 +00:00
})
process.on('SIGINT', () => {
console.log('\n\nShutting down mock backend...')
server.close(() => {
console.log('Server stopped.')
process.exit(0)
})
})