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-01-24 22:59:20 +00:00
|
|
|
|
default: {
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearInterval(pingInterval)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 30000) // Ping every 30 seconds
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
wsClients.delete(ws)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
ws.on('error', (error) => {
|
|
|
|
|
|
console.error('[WebSocket Error]', error)
|
|
|
|
|
|
clearInterval(pingInterval)
|
|
|
|
|
|
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-01-27 23:06:18 +00:00
|
|
|
|
setInterval(async () => {
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
// Broadcast update to connected clients
|
|
|
|
|
|
broadcastUpdate([
|
|
|
|
|
|
{
|
|
|
|
|
|
op: 'replace',
|
|
|
|
|
|
path: '/package-data',
|
2026-02-18 08:30:12 +00:00
|
|
|
|
value: mockData['package-data']
|
2026-01-27 23:06:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
])
|
|
|
|
|
|
}, 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|