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'
2026-01-27 23:06:18 +00:00
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 )
2026-01-27 23:06:18 +00:00
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
}
2026-01-27 23:06:18 +00:00
// 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' ,
2026-02-17 15:03:34 +00:00
'mempool-electrs' : 'mempool-electrs' ,
2026-01-27 23:06:18 +00:00
'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' ,
2026-02-17 15:03:34 +00:00
icon : '/assets/img/app-icons/fedimint.png' ,
2026-01-27 23:06:18 +00:00
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'
} ,
2026-02-17 15:03:34 +00:00
'mempool-electrs' : {
title : 'Electrs' ,
icon : '/assets/img/app-icons/electrs.svg' ,
description : 'Electrum protocol indexer for Bitcoin'
} ,
2026-01-27 23:06:18 +00:00
'ollama' : {
title : 'Ollama' ,
2026-02-01 18:46:35 +00:00
icon : '/assets/img/app-icons/ollama.png' ,
2026-01-27 23:06:18 +00:00
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 ,
2026-03-06 01:11:00 +00:00
icon : '/assets/icon/pwa-192x192-v2.png' ,
2026-01-27 23:06:18 +00:00
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.' ,
2026-03-06 01:11:00 +00:00
icon : '/assets/icon/pwa-192x192-v2.png'
2026-01-24 22:59:20 +00:00
}
}
2026-03-06 01:11:00 +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 ` ,
2026-03-06 01:11:00 +00:00
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 {
2026-02-18 08:30:12 +00:00
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 ,
} ,
2026-01-27 23:06:18 +00:00
'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' ,
} ,
}
2026-02-18 08:30:12 +00:00
// 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' ,
2026-02-18 08:42:24 +00:00
'lan-address' : 'http://192.168.1.166'
2026-02-18 08:30:12 +00:00
}
} ,
status : 'running'
}
}
}
function mergePackageData ( dockerApps ) {
return { ... dockerApps , ... staticDevApps }
}
2026-01-27 23:06:18 +00:00
// Initialize package data from Docker on startup
async function initializePackageData ( ) {
console . log ( '[Docker] Querying running containers...' )
const dockerApps = await getDockerContainers ( )
2026-02-18 08:30:12 +00:00
mockData [ 'package-data' ] = mergePackageData ( dockerApps )
2026-01-27 23:06:18 +00:00
2026-02-18 08:30:12 +00:00
const appCount = Object . keys ( mockData [ 'package-data' ] ) . length
const runningCount = Object . values ( mockData [ 'package-data' ] ) . filter ( app => app . state === 'running' ) . length
2026-01-27 23:06:18 +00:00
console . log ( ` [Docker] Found ${ appCount } containers ( ${ runningCount } running) ` )
if ( appCount > 0 ) {
console . log ( '[Docker] Apps detected:' )
2026-02-18 08:30:12 +00:00
Object . entries ( mockData [ 'package-data' ] ) . forEach ( ( [ id , app ] ) => {
2026-01-27 23:06:18 +00:00
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 ` )
2026-02-17 15:03:34 +00:00
return res . json ( { result : true } )
2026-01-24 22:59:20 +00:00
}
case 'auth.isOnboardingComplete' : {
return res . json ( { result : userState . onboardingComplete } )
}
2026-03-02 08:34:13 +00:00
case 'auth.resetOnboarding' : {
userState . onboardingComplete = false
console . log ( '[Auth] Onboarding reset' )
return res . json ( { result : true } )
}
2026-02-17 15:03:34 +00:00
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' } } )
}
2026-03-02 08:34:13 +00:00
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' } )
}
feat: add TOTP 2FA, API key switcher, login progress bar, and alpha hardening plan
- TOTP 2FA: full setup/confirm/disable/login flow with Argon2id + ChaCha20-Poly1305
encrypted secret storage, QR code generation, and bcrypt-hashed backup codes
- API key switcher: OAuth vs personal API key toggle in AIUI chat settings with
status indicator, key validation, and help text
- Login progress bar: server startup detection with health check polling, form
disabled until server is ready
- AI quarantine docs: comprehensive HTML page documenting all 6 security layers
- Settings: AI Data Access permission toggles with per-category control
- Alpha hardening plan: 28-task overnight automation plan across 7 phases
(onboarding, login, app install, AIUI, UI polish, security, ISO build)
- Backlog: node discovery spatial map feature for alpha demo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:23:57 +00:00
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 } } )
}
2026-03-07 22:36:45 +00:00
case 'node-messages-received' :
case 'node.messages' :
case 'node.notifications' : {
return res . json ( { result : [ ] } )
}
2026-01-24 22:59:20 +00:00
default : {
2026-03-07 22:36:45 +00:00
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 ,
} ,
} )
}
} )
2026-03-07 22:50:05 +00:00
// =============================================================================
// 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 \n ch_001,ACINQ,5000000,2450000,2550000,active \n ch_002,WalletOfSatoshi,2000000,1200000,800000,active \n ch_003,Voltage,10000000,4500000,5500000,active \n ch_004,Kraken,3000000,1800000,1200000,active ` ,
'/Documents/sovereignty-manifesto.txt' : ` THE SOVEREIGNTY MANIFESTO \n ========================= \n \n We hold these truths to be self-evident: \n \n 1. Your data belongs to you. \n 2. Your money should be uncensorable. \n 3. Your communications should be private. \n 4. Your compute should be sovereign. \n 5. Your identity should be self-issued. \n \n Run 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' )
}
} )
2026-03-07 23:07:38 +00:00
// =============================================================================
2026-03-07 23:22:30 +00:00
// AIUI Web Search stub (demo mode — no real search backend)
// =============================================================================
app . get ( '/aiui/api/web-search' , ( req , res ) => {
res . json ( { results : [ ] } )
} )
2026-03-07 23:07:38 +00:00
// 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 ]
2026-03-07 23:22:30 +00:00
// 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 )
2026-03-07 23:07:38 +00:00
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 ( )
} )
2026-03-07 23:24:27 +00:00
// 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 )
2026-03-07 22:36:45 +00:00
clearInterval ( heartbeatInterval )
2026-01-24 22:59:20 +00:00
}
} else {
clearInterval ( pingInterval )
2026-03-07 22:36:45 +00:00
clearInterval ( heartbeatInterval )
2026-01-24 22:59:20 +00:00
}
} , 30000 ) // Ping every 30 seconds
2026-03-07 22:36:45 +00:00
// Send periodic heartbeat data so clients don't think the connection is dead
const heartbeatInterval = setInterval ( ( ) => {
if ( ws . readyState === 1 ) {
try {
2026-03-07 23:22:30 +00:00
ws . send ( JSON . stringify ( { type : 'heartbeat' , rev : Date . now ( ) } ) )
2026-03-07 22:36:45 +00:00
} 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 )
2026-03-07 22:36:45 +00:00
clearInterval ( heartbeatInterval )
2026-01-24 22:59:20 +00:00
wsClients . delete ( ws )
} )
ws . on ( 'error' , ( error ) => {
console . error ( '[WebSocket Error]' , error )
clearInterval ( pingInterval )
2026-03-07 22:36:45 +00:00
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 ( )
2026-01-27 23:06:18 +00:00
// 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 ) } ║
2026-01-27 23:06:18 +00:00
║ Docker API : ✅ Connected ║
2026-01-24 22:59:20 +00:00
║ ║
╚ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ╝
` )
console . log ( 'Mock backend is running. Press Ctrl+C to stop.\n' )
2026-01-27 23:06:18 +00:00
2026-02-18 08:30:12 +00:00
// Periodically update package data from Docker (merge with static dev apps)
2026-03-07 20:53:02 +00:00
// 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 )
} )
} )