feat(demo): app launch UIs, "No demo" gating, onboarding skip, 12 nodes

App launching (DEMO):
- resolveAppUrl routes every app to its demo target: mock UIs for Bitcoin Core,
  ElectrumX, Fedimint (served by the backend), IndeeHub → iframe indee.tx1138.com,
  Mempool → mempool.space/testnet (new tab); all others → a generic "Demo preview"
  notice page.
- Non-demoable apps show a disabled "No demo" install button (marketplace details,
  app grid, featured apps).

Onboarding:
- Demo treats the visitor as fully set up so the onboarding WIZARD (seed/identity)
  is never forced; the welcome intro still replays per day. Intro CTA goes straight
  to login; wizard entry points + login restart-onboarding link hidden in demo.

Network:
- federation.list-nodes now returns 12 trusted/federated nodes (9 trusted, 3
  observer); transport.peers already at 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-22 10:26:35 -04:00
co-authored by Claude Opus 4.8
parent 2715f2d847
commit 2cffa79d9d
9 changed files with 256 additions and 114 deletions
+139 -81
View File
@@ -182,10 +182,11 @@ const SEED_BTCRELAY = {
}
// User state (simulated file-based storage). Returns a fresh object per session.
// In DEMO mode the effective dev mode is "onboarding" so the intro/onboarding can
// play for each new visitor (the per-day replay gate lives in the frontend).
// In DEMO the visitor is always treated as fully set up ("existing") so the
// onboarding WIZARD (seed/identity/backup) is never forced by the route guard.
// The welcome INTRO still shows via the frontend's per-day replay gate.
function seedUserState() {
const mode = DEMO ? 'onboarding' : DEV_MODE
const mode = DEMO ? 'existing' : DEV_MODE
switch (mode) {
case 'setup':
// Setup mode: user needs to set a password (simple setup, not onboarding).
@@ -982,6 +983,117 @@ app.get('/app/bitcoin-ui/', async (_req, res) => {
}
})
// ── Mock app UIs served in the in-app iframe (DEMO) ─────────────────────────
function demoAppShell(title, accent, bodyHtml) {
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0b0f1a;color:#e6e8ee;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh;padding:28px}
.wrap{max-width:860px;margin:0 auto}
.hd{display:flex;align-items:center;gap:14px;margin-bottom:22px}
.dot{width:11px;height:11px;border-radius:50%;background:${accent};box-shadow:0 0 12px ${accent}}
h1{font-size:22px;font-weight:650}
.sub{color:#8b93a7;font-size:13px;margin-top:2px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:18px}
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;padding:16px}
.k{color:#8b93a7;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
.v{font-size:20px;font-weight:650;margin-top:6px}
.v.mono{font-family:ui-monospace,Menlo,monospace;font-size:13px;word-break:break-all;font-weight:500}
.bar{height:8px;border-radius:6px;background:rgba(255,255,255,.08);overflow:hidden;margin-top:10px}
.bar>i{display:block;height:100%;background:${accent}}
.badge{display:inline-flex;align-items:center;gap:6px;background:rgba(34,197,94,.15);color:#86efac;border:1px solid rgba(34,197,94,.3);padding:4px 10px;border-radius:999px;font-size:12px}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{text-align:left;padding:9px 6px;border-bottom:1px solid rgba(255,255,255,.06)}
th{color:#8b93a7;font-weight:500;font-size:11px;text-transform:uppercase}
.demo-tag{position:fixed;bottom:14px;right:16px;font-size:11px;color:#6b7280}
</style></head><body><div class="wrap">${bodyHtml}</div>
<div class="demo-tag">Archipelago demo · signet</div></body></html>`
}
// 12 trusted/federated nodes for the demo Federation view.
function demoFederationNodes() {
const names = [
['archy-198', 'trusted', ['bitcoin-knots', 'lnd', 'mempool', 'electrs']],
['arch-tailscale-1', 'trusted', ['bitcoin-knots', 'lnd', 'nextcloud']],
['bunker-alpha', 'trusted', ['bitcoin-knots', 'vaultwarden']],
['seaside-node', 'trusted', ['bitcoin-knots', 'lnd', 'fedimint']],
['highland-relay', 'trusted', ['bitcoin-knots', 'electrs', 'mempool']],
['orchard-12', 'trusted', ['bitcoin-knots', 'immich']],
['nimbus-vps', 'trusted', ['bitcoin-knots', 'lnd', 'thunderhub']],
['rivertown', 'trusted', ['bitcoin-knots', 'jellyfin']],
['granite-pi', 'trusted', ['bitcoin-knots', 'lnd']],
['saltmarsh', 'observer', ['bitcoin-knots']],
['dustbowl-node', 'observer', ['bitcoin-knots', 'electrs']],
['frontier-7', 'observer', ['bitcoin-knots', 'mempool']],
]
return names.map(([name, trust, apps], i) => {
const seenAgo = 60000 + i * 95000
return {
did: `did:key:z6Mk${randomHex(20)}`,
pubkey: randomHex(32),
onion: `${name.replace(/[^a-z0-9]/g, '')}${randomHex(8)}.onion`,
trust_level: trust,
added_at: new Date(Date.now() - (i + 2) * 86400000).toISOString(),
name,
last_seen: new Date(Date.now() - seenAgo).toISOString(),
last_state: {
timestamp: new Date(Date.now() - seenAgo).toISOString(),
apps: apps.map(id => ({ id, status: 'running', version: '1.0' })),
cpu_usage_percent: Math.round((6 + (i * 7) % 55) * 10) / 10,
mem_used_bytes: (2 + (i % 6)) * 1_000_000_000,
mem_total_bytes: (i % 2 ? 32 : 16) * 1_000_000_000,
disk_used_bytes: (400 + i * 90) * 1_000_000_000,
disk_total_bytes: (i % 2 ? 2000 : 1000) * 1_000_000_000,
uptime_secs: 86400 * (i + 1),
tor_active: trust === 'trusted',
},
}
})
}
app.get(['/app/electrumx/', '/app/electrs/', '/app/archy-electrs-ui/'], (_req, res) => {
res.type('html').send(demoAppShell('Electrs — Electrum Server', '#22d3ee', `
<div class="hd"><span class="dot"></span><div><h1>Electrs</h1><div class="sub">Electrum server in Rust · signet</div></div></div>
<div class="grid">
<div class="card"><div class="k">Status</div><div class="v"><span class="badge">● Serving clients</span></div></div>
<div class="card"><div class="k">Indexed height</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
<div class="card"><div class="k">RPC port</div><div class="v mono">50002 (SSL)</div></div>
<div class="card"><div class="k">Connected wallets</div><div class="v">3</div></div>
<div class="card"><div class="k">Mempool txs</div><div class="v">12,840</div></div>
<div class="card"><div class="k">DB size</div><div class="v">58.2 GB</div></div>
</div>
<div class="card"><div class="k" style="margin-bottom:8px">Recent client subscriptions</div>
<table><thead><tr><th>Wallet</th><th>Address type</th><th>Subscribed</th></tr></thead><tbody>
<tr><td>Sparrow</td><td>P2WPKH</td><td>2 min ago</td></tr>
<tr><td>BlueWallet</td><td>P2TR</td><td>9 min ago</td></tr>
<tr><td>Electrum</td><td>P2WPKH</td><td>21 min ago</td></tr>
</tbody></table>
</div>`))
})
app.get(['/app/fedimint/', '/app/fedimintd/'], (_req, res) => {
res.type('html').send(demoAppShell('Fedimint — Guardian', '#a78bfa', `
<div class="hd"><span class="dot"></span><div><h1>Fedimint Guardian</h1><div class="sub">Archipelago Federation · 4-of-5 consensus</div></div></div>
<div class="grid">
<div class="card"><div class="k">Consensus</div><div class="v"><span class="badge">● Online</span></div></div>
<div class="card"><div class="k">Epoch</div><div class="v">48,217</div></div>
<div class="card"><div class="k">Guardians online</div><div class="v">5 / 5</div></div>
<div class="card"><div class="k">E-cash issued</div><div class="v">12,480,000 sat</div></div>
<div class="card"><div class="k">Lightning gateway</div><div class="v"><span class="badge">● Connected</span></div></div>
<div class="card"><div class="k">Block sync</div><div class="v">902,418</div><div class="bar"><i style="width:100%"></i></div></div>
</div>
<div class="card"><div class="k" style="margin-bottom:8px">Guardians</div>
<table><thead><tr><th>Guardian</th><th>Peer</th><th>Status</th></tr></thead><tbody>
<tr><td>Guardian Alpha</td><td>peer-0</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Beta</td><td>peer-1</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Gamma</td><td>peer-2</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Delta</td><td>peer-3</td><td><span class="badge">online</span></td></tr>
<tr><td>Guardian Epsilon</td><td>peer-4</td><td><span class="badge">online</span></td></tr>
</tbody></table>
</div>`))
})
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
res.json({
ok: true,
@@ -2003,84 +2115,7 @@ app.post('/rpc/v1', (req, res) => {
// Federation (multi-node clusters)
// =====================================================================
case 'federation.list-nodes': {
return res.json({
result: {
nodes: [
{
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
onion: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion',
trust_level: 'trusted',
added_at: '2026-02-15T10:30:00Z',
name: 'archy-198',
last_seen: new Date(Date.now() - 120000).toISOString(),
last_state: {
timestamp: new Date(Date.now() - 120000).toISOString(),
apps: [
{ id: 'bitcoin-knots', status: 'running', version: '27.1' },
{ id: 'lnd', status: 'running', version: '0.18.0' },
{ id: 'mempool', status: 'running', version: '3.0' },
{ id: 'electrs', status: 'running', version: '0.10.0' },
],
cpu_usage_percent: 18.3,
mem_used_bytes: 6_200_000_000,
mem_total_bytes: 16_000_000_000,
disk_used_bytes: 820_000_000_000,
disk_total_bytes: 1_800_000_000_000,
uptime_secs: 604800,
tor_active: true,
},
},
{
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
pubkey: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
onion: 'peer2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion',
trust_level: 'trusted',
added_at: '2026-03-01T14:00:00Z',
name: 'arch-tailscale-1',
last_seen: new Date(Date.now() - 300000).toISOString(),
last_state: {
timestamp: new Date(Date.now() - 300000).toISOString(),
apps: [
{ id: 'bitcoin-knots', status: 'running', version: '27.1' },
{ id: 'lnd', status: 'running', version: '0.18.0' },
{ id: 'nextcloud', status: 'running', version: '29.0' },
],
cpu_usage_percent: 42.1,
mem_used_bytes: 10_500_000_000,
mem_total_bytes: 32_000_000_000,
disk_used_bytes: 1_200_000_000_000,
disk_total_bytes: 2_000_000_000_000,
uptime_secs: 259200,
tor_active: true,
},
},
{
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
pubkey: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
onion: 'peer3mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion',
trust_level: 'observer',
added_at: '2026-03-10T09:15:00Z',
name: 'bunker-alpha',
last_seen: new Date(Date.now() - 3600000).toISOString(),
last_state: {
timestamp: new Date(Date.now() - 3600000).toISOString(),
apps: [
{ id: 'bitcoin-knots', status: 'running', version: '27.1' },
{ id: 'vaultwarden', status: 'running', version: '1.31.0' },
],
cpu_usage_percent: 5.8,
mem_used_bytes: 2_100_000_000,
mem_total_bytes: 8_000_000_000,
disk_used_bytes: 450_000_000_000,
disk_total_bytes: 1_000_000_000_000,
uptime_secs: 1209600,
tor_active: false,
},
},
],
},
})
return res.json({ result: { nodes: demoFederationNodes() } })
}
case 'federation.invite': {
@@ -3826,6 +3861,29 @@ app.get('/app/thunderhub/api/invoices', (req, res) => res.json(MOCK_LND_DATA.inv
app.get('/app/thunderhub/api/payments', (req, res) => res.json(MOCK_LND_DATA.payments))
app.get('/app/thunderhub/api/forwards', (req, res) => res.json(MOCK_LND_DATA.forwarding))
// Generic app shell for any launched app without a dedicated mock UI — a clean
// "not interactive in the demo" notice with the app's icon. Registered after all
// specific /app/... routes so those win; only bare /app/<id>[/] reaches here.
app.get(['/app/:id', '/app/:id/'], (req, res) => {
const id = String(req.params.id || '').replace(/[^a-z0-9._-]/gi, '')
const title = id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
res.type('html').send(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
<style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}
body{background:#0b0f1a;color:#e6e8ee;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:flex;align-items:center;justify-content:center}
.card{text-align:center;padding:48px 40px;max-width:380px}
img{width:84px;height:84px;border-radius:20px;object-fit:cover;margin-bottom:20px;background:rgba(255,255,255,.05)}
h1{font-size:22px;font-weight:650;margin-bottom:8px}
p{color:#8b93a7;font-size:14px;line-height:1.5}
.tag{margin-top:18px;display:inline-block;font-size:12px;color:#fbbf24;border:1px solid rgba(251,191,36,.3);background:rgba(251,191,36,.1);padding:5px 12px;border-radius:999px}
</style></head><body><div class="card">
<img src="/assets/img/app-icons/${id}.png" onerror="this.onerror=null;this.src='/assets/icon/favico-black.svg'" alt="">
<h1>${title}</h1>
<p>This app isn't interactive in the demo, but it runs fully on a real Archipelago node.</p>
<div class="tag">Demo preview</div>
</div></body></html>`)
})
// Health check
app.get('/health', (req, res) => {
res.status(200).send('healthy')