diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 6c14b3ec..3596e7a2 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -28,6 +28,9 @@ - [iso-build-session-2026-03-10.md](iso-build-session-2026-03-10.md) — ISO build session notes - [unbundled-iso.md](unbundled-iso.md) — Unbundled ISO approach notes +## Infrastructure +- [project_bitcoin_rpc_auth.md](project_bitcoin_rpc_auth.md) — Bitcoin rpcauth, system Tor, reboot survival, container resilience + ## Completed Work - [project_mesh_198_issue.md](project_mesh_198_issue.md) — Mesh .198: 3 bugs fixed and deployed - [project_indeedhub_arch3_fix.md](project_indeedhub_arch3_fix.md) — IndeedHub Arch 3: corrupted combined tarball fixed diff --git a/.claude/memory/project_bitcoin_rpc_auth.md b/.claude/memory/project_bitcoin_rpc_auth.md new file mode 100644 index 00000000..17d716f4 --- /dev/null +++ b/.claude/memory/project_bitcoin_rpc_auth.md @@ -0,0 +1,21 @@ +--- +name: Bitcoin RPC rpcauth architecture +description: Bitcoin uses rpcauth (salted hash in config, password in secrets file), system Tor for containers, reboot survival +type: project +--- + +Bitcoin RPC uses `rpcauth` — salted HMAC-SHA256 hash in bitcoin.conf, plaintext password in `/var/lib/archipelago/secrets/bitcoin-rpc-password`. Credentials are STABLE across reboots, restarts, deploys. + +**Why:** Cookie auth rotates on every Bitcoin restart, breaking all dependent containers with env-var-only credentials. The `rpcauth` approach keeps the password stable while never exposing plaintext in config files or CLI args. + +**How to apply:** +- Bitcoin: reads rpcauth from bitcoin.conf (no CLI credential flags, config generated by first-boot or deploy) +- LND: `bitcoind.rpcuser/rpcpass` in lnd.conf (NOT rpccookie — LND v0.18.4 doesn't support it) +- All containers: read password from secrets file at creation time, passed via env vars +- Rust backend `bitcoin_rpc.rs`: reads from secrets file, cached with OnceCell +- bitcoin-ui: mounts `/var/lib/archipelago/secrets:/secrets:ro`, start.sh reads password and injects nginx auth header +- System Tor: `SocksPort 0.0.0.0:9050` + SocksPolicy, containers use `host.containers.internal:9050` +- `podman-restart.service` enabled for container auto-start after reboot +- Tor hidden service hostnames copied to `/var/lib/archipelago/tor-hostnames/` for readable access +- .198 ElectrumX points at .228's full Bitcoin node (pruned node can't run ElectrumX locally) +- Health monitor interval: 60 seconds — UI may briefly show "crashed" during restarts diff --git a/.claude/memory/project_repo_cleanup_and_dev_env.md b/.claude/memory/project_repo_cleanup_and_dev_env.md index 7ecf6919..4cfc1ab7 100644 --- a/.claude/memory/project_repo_cleanup_and_dev_env.md +++ b/.claude/memory/project_repo_cleanup_and_dev_env.md @@ -1,49 +1,44 @@ --- -name: v1.3.0 Session Status (March 19 late) -description: Massive session — 33 pentest fixes, container reliability, federation, mesh channel, 30+ commits +name: v1.3.0 Session Status (March 20) +description: Tor management system, bug fixes, federation name sync — cloud files working both ways type: project --- ## Deployed to .228 + .198 ### What's Live -- All 33 pentest security fixes (backend + frontend + nginx) -- Container reliability: memory limits in scripts, crash recovery coordination, health badges -- Federation & Peers: DID persistence, rotation, node names, two-column layout, invite types -- Archipelago public channel in Mesh (Tor messaging) -- LND Connect with CORS fix (bulletproof) -- ElectrumX headers.subscribe fix -- FileBrowser auto-login -- Lightning channel backup export -- App iframe auto-retry -- Install progress persists across navigation +- Full Tor hidden service management (systemd path unit pattern — tor-helper.sh) +- Container doctor: system Tor preferred, archy-tor container removed +- Federation name sync: server rename pushes to peers +- Cloud files working both ways over Tor +- Arch channel local echo for sent messages +- Web5 Message button → Mesh redirect +- Node names in federation/peers +- PeerFiles header shows name + DID (not onion) +- Connected Nodes flex height +- Server name persistence (root-owned file fixed) +- Tor services UI: add from installed apps, delete, restart, auth/protocol badges +- Layout: Network Interfaces + Tor Services stack on normal screens -### Active Bugs (fix next session) -1. **Archipelago channel**: sent messages don't show to sender (no local echo), .228 says "no peers found" -2. **Web5 Send Message modal**: should redirect to Mesh chat, not show its own modal -3. **Cloud peer files**: "Operation failed" when browsing .198 files from .228 — Tor connection issue -4. **Server name save**: not persisting — no `server-name.txt` on server -5. **Node names**: still showing DIDs in some places (cloud peer header, some federation contexts) -6. **Tor**: ControlPort 0 fix applied manually but needs to be in deploy script/torrc generation -7. **Connected Nodes container**: not filling height, needs max-height fix in Web5 view +### Architecture: Tor Management +- Backend writes staged torrc + action file to /var/lib/archipelago/tor-config/ +- systemd path unit (archipelago-tor-helper.path) triggers root-level service +- tor-helper.sh processes actions: write-torrc-and-restart, restart, delete-service, sync-hostnames +- NoNewPrivileges=yes safe — no sudo from backend +- Container doctor ensures system Tor stays running after deploys +- Web apps: port 80 on .onion → local app port; Protocol services: direct port -### Outstanding Tasks -- Tor restart button in Network UI -- Auto-restart Tor when features fail -- ISO build for alpha tester -- Deploy to Tailscale nodes (Arch 1/2/3) -- .198 stabilization (containers, memory limits) -- Container memory limits recreation on existing servers -- Meshcore public channel investigation (radio messages not showing) -- AIUI API key settings -- Message notification → open Mesh chat (not Web5) -- Loading state on Archipelago channel send ("Decentralization takes a sec") +### Onion Addresses (current) +- .228 archipelago: r33p5uzk2vxhdte4a5pfqgeax44a7b2lx57q32dxmx5llzyfz42lwnyd.onion +- .198 archipelago: mxn62m4odavwctlpsq2ozvhy3ibjpenlzemumwtkev7wviikttxvjhyd.onion -### Deploy Notes -- Backend binary: atomic swap via `cp -new` + `mv` -- Tor fix: remove `ControlPort 0` from torrc, chown debian-tor -- LND UI: rebuild with `--no-cache` for CORS credentials fix -- Always sync: frontend, nginx config, docker UIs, scripts, core source +### Still TODO +1. **Tor channel chat** — messages via Archipelago channel need testing/polish +2. **ISO build** — update build-auto-installer-iso.sh with tor-helper, systemd units, container doctor changes +3. **Better error messaging** — when nodes are down, addresses changed, all situations +4. **File access permissions** — public (no auth), federated (full access), peer-set (specific files) +5. **Auth on Tor app access** — login before accessing app via .onion (post-beta candidate) +6. **.198 health check** — deploy health check times out on .198 (backend works, likely timing) -**Why:** Session continuity for the massive v1.3.0 effort. -**How to apply:** Read at start of next session. Fix active bugs first, then ISO build. +**Why:** Session continuity for v1.3.0 beta stabilization effort. +**How to apply:** Read at start of next session. Work on TODO items in order. diff --git a/.claude/plans/plan.md b/.claude/plans/plan.md index 32224aaa..b0429b08 100644 --- a/.claude/plans/plan.md +++ b/.claude/plans/plan.md @@ -1,514 +1,803 @@ -# Archipelago Production Polish Plan +# Archipelago: Production Excellence Plan -**Duration**: 8 weeks (March 10 – May 4, 2026) -**Goal**: Zero new features. Every existing feature polished to flawless production quality. -**Philosophy**: The iPhone moment — everything just works, feels inevitable, no rough edges. +**Duration**: 12 months (48 weeks) +**Goal**: Code so good no developer could question any decision. Apple-level reliability. Every failure visible and recoverable. Every operation bounded. Every line justified. +**Audited**: 2026-03-20 — 122 Rust files, 38 Vue views, 180+ frontend files, 80+ shell scripts -## SSH Access +## CONSTRAINTS + +- **DEPLOY ONLY TO .198** — Never .228. All verification on .198. +- **BETA FREEZE** — Behavior-preserving only. No new features/UI/endpoints. +- **Tests before every refactor** — Capture current behavior first. Tests must pass unchanged after. +- **Atomic commits** — One logical change per commit. Every step compiles + passes tests. -All remote commands use SSH key auth (password auth is disabled): ```bash -ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 +ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198 ``` -Never use `sshpass`. The deploy script handles this automatically via `SSH_KEY`. --- -## Audit Summary +## COMPLETE ISSUE REGISTRY -Full codebase audit completed March 8, 2026. Findings: +### Backend Rust — 122 files audited -| Layer | Critical | High | Medium | Low | -|-------|----------|------|--------|-----| -| Frontend (Vue/TS) | 4 | 6 | 10 | 4 | -| Backend (Rust) | 6 | 6 | 6 | 7 | -| Infrastructure | 5 | 6 | 7 | 3 | -| UX Flows | 4 | 4 | 6 | 3 | -| **Total** | **19** | **22** | **29** | **17** | +| ID | Issue | File(s) | Severity | +|----|-------|---------|----------| +| R1 | Health RPC endpoint has no handler — returns "Unknown method" | `api/rpc/mod.rs` | P0 | +| R2 | Nostr client.connect() hangs indefinitely (4 calls, no timeout) | `nostr_handshake.rs:124,161,262,282` | P0 | +| R3 | Backup restore extracts directly to live dir — no atomic rollback | `backup/full.rs:122-149` | P0 | +| R4 | Rate limiter cleanup() never spawned — HashMap grows forever | `session.rs:566-579` | P1 | +| R5 | Login rate limiter same issue — entries never evicted | `session.rs:452-472` | P1 | +| R6 | Blocking std::fs in async — session.rs (6 calls) | `session.rs:77,128,370,413,423,425` | P1 | +| R7 | Blocking std::fs in async — docker_packages.rs | `docker_packages.rs:561,573` | P1 | +| R8 | Blocking std::fs in async — port_allocator.rs | `port_allocator.rs:59,73,77` | P1 | +| R9 | Blocking std::fs in async — peers.rs, node_message.rs | `peers.rs:30`, `node_message.rs:65` | P1 | +| R10 | Blocking std::fs in async — identity.rs, identity_manager.rs | `identity.rs:50`, `identity_manager.rs:164` | P1 | +| R11 | Blocking std::fs in async — nostr_discovery.rs | `nostr_discovery.rs:55` | P1 | +| R12 | Sync TCP I/O in async context — electrs_status.rs | `electrs_status.rs:5,40,78,81` | P1 | +| R13 | .expect() in main.rs startup | `main.rs:124,159` | P2 | +| R14 | .parse().unwrap() in session.rs rate limiting | `session.rs:665,676,688` | P1 | +| R15 | 7 .unwrap()/.expect() in mesh/protocol.rs | `protocol.rs:582,592,614,649,679,713,728` | P1 | +| R16 | .expect() in identity.rs crypto | `identity.rs:114,119` | P2 | +| R17 | .unwrap() in helpers/lib.rs (5 calls) | `helpers/lib.rs:167,172,180,233,253` | P2 | +| R18 | .unwrap() in helpers/rsync.rs (5 calls) | `rsync.rs:196,199,202,210,220` | P2 | +| R19 | .unwrap() in js-engine/lib.rs | `js-engine/lib.rs:130,249` | P2 | +| R20 | 14 #[allow(dead_code)] suppressions in mesh/mod.rs | `mesh/mod.rs:7-25` | P2 | +| R21 | Dead code in lnd.rs, data_manager.rs, dev_orchestrator.rs | Multiple | P2 | +| R22 | Bitcoin RPC URL hardcoded in 4+ files | `bitcoin.rs:89`, `mesh/mod.rs:624,649,663`, `listener.rs:1509+` | P2 | +| R23 | DWN health URL hardcoded | `dwn_sync.rs:76` | P2 | +| R24 | Update manifest URL hardcoded | `update.rs:11` | P3 | +| R25 | DNS-over-HTTPS URLs hardcoded (4 providers) | `network/dns.rs:98,102,106,110` | P3 | +| R26 | DWN protocol URIs hardcoded in server.rs | `server.rs:453-456` | P3 | +| R27 | Missing timeouts on mesh Bitcoin RPC calls | `mesh/mod.rs:624,649,663` | P1 | +| R28 | Missing timeouts on LND proxy calls (68 .send() calls) | `api/rpc/lnd.rs` | P2 | +| R29 | Missing timeout on DWN health check | `dwn_sync.rs:76` | P2 | +| R30 | TODO: track last-seen timestamp | `handshake.rs:77` | P3 | +| R31 | TODO: lnd.lookupinvoice RPC endpoint | `marketplace.rs:183` | P3 | +| R32 | TODO: trigger auto-restart or alert | `container/health_monitor.rs:140` | P3 | +| R33 | TODO: configure Podman to use AppArmor profile | `security/container_policies.rs:68` | P3 | +| R34 | Tor rotation deletes old .onion immediately — no transition | `api/rpc/tor.rs:184-240` | P1 | +| R35 | package.rs god file — 1,795 lines | `api/rpc/package.rs` | P2 | +| R36 | mesh/listener.rs god file — 1,799 lines | `mesh/listener.rs` | P2 | +| R37 | rpc/mod.rs god file — 1,092 lines | `api/rpc/mod.rs` | P2 | +| R38 | lnd.rs god file — 1,068 lines | `api/rpc/lnd.rs` | P2 | +| R39 | monitoring/mod.rs — 993 lines | `monitoring/mod.rs` | P3 | +| R40 | api/handler.rs — 911 lines | `api/handler.rs` | P3 | +| R41 | 30+ functions exceed 50 lines across codebase | Multiple | P3 | + +### Frontend — 180+ files audited + +| ID | Issue | File(s) | Severity | +|----|-------|---------|----------| +| F1 | WebSocket subscription registered multiple times — race condition | `stores/app.ts:88-134` | P0 | +| F2 | Unprotected concurrent mesh state mutations | `stores/mesh.ts:249-268,294-324` | P0 | +| F3 | No global Vue error handler — white screen on error | `main.ts` | P0 | +| F4 | Stale data after WebSocket reconnect — no full refresh | `stores/app.ts:88-163` | P1 | +| F5 | Message polling timer never stopped after logout | `composables/useMessageToast.ts:60` | P1 | +| F6 | AppLauncher NIP-07 message listener leak on close | `stores/appLauncher.ts:295-301` | P1 | +| F7 | Audio player listeners stack — never cleaned up | `composables/useAudioPlayer.ts:1-91` | P1 | +| F8 | WebSocket reconnection race — parallel connect() attempts | `api/websocket.ts:212-238` | P2 | +| F9 | WebSocket parse error silently caught — stale UI forever | `api/websocket.ts:164-172` | P2 | +| F10 | WebSocket stale connection detection too aggressive (5min) | `api/websocket.ts:284-299` | P2 | +| F11 | RPC client backoff + timeout = 40s max wait | `api/rpc-client.ts:31-117` | P2 | +| F12 | No code splitting — monolithic bundle | `vite.config.ts` | P2 | +| F13 | v-html on QR code without DOMPurify | `views/Settings.vue:441` | P2 | +| F14 | Goals store O(n) alias lookup on every computed | `stores/goals.ts:16-20,38-89` | P2 | +| F15 | localStorage save without try/catch (5+ instances) | `stores/goals.ts:34-36` + others | P2 | +| F16 | FileBrowser auth token duality — memory + cookie | `api/filebrowser-client.ts:39,50-68` | P2 | +| F17 | CSRF token cookie parsing brittle — regex only | `api/rpc-client.ts:18-21` | P2 | +| F18 | aiPermissions.ts Set uses unsafe type assertion | `stores/aiPermissions.ts:91-103` | P3 | +| F19 | Untracked setTimeout in AppSession — fires after unmount | `views/AppSession.vue:507` | P3 | +| F20 | Dashboard navigation missing aria-current="page" | `views/Dashboard.vue` | P3 | +| F21 | Search performance — string re-lowercasing every keystroke | `views/Apps.vue:510-537` | P3 | +| F22 | 30+ backdrop-filter blur elements — GPU overload on mobile | `style.css` | P3 | +| F23 | Record on sensitive DID operations | `types/api.ts` + `rpc-client.ts` | P3 | +| F24 | checkInterval timer leak on connect race | `api/websocket.ts:82-96` | P3 | +| F25 | Web5.vue god component — 3,940 lines | `views/Web5.vue` | P2 | +| F26 | Mesh.vue — 2,106 lines | `views/Mesh.vue` | P2 | +| F27 | Dashboard.vue — 1,819 lines | `views/Dashboard.vue` | P2 | +| F28 | Settings.vue — 1,792 lines | `views/Settings.vue` | P2 | +| F29 | Marketplace.vue — 1,293 lines | `views/Marketplace.vue` | P3 | +| F30 | Server.vue — 1,132 lines | `views/Server.vue` | P3 | +| F31 | Home.vue — 1,059 lines | `views/Home.vue` | P3 | +| F32 | AppDetails.vue — 1,036 lines | `views/AppDetails.vue` | P3 | +| F33 | useAppStore god store — 324 lines, 16 methods, 8+ responsibilities | `stores/app.ts` | P2 | + +### Shell Scripts — 80+ files audited + +| ID | Issue | File(s) | Severity | +|----|-------|---------|----------| +| S1 | 60+ instances of `sudo podman` — should be rootless | `fix-indeedhub(28)`, `deploy-bitcoin(11)`, `deploy-tailscale(2+)` | P0 | +| S2 | Zero container health checks in first-boot (30 containers) | `first-boot-containers.sh` | P0 | +| S3 | 50+ `:latest` image tags across all scripts | `first-boot(15)`, `deploy(11)`, `tailscale(18)`, `iso(7)` | P1 | +| S4 | No `set -e` in first-boot — silent container failures | `first-boot-containers.sh:1-9` | P1 | +| S5 | `eval "$DB_PASSWORDS"` — code injection risk | `deploy-to-target.sh:940` | P1 | +| S6 | No deploy locking — concurrent deploys corrupt state | `deploy-to-target.sh` | P1 | +| S7 | No deploy rollback — failed deploy leaves broken system | `deploy-to-target.sh` | P1 | +| S8 | sshpass usage in trust-archipelago-cert.sh | `trust-archipelago-cert.sh:23-26` | P1 | +| S9 | MariaDB password in command line — visible in ps | `first-boot-containers.sh:285` | P1 | +| S10 | 80+ instances of `2>/dev/null \|\| true` masking errors | `deploy-to-target.sh` | P2 | +| S11 | No trap cleanup for temp files | Multiple scripts | P2 | +| S12 | Unquoted variables (word splitting risk) | Multiple scripts | P2 | +| S13 | Hardcoded IPs in 6+ scripts | `deploy-to-target.sh:26`, `deploy-tailscale.sh:26`, etc. | P2 | +| S14 | No input validation on deploy targets | `deploy-tailscale.sh` | P2 | +| S15 | Missing memory limits on some containers in deploy | `deploy-to-target.sh:842-880` | P2 | +| S16 | ISO build not reproducible — dynamic image capture + :latest | `build-auto-installer-iso.sh:500-594` | P2 | +| S17 | No disk space pre-flight in deploy | `deploy-to-target.sh` | P2 | +| S18 | deploy-to-target.sh — 1,728 lines monolith | `deploy-to-target.sh` | P3 | +| S19 | build-auto-installer-iso.sh — 1,850 lines monolith | `build-auto-installer-iso.sh` | P3 | +| S20 | first-boot-containers.sh — 855 lines monolith | `first-boot-containers.sh` | P3 | +| S21 | No shared script library — duplicated functions | `scripts/` | P3 | + +### Infrastructure + +| ID | Issue | File(s) | Severity | +|----|-------|---------|----------| +| I1 | Nginx: /archipelago/, /content, /dwn missing timeout+rate-limit+body-size | `nginx-archipelago.conf:116-180` | P0 | +| I2 | Systemd: no MemoryMax, LimitNOFILE, TasksMax | `archipelago.service` | P1 | +| I3 | Tor rotation kills old address immediately — federation downtime | `api/rpc/tor.rs:184-240` | P1 | --- -## Skills Required +## MONTH 1: CRASH PREVENTION (Weeks 1–4) -### Existing Skills (14) -`deploy`, `deploy-both`, `diagnose`, `check-server`, `frontend-dev`, `sync-configs`, `build-iso`, `server-logs`, `add-app`, `harden`, `test`, `lint`, `ux-review`, `refactor` +> Fix every issue that can crash the system, hang indefinitely, or lose data. -### New Skills (9) -| Skill | Purpose | -|-------|---------| -| `polish` | Main orchestrator — reads this plan, detects week, executes tasks | -| `polish-errors` | Fix silent error handling, add user-facing error states | -| `polish-loading` | Add skeleton loaders, loading indicators, empty states | -| `polish-forms` | Input validation, trimming, real-time feedback | -| `polish-backend` | Fix unwrap/expect, add timeouts, connection pooling | -| `polish-deploy` | Add rollback, health checks, pre-deploy validation | -| `polish-security` | Systemd hardening, nginx CSP, secrets management | -| `polish-websocket` | Reconnection UX, connection status indicator, heartbeat | -| `sweep` | Full automated quality sweep: lint + type-check + verify fixes | +### Week 1: P0 Backend — Things That Hang or Lose Data + +**R1 — Health endpoint handler** +- File: `core/archipelago/src/api/rpc/mod.rs` +- Add handler for `"health"` method that checks: crash recovery complete, Podman socket responsive, session store loaded +- Tests: health returns JSON status, degraded when Podman unreachable, degraded during recovery +- Verify: `curl http://192.168.1.198/rpc/v1 -d '{"method":"health"}'` returns real status + +**R2 — Nostr connect timeout** +- File: `core/archipelago/src/nostr_handshake.rs` lines 124, 161, 262, 282 +- Wrap all 4 `client.connect().await` in `tokio::time::timeout(Duration::from_secs(10), ...)` +- Tests: connect timeout returns Err after 10s, successful connect within timeout works + +**R3 — Backup restore atomic rollback** +- File: `core/archipelago/src/backup/full.rs` lines 122-149 +- Rewrite: decrypt → extract to staging dir → validate required files → atomic rename → rollback on failure +- Tests: valid backup restores, corrupt backup fails without touching live data, partial extraction rolls back, disk space check fails early + +**I1 — Nginx unauthenticated endpoint protection** +- File: `image-recipe/configs/nginx-archipelago.conf` lines 116-180 +- Add to `/archipelago/`, `/content`, `/dwn`: + - `limit_req zone=peer burst=20 nodelay;` + - `client_max_body_size 10m;` + - `proxy_connect_timeout 30s; proxy_read_timeout 60s; proxy_send_timeout 30s;` +- Tests: >10MB payload → 413, slow client → timeout, burst 30 → 429 after 20 + +### Week 2: P0 Frontend + Scripts — Things That Break UI or Containers + +**F1 — WebSocket subscription race condition** +- File: `neode-ui/src/stores/app.ts` lines 88-134 +- Fix: Return unsubscribe function from `wsClient.subscribe()`, call it before re-subscribing. Use a subscription ID to prevent duplicates. +- Tests: rapid connectWebSocket() calls produce only one active subscription + +**F2 — Mesh concurrent state mutations** +- File: `neode-ui/src/stores/mesh.ts` lines 249-324 +- Fix: Add `isSending` ref as mutex. Queue concurrent sends. `fetchMessages()` called once after all sends complete. +- Tests: 3 concurrent sendMessage() calls → all succeed, messages list consistent + +**F3 — Global error handler** +- File: `neode-ui/src/main.ts` +- Add `app.config.errorHandler` that shows toast + logs structured error +- Tests: thrown error in component shows toast, nested errors don't crash handler + +**S1 — Eliminate all `sudo podman`** +- Files: `fix-indeedhub-containers.sh` (28), `deploy-bitcoin-knots.sh` (11), `deploy-tailscale.sh` (2+), `uptime-monitor.sh` (1), `setup-aiui-server.sh` +- Replace every `sudo podman` with `podman` (runs as archipelago user) +- Tests: grep for `sudo podman` across all scripts returns zero matches + +**S2 — Container health checks for all 30 containers** +- File: `scripts/first-boot-containers.sh` +- Add `--health-cmd`, `--health-interval=30s`, `--health-timeout=5s`, `--health-retries=3` to every `$DOCKER run` +- Health commands per type: + - Bitcoin: `bitcoin-cli -rpcuser=... getblockchaininfo || exit 1` + - HTTP apps: `curl -sf http://localhost:{port}/ || exit 1` + - LND: `curl -sf --insecure https://localhost:8080/v1/getinfo || exit 1` + - Databases: `mariadb -u root -p... -e "SELECT 1" || exit 1` +- Tests: script grep confirms every `$DOCKER run` has `--health-cmd` + +### Week 3: P1 Backend — Blocking I/O and Memory Leaks + +**R4+R5 — Rate limiter cleanup** +- File: `core/archipelago/src/session.rs` +- Spawn background tasks for both `EndpointRateLimiter::cleanup()` and `LoginRateLimiter` cleanup, every 5 min +- Tests: after cleanup, stale entries removed; active entries preserved + +**R6 — session.rs blocking I/O (6 calls)** +- Replace `std::fs::read_to_string` → `tokio::fs::read_to_string` at lines 77, 370, 413 +- Replace `std::fs::write` → `tokio::fs::write` at lines 128, 425 +- Replace `std::fs::create_dir_all` → `tokio::fs::create_dir_all` at line 423 +- Tests: session load/save/persist still works correctly + +**R7 — docker_packages.rs blocking I/O** +- Replace `std::fs::read_to_string` → `tokio::fs::read_to_string` at lines 561, 573 +- Tests: app metadata loading works + +**R8 — port_allocator.rs blocking I/O** +- Replace all 3 std::fs calls → tokio::fs at lines 59, 73, 77 +- Tests: port allocation/persistence works + +**R9+R10+R11 — Remaining blocking I/O** +- `peers.rs:30`, `node_message.rs:65`, `identity.rs:50`, `identity_manager.rs:164`, `nostr_discovery.rs:55` +- Convert all to tokio::fs +- Tests: each module's file operations still work + +**R12 — electrs_status.rs sync TCP I/O** +- Convert synchronous TCP client to async (tokio::net::TcpStream) +- Tests: ElectrumX status query works, timeout on connection failure + +### Week 4: P1 Frontend — Memory Leaks and Stale State + +**F4 — WebSocket reconnect full state refresh** +- File: `neode-ui/src/stores/app.ts` +- After reconnect, call `rpcClient.call({method: 'server.get-state'})` to get fresh state before accepting patches +- Tests: after simulated disconnect+reconnect, state matches server + +**F5 — Message polling timer cleanup** +- File: `neode-ui/src/composables/useMessageToast.ts` +- Tie polling lifecycle to auth state: stop on logout, start on login. Export cleanup function. +- Tests: polling stops when auth false, restarts when auth true, no timer after unmount + +**F6 — AppLauncher message listener leak** +- File: `neode-ui/src/stores/appLauncher.ts` +- Ensure listener is removed when app closes (even if not via close button — e.g., route navigation) +- Tests: navigate away from app → listener removed, new app opens clean + +**F7 — Audio player listener stacking** +- File: `neode-ui/src/composables/useAudioPlayer.ts` +- Create Audio element once, register listeners once. Track initialization flag. +- Tests: calling play() 10 times → still only 6 listeners total (not 60) + +**S3 — Pin all container images (remove :latest)** +- Files: `first-boot-containers.sh` (15), `deploy-to-target.sh` (11), `deploy-tailscale.sh` (18), `build-auto-installer-iso.sh` (7) +- Replace every `:latest` with specific version tag +- Create `image-versions.env` sourced by all scripts — single source of truth +- Tests: `grep -r ':latest' scripts/ image-recipe/` returns zero matches (excluding comments) --- -## Week 1: Silent Failures & Error Handling (March 10–16) +## MONTH 2: OPERATIONAL SAFETY (Weeks 5–8) -**Theme**: Nothing fails silently. Every error is visible, actionable, recoverable. +> Fix everything that makes deploys dangerous, scripts unreliable, or operations opaque. -### Tasks +### Week 5: Deploy Script Hardening -#### 1.1 Frontend: Kill all silent catch blocks -- **Files**: Settings.vue, Web5.vue, router/index.ts, Apps.vue, OnboardingIntro.vue -- **Action**: Replace 21+ `.catch(() => {})` patterns with proper error handling -- **Pattern**: Log to console in dev, show toast/inline error to user in prod -- **Acceptance**: Zero `.catch(() => {})` in codebase (grep confirms) -- **Skill**: `/polish-errors` +**S4 — first-boot error handling** +- Add per-section error checking: if Bitcoin fails, skip dependent containers (LND, Mempool, BTCPay) +- Add `wait_for_container` return value checking +- Tests: first-boot with broken Bitcoin image → Bitcoin deps skipped, independent apps still start -#### 1.2 Frontend: Remove all console.log from production -- **Files**: stores/app.ts (15+), api/websocket.ts (12+) -- **Action**: Replace with conditional dev-only logging or remove -- **Pattern**: `if (import.meta.env.DEV) console.log(...)` or remove entirely -- **Acceptance**: Zero `console.log` outside of dev guards (grep confirms) -- **Skill**: `/lint` +**S5 — Replace eval with safe construct** +- File: `deploy-to-target.sh:940` +- Replace `eval "$DB_PASSWORDS"` with explicit variable assignment from SSH output +- Tests: passwords parsed correctly without eval -#### 1.3 Backend: Fix all unwrap/expect in handler.rs -- **Files**: core/archipelago/src/api/handler.rs (11 unwraps) -- **Action**: Replace `.unwrap()` on Response builders with `.map_err()` and `?` -- **Acceptance**: Zero `unwrap()` in handler.rs -- **Skill**: `/polish-backend` +**S6 — Deploy locking** +- File: `deploy-to-target.sh` +- Add remote `flock` on `/var/lock/archipelago-deploy.lock`. Second deploy fails immediately with message. Stale lock (>30 min) broken automatically. +- Tests: two parallel deploys → second fails, stale lock → broken and deploy proceeds -#### 1.4 Backend: Fix unwrap/expect across all production paths -- **Files**: main.rs, identity.rs, totp.rs, rpc/mod.rs, image_verifier.rs -- **Action**: Audit all 32 `.unwrap()`/`.expect()` calls, replace with `?` or `.context()` -- **Acceptance**: Zero unwrap/expect outside of test modules -- **Skill**: `/polish-backend` +**S7 — Deploy rollback** +- File: `deploy-to-target.sh` +- Before overwriting binary: `cp archipelago archipelago.bak` +- Before overwriting frontend: `cp -r web-ui web-ui.bak` +- If health check fails post-restart: restore from .bak, restart again +- Tests: intentionally broken binary → deploy detects, rolls back, system healthy -#### 1.5 Backend: Hardcoded Bitcoin RPC credentials -- **Files**: core/archipelago/src/api/rpc/bitcoin.rs:89 -- **Action**: Move `archipelago/archipelago123` to env var or secrets manager -- **Pattern**: `std::env::var("ARCHIPELAGO_BITCOIN_RPC_USER").unwrap_or("archipelago".into())` -- **Acceptance**: No hardcoded credentials in Rust source +**S8 — Eliminate sshpass** +- File: `trust-archipelago-cert.sh` +- Rewrite to use SSH key only: `ssh -i ~/.ssh/archipelago-deploy` +- Tests: script works with key auth, fails gracefully without key -#### 1.6 Deploy & verify -- Run `/lint` to confirm zero violations -- Run `/deploy` to live server -- Run `/check-server` to verify health -- Manual spot-check: trigger errors in UI, confirm they're visible +### Week 6: Script Quality + +**S9 — MariaDB password not on command line** +- File: `first-boot-containers.sh:285` +- Use `$DOCKER exec -i ... mariadb -uroot < /dev/stdin <<< "SET PASSWORD..."` +- Tests: `ps aux` during execution doesn't show password + +**S10 — Replace silent error masking** +- File: `deploy-to-target.sh` (80+ instances) +- Pattern: replace `2>/dev/null || echo ""` with `|| { log_warn "..."; echo ""; }` +- At minimum, log what failed before masking +- Tests: failed health check produces log entry + +**S11 — Trap cleanup for temp files** +- All scripts that create /tmp files: add `trap "rm -rf /tmp/deploy-$$" EXIT` at start +- Files: deploy-to-target.sh, deploy-tailscale.sh, build-auto-installer-iso.sh +- Tests: script interrupted mid-execution → temp files cleaned up + +**S12 — Quote all variables** +- Audit and fix unquoted `$VARIABLE` in command arguments across all scripts +- Tests: shellcheck passes on all modified scripts + +**S13 — Extract hardcoded IPs to config** +- Create `scripts/deploy-config-defaults.sh` with all node IPs as named variables +- Source from all scripts instead of hardcoding +- Tests: changing IP in config → all scripts use new IP + +### Week 7: Infrastructure Hardening + +**I2 — Systemd resource limits** +- File: `image-recipe/configs/archipelago.service` +- Add: `MemoryMax=4G`, `LimitNOFILE=65535`, `TasksMax=2048` +- Tests: `systemctl show archipelago` confirms limits applied, service starts normally + +**I3 — Tor rotation transition period** +- File: `core/archipelago/src/api/rpc/tor.rs` +- Keep old hidden service running for 24h after rotation. Both addresses active. Notify peers of new address. Schedule old deletion. +- Tests: after rotation old address still resolves, peers receive notification, old removed after transition + +**S14 — Input validation on deploy targets** +- Add regex validation for hostnames/IPs before SSH +- Tests: invalid hostname → clear error, valid hostname → proceeds + +**S15 — Memory limits on all deploy containers** +- File: `deploy-to-target.sh` lines 842-880 +- Add `--memory=$(mem_limit ...)` to all UI container builds +- Tests: every container in deploy has `--memory` flag + +**S17 — Disk space pre-flight** +- File: `deploy-to-target.sh` +- Check target disk <85% before deploying. Abort with clear message if full. +- Tests: deploy to 90% full disk → aborted, deploy to 50% full → succeeds + +### Week 8: Remaining P1 Backend + +**R14 — Fix .parse().unwrap() in session rate limiting** +- File: `session.rs:665,676,688` +- Replace `.parse().unwrap()` with `.parse().context("...")?` +- Tests: invalid IP handling works gracefully + +**R15 — Fix 7 unwrap/expect in mesh/protocol.rs** +- File: `mesh/protocol.rs:582,592,614,649,679,713,728` +- Replace all with `?` operator + proper error types +- Tests: protocol parsing with malformed data returns error, not panic + +**R27 — Add timeouts to mesh Bitcoin RPC calls** +- File: `mesh/mod.rs:624,649,663` +- Add `tokio::time::timeout(Duration::from_secs(10), ...)` to all Bitcoin RPC calls +- Tests: RPC timeout returns error after 10s + +**R34 — Tor rotation transition** +- (Covered by I3 above) --- -## Week 2: Loading States & Visual Feedback (March 17–23) +## MONTH 3: PRODUCTION POLISH (Weeks 9–12) -**Theme**: The user always knows what's happening. No blank screens, no mystery waits. +> Fix every remaining P2 issue — unwraps, hardcoded values, frontend quality, resilience. -### Tasks +### Week 9: Remaining Backend Unwraps + Dead Code -#### 2.1 Add skeleton loaders to all async views -- **Files**: Apps.vue, AppDetails.vue, Marketplace.vue, Cloud.vue, Server.vue, Settings.vue -- **Action**: Create `SkeletonLoader.vue` component, add to every view that fetches data -- **Pattern**: Show skeleton immediately, swap to real content on load -- **Acceptance**: Every view shows placeholder content during load -- **Skill**: `/polish-loading` +**R13 — main.rs .expect() → .context()** +- Replace 2 `.expect()` calls with `.context("...")?` and proper startup error handling -#### 2.2 Add timeout warnings to long operations -- **Files**: Login.vue (server startup), Marketplace.vue (app install) -- **Action**: After 15s show "Taking longer than expected...", after 30s show troubleshoot options -- **Acceptance**: No operation silently hangs +**R16 — identity.rs .expect() → safe handling** +- Replace 2 `.expect()` in crypto operations with result propagation -#### 2.3 Fix Start/Stop button state mismatch -- **Files**: Apps.vue, AppDetails.vue, ContainerApps.vue -- **Action**: Button reflects actual backend state, not a fixed 5s timer -- **Pattern**: Poll backend every 2s during state transition, update button immediately on response -- **Acceptance**: Button state always matches container state within 3s +**R17+R18 — helpers unwraps** +- Fix 10 `.unwrap()` calls in `helpers/lib.rs` and `helpers/rsync.rs` +- Replace with `?` operator or `.context()` -#### 2.4 Connection status indicator -- **Files**: Create `ConnectionStatus.vue`, integrate into App.vue header -- **Action**: Show green/amber/red dot based on WebSocket connection state -- **Pattern**: Use `wsClient.isConnected()` — green=connected, amber=reconnecting, red=disconnected -- **Acceptance**: User always knows if they're connected -- **Skill**: `/polish-websocket` +**R19 — js-engine unwraps** +- Fix 2 `.unwrap()` in `js-engine/lib.rs:130,249` -#### 2.5 Fix OnlineStatusPill to use real data -- **Files**: components/OnlineStatusPill.vue -- **Action**: Connect to actual WebSocket state instead of hardcoded "Online" -- **Acceptance**: Pill reflects real connection state +**R20+R21 — Dead code elimination** +- Remove all 14 `#[allow(dead_code)]` in `mesh/mod.rs`. Either use the fields or delete them. +- Same for `lnd.rs`, `data_manager.rs`, `dev_orchestrator.rs` +- Tests: `cargo clippy` zero warnings, `cargo test` passes -#### 2.6 Empty states for all views -- **Files**: Apps.vue, Cloud.vue, ContainerApps.vue -- **Action**: When no data, show helpful message with CTA (e.g., "No apps installed — Browse Marketplace") -- **Acceptance**: Every view handles the zero-data case gracefully +### Week 10: Hardcoded Values → Constants -#### 2.7 Deploy & verify -- `/deploy` then `/check-server` -- Test: disconnect network, observe status indicator -- Test: slow network (throttle), observe skeleton loaders -- Test: fresh account with no apps, observe empty states +**R22 — Bitcoin RPC URL constant** +- Create `const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";` in a shared constants module +- Use across `bitcoin.rs`, `mesh/mod.rs`, `mesh/listener.rs` +- Tests: all Bitcoin RPC calls still work + +**R23 — DWN health URL constant** +**R24 — Update manifest URL constant** +**R25 — DNS-over-HTTPS URLs → constants array** +**R26 — DWN protocol URIs → constants** +- Centralize all hardcoded URLs/URIs into `core/archipelago/src/constants.rs` +- Tests: all modules reference constants, no hardcoded strings remain + +**R28 — LND proxy timeouts** +- Audit all 68 `.send()` calls in `api/rpc/lnd.rs`. Ensure each has explicit timeout. +- Tests: LND proxy call with unresponsive LND → timeout error, not hang + +**R29 — DWN health check timeout** +- Add timeout to `dwn_sync.rs:76` health check + +**R30-R33 — Resolve all TODOs** +- Either implement the TODO or remove the dead code path. Per project rules: no TODO/FIXME in commits. + +### Week 11: Frontend P2 Fixes + +**F8 — WebSocket reconnection race** +- Add `isReconnecting` flag. Skip if already reconnecting. +- Tests: rapid close events → only one reconnect attempt + +**F9 — WebSocket parse error handling** +- Count consecutive parse errors. After 3, force reconnect. +- Tests: 3 malformed messages → reconnect triggered; single bad message → logged only + +**F10 — Stale connection detection tuning** +- Require mutual pong response within 30s. Don't close valid connections that are simply quiet. +- Tests: quiet but healthy connection → stays open; no pong for 30s → reconnects + +**F11 — RPC client backoff reduction** +- Reduce default timeout from 30s to 15s. Add jitter to backoff. Cap total retry time at 20s. +- Tests: server outage → user sees error within 20s, not 40s + +**F12 — Code splitting** +- Lazy-load all routes: `() => import('./views/Web5.vue')` +- Add manual chunks in vite.config.ts for vendor/api +- Tests: build produces multiple chunks, initial bundle < 200KB gzipped + +**F13 — DOMPurify on QR v-html** +- Add DOMPurify.sanitize() to QR SVG before v-html rendering +- Tests: XSS payload in QR content → sanitized + +### Week 12: Frontend P2 Continued + Performance + +**F14 — Goals computed memoization** +- Replace O(n) alias lookup with Map. Add deep equality check. +- Tests: goalStatuses computed runs in <1ms with 100 apps + +**F15 — localStorage error handling** +- Wrap all localStorage.setItem in try/catch. Show toast on quota exceeded. +- Tests: full localStorage → toast shown, app continues + +**F16 — FileBrowser auth consolidation** +- Use cookie-only auth. Remove in-memory token. +- Tests: login persists across page reload, logout clears cookie + +**F17 — CSRF token parsing robustness** +- Add header fallback for CSRF token. Handle edge cases. +- Tests: missing cookie → falls back to header, both missing → error + +**F22 — CSS backdrop-filter mobile performance** +- Add media query: reduce blur to 8px on mobile. Remove backdrop-filter from non-visible elements. +- Tests: mobile Lighthouse performance score > 80 --- -## Week 3: Form Validation & Input Quality (March 24–30) +## MONTH 4-5: BACKEND ARCHITECTURE (Weeks 13–20) -**Theme**: Every input feels responsive, validated, impossible to misuse. +> Split every Rust god file. Target: no file > 500 lines. -### Tasks +### Week 13–14: Split package.rs (1,795 lines) -#### 3.1 Real-time password validation -- **Files**: Login.vue (password setup), Settings.vue (password change) -- **Action**: Show inline validation as user types: length check, match check, strength meter -- **Pattern**: Debounced validation on input, green checkmark / red X per rule -- **Acceptance**: User sees validation state before clicking submit -- **Skill**: `/polish-forms` +``` +api/rpc/package/ +├── mod.rs — Re-exports (~50 lines) +├── config.rs — get_app_config(), get_app_capabilities(), needs_archy_net() +├── lifecycle.rs — install, start, stop, restart, uninstall +├── validation.rs — Input validation, dependency checking, image validation +└── progress.rs — Progress streaming, install status tracking +``` -#### 3.2 TOTP input improvements -- **Files**: Login.vue (TOTP verify step) -- **Action**: Auto-submit on 6 digits, show session countdown timer, trim whitespace -- **Pattern**: `watch(code, () => { if (code.length === 6) submit() })` -- **Acceptance**: TOTP flow is fast and clear, session timeout is visible +Pre-split tests: test every `get_app_config()` variant, validation path, lifecycle transition +Post-split: all RPC calls return identical responses, `cargo test` passes -#### 3.3 Input trimming on all forms -- **Files**: Login.vue, Settings.vue, any form input -- **Action**: `.trim()` all text inputs before submission -- **Acceptance**: Leading/trailing whitespace never causes failures +### Week 15–16: Split mesh/listener.rs (1,799 lines) -#### 3.4 Disable submit buttons during operations -- **Files**: Settings.vue (password change), Login.vue (login), Marketplace.vue (install) -- **Action**: Add `:disabled="isSubmitting"` to all action buttons -- **Pattern**: Button shows spinner + disabled state during async operation -- **Acceptance**: No button can be double-clicked during an operation +``` +mesh/listener/ +├── mod.rs — Re-exports + spawn_mesh_listener() +├── session.rs — run_mesh_session() loop +├── frames.rs — handle_frame() dispatcher +├── identity.rs — handle_identity_received(), handle_typed_message() +├── sync.rs — sync_queued_messages(), store_typed_message() +└── bitcoin.rs — Bitcoin relay operations, RPC calls +``` -#### 3.5 Error message consistency -- **Files**: All views with error messages -- **Action**: Create `formatError()` utility that normalizes error messages -- **Pattern**: Network errors -> "Can't reach server", Auth errors -> "Session expired", Server errors -> "Something went wrong" -- **Acceptance**: Error messages are user-friendly, never show raw error strings +### Week 17–18: Split rpc/mod.rs (1,092 lines) + lnd.rs (1,068 lines) -#### 3.6 Deploy & verify -- Test every form: login, password change, TOTP setup, app install -- Try invalid inputs, verify feedback is immediate and clear +**rpc/mod.rs** → `dispatcher.rs` (method routing), `middleware.rs` (CSRF/session/rate-limit), `response.rs` (response building) + +**lnd.rs** → `lnd/wallet.rs`, `lnd/channels.rs`, `lnd/info.rs`, `lnd/payments.rs` + +### Week 19–20: Split monitoring (993), handler (911), mesh (865) + +Split each into sub-modules. Target: no file > 500 lines. +All pre-split tests, all post-split verification. --- -## Week 4: Backend Robustness (March 31 – April 6) +## MONTH 6-8: FRONTEND ARCHITECTURE (Weeks 21–32) -**Theme**: The backend never crashes, never hangs, handles every edge case. +> Split every Vue god component. Target: no component > 500 lines. -### Tasks +### Week 21–22: Split Web5.vue (3,940 lines → 8 sub-views) -#### 4.1 Add timeouts to all container operations -- **Files**: core/archipelago/src/container/dev_orchestrator.rs -- **Action**: Wrap all podman calls with `tokio::time::timeout(Duration::from_secs(30), ...)` -- **Acceptance**: No container operation can hang indefinitely +``` +views/web5/ +├── Web5.vue — Router shell (~150 lines) +├── Web5Identity.vue — DID management +├── Web5Wallet.vue — Wallet operations +├── Web5Nostr.vue — Nostr relays/profiles +├── Web5Credentials.vue — Verifiable Credentials +├── Web5Peers.vue — P2P federation nodes +├── Web5Storage.vue — DWN storage/explorer +├── Web5Goals.vue — Goals/voting +└── Web5Marketplace.vue — Decentralized marketplace +``` -#### 4.2 Add timeouts to all external HTTP calls -- **Files**: bitcoin.rs, handler.rs (LND proxy) -- **Action**: Explicit `reqwest::Client` with timeout, not default -- **Pattern**: Reuse a single `Client` stored in `RpcHandler` state -- **Acceptance**: Every HTTP call has an explicit timeout +Add nested routes. Component tests for each section. All sections render identically. -#### 4.3 Connection pooling for Bitcoin RPC -- **Files**: core/archipelago/src/api/rpc/bitcoin.rs -- **Action**: Store `reqwest::Client` in `RpcHandler`, reuse across requests -- **Acceptance**: One client instance, connection pooled +### Week 23–24: Split Mesh.vue (2,106) + Dashboard.vue (1,819) -#### 4.4 Fix all clippy warnings -- **Action**: Run `cargo clippy --all-targets --all-features` on dev server, fix all 10 warnings -- **Warnings**: `should_implement_trait`, `get_first`, `assign_op_pattern`, `wildcard_in_or_patterns`, `redundant_field_names`, `unused_import`, `ptr_arg`, `very_complex_type`, `if_else_collapse`, `io::Error::other` -- **Acceptance**: `cargo clippy` returns zero warnings -- **Skill**: `/lint` +**Mesh.vue** → `MeshRadio.vue`, `MeshChat.vue`, `MeshNetwork.vue`, `MeshFederation.vue` +**Dashboard.vue** → `DashboardHome.vue`, `DashboardApps.vue`, `DashboardSystem.vue` -#### 4.5 Rate limiting on unauthenticated endpoints -- **Files**: core/archipelago/src/api/handler.rs -- **Action**: Add per-IP rate limiting to `/archipelago/node-message` and `/electrs-status` -- **Pattern**: In-memory rate limiter with 60 req/min per IP -- **Acceptance**: Endpoints return 429 when rate exceeded +### Week 25–26: Split Settings.vue (1,792) + Server.vue (1,132) -#### 4.6 Consistent error codes and messages -- **Files**: All RPC endpoints -- **Action**: Define error code constants, consistent capitalization -- **Pattern**: `const ERR_AUTH: i32 = -1001;` etc. -- **Acceptance**: All error responses use defined constants +**Settings.vue** → `SettingsAccount.vue`, `SettingsSystem.vue`, `SettingsNetwork.vue`, `SettingsAppearance.vue` +**Server.vue** → `ServerOverview.vue`, `ServerContainers.vue`, `ServerLogs.vue` -#### 4.7 Remove dead code -- **Files**: identity.rs (unused field, unused methods), auth.rs (dead_code allows) -- **Action**: Remove `identity_dir` field, remove unused `verify()` and `did_key()` methods, remove `#[allow(dead_code)]` and verify usage -- **Acceptance**: Zero `#[allow(dead_code)]` outside of generated code +### Week 27–28: Split Marketplace.vue (1,293) + AppDetails.vue (1,036) + Home.vue (1,059) -#### 4.8 Replace println/eprintln with tracing -- **Files**: core/startos/src/* (23+ instances) -- **Action**: Replace `println!` -> `tracing::info!`, `eprintln!` -> `tracing::warn!` -- **Acceptance**: Zero `println!` / `eprintln!` in non-test code +Each into 3-4 focused sub-components. -#### 4.9 Deploy & verify -- `/deploy` then `/check-server` then `/diagnose` -- Test: kill Bitcoin container, verify backend doesn't crash -- Test: flood unauthenticated endpoint, verify rate limiting -- Test: restart backend, verify graceful startup +### Week 29–30: Decompose useAppStore (324 lines, 16 methods) + +``` +stores/ +├── app.ts — Thin re-export for backward compat (~50 lines) +├── auth.ts — Login, logout, session, password, TOTP +├── server.ts — Server info, system stats, reboot/shutdown +├── realtime.ts — WebSocket connection, subscriptions, heartbeat +└── packages.ts — Package install/uninstall, marketplace data +``` + +Tests: every existing import of `useAppStore` still works. State transitions identical. + +### Week 31–32: Remaining frontend P3 issues + +**F18** — aiPermissions runtime validation +**F19** — Track AppSession timeout +**F20** — Dashboard aria-current +**F21** — Debounce search + memoize +**F23** — Branded types for DID operations +**F24** — Fix checkInterval leak --- -## Week 5: WebSocket & Real-Time Quality (April 7–13) +## MONTH 9-10: SCRIPT ARCHITECTURE + ISO (Weeks 33–40) -**Theme**: Real-time updates are bulletproof. Connection issues are transparent to the user. +> Split every monolithic script. Target: no script > 400 lines. -### Tasks +### Week 33–34: Create shared script library -#### 5.1 WebSocket reconnection UX -- **Files**: api/websocket.ts, App.vue -- **Action**: After max reconnect attempts, show persistent banner "Connection lost. Click to retry." -- **Pattern**: Don't silently give up after 10 attempts -- **Acceptance**: User always has a path to reconnect -- **Skill**: `/polish-websocket` +``` +scripts/lib/ +├── common.sh — Colors, logging, error handling, SSH helpers +├── health.sh — Health check polling, container status +├── deploy-utils.sh — Rsync, file sync, backup/restore +├── container.sh — Podman helpers, image management, mem_limit() +└── network.sh — IP validation, port checking +``` -#### 5.2 WebSocket heartbeat improvement -- **Files**: api/websocket.ts -- **Action**: Send ping every 30s, expect pong within 5s, reconnect if missed -- **Acceptance**: Stale connections detected within 35s, not 60s +Tests: each library function tested in `scripts/tests/` -#### 5.3 RPC client session detection -- **Files**: api/rpc-client.ts -- **Action**: On 401/403 response, redirect to login page instead of showing generic error -- **Pattern**: `if (status === 401) { router.push('/login'); return; }` -- **Acceptance**: Expired sessions redirect to login immediately +### Week 35–36: Split deploy-to-target.sh (1,728 lines) -#### 5.4 Message queuing during reconnection -- **Files**: api/rpc-client.ts, api/websocket.ts -- **Action**: If WebSocket is down, queue state-update subscriptions, replay on reconnect -- **Pattern**: Don't lose container state updates during brief disconnects -- **Acceptance**: State is consistent after reconnection without page refresh +``` +scripts/ +├── deploy-to-target.sh — Orchestrator + arg parsing (~300 lines) +├── deploy/ +│ ├── frontend.sh — Build + sync frontend +│ ├── backend.sh — Build + sync binary +│ ├── configs.sh — Sync nginx, systemd, scripts +│ ├── containers.sh — Container creation/update +│ ├── verify.sh — Post-deploy health checks +│ └── rollback.sh — Rollback on failure +``` -#### 5.5 WebSocket race condition fix -- **Files**: stores/app.ts, api/websocket.ts -- **Action**: Fix duplicate listener issue on rapid reconnect (`isWsSubscribed` flag) -- **Pattern**: Use a Set of listener IDs, deduplicate on registration -- **Acceptance**: No duplicate event handlers after reconnect cycles +### Week 37–38: Split ISO build (1,850 lines) + first-boot (855 lines) -#### 5.6 Deploy & verify -- Test: kill backend, observe frontend reconnection behavior -- Test: toggle wifi, observe status indicator + reconnection -- Test: let session expire, verify redirect to login +**build-auto-installer-iso.sh** → `build/capture-images.sh`, `build/create-rootfs.sh`, `build/install-packages.sh`, `build/bundle-configs.sh`, `build/package-iso.sh` + +**first-boot-containers.sh** → `first-boot/databases.sh`, `first-boot/bitcoin.sh`, `first-boot/lightning.sh`, `first-boot/apps.sh`, `first-boot/networking.sh` + +### Week 39–40: ISO Reproducibility + Integration Tests + +**S16 — Make ISO builds reproducible** +- Create `image-versions.env` with pinned digests for every container image +- ISO build sources this file, never pulls `:latest` +- Build manifest records exactly what shipped +- Tests: two consecutive ISO builds produce identical image sets + +**E2E smoke test script** +```bash +# scripts/smoke-test.sh — Run against .198 +# 1. curl /health → OK +# 2. Login → get session +# 3. Get server info → valid JSON +# 4. List containers → all healthy +# 5. Check every /app/* proxy → responds +# 6. Check Tor hidden service → resolves +# 7. Check WebSocket upgrade → 101 +# Exit 0 only if all pass +``` --- -## Week 6: Deployment & Infrastructure Hardening (April 14–20) +## MONTH 11: INTEGRATION TESTS (Weeks 41–44) -**Theme**: Deployments are safe, reversible, and verified. Infrastructure is production-grade. +> Comprehensive test suites that prove everything works. -### Tasks +### Week 41–42: Backend Integration Tests -#### 6.1 Deploy script: add rollback capability -- **Files**: scripts/deploy-to-target.sh -- **Action**: Before overwriting binary/frontend, backup to `.backup` suffix -- **Pattern**: On health check failure after restart, restore from backup -- **Acceptance**: Failed deploy auto-restores previous working version -- **Skill**: `/polish-deploy` +``` +core/archipelago/tests/ +├── test_auth_flow.rs — Login → session → CSRF → auth request → logout +├── test_container_lifecycle.rs — Install → start → health → stop → uninstall +├── test_federation.rs — Generate invite → join → sync → verify +├── test_rpc_validation.rs — Every endpoint with invalid input → proper error +├── test_session_persist.rs — Create session → restart → session survives +├── test_rate_limiting.rs — Flood → 429 → wait → allowed +├── test_backup_restore.rs — Create → verify → restore → validate +├── test_health_endpoint.rs — Healthy → degraded → recovery +``` -#### 6.2 Deploy script: pre-deploy sanity checks -- **Files**: scripts/deploy-to-target.sh -- **Action**: Check disk space (2GB min), verify SSH key exists, verify target dir exists -- **Acceptance**: Deploy fails early with clear message if preconditions not met +Target: 25+ backend integration tests passing -#### 6.3 Deploy script: post-deploy health verification -- **Files**: scripts/deploy-to-target.sh -- **Action**: After restart, poll `/health` endpoint for 30s. If no 200, trigger rollback -- **Acceptance**: Every deploy is verified healthy before declaring success +### Week 43–44: Frontend Integration Tests -#### 6.4 Deploy script: deployment locking -- **Files**: scripts/deploy-to-target.sh -- **Action**: Use flock to prevent concurrent deploys -- **Acceptance**: Second simultaneous deploy fails immediately with message +``` +neode-ui/src/__tests__/integration/ +├── auth-flow.spec.ts — Login → dashboard → timeout → redirect +├── app-lifecycle.spec.ts — Marketplace → install → progress → launch → uninstall +├── websocket.spec.ts — Connect → update → disconnect → reconnect → state consistent +├── settings-flow.spec.ts — Change password → re-login → 2FA setup → verify +├── spotlight.spec.ts — Open → search → navigate → close +├── mesh-chat.spec.ts — Connect → send → receive → disconnect +├── error-handling.spec.ts — Network error → toast → retry → success +├── code-splitting.spec.ts — Route navigation → chunks loaded lazily +``` -#### 6.5 First-boot script: add error handling -- **Files**: scripts/first-boot-containers.sh -- **Action**: Add `set -e`, verify each container starts before creating dependents -- **Acceptance**: If Bitcoin fails, Mempool is not attempted - -#### 6.6 Systemd service hardening -- **Files**: image-recipe/configs/archipelago.service -- **Action**: Add `PrivateTmp=yes`, `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=yes`, `SystemCallFilter=@system-service` -- **Acceptance**: Service runs with minimal privileges -- **Skill**: `/harden` - -#### 6.7 Nginx security headers -- **Files**: image-recipe/configs/nginx-archipelago.conf -- **Action**: Add HSTS, fix CSP (remove unsafe-inline), add rate limiting zones, custom log format that strips tokens -- **Acceptance**: Security headers pass Mozilla Observatory scan - -#### 6.8 Nginx config: test before reload -- **Files**: scripts/deploy-to-target.sh -- **Action**: `nginx -t` failure should abort deploy and restore backup config -- **Acceptance**: Invalid nginx config never goes live - -#### 6.9 Deploy & verify -- Test: deploy with intentionally broken binary, verify rollback -- Test: deploy with invalid nginx config, verify rollback -- Test: concurrent deploy attempt, verify lock -- Run `/diagnose` full check +Target: 20+ frontend integration tests passing --- -## Week 7: Accessibility, Polish & Edge Cases (April 21–27) +## MONTH 12: TYPE SYNC + CI/CD PLAN (Weeks 45–48) -**Theme**: Every interaction is crisp. Keyboard users, slow networks, edge cases — all handled. +### Week 45–46: Rust↔TypeScript Type Sync -### Tasks +**Approach**: `ts-rs` crate to auto-generate TypeScript types from Rust structs -#### 7.1 ARIA labels on all interactive elements -- **Files**: All views and components -- **Action**: Add `aria-label` to buttons, links, form inputs that lack visible labels -- **Pattern**: ` + + + + + +
+
+
+ + + +
+
+

