diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs
index 968c0af4..f9befff0 100644
--- a/core/archipelago/src/container/docker_packages.rs
+++ b/core/archipelago/src/container/docker_packages.rs
@@ -402,7 +402,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
"photoprism" => AppMetadata {
title: "PhotoPrism".to_string(),
description: "AI-powered photo management".to_string(),
- icon: "/assets/img/app-icons/photoprims.svg".to_string(),
+ icon: "/assets/img/app-icons/photoprism.svg".to_string(),
repo: "https://github.com/photoprism/photoprism".to_string(),
tier: "",
},
diff --git a/loop/plan.md b/loop/plan.md
index a32d1855..900fa6ad 100644
--- a/loop/plan.md
+++ b/loop/plan.md
@@ -1,473 +1,245 @@
-# Archipelago 5-Year Production Hardening Plan
+# Overnight Plan — Archy Refactoring & App Integration Hardening
-**Version**: 2.0
-**Period**: March 2026 -- March 2031
-**Goal**: Production-ready Bitcoin Node OS at 10,000 users with zero failures, 100% uptime, full inter-node federation
-**Visual constraint**: NEVER change animations, user experience, or flow -- only clean up duplications, information hierarchy, and cosmetic issues
-**Web5 additions**: did:dht, DWN protocol definitions for interoperable schemas, Verifiable Credentials (per TBD assessment)
-
-**Primary test node**: `192.168.1.228` (Arch 1) — 4-core i3-8100T, 16GB RAM, 1.8TB NVMe
-**Secondary test node**: `192.168.1.198` (Arch 2) — 8GB RAM, 457GB disk
-**SSH**: `ssh -i ~/.ssh/archipelago-deploy archipelago@{IP}`
-**Deploy**: `./scripts/deploy-to-target.sh --both`
+> Make the Archy codebase rock-solid: fix all broken containers/iframes, perfect app installation/management/icons, get IndeedHub + Nostr signer flawless, and begin critical refactoring. No new features, no design changes. Bitcoin only.
+> See `docs/refactoring-plan.md` for the full 3-year plan. See `CLAUDE.md` for all project rules and conventions.
+> Deploy after every change: `./scripts/deploy-to-target.sh --live` — test at http://192.168.1.228
---
-## SECURITY RULE: No Tor Address Publishing to Nostr Relays (2026-03-13)
+## Phase 1: Fix App Icon Consistency
-**NEVER publish .onion addresses to public Nostr relays.** This was removed on 2026-03-13 because broadcasting Tor addresses to public relays defeats the purpose of Tor's privacy. All `publish_node_identity` calls have been removed from:
-- `tor.rs` — address rotation no longer publishes to relays
-- `node.rs` — `node.nostr-publish` RPC now returns an error
-- `network.rs` — visibility changes no longer publish to relays
+- [x] **Fix PhotoPrism icon typo in backend metadata**: In `core/archipelago/src/container/docker_packages.rs`, the `get_app_metadata()` function references `photoprims.svg` (missing 'h') for the PhotoPrism icon. Search for `photoprims` and replace with `photoprism`. Verify the icon file exists at `neode-ui/public/assets/img/app-icons/photoprism.svg`. Run `cargo clippy --all-targets --all-features` in `core/` on the dev server after the fix.
-Nodes connect via **federation ID** (DID), not public Nostr discovery. Federation peer notification (private peer-to-peer) is still allowed.
+- [ ] **Fix IndeedHub duplicate icon — consolidate to indeedhub.png**: Two icon files exist: `neode-ui/public/assets/img/app-icons/indeedhub.ico` and `indeehub.ico` (typo). Delete `indeehub.ico`. Convert `indeedhub.ico` to `indeedhub.png` (better format consistency). Update all references: (1) `neode-ui/src/utils/dummyApps.ts` line ~518 — change `indeehub.ico` to `indeedhub.png`, (2) `neode-ui/src/views/Marketplace.vue` line ~913 — change `indeehub.ico` to `indeedhub.png`, (3) `core/archipelago/src/container/docker_packages.rs` lines ~451-454 — change `indeehub.ico` to `indeedhub.png`. Search the entire codebase for `indeehub` (missing 'd') and fix all occurrences to `indeedhub`. Run `cd neode-ui && npm run type-check` to verify.
-Tor rotation now **immediately destroys** the old address (no transition period). Old keys are deleted, not renamed.
-
-All Tor addresses on .228 and .198 were rotated on 2026-03-13 to invalidate any previously published addresses.
+- [ ] **Audit all app icons match their references**: Cross-check every icon path referenced in `docker_packages.rs` `get_app_metadata()` against actual files in `neode-ui/public/assets/img/app-icons/`. Verify each app in the `Marketplace.vue` `getCuratedAppList()` function has an icon that exists. If any icon is missing, check if a similar-named file exists (e.g., wrong extension). Fix all mismatches. Remove orphaned icons that no app references (e.g., `atob.png`, `community-store.png`, `k484.png`, `lorabell.png`, `morphos.png` — verify they're truly unused first). Standardize: prefer `.png` or `.svg` over `.ico` and `.webp` where possible without changing existing working icons.
---
-## Critical Findings from Investigation (2026-03-13)
+## Phase 2: Fix Container Crash Loops & Health
-### Server .228 Issues
-- **6 containers in crash loops**: archy-nbxplorer (3,535 restarts), archy-mempool-web (2,041), mempool-api (906), btcpay-server (888), mempool-electrs (529), immich_server (439)
-- **Root cause**: Container networking DNS failures — mempool-web can't resolve "mempool-api" upstream, nbxplorer can't connect to Postgres
-- **Load average 5.44 on 4 cores** — entirely caused by crash/restart cycles consuming CPU
-- **ollama in Created state** — never started, consuming a container slot
-- **Podman rootless warning**: "/" is not a shared mount
+- [ ] **Diagnose and fix container networking DNS failures**: SSH to 192.168.1.228 (`sshpass -p 'EwPDR8q45l0Upx@' ssh -o StrictHostKeyChecking=no archipelago@192.168.1.228`). Run `sudo podman ps -a --format '{{.Names}} {{.Status}}' | grep -i restart` to identify containers in crash loops. The known issue is DNS resolution failures — containers can't resolve each other by name (e.g., mempool-web can't find mempool-api). Check if the `archy-net` Podman network exists: `sudo podman network ls`. If missing, create it: `sudo podman network create archy-net`. Reconnect all containers that need inter-container DNS to this network. Verify with `sudo podman exec archy-mempool-web ping mempool-api`. Restart affected containers and monitor for 2 minutes to confirm no more crash loops.
-### Server .198 Issues
-- **No federation configured** — /var/lib/archipelago/federation/ is empty
-- **Tor container outdated** (v0.4.6.10) — warns "missing protocols: FlowCtrl=2 Relay=4", will eventually stop working
-- **Tor failing every 5 minutes**: "No more HSDir available to query" — can't resolve .onion addresses
-- **Memory critically low**: 147MB free of 8GB, NO SWAP configured
-- **Nostr identity revoked** — nostr_revoked file exists but empty
-- **Containers run under root** — rootless podman shows nothing, sudo podman shows 35 containers
+- [ ] **Fix .198 server swap and memory**: SSH to 192.168.1.198. Check current swap: `free -h`. If no swap configured, create a 4GB swap file: `sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile`. Add to `/etc/fstab`: `/swapfile none swap sw 0 0`. Verify with `free -h`. This prevents OOM kills that crash containers.
-### Cross-Node Issues
-- .228 → .198 HTTP health: OK (basic connectivity works)
-- .198 → .228 HTTP health: OK
-- .198 has ZERO federation peers — no nodes.json, never joined federation
-- Tor-based federation impossible from .198 — Tor can't resolve hidden services
-- No swap on either server — OOM kills likely under load
-- ping not installed on .228 (missing iputils-ping)
+- [ ] **Stop and remove ollama container if not needed**: SSH to 192.168.1.228. Check ollama status: `sudo podman ps -a | grep ollama`. If it's in "Created" state and never started, remove it: `sudo podman rm ollama`. This frees a container slot and removes clutter from the app list. If the user has ollama in their installed apps, leave it but start it: `sudo podman start ollama`.
+
+- [ ] **Verify all core Bitcoin containers are healthy**: SSH to 192.168.1.228. Check these containers are running and healthy: `bitcoin-knots`, `lnd`, `mempool-api`, `archy-mempool-web`, `mempool-electrs`, `btcpay-server`, `archy-nbxplorer`. Run `sudo podman ps --format '{{.Names}}\t{{.Status}}' | grep -E "(bitcoin|lnd|mempool|btcpay|nbxplorer|electrs)"`. For any that are not "Up", check logs: `sudo podman logs --tail 50 {container-name}`. Fix the root cause (usually missing network, wrong env var, or dependency not ready). After fixes, run `curl -s http://localhost:5678/health` to verify the Archy backend sees them all.
---
-## User Stories & Acceptance Tests
+## Phase 3: Fix Iframe Embedding for All Apps
-Every test must pass **10 consecutive times** from BOTH .228→.198 AND .198→.228 directions.
+- [ ] **Audit X-Frame-Options headers for all proxied apps**: SSH to 192.168.1.228. For each app with a known port, check the actual response headers: `for port in 81 3000 3001 4080 7777 8080 8081 8082 8083 8085 8096 8123 8175 8176 8190 8240 8334 8888 9000 9001 9980 11434 2283 2342 23000 50002; do echo "Port $port:"; curl -sI http://localhost:$port/ 2>/dev/null | grep -i "x-frame\|content-security-policy" || echo " (no frame restrictions)"; done`. Record the results. Compare against the blocking list in `neode-ui/src/stores/appLauncher.ts` (lines 23-31, the `XFRAME_BLOCKED_PORTS` array). Update the blocking list to match reality — if an app no longer sends X-Frame-Options DENY, remove it from the blocked list. If an app sends it but isn't in the list, add it.
-### US-01: System Health
-> As a node operator, I want my server to boot cleanly with all services running, zero crashed containers, and stable resource usage, so I never have to manually intervene.
+- [ ] **Ensure nginx strips X-Frame-Options for iframe-compatible apps**: In `image-recipe/configs/nginx-archipelago.conf`, verify every `/app/{id}/` location block includes `proxy_hide_header X-Frame-Options;` for apps that should work in iframes. Apps that genuinely can't work in iframes (BTCPay with DENY, Home Assistant with SAMEORIGIN that rejects proxy origin) should open in new tabs. For apps like Grafana (port 3000) — check if setting the env var `GF_SECURITY_ALLOW_EMBEDDING=true` on the Grafana container fixes it, then remove it from the blocked list. For Nextcloud (port 8085) — check if the nginx `sub_filter` approach or Nextcloud's `overwriteprotocol` setting allows embedding. For Uptime Kuma (port 3001) — it may work with the header stripped. Test each by loading `http://192.168.1.228/app/{id}/` in a browser iframe or `curl -sI http://192.168.1.228/app/{id}/ | grep -i frame`.
-### US-02: Container Lifecycle
-> As a node operator, I want every installed app to start, run, survive reboots, and recover from crashes automatically, so my services are always available.
+- [ ] **Fix nginx sub_filter for apps with root-relative asset paths**: Apps served under `/app/{id}/` may have root-relative paths like `/static/main.js` that break because they resolve to the Archy root, not the app root. In `image-recipe/configs/nginx-archipelago.conf`, check IndeedHub's location block (lines 334-367) — it already uses `sub_filter` to rewrite paths. Verify the same pattern exists for other Next.js/React apps that need it (Penpot on 9001, Immich on 2283, Fedimint UI on 8175). For each, test: load the app at `http://192.168.1.228/app/{id}/`, open browser dev tools Network tab, check for 404s on static assets. If assets 404, add appropriate `sub_filter` rules to their nginx location block. After changes, sync the config: `scp image-recipe/configs/nginx-archipelago.conf archipelago@192.168.1.228:/tmp/ && ssh archipelago@192.168.1.228 'sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago && sudo nginx -t && sudo systemctl reload nginx'`.
-### US-03: Federation Join
-> As a node operator, I want to invite another node to my federation using an invite code, so we can share status and deploy apps to each other.
-
-### US-04: Federation Sync
-> As a node operator, I want to see all my federated peers' status (online/offline, apps, resources) updated every 5 minutes, so I know my network health.
-
-### US-05: Tor Hidden Services
-> As a node operator, I want each app to have a .onion address that works reliably, so my services are accessible over Tor without exposing my IP.
-
-### US-07: File Sharing
-> As a node operator, I want to share files with federated peers over Tor with access controls (free, peers-only, paid), so I can selectively distribute content.
-
-### US-08: DWN Sync
-> As a node operator, I want DWN messages and protocols to replicate bidirectionally between my federated nodes over Tor, so my decentralized data is available everywhere.
-
-### US-09: NIP-07 Signing
-> As a node operator, I want iframe apps to use window.nostr to sign events with my node's Nostr key (with consent), so I can use Nostr apps with my sovereign identity.
-
-### US-10: Backup/Restore
-> As a node operator, I want to create encrypted backups and restore them on a fresh install, so I never lose my data or identity.
-
-### US-11: Dashboard Monitoring
-> As a node operator, I want real-time CPU, RAM, disk, and container health displayed on my dashboard, so I can spot problems before they escalate.
-
-### US-12: Auto-Updates
-> As a node operator, I want my node to check for updates, download them with integrity verification, and apply them with rollback capability.
-
-### US-13: Identity & Credentials
-> As a node operator, I want W3C DID Documents and Verifiable Credentials that work with did:dht for discoverable DIDs and proper VCs for proving identity claims between nodes.
-
-### US-14: Web UI Navigation
-> As a node operator, I want every page in the UI to load correctly, show real data (not hardcoded), and navigate without broken links or dead buttons.
-
-### US-15: Boot Recovery
-> As a node operator, I want all containers to automatically restart after any reboot, crash, or power loss, with zero manual intervention required.
+- [ ] **Deploy and verify iframe loading for all apps**: Deploy with `./scripts/deploy-to-target.sh --live`. After deploy, test each app iframe by hitting the Archy UI at `http://192.168.1.228`, navigating to Apps, and clicking each installed app. Verify: (1) iframe apps load content (not blank white), (2) blocked apps open in new tab cleanly, (3) no mixed-content warnings in console. Log any remaining issues for the next phase.
---
-## Phase 1: Emergency Stabilization (Week 1-2)
+## Phase 4: IndeedHub + Nostr Signer Integration
-### Sprint 1: Stop the Crash Loops
+- [ ] **Verify IndeedHub container is running and accessible**: SSH to 192.168.1.228. Check: `sudo podman ps | grep indeedhub`. If not running, check if the image exists: `sudo podman images | grep indeedhub`. If no image, pull from manifest: the image is `git.tx1138.com/lfg2025/indeedhub:latest` (from `apps/indeedhub/manifest.yml`). Pull and start: `sudo podman pull git.tx1138.com/lfg2025/indeedhub:latest && sudo podman run -d --name indeedhub --restart unless-stopped -p 7777:3000 --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --security-opt no-new-privileges --user 1001 git.tx1138.com/lfg2025/indeedhub:latest`. Verify it responds: `curl -sI http://localhost:7777/`. Check nginx proxy works: `curl -sI http://localhost/app/indeedhub/`.
-- [x] **CRASH-01** — Fix container networking on .228. **Root cause**: UFW blocking all traffic from Podman subnets (10.88.0.0/16, 10.89.0.0/16) to host, preventing Aardvark DNS resolution. **Fix**: `ufw allow from 10.88.0.0/16` and `ufw allow from 10.89.0.0/16`. All containers on archy-net can now resolve hostnames. mempool-web stable 30+ minutes, 0 restarts.
+- [ ] **Fix IndeedHub port mapping inconsistency**: In `core/archipelago/src/container/docker_packages.rs`, line ~139-141 hardcodes `http://localhost:8190` for IndeedHub. But nginx and the frontend use port 7777. Update `docker_packages.rs` to use port 7777: change `Some("http://localhost:8190".to_string())` to `Some("http://localhost:7777".to_string())`. Also verify `apps/indeedhub/manifest.yml` — if it says port 8190, update to 7777 to match the actual deployment. In `neode-ui/src/stores/appLauncher.ts` line 67, confirm `'7777': '/app/indeedhub/'` is correct. Deploy with `./scripts/deploy-to-target.sh --live` and test.
-- [x] **CRASH-02** — Fix archy-nbxplorer Postgres connection on .228. **Same root cause as CRASH-01**: UFW blocking DNS. After UFW fix, nbxplorer resolves archy-btcpay-db hostname and connects to Postgres. Both nbxplorer and btcpay-server stable 30+ minutes.
+- [ ] **Verify nostr-provider.js injection works for IndeedHub iframe**: The NIP-07 Nostr signer works by nginx injecting `neode-ui/public/nostr-provider.js` into the iframe via `sub_filter`. Check the IndeedHub nginx location block in `image-recipe/configs/nginx-archipelago.conf` (lines 334-367) includes a `sub_filter` that injects `` into the HTML response. If missing, add: `sub_filter '' '';` with `sub_filter_once on;` and `sub_filter_types text/html;`. Sync nginx config to server and reload. Verify by loading IndeedHub in the Archy iframe and checking browser dev tools console for `window.nostr` availability — run `JSON.stringify(Object.keys(window.nostr))` in the iframe console, should show `["getPublicKey","signEvent","getRelays","nip04","nip44"]`.
-- [x] **CRASH-03** — Fix immich_server crash loop on .228. **Same root cause as CRASH-01**: UFW blocking DNS. Immich components on immich-net could not resolve each other. After UFW fix, immich_server started and is running stable 30+ minutes. Logs show successful Nest application startup on port 2283.
+- [ ] **Test full NIP-07 signing flow with IndeedHub**: Open Archy at `http://192.168.1.228`, go to Apps, click IndeedHub. Expected flow: (1) NostrIdentityPicker modal appears on first launch asking which identity to use, (2) select an identity with a Nostr key, (3) IndeedHub loads in iframe, (4) when IndeedHub requests `window.nostr.getPublicKey()`, the Archy parent responds with the selected identity's Nostr pubkey, (5) when IndeedHub requests `window.nostr.signEvent(event)`, NostrSignConsent modal appears, (6) user approves, event is signed via `identity.nostr-sign` RPC, (7) signed event returned to IndeedHub. Test each step. If NostrIdentityPicker doesn't show, check `AppSession.vue` line ~302-304 `isIdentityAwareApp()` includes 'indeedhub'. If signing fails, check RPC logs: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago --since "5 min ago" | grep -i nostr'`.
-- [x] **CRASH-04** — Removed ollama on .228. `sudo podman rm ollama`. Container gone, total count reduced from 33 to 32.
+- [ ] **Ensure IndeedHub content loads fully — all pages, media, navigation**: After the Nostr flow works, navigate through IndeedHub's content inside the iframe. Check: (1) all pages/routes load (no blank screens), (2) media content (videos, images) loads, (3) navigation within IndeedHub works without breaking the iframe, (4) no console errors related to CORS, mixed content, or CSP. If videos don't load, check if the video hosting domain is blocked by CSP headers — may need to add `Content-Security-Policy` adjustments in the nginx location block. If internal navigation causes the iframe to navigate to a bare URL (not under `/app/indeedhub/`), add `sub_filter` rules to rewrite the app's internal links.
-- [x] **CRASH-05** — Verified .228 stability. All 32 containers running, zero exited, zero new crash loops for 30+ minutes. Load avg ~5.3 (high due to 32 containers on 4-core machine, not crash loops — was same before). Memory 1.8GB available (needs swap, see STAB-02). Health checks passing.
-
-### Sprint 2: Stabilize .198
-
-- [x] **STAB-01** — Added 4GB swap on .198. Created /swapfile, added to /etc/fstab for persistence. `free -h` shows 4.0Gi swap.
-
-- [x] **STAB-02** — Added 8GB swap on .228. Recreated existing 4GB swapfile as 8GB. Added to /etc/fstab. `free -h` shows 8.0Gi swap.
-
-- [x] **STAB-03** — Updated Tor on .198 (system service, not container). Added Tor Project apt repo, upgraded from 0.4.7.16 to 0.4.9.5. Restarted service, bootstrapped 100% in 10s. No "missing protocols" warnings. Hidden service hostname readable: mq2leoozlaouf6yuab7wf5i6le4fp7d52bo4l5cp5nkxo3udbkumqtad.onion.
-
-- [x] **STAB-04** — Tor .onion resolution working on .198 after upgrade to 0.4.9.5. Local onion resolves (curl returns "OK"). Cross-node: .198 can reach .228's onion (2vbxxly...onion/health returns "OK"). "No more HSDir available" errors stopped.
-
-- [x] **STAB-05** — Nostr identity on .198 is functional. `nostr_revoked` is intentional — blocks old-style discovery that leaked onion addresses. New `publish_presence` via nostr_handshake works independently. Pubkey exists: `a37e28bc663b0eff59c954247b2a0b00e110babf50bcf3f2e080a8ba6888c03a`. 8 relays configured. Backend restarted cleanly after removing stale empty revocation file (it correctly recreated it).
-
-- [x] **STAB-06** — Federation already established between .228 and .198. Verified: .228 `federation.list-nodes` shows 2 trusted peers with today's timestamps and app lists. .198 has nodes.json (3.6KB) and peers.json with valid onion address. Password reset to `password123` on .228 for future RPC access.
-
-- [x] **STAB-07** — Rootless vs root podman on .198 is correctly aligned. Backend runs as root (systemd User=root), uses `sudo podman` via PodmanClient. Root podman shows all 34 containers. Backend's running-containers.json tracks all 34. Health monitor works.
+- [ ] **Test NIP-04 and NIP-44 encryption/decryption**: In IndeedHub (or manually via browser console in the iframe), test the encryption methods: (1) `window.nostr.nip04.encrypt(somePubkey, "test message")` — should return ciphertext, (2) `window.nostr.nip04.decrypt(somePubkey, ciphertext)` — should return "test message", (3) same for `nip44.encrypt` and `nip44.decrypt`. If any fail, check RPC handlers in `core/archipelago/src/api/rpc/identity.rs` — the `handle_identity_nostr_encrypt_nip04/nip44` and decrypt handlers (lines 428-496). Check that the identity manager has the required keys.
---
-## Phase 2: Cross-Node Test Suite (Week 3-4)
+## Phase 5: App Installation & Management Polish
-### Sprint 3: Create Bulletproof Test Harness
+- [ ] **Verify install flow for every Bitcoin-related marketplace app**: In the Archy UI at `http://192.168.1.228`, go to Marketplace. For each Bitcoin-related app (Bitcoin Knots, LND, Mempool, BTCPay, Electrs, Fedimint), click through to the detail page. Verify: (1) icon loads correctly (not fallback logo), (2) description is accurate, (3) "Install" button appears if not installed, (4) dependency warnings show correctly (Mempool requires Bitcoin Knots + Electrs, BTCPay requires Bitcoin Knots), (5) if already installed, status shows correctly. Fix any issues found in `neode-ui/src/views/MarketplaceAppDetails.vue`. Note: Archy is Bitcoin only — remove any Monero or Liquid entries from `Marketplace.vue` `getCuratedAppList()` if present.
-- [x] **TEST-01** — Created `scripts/test-cross-node.sh`. TAP-format output, `--iterations N` flag, tests US-01 (health), US-05 (Tor), US-09 (NIP-07). 31/32 passed on first run. Bidirectional .228↔.198.
+- [ ] **Remove non-Bitcoin altcoin entries from marketplace**: Search `neode-ui/src/views/Marketplace.vue` for "monero", "liquid", "litecoin", or any non-Bitcoin cryptocurrency entries in the `getCuratedAppList()` function. Remove them entirely. Archy is a Bitcoin-only platform. Run `cd neode-ui && npm run type-check` after changes.
-- [x] **TEST-02** — US-01 health tests in test-cross-node.sh. All 6 checks per node (health, services, memory, load, disk, containers). Both nodes pass. .228 load dropped to 3.78 (from 5.44 pre-fix).
+- [ ] **Fix dependency checks — frontend must match backend**: In `neode-ui/src/views/MarketplaceAppDetails.vue`, find the hardcoded dependency definitions (around lines 447-456). Cross-reference with `core/archipelago/src/api/rpc/package.rs` lines 64-96 where backend dependency checks are defined. Ensure they match exactly. If backend checks for `has_bitcoin` before installing `electrs`, the frontend dependency list for `electrs` must show `bitcoin-knots` as a prerequisite. Update the frontend to match the backend. Ideally, add an RPC method `package.get-dependencies` that returns the dependency list from the backend, and have the frontend call it instead of hardcoding — but for now, just make the hardcoded lists match.
-- [x] **TEST-03** — US-02 Container Lifecycle tests added to test-cross-node.sh. Per node: (1) all-running check (zero exited), (2) container count >= 20, (3) stop filebrowser → health monitor auto-restarts within 90s (tested: .228 in 40-50s, .198 in 15-35s). .198 has pre-existing searxng exit 127 (broken entrypoint). 10/12 checks pass per run.
+- [ ] **Verify start/stop/restart works for all installed apps**: In the Archy UI, go to Apps. For each installed app, test: (1) click Stop — container stops, UI updates to "Stopped" state, (2) click Start — container starts, UI updates to "Running" state with health indicator, (3) click the app — it launches (iframe or new tab as appropriate). Check that the container store (`neode-ui/src/stores/container.ts`) correctly polls for status changes after start/stop actions. If status doesn't update, check the WebSocket state broadcasting in `core/archipelago/src/state.rs`.
-- [x] **TEST-04** — US-03 Federation Join tests added to test-cross-node.sh. Per node per iteration: (1) peers present >= 1, (2) trust_level == "trusted", (3) DID starts with "did:", (4) last_seen within 10 min. Fixed stale onion addresses in federation nodes.json on both servers (Tor rotation made old addresses unreachable). All 16/16 checks passing after fix.
-
-- [x] **TEST-05** — US-04 Federation Sync tests added to test-cross-node.sh. Per node: (1) sync-state returns results, (2) at least 1 sync succeeds, (3) synced node has apps > 0, (4) last_seen updated within 2 min after sync. .228 syncs 2 peers (23 apps each), .198 syncs 1 peer (25 apps). All 16/16 checks passing.
-
-- [x] **TEST-06** — US-05 Tor tests in test-cross-node.sh. Both directions pass: .228→.198 via Tor returns "OK", .198→.228 via Tor returns "OK". 4/4 passed (2 iterations x 2 directions).
-
-- [x] **TEST-08** — US-07 tests: File Sharing (10x). content.add, content.list-mine, content.browse-peer bidirectionally over Tor (.228↔.198). Fixed ssh_sudo compound command bug (chown ran without sudo, killed script via set -e). All 50/50 checks pass (10 iterations × 5 checks: add-A, list-A, browse-A→B, add-B, browse-B→A).
-
-- [x] **TEST-09** — US-08 tests: DWN Sync (10x). Fixed DWN sync: made sync endpoint async (background task with polling), added 90s overall timeout, deduplicated peer onion addresses, batched message pushes (50/batch), added connect_timeout, fixed HTTP handler to process all messages in batch. All 50/50 checks pass (10 iterations × 5 checks: register, write-3, sync, received-on-198, bidirectional). Each iteration completes in ~35s over Tor.
-
-- [x] **TEST-10** — US-09 NIP-07 provider injection test in test-cross-node.sh. nostr-provider.js detected in /app/mempool/ on both nodes. 4/4 passed.
-
-- [x] **TEST-11** — US-10 tests: Backup/Restore (10x). Added US-10 section to test-cross-node.sh. Tests create/list/verify/delete cycle on both nodes. Increased backup.create rate limit from 3/600 to 10/600. Cleaned up 21K+ stale DWN test messages on both nodes that were inflating backup size. All 80/80 checks pass (10 iterations × 4 checks × 2 nodes).
-
-- [x] **TEST-12** — US-15 Boot Recovery. Added US-15 section to test-cross-node.sh with `--skip-reboot` flag. **.228**: 9/9 pass — 32/32 containers survive all 3 reboots, 0 exited, health OK ~5s post-SSH. **.198**: crash recovery blocks health for 260s (34 containers × ~10s sequential); needs CONT-02. (KNOWN ISSUE: .228 unreachable after 3rd reboot — SSH/HTTP down despite ICMP. Likely UFW rules didn't persist. Needs physical access.)
+- [ ] **Fix route-to-package-key mapping divergence**: In `neode-ui/src/views/AppDetails.vue` lines 501-529, the route ID to backend container name mapping is hardcoded. Verify every mapping is correct by checking actual container names on the server: `ssh archipelago@192.168.1.228 'sudo podman ps --format "{{.Names}}"'`. Fix any mismatches. Known issues: `mempool` maps to `mempool-web` but backend may use `archy-mempool-web`. Check `electrs` maps to `mempool-electrs` or `archy-electrs`. Run `cd neode-ui && npm run type-check` after changes.
---
-## Phase 3: UI Cosmetic Cleanup (Week 5-6)
+## Phase 6: Backend Critical Fixes
-### Sprint 4: Information Hierarchy & Deduplication
+- [ ] **Fix session TTL clock bug — use SystemTime instead of Instant**: Read `core/archipelago/src/session.rs`. Find where `Instant::now()` is used for session TTL/expiry (around line 97). `Instant` is monotonic but can drift on sleep/hibernate — common on NUC/Pi hardware. Replace with `SystemTime::now()` for absolute time comparison. The `FULL_SESSION_TTL` (24 hours) and `PENDING_TOTP_TTL` (5 minutes) checks should use `SystemTime::elapsed()` or store `SystemTime` timestamps and compare with `SystemTime::now()`. Run `cargo test --all-features` in `core/` on the dev server.
-- [x] **UI-CLEAN-01** — Audited all views. Dashboard/Home: CLEAN (real RPC data). Server.vue: servicesRunning/connectivityStatus hardcoded, autoSync no backend, logCount never updated. Web5.vue: walletConnected never updated, DID status localStorage-only.
+- [ ] **Enforce RBAC in RPC handler**: Read `core/archipelago/src/auth.rs` — find the `UserRole` enum and `can_access()` method. Then read `core/archipelago/src/api/rpc/mod.rs` — find where authenticated requests are dispatched to handlers. Add a role check before dispatching: after validating the session, get the user's role, call `role.can_access(method_name)`, and return an authorization error if denied. For now, all users created via onboarding should default to `Admin` role (single-user system), but this lays the groundwork for multi-user. Run `cargo clippy --all-targets --all-features && cargo test --all-features` on the dev server.
-- [x] **UI-CLEAN-02** — Dashboard (Home.vue) verified CLEAN. CPU/RAM/disk from system.stats RPC, container counts from store, uptime from RPC. Web5 card fetches from identity/dwn/credentials RPCs. Cloud stats from FileBrowser API. No hardcoded data.
+- [ ] **Remove dead code and #[allow(dead_code)]**: Search `core/` for all `#[allow(dead_code)]` and `#[allow(unused)]` annotations. For each: (1) if the code is genuinely unused and not part of a planned feature, delete it, (2) if it should be used (like RBAC — now wired up in previous task), remove the allow annotation. Key file: `core/archipelago/src/auth.rs` lines ~70, 83, 88. Run `cargo clippy --all-targets --all-features` to verify no new warnings.
-- [x] **UI-CLEAN-03** — Fixed Server.vue: added connectivity check on mount (was hardcoded 'connected'), restart now polls health endpoint instead of assuming success after 2s. Network data already fetches from real RPC endpoints (diagnostics, vpn, dns, interfaces). Deployed and verified.
-
-- [x] **UI-CLEAN-04** — Verified Web5.vue information hierarchy. All data from real RPC endpoints: DID from `identity.create-did` (cached in localStorage), wallet from `lnd.getinfo` on mount, Nostr relays from `nostr.list-relays`, DWN from `dwn.status`/`dwn.list-protocols`/`dwn.query-messages`, credentials from `identity.list-credentials`. No hardcoded placeholder numbers. Zero fake data.
-
-- [x] **UI-CLEAN-05** — Verified Settings.vue has zero section duplication. Account (server name, version, session, password, DID/Tor identity) is unique to Settings. 2FA is unique. Backup is unique. System Updates links to `/dashboard/settings/update`. DID/Tor appear as read-only identity display in Settings vs. interactive management in Web5 — different contexts, not duplication. Webhooks, AI Data Access, Claude Auth, Interface Mode all unique to Settings.
-
-- [x] **UI-CLEAN-06** — Verified Marketplace.vue curated app list accuracy. All 33 apps have valid icons (verified all files exist in app-icons/). Fixed `photoprims.svg` → `photoprism.svg` typo in filename, Marketplace.vue, and mock-backend.js. Docker images reference legitimate registries (docker.io, ghcr.io). External web apps (nostrudel, botfights, nwnn, etc.) correctly use webUrl with empty dockerImage. Deployed and verified.
-
-- [x] **UI-CLEAN-07** — Verified Cloud.vue file management. File sections (Photos, Music, Documents, All) use `fileBrowserClient.listDirectory()` with real paths (/Photos, /Music, /Documents, /). Peer Files shows `rpcClient.federationListNodes()` count and links to PeerFiles view. Upload via `cloudStore.uploadFile()` → `fileBrowserClient`. Download via `fileBrowserClient.downloadUrl()`. Zero hardcoded data.
-
-- [x] **UI-CLEAN-08** — Verified Federation.vue accuracy. Node list from `rpcClient.federationListNodes()`. Online/offline based on `last_seen` 10-min threshold. NetworkMap component renders with computed `mapNodes`/`mapLinks` from real data. Generate invite via `federationInvite()` RPC. Sync via `federationSyncState()` RPC. DWN sync status from `dwn.status` RPC. Self DID from `getNodeDid()`. Zero hardcoded data.
-
-- [x] **UI-CLEAN-09** — Verified Chat.vue state. Checks AIUI availability via `fetch('/aiui/', { method: 'HEAD' })`. Shows loading spinner while checking. Renders iframe when available. Shows clean fallback: "AI Assistant needs to be enabled before use. Go to Settings to configure your AI provider API key." No broken UI, no errors.
-
-- [x] **UI-CLEAN-10** — Verified Apps.vue installed app display. Real containers from `store.packages` (WebSocket from backend's `podman ps`). Status badges: running=green, stopped=gray, starting/installing=yellow/blue via `getStatusClass()`. Web-only apps (Indeehub, BotFights, etc.) are intentional external bookmarks, not phantom containers. Click navigates to `/dashboard/apps/${id}`. Fallback SVG placeholder for broken icons.
-
-- [x] **UI-CLEAN-11** — Type-check passes. `npm run type-check` exits 0.
-
-- [x] **UI-CLEAN-12** — Build passes. `npm run build` exits 0, 146 precache entries, 2.81s build time.
+- [ ] **Deploy and verify backend fixes**: Run `./scripts/deploy-to-target.sh --live`. After deploy: (1) verify login still works at `http://192.168.1.228` (password: `password123`), (2) verify session persists after navigating between pages, (3) check logs for any new errors: `ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago --since "2 min ago" | grep -i error'`.
---
-## Phase 4: Backend Hardening (Week 7-10)
+## Phase 7: Frontend Cleanup
-### Sprint 5: Container Management Reliability
+- [ ] **Remove dead dockerode dependency**: Run `cd /Users/dorian/Projects/archy/neode-ui && npm uninstall dockerode` and `npm uninstall @types/dockerode` if it exists. Search the codebase for any remaining imports: `grep -r "dockerode" neode-ui/src/`. Remove any dead imports found. Run `npm run type-check` to verify nothing breaks.
-- [x] **CONT-01** — Audited container network topology on .198 (4 networks: archy-net, immich-net, penpot-net, podman). Fixed `needs_archy_net` in package.rs to include `lnd`, `archy-nbxplorer`, `nbxplorer` (were missing — would install on wrong network via UI). Moved fedimint + fedimint-gateway from default podman network to archy-net on .198. Created `docs/network-topology.md` with full diagram. (.228 audit pending — SSH unreachable. penpot-frontend/backend missing on .198.)
+- [ ] **Fix the 10 failing frontend tests**: Run `cd /Users/dorian/Projects/archy/neode-ui && npm run test -- --reporter=verbose 2>&1 | head -100` to see which tests fail. Known failures: (1) `src/stores/__tests__/appLauncher.test.ts` — URL rewriting tests expecting different proxy behavior, (2) `src/views/__tests__/settings.test.ts` — heading selector `h1` not finding the heading element. For each failing test, read the test file and the component/store it tests. Update test expectations to match current implementation. Do NOT change the production code to match tests — fix the tests. Run `npm run test` until all pass.
-- [x] **CONT-02** — Added container dependency ordering to health_monitor.rs via StartupTier enum (Database → CoreInfra → DependentService → Application → Frontend). Unhealthy containers sorted by tier before restart. 5s delay between tiers to let dependencies stabilize. container_tier() classifies all known containers into proper startup order.
-
-- [x] **CONT-03** — Added `get_health_check_args()` function in package.rs with health checks for 20+ apps: bitcoin-knots (bitcoin-cli), lnd (lncli), btcpay-server (HTTP), mempool-api (HTTP /api/v1/backend-info), nextcloud, homeassistant, grafana, jellyfin, vaultwarden, uptime-kuma, filebrowser, searxng, photoprism, immich, dwn, portainer, ollama, fedimint, nostr-relay, nginx-proxy-manager. All use 30-60s intervals, 3 retries, 60s start period.
-
-- [x] **CONT-04** — Added exponential backoff to health monitor restarts: 10s, 30s, 90s delays (BACKOFF_DELAYS_SECS). RestartTracker now tracks last_failure timestamps and checks backoff_elapsed() before retrying. After MAX_RESTART_ATTEMPTS (3), container marked failed. Auto-reset after STABILITY_RESET_SECS (3600s = 1 hour) via should_reset_failed().
-
-- [x] **CONT-05** — Added `get_memory_limit()` function in package.rs with per-app limits replacing the blanket 2g default. Heavy: bitcoin-knots (2g), onlyoffice (2g), ollama (4g). Medium: lnd/fedimint/homeassistant/mempool-api/searxng (512m), electrs/nextcloud/immich/btcpay/jellyfin/photoprism (1g). Light: mempool-web/grafana/vaultwarden/uptime-kuma/filebrowser/dwn/portainer/nostr-relay/nginx-proxy-manager (256m). Databases: postgres (512m), redis/valkey (128m).
-
-- [x] **CONT-06** — Verified: rootless podman mount warning no longer appears. `sudo podman ps 2>&1 | grep warning` returns empty on .228. Backend runs as root (`sudo podman`), not rootless, so the warning is not applicable.
-
-### Sprint 6: Backend Security & Reliability
-
-- [x] **SEC-01** — Audited all 100+ RPC endpoints. Fixes applied: (1) Error sanitization via `sanitize_error_message()` in mod.rs — strips internal paths, returns generic messages for non-validation errors. (2) Identity ID validation via `validate_identity_id()` — blocks path traversal in identity.get/delete/set-default/sign. (3) DID validation via `validate_did()` — blocks path traversal in federation.remove-node/set-trust. (4) Message size limit (1MB) on node-send-message. (5) DWN data size limit (10MB) on dwn.write-message. Auth/CSRF strong across all endpoints. No shell injection found (all commands use .args() array).
-
-- [x] **SEC-02** — Added rate limiting to federation endpoints in session.rs EndpointRateLimiter: federation.join (5/60s), federation.invite (10/300s), federation.peer-joined (10/60s), federation.peer-address-changed (10/60s), federation.get-state (30/60s). Rate limiter already runs before auth check in mod.rs, so unauthenticated inter-node RPCs are also covered.
-
-- [x] **SEC-03** — Verified CSRF validation in mod.rs lines 206-234: all non-UNAUTHENTICATED_METHODS require both session cookie AND X-CSRF-Token header matching csrf_token cookie. Token is 32-byte random hex generated on login (line 712-715). SameSite=Strict + HttpOnly flags set. 100% of authenticated endpoints reject requests without valid CSRF token.
-
-- [x] **SEC-04** — Audited container security profiles. All containers via package.install get: `--cap-drop=ALL` (line 258), `--security-opt=no-new-privileges:true` (line 259), `--restart=unless-stopped` (line 183), per-app capabilities via `get_app_capabilities()`. Read-only filesystem for 8 compatible apps via `is_readonly_compatible()`. Memory limits via `get_memory_limit()`. Image pinning: 7 Docker Hub images still use `:latest` (bitcoin-knots, photoprism, searxng, tailscale, adguardhome, nginx-proxy-manager, mempool-electrs). Localhost-built UIs use `:latest` intentionally.
-
-- [x] **SEC-05** — Configured log rotation on both nodes. Journald: set SystemMaxUse=500M, MaxRetentionSec=7day, Compress=yes in /etc/systemd/journald.conf.d/archipelago.conf. Vacuumed .228 journal from 3.0GB to 459.7MB. Added /etc/logrotate.d/archipelago for crowdsec and archipelago logs (daily, 7 days, compress). Nginx logrotate already existed.
-
-- [x] **SEC-06** — Verified all 4 security headers present on both nodes: X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Content-Security-Policy (with frame-src *), Referrer-Policy: strict-origin-when-cross-origin.
+- [ ] **Add 404 catch-all route**: In `neode-ui/src/router/index.ts`, add a catch-all route at the end of the routes array: `{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFound.vue') }`. Create `neode-ui/src/views/NotFound.vue` — a simple view using the existing `.glass-card` class with "Page not found" message and a router-link back to `/dashboard`. Use `