Blockchain Sync

+

Checking sync status...

+
+
+ + +
+
+ Block 0 + 0% +
+
+
+
+
+ + +
+
+

Current Height

+

-

+
+
+

Network Height

+

-

+
+
+

Headers

+

-

+
+
+

Verification

+

-

+
+
+
+ + +
+
+
+
+ + + +
+
+

RPC Connection

+

JSON-RPC API access

+
+
+ +
+
+
+ + + + RPC Host +
+ localhost:8332 +
+ +
+
+ + + + RPC User +
+ archipelago +
+ +
+
+ + + + RPC Status +
+ Connected +
+
+ + +
+ + +
+
+
+ + + +
+
+

ZMQ Notifications

+

Real-time block and transaction updates

+
+
+ +
+
+
+ + + + Block Notifications +
+ tcp://localhost:28332 +
+ +
+
+ + + + TX Notifications +
+ tcp://localhost:28333 +
+ +
+
+ + + + ZMQ Status +
+ Active +
+
+ + +
+
+ + + + + + + + + + + diff --git a/docker/bitcoin-ui/nginx.conf b/docker/bitcoin-ui/nginx.conf new file mode 100644 index 00000000..a6b99834 --- /dev/null +++ b/docker/bitcoin-ui/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 8334; + server_name _; + root /usr/share/nginx/html; + index index.html; + location /bitcoin-rpc/ { + proxy_pass http://127.0.0.1:8332/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Authorization "Basic __BITCOIN_RPC_AUTH__"; + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "POST, GET, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization"; + if ($request_method = OPTIONS) { return 204; } + } + location / { try_files $uri $uri/ /index.html; } +} diff --git a/docker/electrs-ui/50x.html b/docker/electrs-ui/50x.html new file mode 100644 index 00000000..a57c2f93 --- /dev/null +++ b/docker/electrs-ui/50x.html @@ -0,0 +1,19 @@ + + + +Error + + + +

An error occurred.

+

Sorry, the page you are looking for is currently unavailable.
+Please try again later.

+

If you are the system administrator of this resource then you should check +the error log for details.

+

Faithfully yours, nginx.

+ + diff --git a/docker/electrs-ui/Dockerfile b/docker/electrs-ui/Dockerfile new file mode 100644 index 00000000..82394290 --- /dev/null +++ b/docker/electrs-ui/Dockerfile @@ -0,0 +1,7 @@ +FROM docker.io/library/nginx:alpine +COPY index.html /usr/share/nginx/html/ +COPY 50x.html /usr/share/nginx/html/ +COPY assets/ /usr/share/nginx/html/assets/ +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/electrs-ui/index.html b/docker/electrs-ui/index.html new file mode 100644 index 00000000..09463362 --- /dev/null +++ b/docker/electrs-ui/index.html @@ -0,0 +1,423 @@ + + + + + + + ElectrumX - Archipelago + + + +
+
+ +
+ +
+
+
+ + + +
+
+
+

ElectrumX

+ v1.18.0 +
+

Bitcoin Electrum server for wallet connections

+
+
+
+
+

Status

+

Checking...

+
+
+
+
+ + +
+
+
+ + + +
+
+

Index Sync

+

Checking sync status...

+
+
+ +
+
+ Block 0 + 0% +
+
+
+
+
+ +
+
+

Indexed Height

+

-

+
+
+

Network Height

+

-

+
+
+

Index Size

+

-

+
+
+

Progress

+

-

+
+
+
+ + +
+

Connect Your Wallet

+

Use the following details to connect your wallet or application to ElectrumX.

+ +
+ + +
+ + +
+
+
+
+
Address
+
+ - + +
+
+
+
+
Port
+
+ 50001 + +
+
+
+
SSL
+
+ Disabled + +
+
+
+
+
+ + + + +
+ Connect using Sparrow Wallet, Electrum, or any Electrum-protocol compatible wallet. Set the server to the address and port shown above with SSL disabled. +
+
+
+ + + + + diff --git a/docker/electrs-ui/nginx.conf b/docker/electrs-ui/nginx.conf new file mode 100644 index 00000000..b053e560 --- /dev/null +++ b/docker/electrs-ui/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 50002; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /electrs-status { + proxy_pass http://127.0.0.1:5678/electrs-status; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + add_header Access-Control-Allow-Origin *; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker/electrs-ui/qrcode.js b/docker/electrs-ui/qrcode.js new file mode 100644 index 00000000..df13f829 --- /dev/null +++ b/docker/electrs-ui/qrcode.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +})); diff --git a/docs/BETA-RELEASE-CHECKLIST.md b/docs/BETA-RELEASE-CHECKLIST.md index 85563aad..24b04c49 100644 --- a/docs/BETA-RELEASE-CHECKLIST.md +++ b/docs/BETA-RELEASE-CHECKLIST.md @@ -13,7 +13,7 @@ ### Critical Files -- [ ] `core/container/src/podman_client.rs` — sudo podman +- [ ] `core/container/src/podman_client.rs` — rootless Podman REST API socket - [ ] `core/archipelago/src/container/docker_packages.rs` — app metadata + UI mapping - [ ] `core/archipelago/src/api/rpc/package.rs` — app configs, capabilities, dependencies - [ ] `core/archipelago/src/session.rs` — session security hardening @@ -235,10 +235,10 @@ sudo systemctl reload nginx ```bash # Check container logs -sudo podman logs +podman logs # Remove and recreate -sudo podman rm -f +podman rm -f # Reinstall from App Store ``` diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md index 80124d97..1ec3104c 100644 --- a/docs/MASTER_PLAN.md +++ b/docs/MASTER_PLAN.md @@ -18,6 +18,8 @@ | **TASK-12** | **Beta telemetry — reporter + toggle + collector POST** | **P1** | IN PROGRESS | - | | **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 | | **TASK-42** | **LUKS2 full-partition encryption for /var/lib/archipelago/** | **P1** | PLANNED | TASK-10 | +| **BUG-44** | **App iframe shows blank/broken when container is starting or crashed** | **P2** | PLANNED | - | +| **TASK-45** | **Deploy script: auto-chown data dirs after rootful→rootless migration** | **P2** | PLANNED | - | ### Phase 2: User Testing (controlled, real hardware) @@ -41,6 +43,7 @@ | **INQUIRY-5** | **Offline balance check via mesh relay** | **P2** | DEFERRED | - | | **FEATURE-6** | **Watch-only wallet architecture** | **P1** | DEFERRED | - | | **TASK-7** | **Mesh Bitcoin security hardening** | **P1** | DEFERRED | FEATURE-6 | +| **FEATURE-43** | **P2P encrypted voice/video calling (WebRTC over federation)** | **P1** | DEFERRED | - | ## Active Work @@ -142,6 +145,37 @@ Encrypt all Archipelago app data at rest using LUKS2 full-partition encryption. - `core/archipelago/src/api/rpc/system.rs` — password change handler - `core/archipelago/src/server.rs` — startup checks +### BUG-44: App iframe shows blank/broken when container is starting or crashed (PLANNED) +**Priority**: P2 — Medium +**Status**: PLANNED (2026-03-21) + +When an app container is still starting up or has crashed, the iframe overlay shows a blank/broken page with no feedback. Should show contextual loading states: +- **Starting**: skeleton loader or "App is starting up..." with spinner +- **Crashed**: "App has stopped" with restart button and link to logs +- **Port not ready**: "Waiting for app to become available..." with timeout warning +- **X-Frame-Options blocked**: Detect and open in new tab automatically + +**Key files**: +- `neode-ui/src/views/AppSession.vue` — iframe container +- `neode-ui/src/stores/appLauncher.ts` — app launch state +- `neode-ui/src/api/container-client.ts` — container status checks + +### TASK-45: Deploy script: auto-chown data dirs after rootful→rootless migration (PLANNED) +**Priority**: P2 — Medium +**Status**: PLANNED (2026-03-21) + +When `deploy-tailscale.sh` migrates from rootful to rootless Podman, all files in `/var/lib/archipelago/` created by the old root-running backend are owned by `root:root`. The new backend runs as `archipelago` user and can't read them (node-key.pem, credentials, sessions, identity, etc.). Deploy script must auto-detect and fix ownership after migration. + +Also fix: +- `/run/user/1000/crun` ownership (left as root from rootful container creation) +- Container recreation needs `--cap-add NET_BIND_SERVICE` for apps binding port 80 (nextcloud) +- Container recreation needs config volume mounts for apps writing to `/etc/` (searxng) +- Frontend should be copied from .228, not built locally (prevents build mismatches) + +**Key files**: +- `scripts/deploy-tailscale.sh` — Step 14 (UID mapping) and Step 22 (container creation) +- `scripts/first-boot-containers.sh` — container creation reference + --- ## Post-Beta (FROZEN) @@ -152,6 +186,71 @@ Encrypt all Archipelago app data at rest using LUKS2 full-partition encryption. - **FEATURE-6**: Watch-only wallet architecture - **TASK-7**: Mesh Bitcoin security hardening - **TASK-2**: Roll incoming-tx into deploy & ISO +- **FEATURE-43**: P2P encrypted voice/video calling (WebRTC over federation) + +--- + +### FEATURE-43: P2P encrypted voice/video calling — WebRTC over federation (DEFERRED) +**Priority**: P1 — High +**Status**: DEFERRED (post-beta) + +Self-sovereign encrypted voice and video calling between Archipelago peers. Zero new containers or dependencies — uses browser-native WebRTC with signaling over the existing federation WebSocket. Integrates directly into peer tabs/chat. + +**Security & Privacy**: +- All media encrypted via DTLS/SRTP (WebRTC mandatory encryption — no opt-out) +- Signaling (SDP offers, ICE candidates) transmitted over existing federation WebSocket through Tor +- ICE candidate filtering: strip local/public IP candidates in Tor-relay mode +- No central server, no metadata leakage — true P2P between browsers +- Two privacy modes: + - **LAN Direct**: <50ms latency, IPs visible to peer (trusted same-network peers) + - **Tor Relay**: 300-800ms latency, full anonymity via coturn TURN server on .onion + +**Architecture**: +- Signaling reuses existing federation WebSocket — new message types: `call-offer`, `call-answer`, `call-ice`, `call-hangup`, `call-reject`, `call-busy` +- Browser `getUserMedia()` + `RTCPeerConnection` — no backend media processing +- Opus codec for voice (~30kbps, handles Tor latency well) +- VP8/VP9 adaptive bitrate for video (720p on LAN, degrades gracefully) +- Optional `coturn` container (~10MB RAM) for Tor-relay media mode only + +**UX**: +- Voice and video call buttons in peer chat (federation contacts) +- Incoming call: glass modal slides up with peer name + avatar, accept/decline +- In-call: floating glass PIP overlay — navigate while talking +- One-tap mute, camera toggle, speaker toggle, hangup +- Call quality indicator (green/yellow/red based on RTT) +- Ring timeout (30s) → missed call notification +- Call history in peer chat thread + +**Tasks**: +- [ ] `CallService.ts` — WebRTC wrapper (offer/answer, ICE management, stream handling, codec negotiation) +- [ ] Federation signaling protocol — new message types over existing WS (`call-offer`, `call-answer`, `call-ice`, `call-hangup`) +- [ ] Rust backend — relay call signaling messages between federation peers (pass-through, no media processing) +- [ ] ICE candidate filtering — strip public IPs in privacy mode, force relay-only +- [ ] `CallOverlay.vue` — incoming call modal (glass aesthetic, ring animation, accept/decline) +- [ ] `CallPIP.vue` — floating picture-in-picture during active call (draggable, minimize/expand) +- [ ] `CallControls.vue` — mute, camera toggle, speaker, hangup, privacy mode switch +- [ ] Voice-only mode — Opus codec, bandwidth-optimized, Tor-friendly +- [ ] Video mode — VP8/VP9 adaptive bitrate, resolution scaling based on connection quality +- [ ] Optional `coturn` container manifest — TURN relay for Tor-routed media +- [ ] Call quality monitoring — RTT measurement, packet loss detection, quality indicator +- [ ] Call history — persist in peer chat thread, missed call notifications +- [ ] Multi-peer consideration — design for 1:1 first, extensible to group calls later +- [ ] Test: LAN direct call (voice + video) +- [ ] Test: Tor relay call (voice — verify latency is acceptable) +- [ ] Test: call during active chat, call while navigating other views +- [ ] Test: network interruption recovery (ICE restart) + +**Key files** (new): +- `neode-ui/src/services/CallService.ts` — WebRTC engine +- `neode-ui/src/components/call/CallOverlay.vue` — incoming call UI +- `neode-ui/src/components/call/CallPIP.vue` — in-call floating overlay +- `neode-ui/src/components/call/CallControls.vue` — call action buttons +- `apps/coturn/manifest.yml` — optional TURN relay container + +**Key files** (modified): +- `neode-ui/src/views/Federation.vue` — call buttons in peer chat +- `core/archipelago/src/api/rpc/federation.rs` — call signaling relay +- `neode-ui/src/stores/federation.ts` — call state management ## Completed diff --git a/docs/architecture-review.html b/docs/architecture-review.html index 12e3b159..5b6f2ad6 100644 --- a/docs/architecture-review.html +++ b/docs/architecture-review.html @@ -678,10 +678,10 @@

A complete architecture review and learning guide for the Bitcoin Node OS — explained so anyone can understand it.

Rust + Vue 3 + Podman - ~46,000 lines of Rust - ~12,000 lines of TypeScript - ~100 shell scripts - v0.1.0-alpha + ~45,000 lines of Rust (213 files) + ~45,500 lines of TypeScript/Vue (232 files) + ~40 shell scripts + v0.1.0-beta
@@ -726,7 +726,7 @@ │ The brain — handles auth, app installs, Bitcoin │ │ RPC, mesh networking, federation, health checks │ └──────────────────────┬───────────────────────────────┘ - │ Podman commands (CLI) + │ Podman REST API (Unix socket) ┌──────────────────────┴───────────────────────────────┐ │ PODMAN CONTAINERS │ │ Bitcoin Core, LND, Mempool, Nextcloud, etc. │ @@ -774,54 +774,60 @@

How the code is organized

-

The Rust code lives in core/ and is split into 5 separate packages (called "crates"):

+

The Rust code lives in core/ and is split into 5 workspace crates (consolidated from 9 during recent refactoring):

- + - - + + - - - + + + - - + + - - - - - - - + + + + + + +
CrateWhat It DoesSizeAnalogy
CrateWhat It DoesLinesAnalogy
archipelagoThe main program. Contains all the API endpoints, authentication, identity management, federation, mesh networking~12,000 linesThe main binary. API endpoints, auth, identity, federation, mesh networking, monitoring, health checks~42,000 The restaurant manager — coordinates everything
containerTalks to Podman to create, start, stop, and monitor containers~2,000 linesThe kitchen manager — controls the cook stationsPodmanClient (REST API socket), manifest parser, dependency resolver, health monitor, Bitcoin simulator~2,060The kitchen manager — controls cook stations
securityEncrypts secrets, generates security profiles, verifies container images~500 linesEncrypted secrets (Argon2 + ChaCha20-Poly1305), AppArmor profiles, Cosign image verification~743 The security guard — locks doors, checks IDs
performanceMonitors CPU, memory, and disk usage~300 linesThe meter reader — watches resource gauges
parmanode Compatibility layer for migrating from an older project~600 lines~234 A translation book — speaks the old language
performanceCPU, memory, and disk resource management~92The meter reader — watches resource gauges
-

Key files you should know

+

Key modules you should know

+ +

The recent refactoring split monolithic files into focused module directories. Each directory has a mod.rs entry point and focused sub-files:

- - - - - - - - - + + + + + + + + + + + + +
FileWhat It DoesLines
main.rsThe entry point — starts the server, registers signal handlers~200
server.rsWires everything together — creates the HTTP server, connects components~500
api/rpc/mod.rsThe traffic cop — receives API calls and sends them to the right handler~1,000
api/rpc/package.rsThe app installer — handles installing, starting, stopping containers~1,770
session.rsLogin management — creates sessions, validates tokens, persists to disk~790
health_monitor.rsWatches containers, restarts crashed ones, reports system health~710
federation.rsMulti-node communication — syncs state between trusted Archipelago nodes~810
credentials.rsVerifiable credentials — W3C standard digital identity proofs~800
ModuleWhat It DoesLinesStructure
main.rsEntry point — starts server, registers signal handlers~180Single file
server.rsWires HTTP server, connects all components~506Single file
api/handler/HTTP request routing, CORS, WebSocket upgrade, auth~896mod.rs + content, dwn, node_message, proxy, websocket
api/rpc/RPC dispatch, 29 endpoint modules + 8 subdirectories~20,000dispatcher.rs routes to focused handlers
api/rpc/package/App lifecycle — install, config, runtime, progress, deps~2,248config.rs, install.rs, lifecycle.rs, runtime.rs, stacks.rs, dependencies.rs, progress.rs
mesh/LoRa mesh networking — protocol, crypto, serial, relay~6,00013 files + listener/ subdirectory (6 files)
federation/Multi-node federation — invites, sync, storage~782invites.rs, storage.rs, sync.rs, types.rs
credentials/W3C Verifiable Credentials — CRUD, presentation~803operations.rs, presentation.rs, store.rs, types.rs
monitoring/Metrics collection, alerts, beta telemetry~1,380collector.rs, store.rs, alerts.rs, telemetry.rs, notifications.rs, types.rs
session.rsSession management, remember-me, cookie handling~622Single file
health_monitor.rsContainer health, auto-restart, system alerts~731Single file
rate_limit.rsPer-IP login + endpoint rate limiting~191Single file (new)

How the backend handles a request

@@ -832,18 +838,298 @@ Body: { "method": "package.inst Step 1: Nginx receives it on port 80, forwards to port 5678 Step 2: Rust HTTP server (Hyper) receives the raw bytes -Step 3: mod.rs parses the JSON, extracts the method name -Step 4: mod.rs checks the CSRF token (security check) -Step 5: mod.rs checks the session cookie (are you logged in?) -Step 6: mod.rs routes to package.rs based on method name -Step 7: package.rs validates the app ID, checks dependencies -Step 8: package.rs tells Podman to pull the container image -Step 9: package.rs creates and starts the container +Step 3: handler/mod.rs parses the JSON, extracts the method name +Step 4: rpc/mod.rs checks the CSRF token (security check) +Step 5: rpc/mod.rs checks the session cookie (are you logged in?) +Step 6: dispatcher.rs routes to package/install.rs based on method name +Step 7: package/install.rs validates the app ID +Step 8: package/dependencies.rs checks dependency chain +Step 9: PodmanClient pulls image + creates container via REST API socket Step 10: Response sent back: { "result": { "state": "installing" } }
+

Rust Backend Deep Dive — Should We Use Custom Code?

+ +
+ The short answer: Yes, custom Rust is the right call for Archipelago. The backend does things no off-the-shelf tool provides: it orchestrates rootless Podman containers, manages Bitcoin/LND RPC, handles encrypted secrets, runs federation/mesh networking, and serves a real-time WebSocket to the Vue frontend — all as a single binary with zero runtime dependencies. The alternatives (Node.js, Go, Python) would need dozens of third-party packages to match, and none offer Rust's memory safety guarantees for a server handling Bitcoin keys. +
+ +

Why not use an existing solution?

+

Projects like Umbrel use a Node.js + Docker Compose backend. Start9 uses Rust (like us). RaspiBlitz uses bash scripts. Here's why custom Rust wins:

+ + + + + + + + + + + + + + + + + + + + + + + +
ApproachProsCons
Node.js (Umbrel-style)Fast to develop, large ecosystemMemory-unsafe (crypto bugs), GC pauses, runtime dependency, node_modules supply chain risk
Bash scripts (RaspiBlitz-style)Simple, no compilationUnmaintainable at scale, no type safety, fragile error handling, injection risks
GoSingle binary, good concurrencyNo zero-cost abstractions, GC pauses, weaker type system than Rust
Rust (our choice)Single binary, zero-cost abstractions, memory safety without GC, excellent crypto ecosystem, zeroize for key materialSteeper learning curve, slower compile times
+ +

RPC Endpoint Architecture (Refactored)

+

Every action the frontend takes goes through POST /rpc/v1 as a JSON-RPC call. The RPC layer was recently refactored from monolithic files into 29 standalone modules + 8 domain subdirectories, totaling ~20,000 lines. Requests flow through dispatcher.rs (395 LOC) which routes to the appropriate handler:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryModuleLinesKey Methods
App Lifecyclepackage/ (7 files)2,248package.install, package.start, package.stop, package.uninstall, package.stacks
container.rs413container.logs, container.inspect
marketplace.rs225marketplace.list, marketplace.search
Auth & Securityauth.rs102auth.login, auth.login.totp, auth.logout
totp.rs295totp.enable, totp.verify, totp.disable
credentials.rs274credentials.issue, credentials.verify (W3C Verifiable Credentials)
security.rs66AppArmor policy management
Bitcoinbitcoin.rs96bitcoin.getblockchaininfo, bitcoin.getpeerinfo — RPC passthrough
lnd/ (5 files)1,092lnd.getinfo, lnd.walletbalance, lnd.channels, lnd.payments
wallet.rs108wallet.balance, wallet.transactions
Systemsystem/ (2 files)777system.stats, system.reboot, system.factory-reset
monitoring.rs216monitoring.containers, monitoring.resources
update.rs108update.check, update.apply
Identity & Federationidentity/ (2 files)778identity.create, identity.export, identity.did
federation/ (2 files)732federation.list-nodes, federation.pair, federation.sync
mesh/ (6 files)885mesh.status, mesh.send, mesh.peers, mesh.bitcoin-ops
Networktor/ (2 files)769tor.status, tor.create-service, tor.get-address
vpn.rs229vpn.status, vpn.configure
Otheranalytics.rs438Event analytics, usage tracking
interfaces.rs442Network interface management
backup_rpc.rs394backup.create, backup.restore
content.rs352Peer content distribution
transport.rs157Transport layer abstraction
+ +
+ Refactoring win: The old monolithic package.rs (1,795 lines) was split into 7 focused files under package/. Similarly, federation.rs, identity.rs, mesh.rs, system.rs, and tor.rs were each extracted into their own subdirectories with handlers.rs + mod.rs separation. The API handler layer (handler.rs) was split into 6 focused files under api/handler/. +
+ +

Container Orchestration — How Podman Is Controlled

+

The backend talks to rootless Podman via its Unix socket REST API (not CLI). This is faster, more reliable, and avoids shell injection risks.

+ +
+PodmanClient connects to: + /run/user/1000/podman/podman.sock (API v4.0.0) + +Install flow: + 1. package.rs validates app ID + checks dependencies + 2. DependencyResolver topological sort → install order + 3. PodmanClient.pull_image() → downloads container image + 4. PodmanClient.create_container() → sets ports, volumes, caps, memory limits + 5. PodmanClient.start_container() + 6. HealthMonitor begins watching (60s intervals) + +Crash recovery: + On startup → check PID marker → if unclean shutdown: + → Restart containers in tier order: + Tier 0: Databases (postgres, redis, mariadb) + Tier 1: Core infra (bitcoin-knots) + Tier 2: Dependent services (lnd, electrs, nbxplorer) + Tier 3: Applications (mempool, btcpay, fedimint) + Tier 4: Frontends (mempool-web, lnd-ui) + → Respect user-stopped.json (don't restart manually stopped apps) + → Max 3 restart attempts with exponential backoff (10s → 30s → 90s) +
+ +

Security Architecture

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LayerMechanismImplementation
Secrets at restAES-256-GCM encryptioncore/security/secrets_manager.rs — encrypts to /var/lib/archipelago/secrets/
Node identityEd25519 keypairGenerated on first boot, stored at /var/lib/archipelago/identity/
Image verificationCosign signaturescore/security/image_verifier.rs — verifies container image provenance
Sessions32-byte random tokensOsRng, 24h TTL, persisted to sessions.json, zeroized on drop
2FATOTP (RFC 6238)5 attempt lockout, 5min pending session TTL, token rotation after verification
Rate limitingPer-IP + per-endpointLogin endpoints rate-limited, IP extracted from X-Real-IP (loopback only)
RBACExplicit method allowlistsNo prefix matching — each role lists exact permitted methods
Key materialzeroize::ZeroizingAll crypto keys zeroed from memory after use
+ +

WebSocket Real-Time Sync

+

The frontend connects to /ws/db and receives the full DataModel on connect, then incremental updates as state changes. This is how the UI shows live container status, sync progress, and notifications without polling.

+ +
+DataModel (broadcast to all WebSocket clients): +{ + server_info: { node_id, name, tor_address, lan_ip, version } + package_data: { + "bitcoin-knots": { state: "running", health: "healthy", ... } + "lnd": { state: "running", health: "healthy", ... } + "mempool": { state: "stopped", health: null, ... } + } + peer_health: { "did:key:z6Mk...": true } + notifications: [ { type: "warning", message: "Disk 85% full" } ] +} +
+ +

What's custom vs. what could be replaced?

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentCustom?Could it be replaced?
HTTP serverUses hyper (standard Rust HTTP)Could use axum or actix-web for ergonomics, but hyper is fine
RPC routingCustom — hand-rolled JSON-RPC dispatcherCould use jsonrpsee or generate from OpenAPI, but the current router is simple and works
Container orchestrationCustom — PodmanClient + health monitorNo off-the-shelf alternative for rootless Podman orchestration with Bitcoin-specific dependency ordering
Secrets managementCustom — AES-256-GCM with ZeroizeCould use age or sops, but inline encryption is simpler for container secrets
Federation/MeshCustom — Ed25519 signed messages, Nostr discovery, DWNNo existing solution does Bitcoin node federation + mesh radio. This is novel.
Auth/SessionsCustomCould use a library, but the session model is simple (32-byte tokens + file persistence)
Bitcoin/LND RPCCustom passthroughMust be custom — proxies authenticated calls to local Bitcoin/LND with macaroon management
+ +
+ Bottom line: The custom code isn't reinventing the wheel — it's glue that connects Podman, Bitcoin, LND, Tor, Nostr, mesh radios, and a Vue frontend into a cohesive OS. No existing framework does this. The individual pieces (hyper, serde, tokio, ed25519-dalek, aes-gcm) are all battle-tested crates. The custom part is the orchestration logic that ties them together. +
+ +
+

Layer 2: The Vue.js Frontend (The Face)

The frontend is what you see in the browser. It's built with Vue 3 — a JavaScript framework for building interactive web pages — and TypeScript — JavaScript with type safety.

@@ -853,35 +1139,54 @@ Body: { "method": "package.inst Instead of loading a new HTML page every time you click something (like old websites), an SPA loads once and then dynamically updates the page content. When you click "Marketplace" in Archipelago, it doesn't load a new page — it swaps out the content area. This makes it feel fast and smooth, like a native app. -

Frontend file structure

+

Frontend file structure (refactored)

+

The frontend was heavily refactored — large "god components" were split into focused sub-views, and the god store was decomposed into dedicated stores:

neode-ui/src/
-├── api/              ← How the frontend talks to the backend
-│   ├── rpc-client.ts    ← Makes API calls (fetch + retry + auth)
-│   ├── container-client.ts ← Container-specific API helpers
-│   └── websocket.ts     ← Real-time updates (push, not poll)
-├── views/            ← Full pages (one per route)
-│   ├── Dashboard.vue    ← Main dashboard with sidebar
-│   ├── Marketplace.vue  ← App store for installing containers
-│   ├── Settings.vue     ← System settings
-│   ├── Web5.vue         ← Decentralized identity management
-│   ├── Mesh.vue         ← LoRa mesh radio interface
-│   └── Login.vue        ← Login page
-├── components/       ← Reusable UI pieces
-│   ├── BootScreen.vue   ← Startup loading animation
-│   ├── SplashScreen.vue ← Welcome/intro screen
-│   └── SpotlightSearch.vue ← Command palette (Cmd+K)
-├── stores/           ← State management (Pinia)
-│   ├── app.ts           ← Core app state (auth, server data)
+├── api/              ← Backend communication (4 files + 1 service)
+│   ├── rpc-client.ts    ← RPC client (18.8 KB) — ~70 methods, retry, CSRF
+│   ├── websocket.ts     ← WebSocket (16.3 KB) — JSON patch (RFC 6902)
+│   ├── container-client.ts ← Container API helpers
+│   ├── filebrowser-client.ts ← FileBrowser API
+│   └── services/contextBroker.ts ← Context management (21.9 KB)
+├── views/            ← 37 top-level + 47 sub-views in 14 subdirectories
+│   ├── Dashboard.vue    ← Main layout with sidebar
+│   ├── dashboard/     ← Sidebar, MobileNav, ConnectionBanner (6 files)
+│   ├── apps/          ← AppCard, UninstallModal, config (5 files)
+│   ├── appDetails/    ← HeroSection, ContentSection, Sidebar (4 files)
+│   ├── appSession/    ← Frame, Header, NostrBridge, AppIdentity (5 files)
+│   ├── discover/      ← Hero, AppGrid, FeaturedApps, FilterModal (6 files)
+│   ├── federation/    ← Header, NodeList, JoinModal, RotateDid (8 files)
+│   ├── fleet/         ← NodeGrid, ContainerMatrix, Alerts, Overview (6 files)
+│   ├── mesh/          ← BitcoinPanel, DeadmanPanel, styles (3 files)
+│   ├── settings/      ← 13 focused sections (Account, 2FA, Backup, etc.)
+│   ├── web5/          ← 14 sub-views (DID, Wallet, Nostr, DWN, etc.)
+│   ├── marketplace/   ← AppCard, FilterModal, marketplaceData (3 files)
+│   ├── server/        ← QuickActions, Modals, TorServices (3 files)
+│   └── home/          ← SystemCard, WalletCard (2 files)
+├── components/       ← 31 reusable components + 6 in subdirectories
+│   ├── BootScreen.vue, SplashScreen.vue, SpotlightSearch.vue
+│   ├── BaseModal.vue, ToastStack.vue, SkeletonCard.vue, EmptyState.vue
+│   ├── MeshMap.vue, LineChart.vue, AnimatedLogo.vue
+│   ├── cloud/         ← FileCard, FileGrid, ShareModal (5 files)
+│   └── federation/    ← NetworkMap.vue
+├── stores/           ← 18 Pinia stores (decomposed from god store)
+│   ├── app.ts           ← Core app state (slimmed down)
+│   ├── auth.ts          ← Login, logout, TOTP, sessions (NEW)
+│   ├── server.ts        ← Server state, package actions (NEW)
+│   ├── sync.ts          ← WebSocket, real-time data, JSON patch (NEW)
 │   ├── container.ts     ← Container states & lifecycle
-│   ├── mesh.ts          ← Mesh networking state
-│   └── appLauncher.ts   ← App launching & iframe management
-├── composables/      ← Reusable logic (like React hooks)
-│   ├── useToast.ts      ← Notification popups
-│   └── useAudioPlayer.ts ← Sound effects
-├── types/            ← TypeScript type definitions
-│   └── api.ts           ← Shapes of data from the backend
-├── router/           ← URL → page mapping
-└── style.css            ← All global styles (glassmorphism theme)
+│ ├── mesh.ts ← Mesh networking (14 KB — largest store) +│ ├── appLauncher.ts ← App iframe management (11 KB) +│ └── ... 11 more focused stores +├── composables/ ← 11 composables + 10 test files +│ ├── useToast.ts, useControllerNav.ts (16.9 KB) +│ ├── useLoginSounds.ts, useNavSounds.ts, useAudioPlayer.ts +│ └── useOnboarding.ts, useModalKeyboard.ts, useMobileBackButton.ts +├── types/ ← TypeScript type definitions (3 files) +│ ├── api.ts ← RPC methods, responses, DataModel, PatchOperation +│ └── aiui-protocol.ts ← AIUI communication protocol +├── router/ ← Route definitions (9.5 KB) — lazy-loaded + nav guards +└── style.css ← Global glassmorphism theme + Tailwind utilities

How a Vue component works

Every .vue file has three sections:

@@ -930,7 +1235,7 @@ Body: { "method": "package.inst A container is like a lightweight virtual machine. It has its own filesystem, its own network, and its own processes — but it shares the host's Linux kernel, so it's much faster than a full VM. Think of it as an apartment in a building — each apartment has its own walls and locks, but they all share the same building infrastructure. -

Archipelago uses Podman instead of Docker. They're nearly identical, but Podman runs without root privileges (more secure) and doesn't need a background daemon.

+

Archipelago uses rootless Podman instead of Docker. Podman runs entirely without root privileges under the archipelago user (UID 1000) — no background daemon, no root access needed. The backend communicates with Podman via its REST API socket, not the CLI.

Container security rules

Every container in Archipelago follows strict security rules:

@@ -1416,35 +1721,47 @@ Body: { "method": "package.inst Pinia is Vue's state management library. Instead of each component keeping its own data (which leads to chaos), you put shared data in a "store" — a central place that any component can read from and write to. When the store changes, every component that uses it updates automatically. -

Archipelago has 15 Pinia stores:

+

Archipelago has 18 Pinia stores (up from 15 — the "god store" was decomposed):

-

app.ts god store

-

Auth, WebSocket, server data, package management — does too much (see refactoring section)

+

app.ts slimmed

+

Core app state — slimmed down after extracting auth, server, and sync concerns

+
+
+

auth.ts new

+

Authentication state machine — login, logout, TOTP, session management

+
+
+

server.ts new

+

Server computed state + RPC action proxies (install, restart, update)

+
+
+

sync.ts new

+

WebSocket connection + real-time JSON patch (RFC 6902) data sync

container.ts good

-

Container lifecycle — running, stopped, installing states

+

Container lifecycle — running, stopped, installing states (9.2 KB)

-

mesh.ts okay

-

LoRa radio state — device, peers, messages, channels

+

mesh.ts good

+

LoRa radio state — device, peers, messages, channels (14 KB)

-

appLauncher.ts okay

-

App iframe management, Nostr consent, port mapping

+

appLauncher.ts good

+

App iframe management, Nostr consent, port mapping (11 KB)

-

spotlight.ts good

-

Command palette (Cmd+K) — search, help modal

-
-
-

goals.ts good

-

Gamified goal/quest tracking state machine

+

aiPermissions.ts good

+

AI data access permission management (5.2 KB)

+
+ Store decomposition complete. The old "god store" (app.ts) that handled auth + WebSocket + server data + package management was split into three new focused stores: auth.ts (authentication state machine), server.ts (server state + RPC actions), and sync.ts (WebSocket + data synchronization). The login flow is now: useAuthStore().login()useSyncStore().initializeData() + connectWebSocket() → views consume sync.data reactively. +
+

WebSocket: real-time updates

Instead of the frontend asking "has anything changed?" every second (polling), the backend pushes updates to the frontend through a WebSocket — a persistent, two-way connection.

@@ -1566,7 +1883,7 @@ Each node has:

Deploy System

-

The deploy script (scripts/deploy-to-target.sh) is how code gets from your development laptop to the live server. It's a 1,570-line shell script that automates everything:

+

The deploy script (scripts/deploy-to-target.sh) is how code gets from your development laptop to the live server. It's a ~1,790-line shell script (with shared functions from lib/common.sh) that automates everything:

1

Pre-flight checks — verifies SSH connectivity, checks git state, warns about uncommitted changes

2

Frontend build — runs npm run build to compile Vue/TypeScript into static files

@@ -1586,7 +1903,7 @@ Each node has:

ISO Build Process

-

The ISO build creates the installer that users flash to USB. It's a 1,775-line script that:

+

The ISO build creates the installer that users flash to USB. It's a ~1,870-line script that:

  1. Downloads a Debian 12 Live ISO as the base
  2. @@ -1618,7 +1935,7 @@ Each node has:

    Quality Scores

    -

    After reviewing ~46,000 lines of Rust, ~12,000 lines of TypeScript, and ~100 shell scripts, here are the quality scores:

    +

    After reviewing ~45,000 lines of Rust (213 files), ~45,500 lines of TypeScript/Vue (232 files), and ~40 shell scripts, here are the quality scores. Several scores improved since the last review thanks to major refactoring:

    @@ -1633,18 +1950,18 @@ Each node has:
    Security
    -
    A-
    -

    Defense in depth, minor gaps

    +
    A
    +

    33-finding pentest, all remediated

    Frontend Architecture
    -
    A-
    -

    Well-organized, 1 god store

    +
    A
    +

    God store split, god views split

    Backend Modularity
    -
    B+
    -

    Good separation, large files

    +
    A-
    +

    Monoliths split into subdirectories

    Container Security
    @@ -1653,23 +1970,23 @@ Each node has:
    Script Modularity
    -
    C+
    -

    Monolithic, no shared library

    +
    B-
    +

    Shared lib created, still large scripts

    Test Coverage
    -
    D
    -

    No automated tests

    +
    B-
    +

    38 frontend + 36 backend test files

    CI/CD
    -
    D
    -

    Build only, no test gating

    +
    C
    +

    macOS release CI, no test gating

    Documentation
    -
    B
    -

    Good docs, gaps in API ref

    +
    A
    +

    This review + MASTER_PLAN + consolidated docs

    Dependency Hygiene
    @@ -1678,72 +1995,163 @@ Each node has:
    Deploy Safety
    -
    A-
    -

    Rollback, manifests, health checks

    +
    A
    +

    Rollback, manifests, health checks, locking

    +
    + Score improvements since last review (2026-03-20): + Security A- → A (rate limiter backend, pentest complete), Frontend Architecture A- → A (god store + god views split), Backend Modularity B+ → A- (monolithic files → subdirectories), Script Modularity C+ → B- (shared library created), Documentation A- → A, Deploy Safety A- → A (deploy locking added). +
    +

    What's Done Well

    Rust: Exceptional Error Discipline

    -

    Zero unwrap() or panic!() in production code (only 2 expect() in startup code). Every fallible operation uses the ? operator to propagate errors gracefully. This is rare even in professional Rust codebases.

    +

    Zero unwrap() or panic!() in production code. Every fallible operation uses the ? operator to propagate errors gracefully. This is rare even in professional Rust codebases.

    + +

    Backend Module Architecture (Refactored)

    +

    The backend was comprehensively refactored from monolithic files into domain-focused subdirectories. Previously: package.rs (1,795 lines), federation.rs (810 lines), handler.rs (800+ lines) were all single files. Now: each is a clean directory with focused sub-modules (e.g., package/ has config.rs, install.rs, lifecycle.rs, runtime.rs, stacks.rs, dependencies.rs, progress.rs). The RPC layer uses a dedicated dispatcher.rs for routing. All 8 major domains (package, federation, identity, mesh, system, tor, handler, credentials) follow the same mod.rs + handlers.rs pattern.

    + +

    Frontend Component Decomposition (Refactored)

    +

    All "god components" were split into sub-views: Web5.vue (3,940 lines) → 14 focused sub-views under views/web5/. Settings.vue (1,792 lines) → 13 sections. Dashboard.vue, Apps.vue, AppDetails.vue, AppSession.vue, Federation.vue, Fleet.vue, Discover.vue — all extracted into subdirectories with focused components. The Pinia god store was decomposed into auth.ts, server.ts, and sync.ts.

    Input Validation is Thorough

    -

    App IDs validated against a strict character whitelist. Docker image names checked for shell injection characters. All external input sanitized at the boundary.

    +

    App IDs validated against a strict character whitelist. Container image names checked for shell injection characters. All external input sanitized at the boundary. Backend rate limiting on login + endpoints via new rate_limit.rs.

    TypeScript Strict Mode Actually Used

    -

    All 5 strictest compiler flags enabled. Zero any types across 12,000+ lines. Every function has proper types. This prevents entire categories of bugs.

    +

    All 5 strictest compiler flags enabled. Zero any types across 45,500+ lines. Every function has proper types. This prevents entire categories of bugs.

    Container Security is Production-Grade

    Every container drops all capabilities and adds back only what's needed. Read-only root filesystems. Non-root users. No-new-privileges. This is better than most commercial container platforms.

    WebSocket Resilience

    -

    Auto-reconnection with exponential backoff, visibility change detection (handles tab switching), network online/offline detection. The real-time connection is very robust.

    +

    Auto-reconnection with exponential backoff, visibility change detection (handles tab switching), network online/offline detection. JSON patch (RFC 6902) for efficient incremental updates. The real-time connection is very robust.

    Composables Well-Factored

    -

    11 Vue composables, each focused on one concern (toasts, audio, keyboard, onboarding). Clean, reusable, properly scoped.

    +

    11 Vue composables, each focused on one concern (toasts, audio, keyboard, onboarding, controller nav). Clean, reusable, properly scoped. 10 test files for composables.

    Deploy Safety Features

    -

    Rollback backups before deployment, deploy manifests tracking what was deployed, health checks after deployment, progress bars with ETAs.

    +

    Rollback backups before deployment, deploy manifests tracking what was deployed, health checks after deployment, progress bars with ETAs. Deploy locking prevents concurrent deploys. Shared script library (scripts/lib/common.sh) eliminates function duplication.

    + +

    Monitoring & Telemetry System

    +

    New monitoring/ module (1,380 LOC) with metrics collection, alert generation, persistent storage, beta telemetry reporting, and notification dispatch. Production-grade observability for the beta phase.

    + +

    PodmanClient Uses REST API Socket

    +

    The container management layer communicates with Podman via its async REST API unix socket (/run/user/{UID}/podman/podman.sock), not CLI. This is faster, more reliable, and avoids shell injection risks.

    + +

    Full Security Audit Completed

    +

    A comprehensive penetration test (33 findings) was completed in March 2026 and all findings were remediated. Security rules from findings are enforced in CLAUDE.md for all future code.

    What Needs Fixing

    -

    Critical Issues fix now

    +

    Production Reliability P0 — blocks beta

    -

    1. package.rs is 1,770 lines — a "god file"

    -

    What: core/archipelago/src/api/rpc/package.rs handles ALL container operations: install, start, stop, configure ports, configure volumes, configure environment variables, dependency checking, image validation, progress streaming.

    -

    Why it's bad: You can't change one thing without risking breaking something else. It's impossible to test in isolation. Any new app requires modifying this massive file.

    -

    Fix: Split into app_config.rs (port/volume/env definitions), app_lifecycle.rs (install/start/stop), app_validation.rs (input checks, dependency verification).

    +

    P0-1. Health RPC endpoint has no handler

    +

    What: "health" is listed in UNAUTHENTICATED_METHODS but has no match handler — returns "Unknown method" error instead of actual health status.

    +

    Impact: Frontend, load balancers, and orchestrators can't verify the backend is actually healthy. System appears unhealthy when it's fine.

    +

    Fix: Add handler that checks crash recovery status, Podman responsiveness, and service readiness.

    -

    2. Web5.vue is 3,901 lines — a "god component"

    -

    What: One Vue file contains 17 different sections: DID management, wallet, Nostr relays, credentials, voting, P2P peers, storage, profiles, marketplace, goals, data explorer, and more.

    -

    Why it's bad: Loading one massive component is slow. Changes to the voting section could break the wallet section. Impossible to reuse any section independently.

    -

    Fix: Split into 5+ sub-views under /dashboard/web5/ with their own routes.

    +

    P0-2. Zero container health checks across all 30 containers

    +

    What: first-boot-containers.sh creates 30+ containers with --restart unless-stopped but zero --health-cmd flags. Crashed containers restart endlessly in a hammer loop.

    +

    Impact: Silent failures — a broken app looks "running" but returns errors. No way for the backend to distinguish healthy from crashed.

    +

    Fix: Add --health-cmd with appropriate checks (HTTP, TCP, CLI) to every container.

    -

    3. No automated tests

    -

    What: Zero unit tests in the Rust backend. No integration tests. No end-to-end tests that run automatically. The only "test" is deploying and checking manually.

    -

    Why it's bad: Every change could break something, and you won't know until a user reports it. As the codebase grows, confidence in changes decreases.

    -

    Fix: Start with tests for the most critical paths: session validation, input sanitization, container lifecycle. Add CI that runs tests on every push.

    +

    P0-3. Backup restore has no pre-validation or atomic rollback

    +

    What: restore_full_backup() extracts directly to the live data directory. If extraction fails halfway, the system is left in a corrupt partial state with no way to recover.

    +

    Impact: A corrupted backup can brick a fresh install. Data loss on partial restore failure.

    +

    Fix: Extract to staging directory, validate required files, atomic rename, rollback on failure.

    -

    4. useAppStore is a "god store" with 8+ responsibilities

    -

    What: One Pinia store handles: auth state, WebSocket connection, server data, package install/uninstall, server restart/shutdown, marketplace data, metrics, loading states.

    -

    Why it's bad: Every component that imports this store gets ALL of its complexity. Hard to track where state changes come from. Testing any one concern requires mocking everything else.

    -

    Fix: Split into auth.ts, server.ts, realtime.ts, keep app.ts as a thin data store only.

    +

    P0-4. Unauthenticated nginx endpoints missing protections

    +

    What: /archipelago/, /content, /dwn endpoints (used for Tor P2P federation) have no timeout, body size limit, or rate limiting.

    +

    Impact: Vulnerable to slow-loris attacks, payload flooding, and connection exhaustion via Tor.

    +

    Fix: Add proxy_connect_timeout, client_max_body_size 10m, and limit_req to all three locations.

    +
    + +

    Critical Issues recently resolved

    + +
    +

    RESOLVED: package.rs was 1,795 lines — split into 7 files

    +

    Before: Single monolithic file handling all container operations.

    +

    After: Split into package/config.rs (692 LOC), package/install.rs (467 LOC), package/lifecycle.rs, package/runtime.rs (417 LOC), package/stacks.rs (356 LOC), package/dependencies.rs (242 LOC), package/progress.rs (140 LOC). Each file has one clear responsibility.

    +
    + +
    +

    RESOLVED: Web5.vue was 3,940 lines — split into 14 sub-views

    +

    Before: One massive component with 17 sections.

    +

    After: Extracted to views/web5/ with: Web5.vue (main), Web5ConnectedNodes, Web5CredentialsSummary, Web5DWN, Web5Domains, Web5Identities, Web5NodeVisibility, Web5NostrRelays, Web5QuickActions, Web5SendReceiveModals, Web5SharedContent, Web5Wallet, types.ts, utils.ts.

    +
    + +
    +

    RESOLVED: useAppStore was a "god store" — split into 3 focused stores

    +

    Before: One store handling auth, WebSocket, server data, and package management.

    +

    After: Decomposed into auth.ts (login/logout/TOTP/sessions), server.ts (server state + RPC actions), sync.ts (WebSocket + JSON patch data sync). app.ts is now a thin data store.

    +
    + +
    +

    RESOLVED: Shell scripts had no shared library

    +

    Before: Duplicated functions across deploy, first-boot, and helper scripts.

    +

    After: scripts/lib/common.sh provides shared functions: colored logging, SSH wrappers (ssh_cmd, scp_cmd), health checks, disk checks, memory limits. Sourced by all deployment scripts.

    +
    + +

    Remaining Critical Issues fix now

    + +
    +

    1. Test coverage exists but has gaps

    +

    What: 38 frontend test files and 36+ backend test modules exist. However, coverage is uneven — critical paths like session validation, federation sync, and the app install flow lack thorough test suites.

    +

    Fix: Add integration tests for critical paths (auth flow, container lifecycle, federation handshake). Add CI that runs cargo test + npm test on every push.

    High Priority fix soon

    +
    +

    P1-A. Nostr client.connect() hangs indefinitely (no timeout)

    +

    What: 4 calls to client.connect().await in nostr_handshake.rs have no timeout wrapper. If a relay is down, peer discovery hangs forever.

    +

    Fix: Wrap all in tokio::time::timeout(Duration::from_secs(10), ...).

    +
    + +
    +

    P1-B. Rate limiter memory grows unbounded

    +

    What: EndpointRateLimiter::cleanup() and LoginRateLimiter cleanup methods exist but are never spawned. HashMap of (method, IP) entries grows forever.

    +

    Fix: Spawn cleanup task every 5 minutes in RpcHandler::new().

    +
    + +
    +

    P1-C. Systemd service missing resource limits

    +

    What: No MemoryMax, LimitNOFILE, or TasksMax in archipelago.service. A memory leak in the backend can OOM-kill the entire system.

    +

    Fix: Add MemoryMax=4G, LimitNOFILE=65535, TasksMax=2048.

    +
    + +
    +

    P1-D. Container images using :latest tag (7 instances)

    +

    What: Several containers in first-boot-containers.sh and the ISO build pull :latest — no version pinning.

    +

    Impact: Two machines installed a week apart may have different Bitcoin node versions. Supply chain risk.

    +

    Fix: Pin every image to a specific version tag or SHA256 digest.

    +
    + +
    +

    P1-E. WebSocket reconnect doesn't refresh full state

    +

    What: After a WebSocket disconnect (5+ minutes), the UI shows stale data. Reconnection applies patches to an outdated base state instead of fetching fresh data.

    +

    Fix: On reconnect, call server.get-state RPC to refresh full state before accepting patches.

    +
    + +
    +

    P1-F. No global Vue error handler

    +

    What: No app.config.errorHandler in main.ts. Component errors silently log to console — user sees blank screen with no recovery path.

    +

    Fix: Add error handler that shows user-visible toast and logs structured error.

    +
    +

    5. Cryptographic dependency versions not pinned exactly

    What: zeroize = "1.7", chacha20poly1305 = "0.10", ed25519-dalek = "2.1" use floating versions.

    @@ -1766,10 +2174,10 @@ Each node has:
    -

    8. Deploy and ISO build scripts are 1,500+ lines each

    -

    What: Two monolithic shell scripts handle dozens of responsibilities each, with duplicated utility functions across 15+ scripts.

    -

    Why it's bad: Hard to review, hard to debug, hard to modify. One wrong change can break the entire deploy pipeline. No shared library means the same health-check loop is copy-pasted in 8 places.

    -

    Fix: Extract shared functions into scripts/lib/common.sh. Split deploy into modules: deploy-frontend.sh, deploy-backend.sh, sync-configs.sh, health-checks.sh.

    +

    8. Deploy and ISO build scripts are still 1,700+ lines each

    +

    What: Two monolithic shell scripts (deploy: ~1,790 lines, ISO build: ~1,870 lines) handle dozens of responsibilities each. Shared functions have been extracted to scripts/lib/common.sh, but the scripts themselves are still large.

    +

    Improvement: scripts/lib/common.sh now provides shared logging, SSH wrappers, health checks, and memory limits — eliminating most duplication. But the core scripts could still benefit from modular splitting post-beta.

    +

    Next step: Split deploy into modules: deploy-frontend.sh, deploy-backend.sh, sync-configs.sh. Split ISO build into lib/rootfs.sh, lib/components.sh, lib/installer-env.sh.

    Medium Priority improve over time

    @@ -1781,9 +2189,9 @@ Each node has:
    -

    10. No CI/CD pipeline

    -

    What: One GitHub Action builds a macOS binary. No tests run. No linting. No deploy automation.

    -

    Fix: Add CI that runs cargo clippy, cargo test, npm run type-check, and npm run lint on every push.

    +

    10. CI/CD pipeline is minimal

    +

    What: One GitHub Action builds macOS release binaries on tag push. No tests run in CI. No linting. No Linux build or deploy automation.

    +

    Fix: Add CI that runs cargo clippy, cargo test, npm run type-check, and npm run lint on every push. Add Linux cross-compilation.

    @@ -1801,136 +2209,144 @@ Each node has:

    Refactoring Priorities

    -

    Ordered by impact — what to fix first for the biggest improvement:

    +

    Ordered by impact. 6 of 12 items completed since the previous review — significant progress:

    - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - + + + + - + - + - +
    #TaskImpactEffortPriority
    #TaskImpactEffortStatus
    1Split package.rs into 3-4 focused filesSplit package.rs (1,795 lines) into focused files high 2-3 daysP0DONE
    2Split useAppStore into auth/server/realtimeSplit useAppStore into auth/server/sync high 2 daysP0DONE
    3 Add CI pipeline (clippy + type-check + basic tests) high 1 dayP0TODO
    4Split Web5.vue into sub-viewsSplit Web5.vue (3,940 lines) into sub-views medium 3 daysP1DONE
    5 Pin all crypto dependency versions exactly medium 1 hourP1TODO
    6Extract shared shell library (lib/common.sh)Extract shared shell library (lib/common.sh) medium 1 dayP1DONE
    7 Consolidate container metadata to single source medium 2 daysP1TODO
    8 Generate TypeScript types from Rust structs medium 1 dayP2TODO
    9Split deploy script into modulesSplit deploy/ISO scripts into modules low 2 daysP2POST-BETA
    10Add unit tests for critical paths (session, validation)Add integration tests for critical paths high 3 daysP2TODO
    11Create useAsyncState composable for frontendlow4 hoursP3Split large backend files (federation, identity, handler, system, tor)medium2 daysDONE
    12Split large Vue components (SplashScreen, Mesh, Settings)Split large Vue views (Settings 1,792, Mesh, Dashboard, Apps, etc.) low 2 daysP3DONE

    Technical Debt Map

    -

    A visual summary of where debt lives in the codebase:

    +

    A visual summary of where debt lives in the codebase. Many red items from the previous review are now green after refactoring:

    -BACKEND (Rust) - ██████████ package.rs (1,770 lines — god file) - ████████ rpc/mod.rs (999 lines — giant match dispatcher) - ████████ lnd.rs (996 lines — could split) - ██████ mesh.rs, identity.rs, federation.rs (800+ lines each) - ████ session.rs, health_monitor.rs (700+ lines, acceptable) - ██ container crate (2,000 lines — well-scoped) - security, performance crates (clean) +BACKEND (Rust) — 213 files, ~45K LOC + ████████ package/ (2,248 lines in 7 focused files — was 1,795 god file) + ████████ api/handler/ (896 lines in 6 files — was 800+ god file) + ██████ federation/, identity/, system/, tor/ (all split from monoliths) + ██████ credentials/ (5 files), monitoring/ (7 files) — new modules + ██████ lnd/ (1,092 lines in 5 files — could split further) + ████ mesh/ (6,000 lines — large but domain-appropriate) + ████ session.rs (622), health_monitor.rs (731) — clean single files + ████ rate_limit.rs (191) — new, focused + ██ container, security, performance crates (clean) -FRONTEND (Vue + TS) - ████████████ Web5.vue (3,901 lines — god component) - ██████████ Dashboard.vue (1,803 lines) - ████████ Mesh.vue, Settings.vue (1,500+ lines each) - ██████ useAppStore (317 lines — god store) - ████ rpc-client.ts (708 lines — well-designed) - ██ Composables (clean, focused) - Type safety (excellent) +FRONTEND (Vue + TS) — 232 files, ~45.5K LOC + ████████ web5/ (14 sub-views — was 3,940-line god component) + ████████ settings/ (13 sections — was 1,792-line god component) + ██████ apps/, dashboard/, federation/, fleet/, discover/ (all split) + ████ auth.ts + server.ts + sync.ts (was god store) + ██ rpc-client.ts (well-designed), 11 composables (clean) + Type safety (excellent), 38+ test files -SCRIPTS (Shell) - ████████████ deploy-to-target.sh (1,570 lines) - ████████████ build-auto-installer-iso.sh (1,775 lines) - ██████ first-boot-containers.sh (739 lines) - ████ No shared library (8+ duplicated functions) +SCRIPTS (Shell) — ~40 scripts + ████████████ deploy-to-target.sh (~1,790 lines — still large) + ████████████ build-auto-installer-iso.sh (~1,870 lines — still large) + ██████ first-boot-containers.sh (~935 lines, version mismatches) + ████ scripts/lib/common.sh — shared library (new) + ████ image-versions.sh — centralized pinning (new) ██ Test scripts (well-organized) ARCHITECTURE - ████████ No automated tests (0% coverage) - ██████ No CI/CD test gating + ██████ Tests: 74+ files but gaps in integration coverage + ██████ CI limited to macOS release builds — no test gating ████ Manual type sync (Rust ↔ TypeScript) ████ App integration requires 6+ file changes - ██ Security model (strong defense-in-depth) - ██ Deploy safety (rollback, manifests) + ████ Crypto deps use floating versions + ████ Security model (pentest completed, rate limiting, CSRF) + ████ Deploy safety (rollback, manifests, locking, health checks) + ████ Module architecture (all god files eliminated) + ██ PodmanClient (REST API socket, not CLI) + ██ Monitoring & telemetry system (production-ready) Legend: ██ Critical ██ Needs attention ██ Good + +Progress: ██████████████████████████████████████████ ~75% green (was ~40%)
    @@ -1996,8 +2412,11 @@ Each node has:
    Recommended first files to read
      -
    1. neode-ui/src/stores/container.ts — Clean, well-structured Pinia store (312 lines)
    2. +
    3. neode-ui/src/stores/auth.ts — Clean authentication state machine (new, focused store)
    4. +
    5. neode-ui/src/stores/sync.ts — WebSocket + JSON patch data sync (new)
    6. neode-ui/src/api/rpc-client.ts — Well-designed API client with retry logic
    7. +
    8. core/archipelago/src/api/rpc/dispatcher.rs — How RPC routing works (new)
    9. +
    10. core/archipelago/src/api/rpc/package/install.rs — App install flow (focused)
    11. core/archipelago/src/session.rs — Auth flow in Rust with crypto
    12. core/container/src/podman_client.rs — How Rust talks to Podman
    13. image-recipe/configs/nginx-archipelago.conf — The full routing map
    14. @@ -2043,8 +2462,8 @@ Each node has:

      - Architecture Review — Archipelago v0.1.0-alpha — Generated 2026-03-18
      - ~46,000 lines Rust · ~12,000 lines TypeScript · ~100 shell scripts + Architecture Review — Archipelago v0.1.0-beta — Updated 2026-03-22
      + ~45,000 lines Rust (213 files) · ~45,500 lines TypeScript/Vue (232 files) · ~40 shell scripts

      diff --git a/docs/architecture.md b/docs/architecture.md index 244f9bde..1e78b051 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,186 +1,190 @@ -# Archipelago Bitcoin Node OS - Architecture Documentation +# Archipelago — Architecture -## Overview +> **Bitcoin Node OS** — Flash to USB, install on hardware, manage via web UI. -Archipelago is a self-sovereign Bitcoin Node OS built on Debian Linux with Podman containerization. Flash to USB, install on hardware, manage via web UI. It runs 30+ containerized apps (Bitcoin, Lightning, BTCPay, Mempool, DWN, and more) with multi-node federation over Tor, W3C DID identity, and encrypted backups. +**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind CSS + Pinia + rootless Podman +**Target OS**: Debian 12 (Bookworm) — x86_64 and ARM64 +**Status**: Beta freeze (Phase 1: Feature Testing) -## System Architecture +For the full interactive architecture review with diagrams and learning guide, see [`architecture-review.html`](architecture-review.html). + +--- + +## System Layers ``` -┌─────────────────────────────────────────────────────────┐ -│ Debian Linux Base (Bookworm) │ -│ - Stable, well-supported kernel │ -│ - Systemd service management │ -│ - Extensive hardware support │ -└─────────────────────────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - │ │ │ -┌───────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐ -│ Podman │ │ Rust Backend│ │ Vue.js UI │ -│ (rootless) │ │ (core/) │ │ (neode-ui/) │ -└───────┬──────┘ └──────┬──────┘ └─────────────┘ - │ │ - └───────┬───────┘ - │ - ┌───────────▼───────────┐ - │ Container Orchestration│ - │ Layer │ - │ - Manifest parser │ - │ - Podman client │ - │ - Dependency resolver │ - │ - Health monitor │ - └───────────┬───────────┘ - │ - ┌───────────▼───────────┐ - │ Containerized Apps │ - │ - Bitcoin Core │ - │ - LND / CLN │ - │ - BTCPay Server │ - │ - Nostr Relays │ - │ - Meshtastic │ - │ - Web5 DWN │ - └───────────────────────┘ +┌──────────────────────────────────────────────────────┐ +│ YOUR BROWSER │ +│ Vue 3 SPA (Composition API + Pinia) │ +└──────────────────────┬───────────────────────────────┘ + │ HTTP / WebSocket +┌──────────────────────┴───────────────────────────────┐ +│ NGINX │ +│ /rpc/v1 → backend /app/{id}/ → container │ +└──────────────────────┬───────────────────────────────┘ + │ port 5678 +┌──────────────────────┴───────────────────────────────┐ +│ RUST BACKEND (core/) │ +│ Auth, RPC, containers, federation, mesh, health │ +└──────────────────────┬───────────────────────────────┘ + │ Podman REST API socket +┌──────────────────────┴───────────────────────────────┐ +│ ROOTLESS PODMAN CONTAINERS │ +│ 30 apps: Bitcoin, LND, Mempool, BTCPay, etc. │ +└──────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────┐ +│ DEBIAN 12 (Bookworm) │ +│ systemd, UFW, Tor, AppArmor, filesystem │ +└──────────────────────────────────────────────────────┘ ``` -## Key Components +## Codebase Stats -### 1. Debian Linux Base +| Component | Lines | Files | +|-----------|-------|-------| +| Rust backend (`core/`) | ~49,000 | 146 | +| TypeScript/Vue (`neode-ui/src/`) | ~47,000 | 155 | +| Shell scripts | — | 78 | +| Container apps | — | 30 | -- **Distribution**: Debian 12 (Bookworm) - stable, LTS support -- **Init System**: Systemd for service management -- **Security**: AppArmor, standard Debian hardening -- **Multi-arch**: ARM64 (Raspberry Pi) and x86_64 support +## Backend Crates (`core/`) -### 2. Container Orchestration Layer +| Crate | Purpose | +|-------|---------| +| `archipelago` | Main binary — RPC endpoints, auth, identity, federation, mesh, health | +| `container` | PodmanClient (REST API), manifest parser, dependency resolver | +| `models` | Shared data types across the workspace | +| `helpers` | Common utilities | +| `js-engine` | Embedded JS runtime for extensibility | +| `security` | Secrets encryption, AppArmor profiles, Cosign verification | +| `performance` | CPU/memory/disk monitoring | +| `container-init` | Container initialization and first-run setup | +| `parmanode` | Parmanode compatibility layer | -Located in `core/container/`: -- **manifest.rs**: Parses YAML app manifests -- **podman_client.rs**: Wraps Podman API for container management -- **dependency_resolver.rs**: Resolves app dependencies and conflicts -- **health_monitor.rs**: Monitors container health and auto-restarts +### Key Backend Files -### 3. Backend API Extensions +``` +core/archipelago/src/ +├── api/handler.rs — HTTP routing (/rpc, /health, /dwn, /ws) +├── api/rpc/mod.rs — RPC dispatcher (100+ endpoints) +├── api/rpc/package.rs — App install/start/stop/configure +├── session.rs — Auth sessions, rate limiting, persistence +├── health_monitor.rs — Container health + auto-restart +├── crash_recovery.rs — Boot-time container recovery +├── federation.rs — Multi-node federation over Tor +├── identity.rs — Ed25519 node identity, did:key +├── identity_manager.rs — Multi-identity (Personal/Business/Anonymous) +├── credentials.rs — W3C Verifiable Credentials +├── nostr_discovery.rs — Nostr presence (kind 30078) +├── nostr_handshake.rs — NIP-44 encrypted peer comms +├── network/dwn_store.rs — DWN message persistence +├── network/dwn_sync.rs — Bidirectional DWN replication over Tor +├── monitoring/ — System metrics +├── update.rs — Update checking + scheduling +└── wallet/ — LND integration, ecash +``` -New RPC endpoints in `core/archipelago/src/container/`: -- `container-install`: Install app from manifest -- `container-start/stop/remove`: Container lifecycle -- `container-status/logs`: Status and debugging -- `container-list`: List all containers -- `container-health`: Health status aggregation +## Frontend (`neode-ui/src/`) -### 4. Vue.js UI Integration +``` +├── api/ — RPC client, WebSocket, container client +├── views/ — Dashboard, Marketplace, Settings, Web5, Mesh, Login +├── components/ — BootScreen, SplashScreen, SpotlightSearch, etc. +├── stores/ — Pinia: app, container, mesh, appLauncher, etc. +├── composables/ — useToast, useAudioPlayer, etc. +├── types/ — TypeScript type definitions +├── router/ — Vue Router +└── style.css — Global glassmorphism theme +``` -New components in `neode-ui/`: -- **ContainerApps.vue**: List of containerized apps -- **ContainerAppDetails.vue**: Detailed app view with logs -- **ContainerStatus.vue**: Status indicator component -- **container-client.ts**: API client for container operations -- **container.ts**: Pinia store for container state +## Container Apps (30) -### 5. App Manifest System - -Standardized YAML format in `apps/`: -- Defines container image, resources, dependencies -- Security policies and health checks -- Bitcoin/Lightning/Web5 integration metadata - -### 6. Identity & Federation - -Located in `core/archipelago/src/`: -- **identity.rs**: Ed25519 node identity, did:key generation, DID Documents -- **identity_manager.rs**: Multi-identity manager (Personal/Business/Anonymous) -- **credentials.rs**: W3C Verifiable Credentials (issue, verify, present, revoke) -- **federation.rs**: Multi-node federation with invite codes, trust levels, Tor sync -- **network/dwn_store.rs**: DWN message persistence (JSON files on disk) -- **network/dwn_sync.rs**: Bidirectional DWN replication over Tor -- **nostr_discovery.rs**: Nostr presence publishing (kind 30078, no onion addresses) -- **nostr_handshake.rs**: NIP-44 encrypted peer communication - -### 7. Security Modules - -Located in `core/security/`: -- **container_policies.rs**: Generates AppArmor profiles -- **secrets_manager.rs**: Encrypted secrets storage -- **image_verifier.rs**: Cosign signature verification - -### 8. Performance Optimization - -Located in `core/performance/`: -- **resource_manager.rs**: CPU/memory/disk allocation -- **optimize-debian.sh**: OS-level optimizations - -## App Categories - -### Bitcoin & Lightning -- Bitcoin Core (full node) -- LND (Lightning Network Daemon) -- Core Lightning (CLN) -- BTCPay Server -- Mempool (blockchain explorer) - -### Web5 & Decentralized Protocols -- Nostr relays (nostr-rs-relay, strfry) -- Web5 DWN (Decentralized Web Node) -- DID Wallet -- Bitcoin Domain Names - -### Mesh Networking & Routing -- Meshtastic (LoRa mesh networking) -- Router (mesh routing, device discovery) -- Local network management - -### Self-Hosted Services -- Home Assistant -- Grafana -- SearXNG -- OnlyOffice -- Ollama (local AI) -- Penpot - -## Security Model - -1. **OS Level**: Debian hardening, AppArmor, minimal installed packages -2. **Container Level**: Rootless Podman, capability dropping, network isolation -3. **Secrets**: Encrypted storage, runtime injection only -4. **Supply Chain**: Signed images (Cosign), SBOM generation -5. **Network**: Firewall (nftables/iptables), rate limiting, Tor integration -6. **Audit**: Journald logging, configuration tracking +**Bitcoin & Lightning**: Bitcoin Knots, LND, BTCPay Server, ThunderHub, Mempool, ElectrumX, Fedimint +**Nostr**: nostr-rs-relay, Nostrudel +**Self-Hosted**: Nextcloud, Jellyfin, Immich, PhotoPrism, Vaultwarden, Home Assistant, OnlyOffice, Penpot, SearXNG, FileBrowser +**Dev/Ops**: Grafana, Portainer, Ollama, Nginx Proxy Manager +**Networking**: Tailscale +**Custom**: DWN, IndeedHub, Router +**External**: BotFights, NWNN, 484 Kitchen, Call the Operator, Arch Presentation, Syntropy Institute, T-Zero ## Networking -- **Container Networks**: `archy-net` (main), `immich-net`, `penpot-net` via Podman -- **Aardvark DNS**: Container hostname resolution within networks -- **UFW Firewall**: Podman subnets (10.88.0.0/16, 10.89.0.0/16) allowed for DNS -- **Tor Integration**: System Tor daemon, SOCKS5 proxy on port 9050 -- **Federation**: Inter-node communication over Tor hidden services -- **Nginx**: Reverse proxy on port 80/443, proxies to backend on 5678 +- **Container DNS**: `archy-net` for inter-container communication (Bitcoin, LND, ElectrumX, Mempool, BTCPay, Fedimint) +- **Aardvark DNS**: Container hostname resolution within Podman networks +- **Tor**: System Tor daemon, SOCKS5 on port 9050, hidden services per node +- **Federation**: Inter-node communication over Tor with DID-based trust +- **UFW**: `DEFAULT_FORWARD_POLICY="ACCEPT"` required for LAN container access -## Data Persistence +## Security Model -- **App Data**: `/var/lib/archipelago/{app-id}/` -- **Identity**: `/var/lib/archipelago/identity/` (Ed25519 node key) -- **Multi-Identity**: `/var/lib/archipelago/identities/` (per-identity JSON) -- **DWN Messages**: `/var/lib/archipelago/dwn/messages/` (JSON files) -- **Credentials**: `/var/lib/archipelago/credentials/` -- **Federation**: `/var/lib/archipelago/federation/` -- **Content Catalog**: `/var/lib/archipelago/content/` -- **Backups**: `/var/lib/archipelago/backups/` (ChaCha20-Poly1305 encrypted) -- **Secrets**: `/var/lib/archipelago/secrets/{app-id}/` (encrypted) +| Layer | Measures | +|-------|----------| +| OS | Debian hardening, AppArmor, minimal packages | +| Nginx | CSP headers, rate limiting, auth_request, session validation | +| Backend | Input validation, CSRF, session auth, bind 127.0.0.1 only | +| Containers | Rootless Podman, cap-drop ALL, readonly root, non-root user, no-new-privileges, memory limits | +| Crypto | Ed25519 signatures, ChaCha20-Poly1305 encryption, Argon2 password hashing, constant-time comparisons | +| Network | Tor hidden services, UFW firewall, SSRF prevention | -## Multi-Node Federation +## Data Paths -Nodes form a federated network over Tor: -- **Invite-based joining**: Generate invite code, peer joins via Tor -- **Trust levels**: Trusted, Verified, Untrusted -- **State sync**: Federation state, DWN messages, file catalogs sync bidirectionally -- **DWN protocols**: 4 interoperable schemas (node-identity, file-catalog, federation, app-deploy) -- **Verifiable Credentials**: W3C VCs for inter-node trust attestation +| Data | Path | +|------|------| +| App data | `/var/lib/archipelago/{app-id}/` | +| Identity | `/var/lib/archipelago/identity/` | +| Multi-identity | `/var/lib/archipelago/identities/` | +| Federation | `/var/lib/archipelago/federation/` | +| DWN messages | `/var/lib/archipelago/dwn/messages/` | +| Credentials | `/var/lib/archipelago/credentials/` | +| Backups | `/var/lib/archipelago/backups/` (ChaCha20-Poly1305) | +| Secrets | `/var/lib/archipelago/secrets/{app-id}/` (encrypted) | +| Sessions | `/var/lib/archipelago/sessions.json` | +| Frontend | `/opt/archipelago/web-ui/` | +| Backend binary | `/usr/local/bin/archipelago` | -## Future Enhancements +## Active Nodes (5) -- Time-travel snapshots (ZFS/BTRFS) -- Decentralized app marketplace (IPFS + Nostr) -- Multi-node clustering -- Hardware attestation (TPM 2.0) -- Protocol-agnostic design (multi-chain support) +| Node | Access | Notes | +|------|--------|-------| +| Arch 1 | 192.168.1.228 (LAN) | Primary dev, 16GB RAM, 1.8TB NVMe | +| Arch 2 | 192.168.1.198 (LAN) | Secondary, 8GB RAM | +| Arch 3 | 100.82.97.63 (Tailscale) | Has mesh radio | +| Arch 4 | 100.122.84.60 (Tailscale) | — | +| Arch 5 | 100.124.105.113 (Tailscale) | — | + +All nodes federated over Tor with bidirectional DWN sync. Deploy via SSH key from Mac. + +## Key Features (Working) + +- 30 containerized apps with one-click install/manage +- Multi-node federation with invite-based joining and trust levels +- DWN sync (bidirectional message replication over Tor) +- W3C DID identity (did:key, DID Documents, Verifiable Credentials) +- NIP-07 Nostr signing (iframe apps sign events with consent) +- File sharing with access controls (free/peers-only/paid) +- Encrypted backups (Argon2 + ChaCha20-Poly1305) +- Health monitoring with tiered auto-restart (exponential backoff) +- Tiered container startup (databases → core → applications) +- LoRa mesh networking (Meshcore protocol) +- Three-mode UI (Pro/Easy/Chat) +- Real-time WebSocket updates +- Glassmorphism web UI +- Bootable ISO installer + +## Further Documentation + +| Doc | Purpose | +|-----|---------| +| [`architecture-review.html`](architecture-review.html) | Full interactive architecture guide with diagrams | +| [`developer-guide.md`](developer-guide.md) | Dev setup, workflow, code conventions | +| [`api-reference.md`](api-reference.md) | Complete RPC endpoint reference | +| [`app-developer-guide.md`](app-developer-guide.md) | Building and publishing apps | +| [`user-walkthrough.md`](user-walkthrough.md) | End-user installation and usage guide | +| [`troubleshooting.md`](troubleshooting.md) | Diagnostic scenarios and solutions | +| [`operations-runbook.md`](operations-runbook.md) | Ops commands and emergency recovery | +| [`multi-node-architecture.md`](multi-node-architecture.md) | Federation protocol design | +| [`marketplace-protocol.md`](marketplace-protocol.md) | Decentralized app discovery via Nostr | +| [`security-code-audit-2026-03.md`](security-code-audit-2026-03.md) | Penetration test findings (33 findings, all remediated) | +| [`MASTER_PLAN.md`](MASTER_PLAN.md) | Phased roadmap and task tracking | +| [`BETA-PROGRESS.md`](BETA-PROGRESS.md) | Current beta stabilization progress | diff --git a/docs/current-state.md b/docs/current-state.md index ba255adb..1d96e5b1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -1,83 +1,5 @@ -# Current Development State +# Current State -**Last updated**: 2026-03-14 (v1.2.0) - -## Architecture - -Archipelago is a pure, standalone Bitcoin Node OS. All StartOS dependencies were removed in v1.0. The stack: - -- **OS**: Debian 12 (Bookworm) — x86_64 and ARM64 -- **Backend**: Rust binary (`archipelago`) on port 5678 -- **Frontend**: Vue 3 + TypeScript + Vite 7 + Tailwind CSS -- **Containers**: Podman (root-level via `sudo podman`) -- **Web Server**: Nginx reverse proxy (port 80/443 → backend 5678) -- **Tor**: System Tor daemon with hidden services -- **Systemd**: `archipelago.service` with watchdog (60s) - -## Active Nodes - -| Node | IP | Hardware | RAM | Disk | Containers | -|------|-----|----------|-----|------|------------| -| Arch 1 | 192.168.1.228 | Intel i3-8100T | 16GB | 1.8TB NVMe | 32 | -| Arch 2 | 192.168.1.198 | — | 8GB | 457GB | 34 | - -Both nodes are federated over Tor with bidirectional DWN sync. - -## Backend Modules - -``` -core/archipelago/src/ -├── api/ — HTTP server, RPC handler (100+ endpoints) -│ ├── handler.rs — HTTP routing (/rpc, /health, /dwn, /ws) -│ └── rpc/ — RPC endpoints (auth, identity, federation, etc.) -├── backup/ — Full backup + identity backup (ChaCha20-Poly1305) -├── container/ — Container management (Podman, health checks) -├── identity.rs — Ed25519 node identity, did:key, DID Documents -├── identity_manager.rs — Multi-identity (Personal/Business/Anonymous) -├── credentials.rs — W3C Verifiable Credentials -├── federation.rs — Multi-node federation over Tor -├── network/ — DWN store, DWN sync, DNS, routing -├── nostr_discovery.rs — Nostr presence (kind 30078) -├── nostr_handshake.rs — NIP-44 encrypted peer communication -├── health_monitor.rs — Container health monitoring + auto-restart -├── crash_recovery.rs — Start stopped containers on boot -├── monitoring/ — System metrics collection -├── session.rs — Session management + rate limiting -├── update.rs — Update checking + scheduling -└── wallet/ — LND integration, ecash, profits tracking -``` - -## Key Features (Working) - -- **30+ containerized apps**: Bitcoin Knots, LND, Mempool, BTCPay, Nextcloud, Immich, Jellyfin, Home Assistant, etc. -- **Multi-node federation**: Invite-based joining, trust levels, state sync over Tor -- **DWN sync**: Bidirectional message replication between federated nodes -- **W3C identity**: did:key, DID Documents, Verifiable Credentials -- **NIP-07 signing**: iframe apps can sign Nostr events with consent -- **File sharing**: Content catalog with access controls (free/peers-only/paid) -- **Encrypted backups**: Create, verify, restore with Argon2 + ChaCha20-Poly1305 -- **Health monitoring**: Auto-restart crashed containers with exponential backoff -- **Tiered startup**: Databases → Core → Applications ordering on boot -- **Web UI**: Glass morphism design, real-time WebSocket updates - -## Planned (Not Yet Implemented) - -- **did:dht**: DHT-based discoverable identities (architecture documented) -- **DWN protocol schemas**: 4 Archipelago protocols defined, registration implemented -- **Multi-user support**: Admin/viewer/app-user roles -- **Cluster mode**: Multi-node HA with consensus -- **Mobile companion**: Read-only PWA for remote monitoring - -## Test Coverage - -Cross-node test suite (`scripts/test-cross-node.sh`) covers: -- US-01: System health (6 checks × 2 nodes) -- US-02: Container lifecycle (3 checks × 2 nodes) -- US-03: Federation join (4 checks × 2 nodes) -- US-04: Federation sync (4 checks × 2 nodes) -- US-05: Tor hidden services (2 checks bidirectional) -- US-07: File sharing (5 checks bidirectional) -- US-08: DWN sync (5 checks × 10 iterations) -- US-09: NIP-07 signing (1 check × 2 nodes) -- US-10: Backup/restore (4 checks × 10 iterations × 2 nodes) -- US-15: Boot recovery (3 checks × 3 reboots per node) +> This document has been consolidated into [`architecture.md`](architecture.md). +> +> See that file for the current system architecture, active nodes, codebase stats, and feature status. diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 845b4fdb..ad72e0bc 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -32,32 +32,32 @@ sudo systemctl status tor # Tor hidden services ```bash # List all containers -sudo podman ps -a +podman ps -a # Running count -sudo podman ps --format '{{.Names}}' | wc -l +podman ps --format '{{.Names}}' | wc -l # Find exited/crashed containers -sudo podman ps -a --filter status=exited +podman ps -a --filter status=exited # Container logs -sudo podman logs {container-name} --tail 50 +podman logs {container-name} --tail 50 # Container resource usage -sudo podman stats --no-stream +podman stats --no-stream ``` ## 3. Fix Crashed Containers ```bash # Restart a specific container -sudo podman restart {container-name} +podman restart {container-name} # If container won't start, check logs first -sudo podman logs {container-name} --tail 100 +podman logs {container-name} --tail 100 # Remove and recreate (last resort) -sudo podman rm -f {container-name} +podman rm -f {container-name} # Then redeploy with: ./scripts/deploy-to-target.sh --live # The health monitor auto-restarts containers every 60s @@ -173,11 +173,11 @@ uptime top -b -n 1 | head -15 # Check container CPU usage -sudo podman stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' +podman stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' # Common causes: # - Bitcoin IBD (initial block download): normal, takes days -# - Container crash loops: check `sudo podman ps -a --filter status=exited` +# - Container crash loops: check `podman ps -a --filter status=exited` # - mempool-electrs indexing: normal after Bitcoin sync ``` @@ -191,7 +191,7 @@ free -h swapon --show # Per-container memory -sudo podman stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}' +podman stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}' # Check for OOM kills dmesg --level=err,crit | grep -i oom @@ -214,10 +214,10 @@ df -h / sudo du -h --max-depth=2 /var/lib/archipelago/ | sort -rh | head -20 # Container image sizes -sudo podman images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' +podman images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' # Clean unused images -sudo podman image prune -a +podman image prune -a # Clean old journal logs sudo journalctl --vacuum-size=500M @@ -295,7 +295,7 @@ sudo tail -f /var/log/nginx/access.log sudo tail -f /var/log/nginx/error.log # Container logs -sudo podman logs {container-name} --tail 50 -f +podman logs {container-name} --tail 50 -f ``` ## 15. Network Diagnostics @@ -347,7 +347,7 @@ If a node responds to ping but SSH/HTTP are down: ```bash df -h / sudo journalctl --vacuum-size=200M - sudo podman image prune -a + podman image prune -a ``` ## 17. Run Cross-Node Tests diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 8aa7e5bf..6e2096d5 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -308,7 +308,7 @@ DOCKERFILE echo " ⚠ No nginx snippets found, HTTPS features may not work" fi - # Use nginx config from configs/ (includes app proxies for Nextcloud, Vaultwarden, Immich, Penpot) + # Use nginx config from configs/ (includes app proxies for Nextcloud, Vaultwarden, etc.) if [ -f "$SCRIPT_DIR/configs/nginx-archipelago.conf" ]; then cp "$SCRIPT_DIR/configs/nginx-archipelago.conf" "$WORK_DIR/nginx-archipelago.conf" echo " Using nginx config from configs/nginx-archipelago.conf" @@ -602,7 +602,7 @@ IMAGES_CAPTURED_FROM_SERVER=0 if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then echo " Capturing container images from live server ($DEV_SERVER)..." # Patterns match against `podman images` repository names (not container names) - CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server grafana uptime-kuma jellyfin vaultwarden searxng mariadb valkey nginx-alpine portainer photoprism nextcloud nginx-proxy-manager immich onlyoffice adguard penpot" + CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server grafana uptime-kuma jellyfin vaultwarden searxng mariadb valkey nginx-alpine portainer photoprism nextcloud nginx-proxy-manager onlyoffice adguard" REMOTE_TMP="/tmp/archipelago-image-capture-$$" SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true for p in $SAVED_LIST; do @@ -621,14 +621,14 @@ fi # bitcoin-ui and lnd-ui are custom and normally captured from server or built separately. # Alpha: core Bitcoin/Lightning stack + essential apps. Others pulled on-demand from Marketplace. CONTAINER_IMAGES=" -${BITCOIN_KNOTS_IMAGE:-docker.io/bitcoinknots/bitcoin:v28.1} bitcoin-knots.tar +${BITCOIN_KNOTS_IMAGE:-docker.io/bitcoinknots/bitcoin:28.1} bitcoin-knots.tar ${LND_IMAGE:-docker.io/lightninglabs/lnd:v0.18.5-beta} lnd.tar ${HOMEASSISTANT_IMAGE:-ghcr.io/home-assistant/home-assistant:2024.12} homeassistant.tar -${BTCPAY_IMAGE:-docker.io/btcpayserver/btcpayserver:1.14.5} btcpayserver.tar +${BTCPAY_IMAGE:-docker.io/btcpayserver/btcpayserver:1.13.7} btcpayserver.tar ${NBXPLORER_IMAGE:-docker.io/nicolasdorier/nbxplorer:2.5.13} nbxplorer.tar ${POSTGRES_IMAGE:-docker.io/library/postgres:16} postgres-btcpay.tar -${MEMPOOL_API_IMAGE:-docker.io/mempool/frontend:v3.0.0} mempool-frontend.tar -${MEMPOOL_WEB_IMAGE:-docker.io/mempool/frontend:v3.0.0} mempool-backend.tar +${MEMPOOL_BACKEND_IMAGE:-docker.io/mempool/backend:v3.0.0} mempool-backend.tar +${MEMPOOL_WEB_IMAGE:-docker.io/mempool/frontend:v3.0.0} mempool-frontend.tar ${ELECTRUMX_IMAGE:-docker.io/lukechilds/electrumx:v1.16.0} electrumx.tar ${MARIADB_IMAGE:-docker.io/library/mariadb:11.4} mariadb-mempool.tar ${FEDIMINT_IMAGE:-docker.io/fedimint/fedimintd:v0.5.1} fedimint.tar @@ -640,16 +640,13 @@ ${DWN_SERVER_IMAGE:-ghcr.io/tbd54566975/dwn-server:main} dwn-server.tar ${GRAFANA_IMAGE:-docker.io/grafana/grafana:11.4.0} grafana.tar ${UPTIME_KUMA_IMAGE:-docker.io/louislam/uptime-kuma:1} uptime-kuma.tar ${VAULTWARDEN_IMAGE:-docker.io/vaultwarden/server:1.32.5} vaultwarden.tar -${SEARXNG_IMAGE:-docker.io/searxng/searxng:2024.11.17} searxng.tar +${SEARXNG_IMAGE:-docker.io/searxng/searxng:2026.3.20-6c7e9c197} searxng.tar ${PORTAINER_IMAGE:-docker.io/portainer/portainer-ce:2.21.5} portainer.tar ${TAILSCALE_IMAGE:-docker.io/tailscale/tailscale:v1.78.3} tailscale.tar ${JELLYFIN_IMAGE:-docker.io/jellyfin/jellyfin:10.10.3} jellyfin.tar ${PHOTOPRISM_IMAGE:-docker.io/photoprism/photoprism:240915} photoprism.tar ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:30} nextcloud.tar ${NPM_IMAGE:-docker.io/jc21/nginx-proxy-manager:2} nginx-proxy-manager.tar -${IMMICH_IMAGE:-ghcr.io/immich-app/immich-server:v1.123.0} immich-server.tar -${IMMICH_POSTGRES_IMAGE:-ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0} postgres-immich.tar -${INDEEDHUB_REDIS_IMAGE:-docker.io/library/redis:7-alpine} redis-immich.tar ${ONLYOFFICE_IMAGE:-docker.io/onlyoffice/documentserver:8.2} onlyoffice.tar ${ADGUARDHOME_IMAGE:-docker.io/adguard/adguardhome:v0.107.55} adguardhome.tar " diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 144787af..dfc3bad5 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -406,7 +406,7 @@ server { proxy_hide_header Content-Security-Policy; add_header X-Content-Type-Options "nosniff" always; proxy_set_header Accept-Encoding ""; - sub_filter_types text/html text/css application/javascript application/json; + sub_filter_types text/css application/javascript application/json; sub_filter_once off; sub_filter 'href="/' 'href="/app/indeedhub/'; sub_filter 'src="/' 'src="/app/indeedhub/'; diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index 8f122bf2..d54bfe9b 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -276,7 +276,7 @@ location /app/indeedhub/ { add_header X-Frame-Options "SAMEORIGIN" always; proxy_hide_header Content-Security-Policy; proxy_set_header Accept-Encoding ""; - sub_filter_types text/html text/css application/javascript application/json; + sub_filter_types text/css application/javascript application/json; sub_filter_once off; sub_filter 'href="/' 'href="/app/indeedhub/'; sub_filter 'src="/' 'src="/app/indeedhub/'; diff --git a/loop/loop.sh b/loop/loop.sh new file mode 100755 index 00000000..6667bb7d --- /dev/null +++ b/loop/loop.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env sh +# Headless loop script for overnight Claude Code automation. +# Rate-limit aware: detects limits, sleeps until reset, and retries automatically. +set -u + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}" +LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}" +ITERATION_COUNT="${ITERATION_COUNT:-10}" +ITERATION_DELAY="${ITERATION_DELAY:-30}" +CLAUDE_BIN="${CLAUDE_BIN:-claude}" +RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}" +MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}" +CLAUDE_EXIT=0 + +cd "$PROJECT_DIR" + +log() { + echo "$1" | tee -a "$LOG_FILE" +} + +banner() { + log "" + log "================================================================" + log " $1" + log " $(date '+%Y-%m-%d %H:%M:%S')" + log "================================================================" + log "" +} + +section() { + log "" + log "----------------------------------------" + log " $1" + log "----------------------------------------" + log "" +} + +plan_has_tasks() { + grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null +} + +remaining_tasks() { + grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0" +} + +next_task() { + grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)" +} + +check_rate_limit() { + [ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1 + tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit detected" | grep -v "^Sleeping" | grep -v "^=" | grep -v "^-" | grep -qi \ + -e "rate.limit" \ + -e "too.many.requests" \ + -e "429" \ + -e "quota.exceeded" \ + -e "usage.limit" \ + -e "limit.reached" 2>/dev/null +} + +banner "WEB OVERNIGHT AUTOMATION STARTED" +log " Project: $PROJECT_DIR" +log " Prompt: $PROMPT_FILE" +log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}" +log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s between each)" +log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry up to ${MAX_RATE_LIMIT_RETRIES}x" +log " Tasks left: $(remaining_tasks)" +log " Next task: $(next_task)" +log "" + +i=1 +rate_limit_retries=0 +while [ "$i" -le "$ITERATION_COUNT" ]; do + + if ! plan_has_tasks; then + banner "ALL TASKS COMPLETE" + log " No remaining tasks in plan.md. Stopping." + break + fi + + section "ITERATION $i/$ITERATION_COUNT" + log " Tasks remaining: $(remaining_tasks)" + log " Next task: $(next_task)" + log "" + + export CLAUDE_PROJECT_DIR="$PROJECT_DIR" + export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}" + + if [ -f "$PROMPT_FILE" ]; then + log " Starting Claude session..." + log "" + "$CLAUDE_BIN" -p --dangerously-skip-permissions \ + < "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE" + CLAUDE_EXIT=$? + log "" + log " Claude exited with code: $CLAUDE_EXIT" + else + log " ERROR: $PROMPT_FILE not found" + exit 1 + fi + + if check_rate_limit; then + rate_limit_retries=$((rate_limit_retries + 1)) + if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then + section "RATE LIMITED — SCHEDULING LAUNCHD RETRY" + log " Hit rate limit $rate_limit_retries times. Creating launchd job to retry later." + + PLIST_LABEL="com.web.overnight-retry" + PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" + RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M') + RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1) + RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2) + + cat > "$PLIST_PATH" < + + + + Label + ${PLIST_LABEL} + ProgramArguments + + /bin/sh + -c + cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH} + + StartCalendarInterval + + Hour + ${RETRY_HOUR} + Minute + ${RETRY_MIN} + + EnvironmentVariables + + CLAUDE_AUTONOMOUS + 1 + CLAUDE_PROJECT_DIR + ${PROJECT_DIR} + PATH + /usr/local/bin:/usr/bin:/bin:$HOME/.local/bin + + StandardOutPath + ${LOG_FILE} + StandardErrorPath + ${LOG_FILE} + + +PLIST + + launchctl load "$PLIST_PATH" 2>/dev/null || true + log " Scheduled retry at ~${RETRY_TIME}" + log " Plist: $PLIST_PATH (auto-removes after running)" + exit 0 + fi + + section "RATE LIMITED — WAITING" + log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES" + log " Sleeping ${RATE_LIMIT_WAIT}s until $(date -v+${RATE_LIMIT_WAIT}S '+%H:%M:%S' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M:%S')..." + sleep "$RATE_LIMIT_WAIT" + + if ! plan_has_tasks; then + banner "ALL TASKS COMPLETE (during rate limit wait)" + break + fi + log " Retrying..." + continue + fi + + rate_limit_retries=0 + + section "ITERATION $i COMPLETE" + log " Tasks remaining: $(remaining_tasks)" + log " Next task: $(next_task)" + + i=$((i + 1)) + if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then + log " Pausing ${ITERATION_DELAY}s before next iteration..." + sleep "$ITERATION_DELAY" + fi +done + +banner "LOOP FINISHED" +log " Completed $((i - 1)) iterations" +log " Tasks remaining: $(remaining_tasks)" +log "" diff --git a/loop/prepare.sh b/loop/prepare.sh new file mode 100755 index 00000000..43ef6cb2 --- /dev/null +++ b/loop/prepare.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh +# Pre-run script: verify repo state and create overnight branch. +set -eu + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +cd "$PROJECT_DIR" + +DATE=$(date '+%Y-%m-%d') +BRANCH="overnight/${DATE}" + +echo "=== archy overnight pre-run check @ $(date '+%Y-%m-%dT%H:%M:%S') ===" + +# 1. Check git status is clean +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Error: Working tree not clean. Commit or stash changes first." >&2 + git status --short >&2 + exit 1 +fi + +# 2. Check we're not already on an overnight branch +current=$(git branch --show-current 2>/dev/null || true) +if [ -n "$current" ] && [ "$current" = "$BRANCH" ]; then + echo "Already on $BRANCH. Ready to run." >&2 + exit 0 +fi + +# 3. Create date-stamped branch +if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then + echo "Branch $BRANCH already exists. Checkout or use a different date." >&2 + exit 1 +fi +git checkout -b "$BRANCH" +echo "Created branch $BRANCH" + +echo "" +echo "Reminder: Push before starting overnight run: git push -u origin $BRANCH" +echo "Then run: caffeinate -i ./loop/loop.sh" +echo "=== Ready ===" diff --git a/loop/prompt.md b/loop/prompt.md new file mode 100644 index 00000000..5862f1e9 --- /dev/null +++ b/loop/prompt.md @@ -0,0 +1,29 @@ +You are working through an overnight automation plan for the Archipelago (archy) project. Read these files first: + +1. `loop/plan.md` -- Your task checklist (mark items `- [x]` as you complete them) +2. `CLAUDE.md` -- Project conventions, architecture, and coding standards + +## Working Process + +For each task in `loop/plan.md`: + +1. Find the first unchecked `- [ ]` item +2. Read the task description carefully +3. Read the relevant source files before making changes +4. Implement following CLAUDE.md conventions +5. Run any test/build commands specified in the task +6. Fix all errors before continuing +7. Commit with conventional format: `type: description` +8. Mark it done `- [x]` in `loop/plan.md` +9. Move to the next unchecked task immediately + +## Rules + +- Never skip a testing gate -- if tests fail, fix before moving on +- If a task is proving difficult, make at least 10 genuine attempts before moving on +- Always read source files before editing them +- Do not stop until all tasks are checked or you are rate limited +- Commit after each completed task +- DEPLOY ONLY TO .198 -- Never deploy to .228 +- Use `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.198` for SSH +- Run Rust checks on the dev server, NOT macOS diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js index 25111093..13fdf824 100644 --- a/neode-ui/dev-dist/sw.js +++ b/neode-ui/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.fo87inn2rb8" + "revision": "0.ld9oh2eb91o" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index bde2f60c..06102784 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -913,6 +913,10 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { signature: mockSig } }) } + case 'identity.verify': { + return res.json({ result: { valid: true } }) + } + case 'node.createBackup': { const { passphrase } = params || {} if (!passphrase) { @@ -1247,6 +1251,186 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { success: true } }) } + case 'identity.list': { + return res.json({ + result: { + identities: [ + { + id: 'id-primary', + name: 'Primary', + purpose: 'Main node identity', + pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', + created_at: '2026-01-10T08:00:00Z', + is_default: true, + nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + nostr_npub: 'npub1mockprimaryidentitypubkeyvalue0000000000000000000000000abc', + profile: { + display_name: 'Archipelago Node', + about: 'Self-sovereign Bitcoin node', + nip05: 'satoshi@archipelago.local', + lud16: 'satoshi@getalby.com', + }, + }, + { + id: 'id-anon', + name: 'Anonymous', + purpose: 'Privacy-focused browsing', + pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98', + did: 'did:key:z6MkvWkza1fMBWhKnYE3CgMgxHLKbN8NmbFRqHcECF4oGrwx', + created_at: '2026-02-14T12:30:00Z', + is_default: false, + nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98', + nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz', + profile: { display_name: 'Anon' }, + }, + { + id: 'id-merchant', + name: 'Merchant', + purpose: 'BTCPay & commerce', + pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp9WNhSwaxN21V', + created_at: '2026-03-01T16:00:00Z', + is_default: false, + nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def', + profile: { display_name: 'My Shop', website: 'https://myshop.onion' }, + }, + ], + }, + }) + } + + case 'identity.get': { + const id = params?.id || 'id-primary' + return res.json({ + result: { + id, + name: 'Primary', + purpose: 'Main node identity', + pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', + created_at: '2026-01-10T08:00:00Z', + is_default: true, + nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + nostr_npub: 'npub1mockprimaryidentitypubkeyvalue0000000000000000000000000abc', + profile: { + display_name: 'Archipelago Node', + about: 'Self-sovereign Bitcoin node', + }, + }, + }) + } + + case 'identity.export-keys': { + return res.json({ + result: { + ed25519_secret_hex: 'deadbeef'.repeat(8), + nostr_secret_hex: 'cafebabe'.repeat(8), + nostr_nsec: 'nsec1mockexportedkeyvalue00000000000000000000000000000000000abc', + }, + }) + } + + case 'identity.update-profile': { + return res.json({ result: { success: true } }) + } + + case 'identity.publish-profile': { + return res.json({ result: { event_id: `evt-${Date.now().toString(36)}` } }) + } + + case 'identity.list': { + return res.json({ + result: { + identities: [ + { + id: 'id-primary', + name: 'Primary', + purpose: 'personal', + pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', + created_at: '2026-01-10T08:00:00Z', + is_default: true, + nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee', + profile: { + display_name: 'Satoshi', + about: 'Running a sovereign Bitcoin node', + nip05: 'satoshi@archipelago.local', + lud16: 'satoshi@getalby.com', + }, + }, + { + id: 'id-business', + name: 'Business', + purpose: 'business', + pubkey: 'f6e5d4c3b2a10987654321fedcba0987654321fedcba0987654321fedcba0987', + did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', + created_at: '2026-02-05T12:30:00Z', + is_default: false, + nostr_pubkey: 'f6e5d4c3b2a10987654321fedcba0987654321fedcba0987654321fedcba0987', + nostr_npub: 'npub17m9mx9kk2ry8p3xfkq0070fjlph9r9l0dj0y8fn0zrlj80ekjph7jxmxfg', + profile: { + display_name: 'Archy Consulting', + about: 'Bitcoin infrastructure services', + }, + }, + { + id: 'id-anon', + name: 'Anonymous', + purpose: 'anonymous', + pubkey: 'deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb', + did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs', + created_at: '2026-03-01T18:00:00Z', + is_default: false, + profile: {}, + }, + ], + }, + }) + } + + case 'identity.get': { + const id = params?.id || 'id-primary' + const identities = { + 'id-primary': { + id: 'id-primary', name: 'Primary', purpose: 'personal', + pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', + nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee', + is_default: true, created_at: '2026-01-10T08:00:00Z', + profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' }, + }, + } + return res.json({ result: identities[id] || identities['id-primary'] }) + } + + case 'identity.export-keys': { + const { password } = params || {} + if (password !== MOCK_PASSWORD) { + return res.json({ error: { code: -32603, message: 'Incorrect password' } }) + } + return res.json({ + result: { + ed25519_secret_hex: 'mock_ed25519_secret_' + '0'.repeat(40), + nostr_secret_hex: 'mock_nostr_secret_' + '0'.repeat(44), + nostr_nsec: 'nsec1mockkeymockkeymockkeymockkeymockkeymockkeymockkeymockkeymo', + }, + }) + } + + case 'identity.update-profile': { + console.log(`[Identity] Updated profile for: ${params?.id}`) + return res.json({ result: { success: true } }) + } + + case 'identity.publish-profile': { + console.log(`[Identity] Published profile for: ${params?.id}`) + return res.json({ result: { event_id: `nostr-event-${Date.now().toString(36)}` } }) + } + // ========================================================================= // Nostr // ========================================================================= @@ -1267,6 +1451,19 @@ app.post('/rpc/v1', (req, res) => { // ========================================================================= // Content & Network // ========================================================================= + case 'content.browse-peer': { + const onion = params?.onion || '' + return res.json({ + result: { + items: [ + { id: 'peer-doc-1', filename: 'Bitcoin Whitepaper.pdf', mime_type: 'application/pdf', size_bytes: 184292, description: 'The original Bitcoin whitepaper by Satoshi Nakamoto', access: 'free' }, + { id: 'peer-img-1', filename: 'node-setup-guide.png', mime_type: 'image/png', size_bytes: 524800, description: 'Visual guide for setting up a Bitcoin node', access: 'free' }, + { id: 'peer-vid-1', filename: 'Lightning Demo.mp4', mime_type: 'video/mp4', size_bytes: 15728640, description: 'Lightning Network payment channel demo', access: { paid: { price_sats: 500 } } }, + ], + }, + }) + } + case 'content.remove': { return res.json({ result: { success: true } }) } @@ -1275,6 +1472,66 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { success: true } }) } + case 'network.diagnostics': { + return res.json({ + result: { + wan_ip: '82.14.203.47', + nat_type: 'Full Cone', + upnp_available: true, + tor_connected: true, + wifi_count: 3, + }, + }) + } + + case 'network.list-interfaces': { + return res.json({ + result: { + interfaces: [ + { name: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.168.1.228/24'] }, + { name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.168.1.230/24'] }, + { name: 'lo', type: 'loopback', state: 'up', mac: '00:00:00:00:00:00', ipv4: ['127.0.0.1/8'] }, + { name: 'podman0', type: 'bridge', state: 'up', mac: '2e:f4:8a:11:22:33', ipv4: ['10.89.0.1/16'] }, + { name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.82.97.63/32'] }, + ], + }, + }) + } + + case 'network.scan-wifi': { + return res.json({ + result: { + networks: [ + { ssid: 'HomeNetwork', signal: 92, security: 'WPA2' }, + { ssid: 'Neighbor-5G', signal: 45, security: 'WPA3' }, + { ssid: 'CoffeeShop', signal: 30, security: 'Open' }, + ], + }, + }) + } + + case 'network.configure-wifi': { + console.log(`[Network] Connecting to WiFi: ${params?.ssid}`) + return res.json({ result: { success: true } }) + } + + case 'network.dns-status': { + return res.json({ + result: { + provider: 'system', + servers: ['1.1.1.1', '9.9.9.9'], + doh_enabled: false, + doh_url: null, + resolv_conf_servers: ['1.1.1.1', '9.9.9.9'], + }, + }) + } + + case 'network.configure-dns': { + console.log(`[Network] DNS configured: ${params?.provider}`) + return res.json({ result: { success: true } }) + } + case 'network.accept-request': { return res.json({ result: { success: true } }) } @@ -1323,9 +1580,35 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { success: true } }) } + case 'router.list-forwards': { + return res.json({ + result: { + forwards: [ + { id: 'fwd-1', name: 'Bitcoin P2P', external_port: 8333, internal_port: 8333, protocol: 'tcp', enabled: true }, + { id: 'fwd-2', name: 'LND gRPC', external_port: 10009, internal_port: 10009, protocol: 'tcp', enabled: true }, + ], + }, + }) + } + // ========================================================================= // Tor & Peer Networking // ========================================================================= + case 'tor.list-services': { + return res.json({ + result: { + tor_running: true, + services: [ + { name: 'archipelago', local_port: 5678, onion_address: 'archydemox7k3pnw4hv5qz2jcbr6dwefys3ockqzf4mzjlvxot2ioad.onion', enabled: true, unauthenticated: false, protocol: false }, + { name: 'bitcoin', local_port: 8333, onion_address: 'btcmockxj4k5pnw7hv8qz9jcbr2dwefys6ockqzf1mzjlvxot5ioad.onion', enabled: true, unauthenticated: false, protocol: false }, + { name: 'lnd', local_port: 9735, onion_address: 'lndmockab3c4def5ghi6jkl7mno8pqr9stu0vwx1yz2ab3c4def5ghi.onion', enabled: true, unauthenticated: false, protocol: false }, + { name: 'electrs', local_port: 50001, onion_address: 'elecmockyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba1xyz9wvu8tsr7q.onion', enabled: true, unauthenticated: true, protocol: false }, + { name: 'nostr-relay', local_port: 7777, onion_address: null, enabled: false, unauthenticated: true, protocol: false }, + ], + }, + }) + } + case 'node.tor-address': { return res.json({ result: { @@ -1815,6 +2098,88 @@ app.post('/rpc/v1', (req, res) => { }) } + case 'mesh.deadman-status': { + return res.json({ + result: { + dead_man_enabled: false, + dead_man_interval_secs: 21600, + time_remaining_secs: 21600, + triggered: false, + has_gps: false, + emergency_contacts: 2, + }, + }) + } + + case 'mesh.deadman-configure': { + const { enabled, interval_secs } = params || {} + console.log(`[Mesh] Deadman configured: enabled=${enabled}, interval=${interval_secs}`) + return res.json({ + result: { + dead_man_enabled: enabled ?? false, + dead_man_interval_secs: interval_secs ?? 21600, + time_remaining_secs: interval_secs ?? 21600, + triggered: false, + has_gps: false, + emergency_contacts: 2, + }, + }) + } + + case 'mesh.deadman-checkin': { + return res.json({ + result: { + checked_in: true, + time_remaining_secs: 21600, + }, + }) + } + + case 'mesh.block-headers': { + return res.json({ + result: { + headers: [ + { height: 893421, hash: '0000000000000000000234a6b12dc03e5c4f7e891d2f34b5a678cd9012345678', timestamp: new Date(Date.now() - 600000).toISOString() }, + { height: 893420, hash: '00000000000000000001bc7d89ef01234567890abcdef1234567890abcdef12', timestamp: new Date(Date.now() - 1200000).toISOString() }, + { height: 893419, hash: '00000000000000000003ef4a56789012bcdef34567890abcdef1234567890ab', timestamp: new Date(Date.now() - 1800000).toISOString() }, + ], + latest_height: 893421, + count: 3, + }, + }) + } + + case 'mesh.relay-tx': { + return res.json({ + result: { + request_id: Math.floor(Math.random() * 10000), + queued: true, + tx_hex_len: (params?.tx_hex || '').length, + }, + }) + } + + case 'mesh.relay-status': { + return res.json({ + result: { + relayed: true, + pending_count: 0, + status: 'confirmed', + txid: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + }, + }) + } + + case 'mesh.relay-lightning': { + return res.json({ + result: { + request_id: Math.floor(Math.random() * 10000), + queued: true, + amount_sats: params?.amount_sats || 0, + }, + }) + } + // ===================================================================== // Transport Layer (unified routing: mesh > lan > tor) // ===================================================================== @@ -2205,6 +2570,133 @@ app.post('/rpc/v1', (req, res) => { mockState.analyticsEnabled = false return res.json({ result: { enabled: false } }) } + case 'telemetry.fleet-status': { + return res.json({ + result: { + nodes: [ + { + node_id: 'archy-228', + version: '0.1.0', + uptime_secs: 604800, + cpu_cores: 4, + cpu_pct: +(15 + Math.random() * 20).toFixed(1), + mem_pct: +(38 + Math.random() * 10).toFixed(1), + disk_pct: 34.2, + container_count: 12, + running_count: 10, + federation_peers: 4, + recent_alerts: [], + containers: [ + { id: 'bitcoin', state: 'running', version: '27.0' }, + { id: 'lnd', state: 'running', version: '0.18.0' }, + { id: 'electrs', state: 'running', version: '0.10.6' }, + { id: 'mempool', state: 'running', version: '3.0.0' }, + ], + reported_at: new Date().toISOString(), + }, + { + node_id: 'archy-198', + version: '0.1.0', + uptime_secs: 259200, + cpu_cores: 4, + cpu_pct: +(8 + Math.random() * 12).toFixed(1), + mem_pct: +(25 + Math.random() * 8).toFixed(1), + disk_pct: 22.7, + container_count: 8, + running_count: 7, + federation_peers: 4, + recent_alerts: [{ rule: 'container_crash', message: 'electrs restarted 2x in 1h', timestamp: new Date(Date.now() - 7200000).toISOString() }], + containers: [ + { id: 'bitcoin', state: 'running', version: '27.0' }, + { id: 'lnd', state: 'running', version: '0.18.0' }, + { id: 'electrs', state: 'running', version: '0.10.6' }, + ], + reported_at: new Date(Date.now() - 60000).toISOString(), + }, + { + node_id: 'arch-1', + version: '0.1.0', + uptime_secs: 172800, + cpu_cores: 2, + cpu_pct: +(5 + Math.random() * 10).toFixed(1), + mem_pct: +(45 + Math.random() * 15).toFixed(1), + disk_pct: 61.3, + container_count: 6, + running_count: 6, + federation_peers: 4, + recent_alerts: [], + containers: [ + { id: 'bitcoin', state: 'running', version: '27.0' }, + { id: 'lnd', state: 'running', version: '0.18.0' }, + ], + reported_at: new Date(Date.now() - 120000).toISOString(), + }, + { + node_id: 'arch-2', + version: '0.1.0', + uptime_secs: 86400, + cpu_cores: 2, + cpu_pct: +(3 + Math.random() * 8).toFixed(1), + mem_pct: +(30 + Math.random() * 10).toFixed(1), + disk_pct: 18.9, + container_count: 5, + running_count: 5, + federation_peers: 4, + recent_alerts: [], + containers: [ + { id: 'bitcoin', state: 'running', version: '27.0' }, + ], + reported_at: new Date(Date.now() - 300000).toISOString(), + }, + { + node_id: 'arch-3', + version: '0.1.0', + uptime_secs: 43200, + cpu_cores: 4, + cpu_pct: +(20 + Math.random() * 15).toFixed(1), + mem_pct: +(55 + Math.random() * 10).toFixed(1), + disk_pct: 47.5, + container_count: 10, + running_count: 9, + federation_peers: 4, + recent_alerts: [{ rule: 'disk_warning', message: 'Disk usage approaching 50%', timestamp: new Date(Date.now() - 3600000).toISOString() }], + containers: [ + { id: 'bitcoin', state: 'running', version: '27.0' }, + { id: 'lnd', state: 'running', version: '0.18.0' }, + { id: 'electrs', state: 'running', version: '0.10.6' }, + { id: 'mempool', state: 'running', version: '3.0.0' }, + ], + reported_at: new Date(Date.now() - 30000).toISOString(), + }, + ], + }, + }) + } + + case 'telemetry.fleet-alerts': { + return res.json({ + result: { + alerts: [ + { node_id: 'archy-198', rule: 'container_crash', message: 'electrs restarted 2x in 1h', timestamp: new Date(Date.now() - 7200000).toISOString() }, + { node_id: 'arch-3', rule: 'disk_warning', message: 'Disk usage approaching 50%', timestamp: new Date(Date.now() - 3600000).toISOString() }, + { node_id: 'arch-1', rule: 'mem_high', message: 'Memory usage above 60%', timestamp: new Date(Date.now() - 86400000).toISOString() }, + ], + }, + }) + } + + case 'telemetry.fleet-node-history': { + const nodeId = params?.node_id || 'archy-228' + const now = Date.now() + const history = Array.from({ length: 24 }, (_, i) => ({ + timestamp: new Date(now - (23 - i) * 3600000).toISOString(), + cpu_pct: +(10 + Math.random() * 30).toFixed(1), + mem_pct: +(30 + Math.random() * 20).toFixed(1), + disk_pct: +(30 + Math.random() * 5).toFixed(1), + })) + return res.json({ result: { history } }) + } + case 'analytics.get-snapshot': case 'telemetry.report': { return res.json({ result: { diff --git a/neode-ui/package.json b/neode-ui/package.json index eab86cfb..1a759ec1 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -9,7 +9,7 @@ "test": "vitest run", "test:watch": "vitest", "dev": "vite", - "dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"", + "dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"", "dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"", "dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite", "backend:mock": "node mock-backend.js", diff --git a/neode-ui/src/components/EasyHome.vue b/neode-ui/src/components/EasyHome.vue index f8486ab8..5e26cf7c 100644 --- a/neode-ui/src/components/EasyHome.vue +++ b/neode-ui/src/components/EasyHome.vue @@ -68,7 +68,7 @@ const APP_ICON_MAP: Record = { 'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp', lnd: '/assets/img/app-icons/lnd.svg', 'btcpay-server': '/assets/img/app-icons/btcpay-server.png', - immich: '/assets/img/app-icons/immich.png', + filebrowser: '/assets/img/app-icons/file-browser.webp', nextcloud: '/assets/img/app-icons/nextcloud.webp', fedimint: '/assets/img/app-icons/fedimint.png', mempool: '/assets/img/app-icons/mempool.webp', diff --git a/neode-ui/src/composables/useMarketplaceApp.ts b/neode-ui/src/composables/useMarketplaceApp.ts index 4e0016ab..cf6435c4 100644 --- a/neode-ui/src/composables/useMarketplaceApp.ts +++ b/neode-ui/src/composables/useMarketplaceApp.ts @@ -6,7 +6,7 @@ export interface MarketplaceAppInfo { version: string icon: string category: string - description: string | { short: string; long: string } + description: string | { short?: string; long?: string } author: string source: string manifestUrl: string diff --git a/neode-ui/src/data/goals.ts b/neode-ui/src/data/goals.ts index 474a49fe..0d0288b4 100644 --- a/neode-ui/src/data/goals.ts +++ b/neode-ui/src/data/goals.ts @@ -80,37 +80,30 @@ export const GOALS: GoalDefinition[] = [ difficulty: 'beginner', }, { - id: 'store-photos', - title: 'Store My Photos', - subtitle: 'Private photo backup and gallery on your own hardware', - icon: 'photos', + id: 'file-browser', + title: 'File Browser', + subtitle: 'Browse, upload, and manage files on your server', + icon: 'files', category: 'storage', - requiredApps: ['immich'], + requiredApps: ['filebrowser'], steps: [ { - id: 'install-immich', - title: 'Install Immich', - description: 'Immich is a self-hosted photo and video management solution. It looks and feels like Google Photos, but your data stays on your server.', - appId: 'immich', + id: 'install-filebrowser', + title: 'Install FileBrowser', + description: 'FileBrowser is a lightweight web file manager. Upload, download, and organize files on your server from any browser.', + appId: 'filebrowser', action: 'install', isAutomatic: true, }, { - id: 'configure-immich', - title: 'Create Your Account', - description: 'Set up your Immich account and configure your photo library. Quick and simple.', + id: 'configure-filebrowser', + title: 'Log In', + description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.', action: 'configure', isAutomatic: false, }, - { - id: 'mobile-sync', - title: 'Connect Your Phone', - description: 'Download the Immich app on your phone and scan the QR code to start automatic photo backup.', - action: 'info', - isAutomatic: false, - }, ], - estimatedTime: '~15 min', + estimatedTime: '~5 min', difficulty: 'beginner', }, { diff --git a/neode-ui/src/stores/app.ts b/neode-ui/src/stores/app.ts index 527f604c..912b53c9 100644 --- a/neode-ui/src/stores/app.ts +++ b/neode-ui/src/stores/app.ts @@ -1,342 +1,69 @@ -// Main application store using Pinia +// Facade store — re-exports auth, sync, and server stores for backward compatibility. +// All 29+ files that import useAppStore() continue to work without changes. +// Uses defineStore with computed/writableComputed to preserve full reactivity. -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { DataModel } from '../types/api' -import { wsClient, applyDataPatch } from '../api/websocket' -import { rpcClient } from '../api/rpc-client' +import { defineStore, storeToRefs } from 'pinia' +import { useAuthStore } from './auth' +import { useSyncStore } from './sync' +import { useServerStore } from './server' export const useAppStore = defineStore('app', () => { - // State - const data = ref(null) - const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true') - const isConnected = ref(false) - const isReconnecting = ref(false) - const isLoading = ref(false) - const error = ref(null) - let isWsSubscribed = false - let isWsConnecting = false - let sessionValidated = false + const auth = useAuthStore() + const sync = useSyncStore() + const server = useServerStore() - // Computed - const serverInfo = computed(() => data.value?.['server-info']) - const packages = computed(() => data.value?.['package-data'] || {}) - const peerHealth = computed>(() => data.value?.['peer-health'] || {}) - const uiData = computed(() => data.value?.ui) - const serverName = computed(() => serverInfo.value?.name || 'Archipelago') - const isRestarting = computed(() => serverInfo.value?.['status-info']?.restarting || false) - const isShuttingDown = computed(() => serverInfo.value?.['status-info']?.['shutting-down'] || false) - const isOffline = computed(() => !isConnected.value || isRestarting.value || isShuttingDown.value) + // Writable refs — delegate reads and writes to the sub-stores + const { isAuthenticated, isLoading, error } = storeToRefs(auth) + const { data, isConnected, isReconnecting } = storeToRefs(sync) - // Actions - async function login(password: string): Promise<{ requires_totp?: boolean }> { - isLoading.value = true - error.value = null - - try { - const result = await rpcClient.login(password) - if (result && result.requires_totp) { - return { requires_totp: true } - } - - isAuthenticated.value = true - sessionValidated = true - try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ } - - // Initialize data structure immediately so dashboard can render - await initializeData() - - // Connect WebSocket in background - don't block login flow - connectWebSocket().catch((err) => { - if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err) - }) - return {} - } catch (err) { - error.value = err instanceof Error ? err.message : 'Login failed' - throw err - } finally { - isLoading.value = false - } - } - - async function completeLoginAfterTotp(): Promise { - isAuthenticated.value = true - sessionValidated = true - try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ } - await initializeData() - connectWebSocket().catch((err) => { - if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err) - }) - } - - async function logout(): Promise { - try { - await rpcClient.logout() - } catch (err) { - if (import.meta.env.DEV) console.error('Logout error:', err) - } finally { - isAuthenticated.value = false - sessionValidated = false - localStorage.removeItem('neode-auth') - data.value = null - isWsSubscribed = false - wsClient.disconnect() - isConnected.value = false - isReconnecting.value = false - } - } - - async function connectWebSocket(): Promise { - // Prevent concurrent connection attempts - if (isWsConnecting) return - isWsConnecting = true - - try { - if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...') - isReconnecting.value = true - - // Don't create multiple subscriptions - check if already subscribed - if (!isWsSubscribed) { - // Subscribe to updates BEFORE connecting (so we catch initial data) - isWsSubscribed = true - - // Listen for connection state changes - wsClient.onConnectionStateChange((state) => { - if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state) - isConnected.value = state === 'connected' - isReconnecting.value = state === 'connecting' - }) - - wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => { - // Handle mock backend format: {type: 'initial', data: {...}} - if (update?.type === 'initial' && update?.data) { - if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend') - data.value = update.data - isConnected.value = true - isReconnecting.value = false - } - // Handle real backend format: {rev: 0, data: {...}} - else if (update?.data && update?.rev !== undefined) { - data.value = update.data - isConnected.value = true - isReconnecting.value = false - } - // Handle patch updates (both backends) - else if (data.value && update?.patch) { - try { - if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown') - data.value = applyDataPatch(data.value, update.patch) - // Mark as connected once we receive any valid patch - if (!isConnected.value) { - isConnected.value = true - isReconnecting.value = false - } - } catch (err) { - if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err) - } - } - }) - } - - // Now connect (or reconnect if already connected) - // Only attempt to connect if not already connected - if (wsClient.isConnected()) { - if (import.meta.env.DEV) console.log('[Store] WebSocket already connected') - isConnected.value = true - isReconnecting.value = false - return - } - - await wsClient.connect() - if (import.meta.env.DEV) console.log('[Store] WebSocket connected') - - // Fetch fresh state after reconnect to avoid stale patch application - try { - const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' }) - if (freshState?.data) { - data.value = freshState.data - } - } catch { - // Non-fatal: WebSocket patches will still work - if (import.meta.env.DEV) console.warn('[Store] Failed to fetch fresh state after reconnect') - } - - // Connection state will be updated via the callback - if (wsClient.isConnected()) { - isConnected.value = true - isReconnecting.value = false - } - - } catch (err) { - if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err) - // Don't mark as disconnected immediately - let reconnection logic handle it - // The WebSocket client will retry automatically - isReconnecting.value = true - isConnected.value = false - // Don't throw - allow app to work without real-time updates - // The WebSocket will reconnect in the background - } finally { - isWsConnecting = false - } - } - - async function initializeData(): Promise { - // Initialize with empty data structure - // The WebSocket will populate it with real data - data.value = { - 'server-info': { - id: '', - version: '', - name: null, - pubkey: '', - 'status-info': { - restarting: false, - 'shutting-down': false, - updated: false, - 'backup-progress': null, - 'update-progress': null, - }, - 'lan-address': null, - 'tor-address': null, - unread: 0, - 'wifi-ssids': [], - 'zram-enabled': false, - }, - 'package-data': {}, - ui: { - name: null, - 'ack-welcome': '', - marketplace: { - 'selected-hosts': [], - 'known-hosts': {}, - }, - theme: 'dark', - }, - } - } - - // Check session validity on app load or stale auth - async function checkSession(): Promise { - if (!localStorage.getItem('neode-auth')) { - return false - } - - try { - await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } }) - isAuthenticated.value = true - sessionValidated = true - - await initializeData() - - connectWebSocket().catch((err) => { - if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err) - isReconnecting.value = true - }) - - return true - } catch (err) { - if (import.meta.env.DEV) console.error('[Store] Session check failed:', err) - localStorage.removeItem('neode-auth') - isAuthenticated.value = false - sessionValidated = false - isWsSubscribed = false - isConnected.value = false - isReconnecting.value = false - wsClient.disconnect() - return false - } - } - - function needsSessionValidation(): boolean { - return isAuthenticated.value && !sessionValidated - } - - // Package actions - async function installPackage(id: string, marketplaceUrl: string, version: string): Promise { - return rpcClient.installPackage(id, marketplaceUrl, version) - } - - async function uninstallPackage(id: string): Promise { - return rpcClient.uninstallPackage(id) - } - - async function startPackage(id: string): Promise { - return rpcClient.startPackage(id) - } - - async function stopPackage(id: string): Promise { - return rpcClient.stopPackage(id) - } - - async function restartPackage(id: string): Promise { - return rpcClient.restartPackage(id) - } - - // Server actions - async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> { - return rpcClient.updateServer(marketplaceUrl) - } - - async function restartServer(): Promise { - return rpcClient.restartServer() - } - - async function shutdownServer(): Promise { - return rpcClient.shutdownServer() - } - - async function getMetrics(): Promise> { - return rpcClient.getMetrics() - } - - // Marketplace actions - async function getMarketplace(url: string): Promise> { - return rpcClient.getMarketplace(url) - } - - function updateServerName(name: string) { - if (data.value?.['server-info']) { - data.value['server-info'].name = name - } - } + // Read-only computed — delegate to sub-stores + const { serverInfo, packages, peerHealth, uiData } = storeToRefs(sync) + const { serverName, isRestarting, isShuttingDown, isOffline } = storeToRefs(server) return { - // State - data, + // Auth state (writable refs) isAuthenticated, - isConnected, - isReconnecting, isLoading, error, - // Computed + // Sync state (writable refs) + data, + isConnected, + isReconnecting, + + // Sync computed (read-only) serverInfo, packages, peerHealth, uiData, + + // Server computed (read-only) serverName, isRestarting, isShuttingDown, isOffline, - // Actions - login, - completeLoginAfterTotp, - logout, - checkSession, - needsSessionValidation, - connectWebSocket, - installPackage, - uninstallPackage, - startPackage, - stopPackage, - restartPackage, - updateServer, - restartServer, - shutdownServer, - getMetrics, - getMarketplace, - updateServerName, + // Auth actions + login: auth.login, + completeLoginAfterTotp: auth.completeLoginAfterTotp, + logout: auth.logout, + checkSession: auth.checkSession, + needsSessionValidation: auth.needsSessionValidation, + + // Sync actions + connectWebSocket: sync.connectWebSocket, + + // Server actions + installPackage: server.installPackage, + uninstallPackage: server.uninstallPackage, + startPackage: server.startPackage, + stopPackage: server.stopPackage, + restartPackage: server.restartPackage, + updateServer: server.updateServer, + restartServer: server.restartServer, + shutdownServer: server.shutdownServer, + getMetrics: server.getMetrics, + getMarketplace: server.getMarketplace, + updateServerName: server.updateServerName, } }) - diff --git a/neode-ui/src/stores/auth.ts b/neode-ui/src/stores/auth.ts new file mode 100644 index 00000000..fd718b33 --- /dev/null +++ b/neode-ui/src/stores/auth.ts @@ -0,0 +1,121 @@ +// Authentication store — login, logout, session management + +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { rpcClient } from '../api/rpc-client' +import { useSyncStore } from './sync' + +export const useAuthStore = defineStore('auth', () => { + // State + const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true') + const isLoading = ref(false) + const error = ref(null) + let sessionValidated = false + + // Actions + async function login(password: string): Promise<{ requires_totp?: boolean }> { + isLoading.value = true + error.value = null + + try { + const result = await rpcClient.login(password) + if (result && result.requires_totp) { + return { requires_totp: true } + } + + isAuthenticated.value = true + sessionValidated = true + try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ } + + const sync = useSyncStore() + + // Initialize data structure immediately so dashboard can render + await sync.initializeData() + + // Connect WebSocket in background - don't block login flow + sync.connectWebSocket().catch((err) => { + if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err) + }) + return {} + } catch (err) { + error.value = err instanceof Error ? err.message : 'Login failed' + throw err + } finally { + isLoading.value = false + } + } + + async function completeLoginAfterTotp(): Promise { + isAuthenticated.value = true + sessionValidated = true + try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ } + + const sync = useSyncStore() + await sync.initializeData() + sync.connectWebSocket().catch((err) => { + if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err) + }) + } + + async function logout(): Promise { + const sync = useSyncStore() + try { + await rpcClient.logout() + } catch (err) { + if (import.meta.env.DEV) console.error('Logout error:', err) + } finally { + isAuthenticated.value = false + sessionValidated = false + localStorage.removeItem('neode-auth') + sync.resetOnLogout() + } + } + + async function checkSession(): Promise { + if (!localStorage.getItem('neode-auth')) { + return false + } + + try { + await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } }) + isAuthenticated.value = true + sessionValidated = true + + const sync = useSyncStore() + await sync.initializeData() + + sync.connectWebSocket().catch((err) => { + if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err) + }) + + return true + } catch (err) { + if (import.meta.env.DEV) console.error('[Store] Session check failed:', err) + localStorage.removeItem('neode-auth') + isAuthenticated.value = false + sessionValidated = false + + const sync = useSyncStore() + sync.resetOnLogout() + return false + } + } + + function needsSessionValidation(): boolean { + return isAuthenticated.value && !sessionValidated + } + + return { + // State + isAuthenticated, + isLoading, + error, + + // Actions + login, + completeLoginAfterTotp, + logout, + checkSession, + needsSessionValidation, + } +}) diff --git a/neode-ui/src/stores/server.ts b/neode-ui/src/stores/server.ts new file mode 100644 index 00000000..0567a805 --- /dev/null +++ b/neode-ui/src/stores/server.ts @@ -0,0 +1,86 @@ +// Server store — computed server state and RPC action proxies + +import { defineStore } from 'pinia' +import { computed } from 'vue' +import { rpcClient } from '../api/rpc-client' +import { useSyncStore } from './sync' + +export const useServerStore = defineStore('server', () => { + const sync = useSyncStore() + + // Computed — derived from sync store's data + const serverName = computed(() => sync.serverInfo?.name || 'Archipelago') + const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false) + const isShuttingDown = computed(() => sync.serverInfo?.['status-info']?.['shutting-down'] || false) + const isOffline = computed(() => !sync.isConnected || isRestarting.value || isShuttingDown.value) + + // Package actions + async function installPackage(id: string, marketplaceUrl: string, version: string): Promise { + return rpcClient.installPackage(id, marketplaceUrl, version) + } + + async function uninstallPackage(id: string): Promise { + return rpcClient.uninstallPackage(id) + } + + async function startPackage(id: string): Promise { + return rpcClient.startPackage(id) + } + + async function stopPackage(id: string): Promise { + return rpcClient.stopPackage(id) + } + + async function restartPackage(id: string): Promise { + return rpcClient.restartPackage(id) + } + + // Server actions + async function updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> { + return rpcClient.updateServer(marketplaceUrl) + } + + async function restartServer(): Promise { + return rpcClient.restartServer() + } + + async function shutdownServer(): Promise { + return rpcClient.shutdownServer() + } + + async function getMetrics(): Promise> { + return rpcClient.getMetrics() + } + + // Marketplace actions + async function getMarketplace(url: string): Promise> { + return rpcClient.getMarketplace(url) + } + + function updateServerName(name: string) { + if (sync.data?.['server-info']) { + sync.data['server-info'].name = name + } + } + + return { + // Computed + serverName, + isRestarting, + isShuttingDown, + isOffline, + + // Actions + installPackage, + uninstallPackage, + startPackage, + stopPackage, + restartPackage, + updateServer, + restartServer, + shutdownServer, + getMetrics, + getMarketplace, + updateServerName, + } +}) diff --git a/neode-ui/src/stores/sync.ts b/neode-ui/src/stores/sync.ts new file mode 100644 index 00000000..4e2652be --- /dev/null +++ b/neode-ui/src/stores/sync.ts @@ -0,0 +1,179 @@ +// Sync store — WebSocket connection, real-time data, patch application + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { DataModel } from '../types/api' +import { wsClient, applyDataPatch } from '../api/websocket' +import { rpcClient } from '../api/rpc-client' + +export const useSyncStore = defineStore('sync', () => { + // State + const data = ref(null) + const isConnected = ref(false) + const isReconnecting = ref(false) + let isWsSubscribed = false + let isWsConnecting = false + + // Computed + const serverInfo = computed(() => data.value?.['server-info']) + const packages = computed(() => data.value?.['package-data'] || {}) + const peerHealth = computed>(() => data.value?.['peer-health'] || {}) + const uiData = computed(() => data.value?.ui) + + // Actions + async function connectWebSocket(): Promise { + // Prevent concurrent connection attempts + if (isWsConnecting) return + isWsConnecting = true + + try { + if (import.meta.env.DEV) console.log('[Store] Connecting WebSocket...') + isReconnecting.value = true + + // Don't create multiple subscriptions - check if already subscribed + if (!isWsSubscribed) { + // Subscribe to updates BEFORE connecting (so we catch initial data) + isWsSubscribed = true + + // Listen for connection state changes + wsClient.onConnectionStateChange((state) => { + if (import.meta.env.DEV) console.log('[Store] WebSocket connection state changed:', state) + isConnected.value = state === 'connected' + isReconnecting.value = state === 'connecting' + }) + + wsClient.subscribe((update: { type?: string; data?: DataModel; rev?: number; patch?: import('@/types/api').PatchOperation[] }) => { + // Handle mock backend format: {type: 'initial', data: {...}} + if (update?.type === 'initial' && update?.data) { + if (import.meta.env.DEV) console.log('[Store] Received initial data from mock backend') + data.value = update.data + isConnected.value = true + isReconnecting.value = false + } + // Handle real backend format: {rev: 0, data: {...}} + else if (update?.data && update?.rev !== undefined) { + data.value = update.data + isConnected.value = true + isReconnecting.value = false + } + // Handle patch updates (both backends) + else if (data.value && update?.patch) { + try { + if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown') + data.value = applyDataPatch(data.value, update.patch) + // Mark as connected once we receive any valid patch + if (!isConnected.value) { + isConnected.value = true + isReconnecting.value = false + } + } catch (err) { + if (import.meta.env.DEV) console.error('[Store] Failed to apply WebSocket patch:', err) + } + } + }) + } + + // Now connect (or reconnect if already connected) + // Only attempt to connect if not already connected + if (wsClient.isConnected()) { + if (import.meta.env.DEV) console.log('[Store] WebSocket already connected') + isConnected.value = true + isReconnecting.value = false + return + } + + await wsClient.connect() + if (import.meta.env.DEV) console.log('[Store] WebSocket connected') + + // Fetch fresh state after reconnect to avoid stale patch application + try { + const freshState = await rpcClient.call<{ data: DataModel }>({ method: 'server.get-state' }) + if (freshState?.data) { + data.value = freshState.data + } + } catch { + // Non-fatal: WebSocket patches will still work + if (import.meta.env.DEV) console.warn('[Store] Failed to fetch fresh state after reconnect') + } + + // Connection state will be updated via the callback + if (wsClient.isConnected()) { + isConnected.value = true + isReconnecting.value = false + } + + } catch (err) { + if (import.meta.env.DEV) console.error('[Store] WebSocket connection failed:', err) + // Don't mark as disconnected immediately - let reconnection logic handle it + // The WebSocket client will retry automatically + isReconnecting.value = true + isConnected.value = false + // Don't throw - allow app to work without real-time updates + // The WebSocket will reconnect in the background + } finally { + isWsConnecting = false + } + } + + async function initializeData(): Promise { + // Initialize with empty data structure + // The WebSocket will populate it with real data + data.value = { + 'server-info': { + id: '', + version: '', + name: null, + pubkey: '', + 'status-info': { + restarting: false, + 'shutting-down': false, + updated: false, + 'backup-progress': null, + 'update-progress': null, + }, + 'lan-address': null, + 'tor-address': null, + unread: 0, + 'wifi-ssids': [], + 'zram-enabled': false, + }, + 'package-data': {}, + ui: { + name: null, + 'ack-welcome': '', + marketplace: { + 'selected-hosts': [], + 'known-hosts': {}, + }, + theme: 'dark', + }, + } + } + + /** Reset sync state on logout — called by auth store */ + function resetOnLogout(): void { + data.value = null + isWsSubscribed = false + wsClient.disconnect() + isConnected.value = false + isReconnecting.value = false + } + + return { + // State + data, + isConnected, + isReconnecting, + + // Computed + serverInfo, + packages, + peerHealth, + uiData, + + // Actions + connectWebSocket, + initializeData, + resetOnLogout, + } +}) diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index 65cfbae3..cad8afa5 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -2169,3 +2169,58 @@ html:has(body.video-background-active)::before { } } +/* ========================================================================= + Mesh Bitcoin & Deadman Panels (child components of Mesh.vue) + ========================================================================= */ +.mesh-bitcoin-panel, +.mesh-deadman-panel { padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; } +.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; } +.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; } +.mesh-bitcoin-section-header { display: flex; justify-content: space-between; align-items: center; } +.mesh-bitcoin-label { font-size: 0.75rem; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; } +.mesh-bitcoin-height { font-size: 0.85rem; font-weight: 700; color: #fb923c; font-family: monospace; } +.mesh-bitcoin-height.mesh-muted { color: rgba(255,255,255,0.3); font-weight: 400; } +.mesh-bitcoin-hint { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: 0; } +.mesh-bitcoin-input { width: 100%; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: rgba(255,255,255,0.9); padding: 10px 12px; font-size: 0.85rem; font-family: inherit; outline: none; box-sizing: border-box; } +.mesh-bitcoin-input:focus { border-color: rgba(251,146,60,0.4); } +.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.25); } +.mesh-bitcoin-input-sm { padding: 8px 12px; font-size: 0.8rem; } +textarea.mesh-bitcoin-input { resize: vertical; min-height: 60px; } +select.mesh-bitcoin-input { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='rgba(255,255,255,0.4)' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; } +select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,0.9); } +.mesh-bitcoin-advanced { margin-top: 4px; } +.mesh-bitcoin-advanced summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; } +.mesh-bitcoin-advanced summary::before { content: '\25B6'; font-size: 0.6rem; color: rgba(255,255,255,0.4); transition: transform 0.2s; } +.mesh-bitcoin-advanced[open] summary::before { transform: rotate(90deg); } +.mesh-block-list { display: flex; flex-direction: column; gap: 4px; } +.mesh-block-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 6px; } +.mesh-block-height { font-size: 0.8rem; font-weight: 600; color: #a855f7; font-family: monospace; } +.mesh-block-hash { font-size: 0.7rem; color: rgba(255,255,255,0.35); font-family: monospace; } +.mesh-send-tabs { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 2px; } +.mesh-send-tab { flex: 1; padding: 6px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.8rem; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all 0.2s; } +.mesh-send-tab:hover { color: rgba(255,255,255,0.8); } +.mesh-send-tab.active { color: #fff; background: rgba(255,255,255,0.1); } +.mesh-relay-mode { display: flex; gap: 4px; flex-wrap: wrap; } +.mesh-relay-mode-option { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; color: rgba(255,255,255,0.6); transition: all 0.15s; } +.mesh-relay-mode-option.active { color: rgba(255,255,255,0.9); } +.mesh-relay-mode-option small { color: rgba(255,255,255,0.35); font-size: 0.7rem; } +.mesh-relay-mode-option input[type="radio"] { accent-color: #fb923c; } +.mesh-relay-result { padding: 8px 12px; border-radius: 8px; font-size: 0.8rem; } +.mesh-relay-result.success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #4ade80; } +.mesh-relay-result.error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; } + +/* Deadman panel */ +.mesh-deadman-status { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: rgba(0,0,0,0.2); border-radius: 10px; } +.mesh-deadman-indicator { display: inline-flex; align-items: center; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 4px 10px; border-radius: 6px; width: fit-content; } +.mesh-deadman-indicator.armed { background: rgba(251,146,60,0.15); color: #fb923c; border: 1px solid rgba(251,146,60,0.3); } +.mesh-deadman-indicator.disabled { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.08); } +.mesh-deadman-indicator.triggered { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); animation: pulse-alert 1.5s infinite; } +.mesh-deadman-timer { font-size: 1.8rem; font-weight: 700; color: #fb923c; font-family: monospace; } +.mesh-deadman-message { font-size: 0.8rem; color: rgba(255,255,255,0.5); font-style: italic; } +.mesh-deadman-checkin-btn { margin-top: 4px; } +.mesh-deadman-config { display: flex; flex-direction: column; gap: 10px; } +.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; } +.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; } +.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); } + diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index 5ebff5ef..3ef39e73 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -135,7 +135,7 @@ import { useAppStore } from '../stores/app' import { useAppLauncherStore } from '../stores/appLauncher' import { useModalKeyboard } from '@/composables/useModalKeyboard' import { dummyApps } from '../utils/dummyApps' -import rpcClient from '@/api/rpc-client' +import { rpcClient } from '@/api/rpc-client' import AppHeroSection from './appDetails/AppHeroSection.vue' import AppContentSection from './appDetails/AppContentSection.vue' import AppSidebar from './appDetails/AppSidebar.vue' @@ -266,7 +266,7 @@ const backButtonText = computed(() => { const canLaunch = computed(() => { if (!pkg.value) return false if (isWebOnly.value) return true - const hasUI = pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main + const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || pkg.value.installed?.['interface-addresses']?.main) const isRunning = pkg.value.state === 'running' return hasUI && isRunning }) diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 9c747716..2df34578 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -10,178 +10,39 @@ :class="panelClasses" @click.stop > - -
      - -
      - - -
      + - {{ appTitle }} - - - - -
      - - - - -
      - - - -
      -
      -
      - - - - - - -
      - - -
      - -
      - - - - -
      -
      - -