diff --git a/.gitea/workflows/demo-images.yml b/.gitea/workflows/demo-images.yml index c6b58198..ba739508 100644 --- a/.gitea/workflows/demo-images.yml +++ b/.gitea/workflows/demo-images.yml @@ -5,7 +5,7 @@ name: Demo images # code (see demo-deploy/ and docs/demo-deployment-design.md). # # Required repo configuration: -# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025 # vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) # secrets.DEMO_REGISTRY_USER # secrets.DEMO_REGISTRY_TOKEN diff --git a/.gitea/workflows/post-install-tests.yml b/.gitea/workflows/post-install-tests.yml index 7c5c4c86..91c53ffc 100644 --- a/.gitea/workflows/post-install-tests.yml +++ b/.gitea/workflows/post-install-tests.yml @@ -4,13 +4,11 @@ on: workflow_dispatch: inputs: target: - description: 'Target node IP (e.g. 192.168.1.198)' + description: 'Target node IP or hostname' required: true - default: '192.168.1.198' password: - description: 'Node password (or "auto" for fresh install)' + description: 'Node UI password (leave blank to use the NODE_UI_PASSWORD secret)' required: false - default: 'auto' jobs: post-install-tests: @@ -22,33 +20,46 @@ jobs: with: fetch-depth: 1 - - name: Run post-install tests on target + - name: Install SSH key + env: + SSH_KEY: ${{ secrets.NODE_SSH_KEY }} run: | - TARGET="${{ github.event.inputs.target }}" - PASSWORD="${{ github.event.inputs.password }}" - if [ "$PASSWORD" = "auto" ]; then - PASSWORD="testpass123!" + if [ -z "$SSH_KEY" ]; then + echo "ERROR: repository secret NODE_SSH_KEY is not configured." + echo "Post-install tests authenticate by key; password auth is not supported." + exit 1 fi + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Run post-install tests on target + env: + TARGET: ${{ github.event.inputs.target }} + NODE_PASSWORD: ${{ github.event.inputs.password }} + NODE_UI_PASSWORD: ${{ secrets.NODE_UI_PASSWORD }} + SSH_USER: ${{ vars.NODE_SSH_USER }} + run: | + PASSWORD="${NODE_PASSWORD:-$NODE_UI_PASSWORD}" + if [ -z "$PASSWORD" ]; then + echo "ERROR: no node password supplied (input or NODE_UI_PASSWORD secret)." + exit 1 + fi + USER_NAME="${SSH_USER:-archipelago}" echo "══════════════════════════════════════════" echo "Running post-install tests on $TARGET" echo "══════════════════════════════════════════" - # Copy test script to target and run - sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \ + scp -o StrictHostKeyChecking=accept-new \ scripts/run-post-install-tests.sh \ - archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \ - scp -o StrictHostKeyChecking=no \ - scripts/run-post-install-tests.sh \ - archipelago@${TARGET}:/tmp/run-post-install-tests.sh + "${USER_NAME}@${TARGET}:/tmp/run-post-install-tests.sh" - # Run tests (with sudo for service checks) - sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \ - archipelago@${TARGET} \ - "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \ - ssh -o StrictHostKeyChecking=no \ - archipelago@${TARGET} \ - "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" + # Password is passed over stdin, never as an argv the node's process + # list (or this job's log) would expose. + printf '%s' "$PASSWORD" | ssh -o StrictHostKeyChecking=accept-new \ + "${USER_NAME}@${TARGET}" \ + "sudo bash /tmp/run-post-install-tests.sh --password-stdin" frontend-tests: runs-on: ubuntu-latest diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index c943bc06..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Keep the served companion APK in sync with main on every push. -# -# When a push to main includes Android changes, rebuild the APK, refresh -# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask -# you to push again (so the refreshed APK rides along in the same push). -# -# Enable once per clone: git config core.hooksPath .githooks -set -euo pipefail - -ROOT="$(git rev-parse --show-toplevel)" -cd "$ROOT" - -# ship-companion.sh already (re)published the APK for this push — don't redo it. -[ -n "${SHIP_COMPANION:-}" ] && exit 0 - -PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW="" -while read -r _local_ref local_sha remote_ref remote_sha; do - if [ "${remote_ref##*/}" = "main" ]; then - PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha" - fi -done -[ "$PUSH_MAIN" = "1" ] || exit 0 - -# Loop-break: if the tip is already the auto APK commit, let the push proceed. -case "$(git log -1 --pretty=%s)" in - *"companion APK"*) exit 0 ;; -esac - -# Only rebuild when this push actually touches the Android app. -ZEROS="0000000000000000000000000000000000000000" -if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then - ANDROID_CHANGED=1 -elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then - ANDROID_CHANGED=0 -else - ANDROID_CHANGED=1 -fi -[ "$ANDROID_CHANGED" = "1" ] || exit 0 - -bash scripts/publish-companion-apk.sh || exit 0 - -DEST="neode-ui/public/packages/archipelago-companion.apk" -if git diff --cached --quiet -- "$DEST"; then - exit 0 # APK unchanged — nothing to do -fi - -git commit -q -m "chore(android): update companion APK download [skip ci]" -echo "" >&2 -echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2 -exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cca99ad..ab3b5ada 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,8 +93,31 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install YAML parser + run: python3 -m pip install --quiet pyyaml + - name: Validate manifests run: | for manifest in apps/*/manifest.yml; do ./scripts/validate-app-manifest.sh --repo-audit "$manifest" done + + # The signed catalog overrides on-disk manifests on every node, so a + # catalog naming a registry host the deployed fleet does not trust breaks + # every install fleet-wide. Blocking, and cheap. + - name: Catalog registry trust floor + run: python3 scripts/check-catalog-registry-trust.py + + # A stale image literal on the fallback install path deploys an old + # image after the manifest has moved on — how a withdrawn, vulnerable + # release gets installed post-fix. Blocking. + - name: Installer image pins + run: python3 scripts/check-installer-image-pins.py + + # Advisory: shows where the release catalog has fallen behind the + # manifests in this repo. Not blocking, because the catalog can only be + # updated through the signing ceremony, so drift is expected between a + # manifest landing and the next signed release. + - name: Catalog drift (advisory) + continue-on-error: true + run: python3 scripts/check-app-catalog-drift.py --catalog releases/app-catalog.json --release diff --git a/.github/workflows/demo-images.yml b/.github/workflows/demo-images.yml index 0471538b..9e0f1733 100644 --- a/.github/workflows/demo-images.yml +++ b/.github/workflows/demo-images.yml @@ -5,7 +5,7 @@ name: Demo images # code (see demo-deploy/ and docs/demo-deployment-design.md). # # Required repo configuration: -# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025 # vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) # secrets.DEMO_REGISTRY_USER # secrets.DEMO_REGISTRY_TOKEN diff --git a/.gitignore b/.gitignore index b99a37af..02574536 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,13 @@ coverage/ releases/** !releases/ !releases/manifest.json +# The signed app catalog and the registry trust floor are source, not build +# output: nodes fetch the catalog from this path on main, and the floor is what +# scripts/check-catalog-registry-trust.py checks it against. Both were being +# swallowed by the rule above — app-catalog.json only stayed tracked because it +# predates it. +!releases/app-catalog.json +!releases/registry-trust-floor.json # Image recipe output image-recipe/output/ @@ -85,6 +92,23 @@ scripts/resilience/reports/ .codex-tmp/ .claude/ .pnpm-store/ + +# Key material and local databases — belt-and-braces so a stray key or a +# copied node database can never be committed. Open-source readiness plan, +# Phase 1 item 5: `.claude/settings.local.json` was previously only caught by +# a machine-global ignore rule, which protects one machine and no contributor. +*.key +*.pem +id_rsa* +*.sqlite +*.sqlite3 +*.db + +# ...except the throwaway TLS fixtures the appgate tests compile in via +# include_bytes!. They are documented non-identity material (see that +# directory's README) and are already tracked; the negation stops the rule +# above from silently dropping them if they are ever regenerated. +!core/archipelago/src/appgate/testdata/*.key **/__pycache__/ *.bak @@ -92,3 +116,49 @@ scripts/resilience/reports/ # app/docs asset path with a descriptive filename. Screenshot *.png uploads/ + +# ── Local-only material ───────────────────────────────────────────────────── +# Present on disk, never tracked: everything describing Archipelago's own +# infrastructure or internal development process. The repo is source code and +# guidelines only. Inventory: .local-only/manifest.txt — wipe: .local-only/wipe.sh +/.local-only/ +/.planning/ +/loop/ +/docs/operations-runbook.md +/docs/hotfix-process.md +/docs/PRODUCTION-MASTER-PLAN.md +/docs/UNIFIED-TASK-TRACKER.md +/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md +/docs/HANDOFF-2026-07-20-fips-peer-files.md +/docs/HANDOFF-2026-07-23-companion-apk-deploy.md +/docs/qr-scanner-snappiness-handover.md +/docs/RETICULUM-TRANSPORT-PROGRESS.md +/docs/combined-test-plan-2026-07-22.md +/docs/pine-voice-release-test-plan.md +/docs/OPEN-SOURCE-READINESS-PLAN.md +/docs/archive/HANDOVER-2026-07-02-iso-feedback.md +/docs/archive/SESSION-1.8.0-OTA-PROGRESS.md +/docs/security/KEY-02-FLEET-ROTATION.md +/docs/security/KEY-03-SIGNING-POSTURE.md +/tests/production-quality/TRACKER.md +/scripts/deploy-config-defaults.sh +/scripts/deploy-tailscale.sh +/scripts/deploy-to-target.sh +/scripts/setup-target-dev.sh +/scripts/setup-aiui-server.sh +/scripts/setup-https-dev.sh +/scripts/debug-frontend.sh +/scripts/node-profile.sh +/scripts/fleet-fips-pair.sh +/scripts/fleet-fips-unpair.sh +/image-recipe/sync-from-live.sh +/docs/security/PHASE-10-VERIFICATION-GUIDE.md +/docs/security/KEY-01-ON-NODE-VERIFICATION.md +/docs/security/KEY-02-ROOTFS-EVIDENCE.md +/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md +/image-recipe/INTEGRATION-GUIDE.md +/docs/multinode-testing-plan.md +/docs/bitcoin-version-bulletproof-rollout.md + +# Generated PWA dev output (vite-plugin-pwa) — never a source artifact +neode-ui/dev-dist/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b79b5f6c..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "indeedhub"] - path = indeedhub - url = http://146.59.87.168:3000/lfg2025/indeehub.git diff --git a/.planning/.continue-here.md b/.planning/.continue-here.md deleted file mode 100644 index 0f76dce7..00000000 --- a/.planning/.continue-here.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -context: default -phase: 09-botfights-platform-upgrade (already complete — this is off-plan work) -task: n/a -total_tasks: n/a -status: paused -last_updated: 2026-08-02T10:34:47.198Z ---- - -# BLOCKING CONSTRAINTS — Read Before Anything Else - -- [ ] CONSTRAINT: Never assume pushing one repo pushed another — this session pushed `archy` repeatedly via `git push gitea-ai main`, but the `botfight` repo's last 4 commits (the entire security-fix body of work) sat **local-only** the whole time and were only discovered/pushed at the very end of this session, during this handoff step. Structural mitigation: whenever a session touches more than one git repo, explicitly run `git status -sb` (ahead/behind vs. the tracked remote) in **every** repo touched before ending the session — not just the one most recently `git push`ed. - -**Do not proceed until the box above is checked (i.e. verify both repos are still in sync with their remotes before doing anything else).** - - -This is **not** a GSD plan/task in progress. Phase 09 (BotFights Platform Upgrade) is fully complete — plans 09-01 through 09-07 all have SUMMARY.md files, the last dated 2026-07-31 05:08. Everything described below happened *after* that, as live, user-directed, reactive work preparing for a same-day BotFights demo ("two real fighters playing with cashu"). None of it was tracked against a PLAN.md task list — the original GSD task (execute 09-06-PLAN.md: bump manifest + sign catalog) completed normally and stopped cleanly at its signing checkpoint, exactly as designed. Everything after that was ad hoc. - -**As of this handoff, everything is committed and pushed in both repos, and both demo nodes are deployed and verified healthy.** There is nothing mid-flight to resume — this file exists so a future session (or this one, after compaction) has the full picture instead of re-discovering it. - - - - -**botfight repo** (`/home/archipelago/Projects/botfight`, pushed to `origin/main` @ `10d4209`): -- iframe embedding fix (X-Frame-Options was unconditional), native Archipelago signer bridge (`nostr-provider.js`), "Sign in with Archipelago" docs for app developers -- Discoverability fixes: mode-picker guide banner, AI-answer visibility, "Latest Bouts" cut off on short viewports -- Fixed a proxy-URL leak (local/Tailscale addresses leaking into AI setup prompts via client-side `window.location.origin` — switched to server-rendered `/api/docs/prompt`) -- "Let BotFights answer for me" — server-side AI bot using an operator-supplied Anthropic/OpenAI API key (poll-mode bots) -- Fixed broken profile images (CSP `img-src`) -- Cashu ecash payments made the **primary** entry-fee AND payout UX (Lightning/NWC now secondary) — Minibits mint, `BOTFIGHTS_WALLET_ENCRYPTION_KEY`, escrow-style entry fee (21 sats, 42-sat winner-take-all pot) -- Fixed anonymous poll-mode bots being locked out of staked/ranked fights (auth gap) -- **Security audit found + fixed 6 instances of the same IDOR pattern** (client-supplied `pubkey` trusted with no verification against a real JWT) — `f5f57e6`, `c162d5e`: - - `POST /api/auth/update` — could hijack any bot's webhook/customization - - `GET /api/payments/winnings/:botId` — **critical**: zero auth at all, leaked live spendable Cashu bearer tokens to anyone who knew a botId (public in every URL) - - `POST /api/payments/connect-wallet` — **critical**: zero ownership check, could redirect any victim bot's future payouts to an attacker's wallet - - `POST /api/payments/claim/:paymentId`, `DELETE /api/payments/disconnect-wallet`, `POST /api/queue/join-ranked/:botId` — same pattern, lower severity - - Fix pattern: pubkey now always derived from `extractPubkeyFromAuth(Authorization: Bearer )`, never trusted from body/query. Added `verifyBotOwner()` helper in `bot-auth.ts` for routes serving both nostr-owner and anonymous-bot-secret audiences. -- Built the two things actually requested when the audit was found: **AI-answer settings reachable for existing bots** (`/api/bots/:name/ai-config`, not just at creation) and a **claim-winnings UI** (Cashu payouts were minted server-side but had zero frontend consumer — `41f1b93`) -- `10d4209`: fixed a real `tsc` error the podman build caught that local verification initially missed (misread a wrapper's exit code instead of the actual log content — lesson: always check log *content*, not just the shell wrapper's `$?`) -- Built + pushed `146.59.87.168:3000/lfg2025/botfights:1.2.11` - -**archy repo** (pushed to `gitea-ai/main`, my commits at `aea17248`/`b0a08345` — many other agents' commits have landed on top since, this is a busy shared tree): -- `apps/botfights/manifest.yml` bumped to 1.2.11; fixed `data_uid` from `1001` to `999` (the container's real internal UID — first attempt copied fedimint-clientd/barkd's value without checking this image's actual `Dockerfile`, which does `useradd --system` with no explicit UID) -- `scripts/image-versions.sh` kept in lockstep -- Catalog regenerated, signed (user ran `sign-catalog.sh`), published — verified live on `146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json` -- Deployed to both nodes via RPC (`package.update`), both verified healthy: - - **archi-dev-box** (local): `botfights` container on `1.2.11`, `/api/health` → ok - - **x250-beta** (`archy-x250-beta.tail08d8f2.ts.net`): `botfights` container on `1.2.11`, `/api/health` → ok, `/api/bots` confirmed identical to `botfights.archipelago-foundation.org` (arena-proxy forwarding correctly) - - - -Nothing blocking the demo. One loose end, likely moot: -- Framework PT (`100.65.115.109`) SSH access is still blocked — the password was rotated 2026-07-26 and the current one isn't recorded anywhere. User redirected the demo plan away from Framework PT to x250-beta earlier in the session, so this probably doesn't matter anymore unless the user brings it up again. - - - -- Cashu is now the primary UX for both paying entry fees AND receiving payouts, Lightning/NWC demoted to a secondary "or connect a Lightning wallet instead" option — explicit user instruction. -- `data_uid: 999:999` (not 1001) in the botfights manifest — verified against the running container's actual `id` output, not assumed from another app's manifest. -- ai-config routes accept EITHER a nostr JWT (new, for browser owners) OR the bot's own secret (existing, for anonymous AI-agent poll-mode bots) — additive, not a replacement, since both audiences are real and pre-existing. - - - -- Framework PT SSH: password unknown since 2026-07-26 rotation. Not currently blocking anything (user moved to x250-beta). - - -## Required Reading (in order) -1. This file, obviously. -2. `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` and `09-07-SUMMARY.md` — the actual last GSD-tracked work in this area, for anyone confused about why there's no PLAN.md for tonight's work. -3. If continuing security work: re-read the fix pattern in `botfight` repo commits `f5f57e6` and `c162d5e` before touching any other route that reads a pubkey — the same bug class may exist elsewhere in the codebase that wasn't audited (only `auth.ts`, `payments.ts`, and `queue.ts` were checked; `bots.ts`, `tournaments.ts`, `bets.ts` were not re-audited for this exact pattern). - -## Critical Anti-Patterns (do NOT repeat these) -- **ANTI-PATTERN: trusting a shell wrapper's exit code instead of the actual command output.** During this session, `tsc --noEmit ... ; echo "EXIT=$?"` was read as "passed" from the *notification summary* (which reports the wrapper's own exit code, always 0 because `echo` always succeeds) rather than the log *content*. This let a real `tsc` compile error through to a `podman build` failure. → Structural mitigation: always `cat`/`Read` the actual log file and look for the error pattern or an explicit `EXIT=N` marker line before treating a background verification command as passed. -- **ANTI-PATTERN: assuming multi-repo work is saved because one repo was pushed.** → Structural mitigation described in the BLOCKING CONSTRAINT above. -- **ANTI-PATTERN (from earlier this session, already corrected): never run `archipelago --version` on a fleet node** — it starts the full daemon rather than printing a version string (deployed binaries predate the flag). Use source-reading instead of the binary for investigation. - -## Infrastructure State -- **archi-dev-box** (local node): `archipelago` daemon healthy, RPC on `127.0.0.1:5678` (session cookie in `/tmp/archy-dev-cookies.txt`, likely stale by the time this is read — re-login with `auth.login` / password `ThisIsWeb54321@`). `botfights` container healthy on `1.2.11`. -- **x250-beta** (`archy-x250-beta.tail08d8f2.ts.net`, tailnet IP rotates — resolve by MagicDNS name): reachable via plain `ssh archipelago@archy-x250-beta.tail08d8f2.ts.net` this session (no password prompt hit — key-based or cached). RPC session cookie in `/tmp/archy-cookies.txt` **on that remote node**, likely stale — re-login same way. `botfights` container healthy on `1.2.11`. -- Both nodes' local `/tmp` filled up mid-session (a 12G tmpfs, hit 0MB free once) — if you hit `ENOSPC` from the harness itself (not the actual command), check `df -h /tmp` and clean up stray large files (this session's culprit: two OTA release assets, ~260MB, downloaded to `/tmp` on the **local** machine as a relay step for an unrelated node update earlier in the session). -- Canonical arena: `https://botfights.archipelago-foundation.org` — both demo nodes proxy to this via `ARENA_UPSTREAM_URL`, confirmed serving identical bot/fight data on both. - - -The user is demoing BotFights live, same day, wants two real fighters paying/winning with Cashu ecash across two real node installs. All of that is now in place and verified. The security audit was NOT originally requested — it was triggered by investigating the user's question "can we confirm the fighter wins all the cashu sats into their node wallet automatically", which led to reading `payments.ts` end to end and discovering the payout claim flow had no frontend UI *and* the backend route serving it had no auth at all. That in turn led to checking every other route with a similar shape, which is how 5 more instances of the same bug were found. This is worth remembering: a seemingly simple product question ("where does the money go") uncovered a real, live, exploitable vulnerability in a publicly-deployed app — treat "let me just check how this actually works end to end" as time well spent, not scope creep. - - - -Nothing is required to "resume" — this was a complete, self-contained session of off-plan work, fully committed, pushed, deployed, and verified. If the user opens a new session and says something like "continue" or "where were we", the right first move is to summarize the state above (both nodes on `1.2.11`, security fixes live, demo-ready), not to look for a GSD plan to execute. If the user wants to resume *GSD-tracked* work specifically, `STATE.md` says Phase 10 (Key-Material Hardening, KEY-01..KEY-04, sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`) is planned and ready to execute — but that is a separate, unrelated thread from tonight's BotFights work, and STATE.md is being actively updated by other concurrent agents working other phases (01, 02, 10) in this shared tree, so re-read it fresh rather than trusting anything cached. - diff --git a/.planning/APP-PORT-AUTH-GATE.md b/.planning/APP-PORT-AUTH-GATE.md deleted file mode 100644 index 3717af7b..00000000 --- a/.planning/APP-PORT-AUTH-GATE.md +++ /dev/null @@ -1,106 +0,0 @@ -# App-port authentication gate — design - -Item 1 of `RELEASE-1.7.121-TASKS.md`. Opened 2026-08-04. - -> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be -> allowed… it must present the login to access the app with an app icon of what you're -> accessing to confirm, and 2FA if present" — operator, 2026-08-03 -> -> "make sure we fix FIPS, Tor, everything … umbrel definitely shows a port when you go to -> tailscale IP or other + port but demands the node login and 2FA if activated" -> — operator, 2026-08-04 - ---- - -## What we already built, and why it did not close this - -The operator's recollection that FIPS and Tor were "done" is correct — but that work was -about **reachability**, and about restricting the **daemon's own** API. Neither one ever -authenticated an app port. Read together, each transport got a door and none got a lock: - -| Layer | What exists today | What it protects | -| --- | --- | --- | -| `server.rs:1271` `is_peer_allowed_path` | Federated peers hitting the **daemon** port may only reach `/health`, `/rpc/v1`, `/content`, `/blob/`, `/dwn/`, `/transport/inbox`, `/archipelago/*` | The daemon's API surface. **Not app ports.** | -| `fips/app_ports.rs` `APP_LAUNCH_PORTS` | 35 app ports **allowed through** the fips0 firewall | Nothing — it *opens* them | -| `server.rs:1130` `app_port_v6_relay_loop` | Daemon relays mesh v6 → v4 loopback for those same ports | Nothing — it *bridges* them | -| `api/rpc/tor/mod.rs:243` | Per-app `HiddenServicePort 80 → 127.0.0.1:` | Nothing — it *publishes* them to an onion | -| `container/quadlet.rs:261` | `PublishPort=0.0.0.0:{host}:{container}` | Nothing — it binds every interface | - -So the app ports are reachable, by construction, over LAN, Tailscale, FIPS mesh and Tor, -and nothing on any of those paths checks a session. This is the same bug class as the -v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but structural rather than -per-endpoint. - -## The rule this design is built on - -**You cannot gate a socket you do not own.** Every previous fix added a check *beside* the -listener, which is why each one only covered the transport it was written for. The gate -has to *be* the listener. - -## Design - -Port numbers do not change. For an app whose UI port is `P`: - -- **The app binds `127.0.0.1:P` only** (`PublishPort=127.0.0.1:P:`), so it is - no longer reachable from any interface. -- **The gate binds `P` on every external address** — LAN IP, Tailscale IP, fips0 ULA — - and on **`127.0.0.2:P`** for Tor. `127.0.0.2` is a distinct loopback address, so it does - not collide with the app on `127.0.0.1:P`, and it means **no app needs a second port - number**. `torrc` changes to `HiddenServicePort 80 127.0.0.2:P`. -- Upstream for the gate is always `127.0.0.1:P`. - -Because the gate owns the socket, LAN / Tailscale / FIPS / Tor are one code path. There is -no per-transport work, and therefore no transport to forget. - -### Request handling - -1. Read the `session` cookie. Cookies are **host-scoped and port-agnostic**, so the - session minted on the dashboard is presented to `:P` automatically — this is the - same mechanism umbrel's "proxy token" relies on. (Scheme still matters: a `Secure` - cookie will not travel to a plain-HTTP app port. See open questions.) -2. **Valid session** → proxy to `127.0.0.1:P`, passing through `Upgrade` so WebSockets work. -3. **No/invalid session** → serve the login page **on the app port itself**, naming the app - and showing its icon, POSTing back to the same origin. The gate verifies the password, - enforces TOTP when enabled, and sets the session cookie — so logging in at - `:P` also logs you into the dashboard, exactly as umbrel behaves. -4. Non-browser clients get `401` with a JSON body rather than an HTML page. - -### What must NOT be gated - -Non-HTTP ports cannot carry a cookie and must be declared, not discovered: -electrum `50002`, bitcoin p2p `8333`, LND gRPC `10009`/`9735`. These need an explicit -manifest field (`auth: none` + rationale) so the exception list is a `grep`, and they are -a firewall/allowlist question, tracked separately. - -Note `api/rpc/tor/mod.rs:238-240` already special-cases lnd's `9735`/`10009` as -`is_protocol_service` — that distinction is the seed of the manifest field. - -## Deploy traps this walks into - -- **Three copies of every container spec** — `apps//manifest.yml`, - `scripts/container-specs.sh`, `scripts/first-boot-containers.sh`. Changing `PublishPort` - in one leaves fresh installs broken while the node looks fixed. This is exactly what bit - lnd-ui (item 4). **Deduplicating these is arguably a prerequisite, not a follow-up.** -- Changing `PublishPort` drifts every app → one-time recreate fleet-wide. -- The gate must rebind when addresses change (Tailscale up/down, DHCP, fips0 re-key). - Precedent exists: `peer_late_bind_loop` in `server.rs` already does this for fips0. -- Verify **on the node**, not from source. v1.7.120's headline bug was a fix that shipped - in the binary and never reached the running container. - -## Open questions for the operator - -1. **Machine clients.** Umbrel's real-world failure mode: Home Assistant (or any API - client) hitting an app's API has no cookie and breaks. Browser-only, or do we mint - per-app long-lived tokens? -2. **TLS/scheme.** The daemon serves plain HTTP with nginx terminating TLS in front. If the - dashboard is HTTPS and app ports are HTTP, a `Secure` session cookie will not be sent — - the gate would prompt for login every time. Either the gate serves TLS on app ports too, - or app ports are HTTP-only on such nodes. - -## Sequencing - -1. Gate module + login page + proxy, behind an env opt-in. -2. Prove on **one** HTTP app on .228, across all four transports. -3. Dedupe the container-spec declarations. -4. Roll to all HTTP apps; declare the non-HTTP exceptions. -5. Repoint `torrc` at `127.0.0.2`. diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json deleted file mode 100644 index cc92f2c1..00000000 --- a/.planning/HANDOFF.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": "1.0", - "timestamp": "2026-08-02T10:34:47.198Z", - "phase": "09", - "phase_name": "BotFights Platform Upgrade", - "phase_dir": ".planning/phases/09-botfights-platform-upgrade", - "plan": null, - "task": null, - "total_tasks": null, - "status": "paused", - "context_type": "ad_hoc_reactive", - "note": "This handoff does NOT track a GSD plan/task. Phase 09's plans 09-01..09-07 are all already complete (SUMMARY.md exists for each, most recent 09-07-SUMMARY.md dated 2026-07-31 05:08). Everything recorded here happened AFTER 09-06/09-07 were done, as live reactive demo-day work directed by the user in conversation, not from a PLAN.md task list. There is no in-progress GSD plan to resume — this is purely a work-state save so uncommitted/unpushed work and node state are not lost.", - "completed_tasks": [ - {"id": "botfight-security-audit", "name": "Found + fixed 6 IDOR/missing-auth vulnerabilities in botfight repo", "status": "done", "commit": "f5f57e6 (auth.ts), c162d5e (payments.ts/queue.ts)"}, - {"id": "botfight-ai-config-existing-bots", "name": "AI-answer settings UI for existing bots (not just at creation)", "status": "done", "commit": "41f1b93"}, - {"id": "botfight-claim-winnings-ui", "name": "Claim-winnings UI (Cashu payouts were backend-only, no frontend consumer)", "status": "done", "commit": "41f1b93"}, - {"id": "botfight-tsc-fix", "name": "Fixed possibly-undefined route param tsc error caught by podman build", "status": "done", "commit": "10d4209"}, - {"id": "botfight-1.2.11-release", "name": "Built + pushed botfights:1.2.11 image to registry", "status": "done"}, - {"id": "archy-manifest-1.2.11", "name": "Bumped apps/botfights/manifest.yml + scripts/image-versions.sh to 1.2.11, regenerated+signed+published catalog", "status": "done", "commit": "aea17248 (manifest bump), b0a08345 (signed catalog)"}, - {"id": "deploy-archi-dev-box", "name": "Updated BotFights to 1.2.11 on archi-dev-box via package.update RPC", "status": "done"}, - {"id": "deploy-x250-beta", "name": "Updated BotFights to 1.2.11 on x250-beta via package.update RPC", "status": "done"}, - {"id": "botfight-push-to-origin", "name": "Pushed 4 local-only botfight commits to origin (were unpushed until this handoff step)", "status": "done", "commit": "d00e792..10d4209 -> origin/main"} - ], - "remaining_tasks": [ - {"id": "framework-pt-access", "name": "Framework PT (100.65.115.109) SSH access still blocked — password was rotated 2026-07-26, current password unknown. User redirected focus to x250-beta instead, so this may no longer be needed for the demo.", "status": "blocked"} - ], - "blockers": [ - {"description": "Framework PT SSH password unknown (rotated, not recorded)", "type": "human_action", "workaround": "User already redirected demo plan to use x250-beta instead of Framework PT — likely moot unless user asks for Framework PT again."} - ], - "async_jobs": [], - "human_actions_pending": [], - "decisions": [ - {"decision": "Made Cashu the primary entry-fee AND payout UX for BotFights, Lightning/NWC secondary", "rationale": "Explicit user instruction: \"please make cashu the primary UX and lightning secondary\"", "phase": "09"}, - {"decision": "Fixed data_uid in apps/botfights/manifest.yml from 1001 to 999", "rationale": "Container's actual internal UID (confirmed via `podman exec botfights id`) is 999, not 1001 — first attempt copied fedimint-clientd/barkd's value without verifying against this specific image's Dockerfile (`useradd --system` with no explicit UID lands at 999)", "phase": "09"}, - {"decision": "Extended ai-config routes to accept EITHER nostr JWT (verifyBotOwner) OR the bot's own secret, rather than replacing bot-secret auth", "rationale": "Poll-mode AI-agent bots (no nostr identity) still need the original bot-secret path; nostr-logged-in browser owners needed a new path that didn't exist before", "phase": "09"} - ], - "uncommitted_files": [], - "unrelated_uncommitted_by_other_agent": [ - "core/archipelago/src/container/prod_orchestrator.rs (archy repo) — modified by a DIFFERENT concurrent agent, not touched by this session. Do NOT stage, commit, or stash this file." - ], - "next_action": "No GSD action required to resume — Phase 09 is fully complete and this was off-plan reactive work, now fully committed and pushed in both repos (archy @ b0a08345, botfight @ 10d4209 on origin/main), deployed to both demo nodes (archi-dev-box + x250-beta, both verified healthy on botfights:1.2.11), and catalog signed+published. If resuming demo work: verify nodes are still healthy (`curl http://127.0.0.1:9100/api/health` on each) since time has passed. If resuming GSD-tracked work: STATE.md says Phase 10 (Key-Material Hardening) is planned and ready to execute — that is a SEPARATE, unrelated GSD phase from tonight's BotFights firefighting.", - "context_notes": "This was a long reactive demo-prep session, not GSD-plan-driven. Started from GSD-executing 09-06-PLAN.md (bump BotFights manifest + sign catalog), which completed normally and STOPPED at the signing checkpoint as designed. Everything after that was live user-directed firefighting for a same-day demo: iframe embedding, native signer bridge, AI-answer feature, Cashu payment integration (both entry-fee and payout sides), a security audit that surfaced a systemic IDOR pattern (client-supplied pubkey trusted without verification) repeated across 6 routes — 2 of them critical (unauthenticated Cashu-token leak, unauthenticated wallet-hijack) — and a second-node deployment to x250-beta that surfaced a real manifest bug (data_uid). The single biggest risk caught in this handoff step itself: 4 botfight-repo commits (the entire security-fix work) were sitting LOCAL-ONLY, never pushed to origin, until this pause-work step explicitly checked ahead/behind counts and pushed them. Always verify `git status -sb` / ahead-behind against the actual remote before ending a session that touched a repo other than the one being actively `git push`ed in the visible workflow — pushing archy did not imply botfight got pushed too, they are separate repos." -} diff --git a/.planning/INGEST-CONFLICTS.md b/.planning/INGEST-CONFLICTS.md deleted file mode 100644 index 703e66c4..00000000 --- a/.planning/INGEST-CONFLICTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Ingest Conflict Report - -Mode: new (fresh bootstrap — no existing .planning/ context to check against) -Precedence: ADR > SPEC > PRD > DOC (no per-doc overrides present) - -## Conflict Detection Report - -### BLOCKERS (0) - -(none) - -### WARNINGS (0) - -(none) - -### INFO (4) - -[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory - Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope - Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene. - -[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults - Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field) - Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md. - -[INFO] ADR numbering gap — ADR-010 absent from ingest set - Found: Classified ADRs run 001–009 and 011; no classification exists for an ADR-010 - Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record. - -[INFO] Cross-reference graph is acyclic - Found: cross_refs edges: ADR-007 → ADR-003; ADR-009 → docs/app-manifest-spec.md (+ code paths); SPEC → out-of-set docs and code only (app-developer-guide.md, manifest-hooks-design.md, marketplace-protocol.md, core/container/src/manifest.rs, api/rpc/package/stacks.rs) - Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md deleted file mode 100644 index b645fc09..00000000 --- a/.planning/PROJECT.md +++ /dev/null @@ -1,115 +0,0 @@ -# Archipelago - -## What This Is - -Archipelago is a self-hosted personal-server platform: a Rust daemon (workspace at `core/`) -plus a Vue 3 frontend (`neode-ui/`, built to `web/dist/neode-ui/`) running on Debian nodes -with rootless Podman, managing ~40 declarative, manifest-driven apps (Bitcoin, Lightning, -mesh/LoRa, federation, media, and more). It ships as OTA-updated releases to a live fleet -and is actively shipping v1.7.x alpha releases. This milestone drives it to the -**developer-ready app platform** north star. - -## Core Value - -A third-party developer can publish an app via the signed/decentralized registry and a user -can install it on their node — every app manifest-driven, manifests shipped via the signed -registry (not OTA disk files), all rootless, secure, robust, and 100%-uptime-capable. - -## Current State (brownfield baseline, 2026-07-29) - -- Single-node production gate is **GREEN** (5/5 on .228, 2026-06-23) — that exit criterion is met. -- ~40 apps are manifest-based and Quadlet-migrated; all multi-container stacks use the - orchestrator stack pattern; the legacy per-app installer anti-pattern is deleted. -- Workstream B (registry-distributed manifests) phases 1+2 are code-complete; the signing - ceremony is done (release-root pinned in `anchor.rs`); the fleet flip is not yet authorized. -- Workstream C (marketplace) is design-only (`docs/marketplace-protocol.md`); no tooling or - trust UX built. Developer CLI suite (`archy app …`) does not exist yet. -- Phase-3 Quadlet default-flip is validated opt-in on .228/.198 but not default. -- Declared next exit criteria: the multinode pass (`docs/multinode-testing-plan.md`) and the - remaining workstreams. - -## Requirements - -### Validated - -- ✓ Single-node lifecycle gate green 5× on .228 (install/UI/stop/start/restart/reinstall/ - reboot-survive/daemon-restart-survive/uninstall) — 2026-06-23 -- ✓ Manifest-driven app packaging for all ~40 apps incl. multi-container stacks (workstream A) -- ✓ Signed catalog + release-root signing ceremony (workstream B phases 1+2, code-complete) - -### Active - -See `.planning/REQUIREMENTS.md` — 20 v1 requirements across MNODE / LIFE / REG / SEC / DEV / MKT, -all mapped to phases in `.planning/ROADMAP.md`. - -### Out of Scope - -- Rootful containers, Docker, privileged containers — invariant (ADR-001/ADR-009) -- Per-app Rust installers / OS-level provisioning — the anti-pattern being deleted -- Centralized gatekept app store — decentralized Nostr marketplace instead (ADR-006) -- Web5 DWN spec compliance — deprioritized after TBD shutdown (ADR-011) -- Custom live voice-call protocol — deprioritized per user 2026-07-01; revisit later -- DHT/iroh distribution backbone (workstream D) — design-only, tracker-marked backlog; v2 - -## Context - -- Repo: `core/` Rust workspace (no root Cargo.toml), `neode-ui/` Vue frontend, `apps/` manifests, - `tests/lifecycle/` + `tests/multinode/` gates, `docs/` authoritative plans. -- Authoritative narrative: `docs/PRODUCTION-MASTER-PLAN.md`; day-to-day open list: - `docs/UNIFIED-TASK-TRACKER.md`. Codebase map: `.planning/codebase/ARCHITECTURE.md` + - `.planning/codebase/CONCERNS.md`. -- Known debt informing this milestone (from CONCERNS.md): federation tombstone-write errors - swallowed; reconciler has no flap observability and no failed-unit self-healing; generated - AppArmor profiles are never applied; multinode test harness curl calls lack timeouts; - SPEC validation is narrower than ADR-009's mandates (non-root UID, pinned tags, seccomp). -- Fleet is live and OTA-updated; all destructive verification happens on designated test - nodes per the deploy roster — never uninvited on in-use nodes. - -## Constraints - -- **Security**: Rootless Podman only; manifest-declared secrets (0600, never logged); - mandatory container security defaults enforced at manifest level (ADR-009) -- **Data safety**: Migrations never destroy data — preserve `/var/lib/archipelago/`, - secrets, credentials, ports, adoption container names; always a rollback path -- **Verification**: Real-node verification before any tag; lifecycle gate runs ON the node, - not via RPC; mesh changes need real-RF E2E tests; re-run the gate after orchestrator changes -- **Process**: Commit + push every unit of work (`git push gitea-ai main`); stage by explicit - path; deploy to the dev pair before any OTA; never commit secrets -- **Tech stack**: Rust (Tokio/Hyper, JSON-RPC 2.0) backend; Vue 3 + Pinia frontend; - Quadlet/systemd-user container units; Ed25519-signed release artifacts - -## Key Decisions - - - -All ten ADRs below are **locked** (Status: Accepted; ingest source `docs/adr/*.md`). They are -non-negotiable inputs to planning and cannot be overridden without a new ADR. - -| ID | Decision | Scope | -|----|----------|-------| -| ADR-001 | Podman over Docker — rootless, daemonless, systemd-native; `archy-net` for inter-container DNS | Container runtime | -| ADR-002 | `did:key` (Ed25519) node identity — self-contained, offline-capable; gaps mitigated via federation trust lists | Identity | -| ADR-003 | Nostr relays (NIP-78, kind 30078) for node + app discovery — multi-relay query, 15-min cache, trust scoring, Tor-compatible | Discovery | -| ADR-004 | Tor hidden services for inter-node RPC/control plane — bulk data via registries, not Tor | Federation transport | -| ADR-005 | ChaCha20-Poly1305 + Argon2id (64MB, 3 iter) for backup encryption | Backups | -| ADR-006 | Nostr relays for marketplace discovery — DID-signed manifests, trust tiers (Verified/Community/Unverified), signature verification before install | Marketplace | -| ADR-007 | Bilateral DID federation trust via single-use invite codes; Trusted/Observer/Untrusted levels | Federation trust | -| ADR-008 | Dual keys from one master seed — Ed25519 canonical identity, secp256k1 for Nostr/Bitcoin/Lightning, linked via NIP-05 | Keys | -| ADR-009 | Manifest-level container security enforcement — readonly_root, no_new_privileges, non-root UID, drop-ALL caps, pinned tags, seccomp; overrides explicit + audited | Container security | -| ADR-011 | DWN deprioritized — keep custom `dwn_store.rs`, stop branding as Web5, invest in Nostr + Tor federation instead | Peer data sync | - -(ADR-010 does not exist in the repo — numbering gap, noted in `.planning/INGEST-CONFLICTS.md`.) - - - -Milestone-level decisions: - -| Decision | Rationale | Outcome | -|----------|-----------|---------| -| Milestone version = 1.8.0-alpha | Decided 2026-07-08 per tracker | — Pending ship | -| Workstream D (DHT) deferred to v2 | Design-only, tracker-marked backlog; not needed for north-star metric | — Pending | -| App manifest canonical schema = `core/container/src/manifest.rs` | SPEC self-declares code wins over doc | ✓ Good | -| Phase-3 Quadlet flip gated on multinode gate reporting clean | Prior uncommitted-flip confusion; flip fresh as a 2-line change when gate is clean | — Pending | - ---- -*Last updated: 2026-07-29 after intel ingest (10 ADRs + 1 SPEC) + codebase mapping* diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md deleted file mode 100644 index 7542bda1..00000000 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ /dev/null @@ -1,554 +0,0 @@ -# Release 1.7.121 — task list - -Opened 2026-08-03, immediately after v1.7.120-alpha shipped. Everything the operator has -asked for since, plus the items v1.7.120 deliberately left open. Ordered by severity. - -Status key: **DONE** (committed) · **READY** (written, not yet committed/tested) · -**OPEN** (not started) · **BLOCKED** (needs an operator decision) - ---- - -## P0 — Security - -### 1. App ports are reachable with no login, on every transport — **OPEN** -> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be -> allowed… it must present the login to access the app with an app icon of what you're -> accessing to confirm, and 2FA if present" — operator, 2026-08-03 - -- Applies to **Tailscale, LAN, Tor, FIPS** alike, and to "ssh access to that port or whatever". -- Required behaviour: an unauthenticated request to any app port serves a **login page - naming and showing the icon of the app being accessed**, then honours **2FA when set**. -- **Research first:** how umbrelOS and StartOS gate app access (operator asked explicitly). - Both are open source — `getumbrel/umbrel` and `Start9Labs/start-os`. Do not guess at - their model; read it. -- This is the same class as the v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but - **fleet-wide across every app port** rather than two endpoints. Those two were closed by - moving authorisation to the resource; this needs a general gate. -- Scope note: `fips/app_ports.rs` holds the mesh allowlist; `is_peer_allowed_path` in - `server.rs` holds the peer HTTP allowlist. Neither currently authenticates app ports. - -#### Research — umbrelOS (verified from their docs/source, 2026-08-03) - -umbrelOS solves this **architecturally, not per-app**: the app's own port is never -published. Each app gets a sidecar `app_proxy` container that owns the published port and -forwards to the app on the internal network. - -- `containers/app-proxy` is described as *"a transparent HTTP proxy to add authentication - to Umbrel apps"* — **every** HTTP request and WebSocket upgrade passes through it and - has its session token checked. -- Tokens come from a separate `app-auth` service; the proxy talks to it over a local port - (default 2000) with a shared secret (`UMBREL_AUTH_SECRET`). Two JWTs exist: an **API - token** in localStorage (`{loggedIn: true}`) for the dashboard's own API, and a - **proxy token** in an **HttpOnly cookie** (`{proxyToken: true}`) for app access. Both - HS256, 7-day expiry. -- Unauthenticated requests are redirected to the login screen. -- Per-app escape hatches, all env vars on the proxy: `PROXY_AUTH_ADD` (bool, **default - true** — so apps are protected unless opted out), `PROXY_AUTH_WHITELIST` (paths exempt, - e.g. `/public/*`), `PROXY_AUTH_BLACKLIST` (paths that must be authed, e.g. `/admin/*`). -- Known friction worth designing around: apps with their own login (Frigate, and the - `PROXY_AUTH_ADD=false` tracker issue) end up double-authenticating, and non-browser API - clients (Home Assistant hitting an app's API) break because they have no cookie. Any - gate we build needs a story for machine clients, not just browsers. - -**The lesson for us:** the reason umbrel doesn't have this bug class is that there is no -unauthenticated path to bind to in the first place. Our apps publish their own ports -directly, so a gate bolted onto one transport leaves the others open — which is exactly -the shape of the `/lnd-connect-info` + `/bitcoin-rpc/` leaks. The fix likely has to move -the port binding, not just add a check. - -#### Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix - -No session cookie, over the Tailscale IP `100.69.68.39`: - -``` -port 18083 HTTP 200 LND - Archipelago -port 8334 HTTP 200 -port 8175 HTTP 200 Fedimint Guardian - Archipelago -port 8336 HTTP 200 FIPS Mesh -port 8090 HTTP 200 -port 7777 HTTP 200 -``` - -`ss -tlnp` confirms these are bound `0.0.0.0`, so the same responses are served on the LAN -IP and every other host address. Re-run this exact loop after the fix: every one must -become the login page, and the ports listed as protocol exemptions (item 1b) must be the -*only* ones still answering. - -#### Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03) - -All four transports converge on `127.0.0.1:`. This is the whole reason the fix -is tractable: it is **one gate, not four**. - -| Transport | Path to the app | Code | -|---|---|---| -| LAN / Tailscale | container publishes the port on the host (`--network host`, so `0.0.0.0:`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` | -| FIPS mesh | daemon binds `[fips0-ULA]:` and raw-TCP-forwards to `127.0.0.1:` | `server.rs:1130` `app_port_v6_relay_loop` | -| FIPS firewall | `tcp dport { …APP_LAUNCH_PORTS… } accept` drop-in opens them all | `fips/config.rs:274`, `fips/app_ports.rs` | -| Tor | `HiddenServicePort 80 127.0.0.1:` per service | `api/rpc/tor/mod.rs:243` | - -#### Design decision (operator, 2026-08-03) - -**Gate app UIs + bearer tokens; protocol ports exempt.** HTTP app UIs get the login gate -(app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002, -bitcoin p2p 8333) stay open but MUST be declared `auth: none` with a rationale in the -manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app -long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). **Zeus -and electrum wallets keep working untouched** — that was the deciding constraint. - -The gate lives in the **daemon**, not a per-app sidecar container (umbrel's `app_proxy` -model): rootless, no extra containers per app, one place to update, and it can reuse the -existing `app_port_v6_relay_loop` rather than fight it. - -#### ⚠️ Trap found while designing — an nft-only gate FAILS OPEN - -The obvious implementation is an nft redirect of inbound app-port traffic to the gate. -But `/etc/fips/fips.nft` is **provisioned out-of-band** and `fips/config.rs:290` treats its -absence as a no-op (`if try_exists("/etc/fips/fips.nft")`). A gate shipped as a `fips.d` -drop-in would therefore be **silently absent on every node without the hardening -baseline** — i.e. it fails open, which is exactly the failure class this item exists to -close. - -Two viable shapes, both fail-closed: -- **(a) Apps bind loopback only**, daemon owns every external bind. Airtight, the true - umbrel model, but requires touching each app's own listen config (nginx.conf etc.). - Note you *cannot* half-do this: while an app holds `0.0.0.0:`, the daemon cannot - bind `:` at all. -- **(b) Daemon owns a dedicated `archipelago-appgate` nft table** with its own - default-deny + redirect, independent of whether `fips.nft` exists, and refuses to start - / alarms loudly if it cannot install it. Non-invasive to apps. - -#### Enabler found — `PortMapping.bind` already does half of (a) - -`core/container/src/manifest.rs:518` — `PortMapping` has a `bind` field, documented as -*"Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set `127.0.0.1` -to keep a port host-local"*. So for **bridge apps that declare `ports:`**, going -loopback-only is a **manifest edit, not app surgery**, and the daemon can then own the -external bind. That is most of the catalog. - -The exception is **host-networked apps** (`security.network_policy: host` — `lnd-ui`, -`bitcoin-ui`, `electrs-ui`): host networking bypasses port mapping entirely, so `bind` has -no effect and `ports:` is deliberately empty. Those bind whatever their internal nginx -binds. We build those images ourselves, so the fix is a `listen 127.0.0.1:;` change -in each `docker/*-ui/nginx.conf` — still no third-party surgery. - -Watch the rootless trap documented at `manifest.rs:532`: a publish bound to an address the -host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228, -2026-07-09). Loopback binds are explicitly always accepted without probing, so this -direction is safe. - -**Tor needs separate handling either way**: the onion connects *from* localhost, so a -redirect that exempts loopback will not catch it. `HiddenServicePort` must be repointed at -the gate, and since that mapping loses the original destination port, each app needs its -own gate port (or an HTTP-level Host mapping). - -#### Primitives that already exist — do NOT build these from scratch - -The gate is mostly assembly, not invention: - -| Need | Existing API | -|---|---| -| Read the session cookie off a request | `session::extract_session_cookie(&HeaderMap) -> Option` (`session.rs:479`) | -| Validate a session | `SessionStore::validate(&token) -> bool` (`session.rs:194`) | -| **Honour 2FA** | Already modelled: `create_pending(totp_secret)` (`:176`) + `upgrade_to_full` (`:247`). A session still pending 2FA **fails `validate()`**, so the gate gets 2FA for free by calling `validate` — no TOTP code in the gate itself | -| **Machine-client bearer tokens** | `device_tokens::create/verify` (`device_tokens.rs:63/:90`) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs **per-app scoping** added for this use | -| Rate limiting | `device_tokens` verification already rides `auth.login`'s limiter | - -So the new code is: the listener/redirect, the app-identification step (which app is this port?), -the login page render (app name + icon), and per-app scoping on `device_tokens`. - -#### Research — StartOS: **DROPPED** (operator, 2026-08-03) - -"don't need the startOS research we decided on a approach already." The umbrelOS read -plus the design decision above settled it; no further prior-art work. - -### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed) - -`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and -`auth_rationale`. The default is the protected one, so exposure is now something a -manifest has to ask for rather than something it gets by saying nothing. - -Validation is two-sided: `auth: none` without a rationale is rejected, **and** a -rationale without `auth: none` is rejected — that combination means the author wrote an -exemption and did not get one, and shipping it silently would leave them believing -otherwise. - -**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8 -noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is -what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three -Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353, -SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.** - -Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the -gate never sees it, and claiming an exemption it does not need would put a meaningless -line in the audit list. If that bind is ever dropped it fails closed. - -Two corpus tests pin this: every shipped manifest must parse, and the exempt set is -frozen at 17 so the node's unauthenticated surface cannot grow by accident. - -### 1c. The gate itself — **IN PROGRESS** - -`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs -exempt, re-read from manifests so a catalog refresh applies without a restart), -`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the -external addresses, sweeps every 60s). - -Design points worth not re-deriving: - -- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`, - `verify_code` + used-step replay protection, `SessionStore::create/create_pending/ - upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path. - Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected - to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker - gets a fresh budget of password guesses by moving to an app port. -- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate - rejects it without knowing anything about second factors. -- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one - sign-in covers the dashboard and every app port on the same host. The corollary is - that an app reached on a *different* host — its own onion — is a separate sign-in. -- **401, not a redirect.** A redirect to a login page is indistinguishable from the app - itself redirecting, and machine clients would follow it and parse HTML as their API - response. -- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use - for the node session and must never be in a position to log or forward it. -- **Machine clients**: `device_tokens` grew `apps: Option>` and - `verify_for_app`. `None` = node-wide (what every existing companion token is — - migrating them by guessing a scope would silently revoke access nobody asked to - revoke); `Some(list)` restricts to those apps. An empty list is rejected rather than - minted, since it would read as "unrestricted" while authorising nothing. - -#### ⚠️ The ordering constraint that shapes the rollout - -A published container port is bound `0.0.0.0:`, which claims **every** host -address. While the app holds that, the gate **cannot** bind `:` at all. -So the gate can only stand in front of an app whose publish has been pinned to loopback -(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible; -all-apps-at-once would recreate every container on the node simultaneously. - -Therefore the rollout is **per app**, and the gate is built to be honest about being -partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded -in `GateStatus::unprotected`. The failure mode this exists to prevent is a gate that -binds nothing, logs at debug, and reports success while every app stays exactly as open -as before — worse than no gate, because it stops anyone looking. (Same reasoning that -killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence -is a silent no-op.) - -**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint -`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt -redirect will not catch it, and the mapping loses the original destination port), gate -the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node. - -### 2. Filebrowser ships an insecure default login — **OPEN** -- Change the default credential **without breaking the dashboard's Cloud view**, which - authenticates to filebrowser on the user's behalf. -- Related prior art: FED-07 rotated the shipped Fedimint gateway credential and had to - recreate the running container for it to take effect (`06e0e695`) — the same trap - applies here. - -### 3. Federation trust escalation — **DONE** (`c0cfc72a`, pushed) -Two independent fail-open paths granted `Trusted` without any operator decision: - -- `federation.peer-joined` is **unauthenticated** (middleware no-session list) and - peer-reachable on `/rpc/v1`. Its ed25519 check verifies the caller against **the pubkey - the caller supplied**, so it proves key possession, never authorisation. A join with no - `invite_token` fell through to `TrustLevel::Trusted.min(claimed_trust)`, and - `claimed_trust` defaults to `Trusted` — so anyone able to reach the node could - self-grant Trusted. **Now capped at `Observer`.** -- `merge_transitive_peers` added every peer advertised by a Trusted source as `Trusted`, - making trust viral across the whole federation graph. **Now `Observer`** — which is what - `NodeStateSnapshot.federated_peers`' own doc comment always said it should be - ("adds them as Observers on her side… doesn't auto-promote to Trusted"). The code - contradicted its own spec. -- Added `FederatedNode.trust_source` (`invite` | `uninvited-join` | `transitive-merge` | - `manual`, `None` = pre-existing/unknown) so existing grants are **auditable**. Per - operator decision: existing peers are **left alone, not auto-demoted**. -- `trust_source` is now **surfaced** in `federation.list-nodes` (as an explicit `null` - when unknown, not omitted — "recorded before this was tracked" is the population that - needs review, so the UI must be able to tell it apart from a field it didn't read) and - rendered under the trust dropdown in the node detail modal as "Granted via:". - -### 3b. Granting Trusted must require the node password — **DONE** (uncommitted at time of writing) -> "to make someone trusted must require the node password to generate the code or change -> in the modal dropdown when you click a node" — operator, 2026-08-03 - -Re-authentication on privilege escalation. Both entry points are covered: - -- **Minting a Trusted invite** (`federation.invite`) — gated on the **resolved** level, - which matters because "Link Your Nodes" sends no `trust_level` at all and falls through - to the `Trusted` default. The invite is a bearer grant of Trusted to whoever redeems - it, so minting it *is* the escalation. Observer invites are untouched. -- **Changing a node's level in the UI dropdown** (`federation.set-trust`) — gated only - when the peer is **not already** Trusted, so the dropdown re-emitting its own value - doesn't demand a password for a no-op. - -Demotion is NOT gated: making something less privileged must never be harder than leaving -it, or the safe action becomes the inconvenient one. The operator path stamps -`TrustSource::Manual`; `set_trust_level` grew an `Option` so automatic -adjustments (the discovery-handshake demotion safety net) pass `None` and leave the -recorded provenance alone rather than laundering an `uninvited-join` peer into looking -operator-approved. - -Wiring: the backend is the sole authority on what counts as an escalation — it returns a -`PASSWORD_REQUIRED:` prefixed error, and the UI prompts and retries only on that. The -frontend never pre-judges, so the rule lives in exactly one place. -`TrustPasswordModal.vue` (modelled on `RotateDidModal.vue`) serves both flows. -`NodeDetailModal`'s select now snaps back to the node's real level on change, because a -cancelled or failed promotion would otherwise leave the dropdown displaying a level the -node never accepted. - -**Follow-up, deliberately not done here:** `federation.join` also grants Trusted (when -redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator -paste rather than a UI toggle, and was outside the two entry points specified — but it is -the third way a node reaches Trusted and should be reviewed. - ---- - -## P1 — Correctness the operator hit directly - -### 4. LND UI never updates over OTA — **DONE** (`5088aef5`, pushed) -- `LND_UI_IMAGE` was `lnd-ui:latest` while `BITCOIN_UI_IMAGE` was pinned to - `1.7.119-alpha`. Podman will not re-pull a tag it already holds locally, so nodes kept a - stale lnd-ui forever. **Now pinned to `1.7.119-alpha`.** -- `scripts/first-boot-containers.sh` declared lnd-ui as bridge `-p 18083:80`. That is the - **third copy** of the declaration the UI agent already corrected in - `scripts/container-specs.sh` and `apps/lnd-ui/manifest.yml` — so **fresh installs** still - produced the reproduced `HTTP 000`. **Now `--network host`, ports empty.** -- Root cause worth fixing separately: the same container spec is declared in three places. - -### 5. Federated/peered nodes must message without a LoRa hop first — **OPEN** -> "make it so federated/peered nodes can message without needing to connect on Lora first -> once connected" - -- Investigate the split contact model (radio contact vs federation peer) — there is prior - art in memory: `project_archy_lora_e2e_rootcause` ("split contact model; don't touch - federation") and `mesh::seed_federation_peers_into_mesh` / - `upsert_federation_peer`, which already mirror federation peers into the mesh table. -- Likely the gap is addressing/route selection rather than transport availability. - -### 6. In-app app updates, independent of OTA — **OPEN** -> "we need app update to see updates in the registry, whether UI or not… show the update -> mechanism in the app… a modal and update now / cancel… same in the detail page… the -> update button should show 'see update' and a different graphic for just ui, app, or both -> together. All pushed through the signed-catalog flow." … "This has to show independent of -> OTA updates as a separate pipeline, I think we've done a lot of work on it." - -- **Operator says much of this already exists — research the codebase before building.** - Known groundwork: the signed catalog (`releases/app-catalog.json`, `sign-catalog.sh`), - catalog→manifest runtime reload, `package.update` RPC, `check-app-catalog-drift.py`, - and `scripts/image-versions.sh` pinning. - -#### What already exists (verified in source, 2026-08-03) — the operator was right - -The whole update *pipeline* is built and is already independent of OTA: - -- `package.check-updates` (`api/rpc/package/update.rs:180`) refreshes the signed catalog - and hot-reloads manifests when it changed — no daemon restart, no OTA involved. -- `package.update` (`spawn_package_update`), `package.versions`, `package.set-config` - version pinning, and `execute_update` (stop → pull → remove → recreate → verify). -- Version awareness: `app_catalog::catalog_versions(app_id)` vs `installed_version()` - (`api/rpc/package/set_config.rs:46`). -- Frontend: `AppCard.vue` already renders an Update button off `pkg['available-update']` - (`:48`, `:128`) and emits `update`. - -#### What is actually MISSING (this is the real scope of item 6) - -1. **The UI-vs-app-vs-both distinction does not exist.** `available-update` is a single - version string — nothing classifies whether the change is the app image, its `*-ui` - image, or both. This is the core of the operator's ask ("a different graphic for just - ui, app, or both together") and needs a backend change, not just an icon. - ⚠️ Compounding factor: per `reference_app_ui_delivery_model`, `*-ui` apps are **not in - the signed catalog** at all — so "is there a UI update" cannot be answered from the - catalog today. That gap has to be closed first or the UI half is unanswerable. -2. **The modal** (Update now / Cancel) — the card currently updates on click, no confirm. -3. **The detail-page affordance** — same treatment as the card. -4. **Button copy**: "See update" rather than "Update". - -### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03) -> "we also need a way to provide multiversion support for all apps and it automatically -> pulls the latest versions from the source app repository, safely, and the user can -> choose to update so we aren't always updating manually" - -#### Verified 2026-08-03: the schema and runtime already exist - -This is much less work than it sounds, because the multiversion machinery built for -Bitcoin generalises as data rather than code: - -- `releases/app-catalog.json` entries already support a `versions[]` array of - `{version, image, default?, deprecated?}`. -- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`, - `catalog_image_for_version`, `package.versions`, version pinning through - `package.set-config`, and `available_update_for_app` falling back to the - `image-versions.sh` baseline pin. - -**It is populated for 2 of 66 apps** — `bitcoin-core` (9 versions) and `bitcoin-knots` -(5). Every other app carries a single `version`. So "multiversion for all apps" is -primarily a **catalog-generation and image-mirroring job**, not new runtime plumbing. - -#### What has to be built - -1. **Populate `versions[]` fleet-wide.** Extend `scripts/generate-app-catalog.py` to emit - a version list per app instead of a single pin. Needs a per-app policy for how many - historical versions to carry and which is `default` (Bitcoin's list shows the shape, - including `deprecated: true` for old-but-installable). -2. **Mirror the images.** A version in the catalog that is not in our registry is a - broken promise — `package.update` would pull and fail. Use the existing skopeo path - (`feedback_skopeo_source_selection`: prefer `.160`, concurrency ≤ 6). -3. **An upstream release-watcher.** 48 manifests already carry a `repo:` URL under - `metadata`, so there is something to poll (GitHub releases / registry tags). It runs - **off-node**, as part of catalog generation. -4. **Keep the signed catalog as the trust boundary.** This is the whole of "safely": - the watcher **proposes** versions, the offline signing ceremony **admits** them, and - nodes only ever install what the signed catalog carries. A node must never pull - straight from an upstream repo — that would put an unsigned third party inside the - supply chain, which is exactly what the signed-registry model exists to prevent. -5. **The user chooses.** Discovery must never auto-apply. `package.check-updates` already - refreshes and hot-reloads without touching the running containers, so "a new version - exists" and "install it" stay separate — which is also what item 6's modal is for. - -**Sequencing note:** 6b's step 1 and item 6's UI-vs-app classification want the same -thing — `*-ui` images represented in the catalog. Doing that once unblocks both. - ---- - -## P2 — Carried over from v1.7.120 - -### 7. `create-release.sh` commits the manifest BEFORE signing — **OPEN** -Release commit always carries an **unsigned** manifest; nodes fetch it from branch `main` -and refuse to auto-apply. Caught manually this cycle. Fix the ordering so it cannot ship. - -### 8. `gitea-vps2` remote is dead, and is the same server as `gitea-ai` — **OPEN** -Stored token fails auth. `source.archipelago-foundation.org` == `146.59.87.168`, so -`git push gitea-ai` already publishes to the "primary" OTA host. Ties into the existing -"migrate VPS2 IP to domain" todo. - -### 9. Fleet SSH host-key rotation — **BLOCKED** (operator decision) -`archipelago-1`, `archy-x250-beta`, `archipelago` share all three SSH host keys; two also -share a TLS private key. Detection shipped; rotation deliberately not performed. - -### 10. 5× lifecycle gate — **OPEN** -Not run for v1.7.120 (disclosed in its changelog). Needs repeated reboots of a live node. - -### 11. `prod_orchestrator.rs:3181` unreachable code — **OPEN** -`bitcoin_host()` returns unconditionally at :3171, so the podman container-name lookup -below is dead on every path. Pre-existing; spotted in the v1.7.120 build warnings. - ---- - -## Notes for whoever picks this up - -- A separate agent is doing **AIUI planning with GSD** — do not touch AIUI. -- AIUI must always be built `VITE_BASE_PATH=/aiui/` (see the memory note); a hand-built - bundle renders a black page. -- Verify security claims on the node, not from the source. v1.7.120's headline bug was a - fix that shipped in the binary and silently never reached the running container. - ---- - -## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not - -### Shipped (committed + pushed) - -| Item | Commit | Verified | -|---|---|---| -| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests | -| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc | -| 4. lnd-ui OTA pin + host networking | `5088aef5` | — | -| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse | -| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate | -| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** | -| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer | -| Gate safety model (`Option`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case | -| Companion rebuild-loop | `719446c0` | podman behaviour proven first | -| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types | - -### The two gate incidents — read before touching the gate again - -Both were ONE mistake: a safety decision read an ABSENT manifest field as a -value. A node's installed manifests always lag the binary, so "absent" is the -normal state, and the daemon acted on instructions no manifest ever gave. - -1. Gating any `session` port regardless of `bind` **published Bitcoin's - loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy. -2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`) - but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container - recreate from pinning them to loopback and **breaking Zeus and every remote - wallet**. - -Now structural: `auth_policy()` classifies (undeclared → reported as -unprotected, always safe), `auth_is_declared()` gates action (undeclared → -never acted on). **Silence is not consent.** - -### Proven on the node, empirically, not by reasoning - -- Gate challenge → login → proxy works end to end over LAN and Tailscale. -- **Daemon-side publish rewriting was removed.** Publishes are built in several - places (`podman_client`, `package::install`, `stacks`); patching one covered - one — the strfry recreate went through another and the pin never fired. -- **Disk manifest edits do not apply to catalog-covered apps.** Even - `bind: 127.0.0.1` written into the node's strfry manifest was overridden by - the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut. -- A loopback-bound host port is **unreachable** from a pasta container, so - loopback-pinning the Wyoming ports would break Home Assistant voice. - -### Open for 1.7.122 - -1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated - UI ports. This is what turns the gate from auditing into enforcing. Nothing - in code can substitute for it. -2. **Release-root rotation** — branch `rotate-release-root`, key - `did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point. - **Sign the rotation release with the OLD key**; only the release after it - uses the new one. Re-sign the catalog too. -3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the - operator's policy they should not be. Correct fix is co-locating Home - Assistant with the pine services on one container network so nothing is - published; needs a node running both. -4. **Item 2** filebrowser default login. **Items 6/6b** app updates + - multiversion (`versions[]` already exists, populated for 2 of 66 apps). -5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise - the cap or split the stage; it is not a code failure. - -## RESUME HERE — next session - -**Landed this session (both pushed):** -- `c0cfc72a` federation trust escalation (items 3) — 42/42 federation tests green -- `5088aef5` lnd-ui OTA pin + host networking (item 4), and this task file - -**v1.7.120-alpha is SHIPPED** — signed, published, assets verified live. Do not re-cut it. - -### Start with item 3b (password gate) — groundwork already located - -Everything needed to implement it, so the next session does not re-search: - -- **The helper to use:** `self.auth_manager.verify_password(password).await?` — returns - `bool`. Existing callers to copy the shape from: `api/rpc/node.rs:176`, - `api/rpc/totp.rs:18` / `:66` / `:121`. -- **Entry point A — minting a Trusted invite:** `handle_federation_invite`, - `api/rpc/federation/handlers.rs:58`. It reads `trust_level` from params and - **defaults to `TrustLevel::Trusted` at :72**. Gate only when the resolved level is - `Trusted`; leave Observer invites unchanged. -- **Entry point B — the UI dropdown:** `handle_federation_set_trust`, - `api/rpc/federation/handlers.rs:326`, dispatched as `"federation.set-trust"` - (`api/rpc/dispatcher.rs:353`). Its parse is at `:342`. -- **Rule:** gate PROMOTION to Trusted only. Demotion must stay ungated — making something - less privileged must never be harder than leaving it. -- Set `TrustSource::Manual` on the operator path so the audit trail distinguishes a - deliberate grant from the capped automatic ones. -- Frontend will need the password prompt in both places (invite modal, node dropdown). - -### Then item 1 (app ports unauthenticated) — the big one - -Start with the research the operator explicitly asked for: how **umbrelOS** -(`getumbrel/umbrel`) and **StartOS** (`Start9Labs/start-os`) gate app access. Read their -model rather than inventing one. Only then design the gate. - -Give this a fresh session with real context — it is the largest item here and is the same -bug class as the `/lnd-connect-info` + `/bitcoin-rpc/` leaks fixed in v1.7.120, but across -every app port and every transport. - -### Working notes -- A separate agent is doing **AIUI planning with GSD** — do not touch AIUI. -- The shared tree has concurrent agents: stage by explicit path, never `git add -A`. -- Verify security claims **on the node**, not from source. v1.7.120's headline bug was a - fix that shipped in the binary and silently never reached the running container. -- A piped command's exit code is the pipe's, not the script's — redirect to a log file and - read the content. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md deleted file mode 100644 index cd48075e..00000000 --- a/.planning/REQUIREMENTS.md +++ /dev/null @@ -1,171 +0,0 @@ -# Requirements: Archipelago (v1.8.0 — Developer-Ready App Platform) - -**Defined:** 2026-07-29 -**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust. - -No PRDs existed in the ingest set; these requirements are derived from the master plan's -declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase/CONCERNS.md`, -`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from -`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built. - -## v1 Requirements - -### Federation & Mesh Hardening (FED) - -- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op) -- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible -- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason -- [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist -- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy -- [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows -- [x] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel -- [x] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring -- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) - -### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30 - -- [ ] **UIFIX-01**: The FIPS/Tor pills on cloud files are kept (never removed by cleanups) and render at mobile widths — on mobile, users can see each file's security/transport state (BLOCKER) -- [x] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER) -- [x] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER) -- [x] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX -- [x] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it) -- [x] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02) - -### UI Performance (PERF) - -- [x] **PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed -- [x] **PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session -- [x] **PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box - -### Multinode Verification (MNODE) - -- [ ] **MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy -- [ ] **MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs) -- [ ] **MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles - -### Lifecycle Perfection (LIFE) - -- [ ] **LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm) -- [ ] **LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping -- [ ] **LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output -- [ ] **LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy -- [ ] **LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar - -### Registry-Distributed Manifests (REG) - -- [ ] **REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback -- [ ] **REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync - -### Security Enforcement (SEC) - -- [ ] **SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides -- [ ] **SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps - -### Developer Tooling (DEV) - -- [ ] **DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules) -- [ ] **DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces -- [ ] **DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`) -- [ ] **DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs - -### Decentralized Marketplace (MKT) - -- [ ] **MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling -- [ ] **MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring -- [ ] **MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed -- [ ] **MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees - -### AIUI — Conversational Node Control & Content Surfaces (AIUI) — added 2026-08-03 - -- [ ] **AIUI-01**: Human-language node control — a typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result, over a permissioned tool-calling bridge rather than raw RPC -- [ ] **AIUI-02**: Conversational settings — the system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted -- [ ] **AIUI-03**: Content surfaces made real — AIUI's designed-but-empty content views render live node data (peer files, music, IndeeHub movies, owned/paid content); audio belongs to the global bottom-bar player and media streams via Range requests, never base64 blobs -- [ ] **AIUI-04**: Sandboxed by construction, permissioned by the user — secrets never reach the browser or the model context; the chat gets an explicit, user-granted, default-closed, revocable capability scope; destructive and identity-touching operations are human-confirmed; tool authority never derives from peer-controlled content (BLOCKER) -- [ ] **AIUI-05**: Delivery and build — AIUI reaches nodes on a delivery path an operator can actually receive updates through, with `VITE_BASE_PATH=/aiui/` enforced by the build script so a hand-built bundle cannot ship a black page -- [ ] **AIUI-06**: Verified on device — in the real embedded iframe on archi-dev-box, mobile included, not only in the local `dev:mock` loop - -## v2 Requirements - -Deferred to a future milestone. Tracked but not in the current roadmap. - -### Distribution Backbone (DIST) - -- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog) - -### Fleet & Hardening (FLEET) - -- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`) -- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage -- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/` -- **FLEET-04**: Dynamic per-app resource rebalancing (cgroup-stats feedback loop) - -## Out of Scope - -| Feature | Reason | -|---------|--------| -| Rootful/privileged containers, Docker | Invariant — ADR-001/ADR-009 | -| Per-app Rust installers / host provisioning | The anti-pattern workstream A deleted | -| Centralized gatekept app store | ADR-006 chose decentralized Nostr marketplace | -| Web5 DWN spec compliance | ADR-011 — deprioritized after TBD shutdown | -| Custom live voice-call protocol | Deprioritized 2026-07-01 per user; no scope decided | - -## Traceability - -Which phases cover which requirements. Updated during roadmap creation. - -| Requirement | Phase | Status | -|-------------|-------|--------| -| FED-01 | Phase 1 | Pending | -| FED-02 | Phase 1 | Pending | -| FED-03 | Phase 1 | Pending | -| FED-04 | Phase 1 | Complete | -| FED-05 | Phase 1 | Pending | -| FED-06 | Phase 1 | Complete | -| FED-07 | Phase 1 | Complete — rotation + recreate verified on archi-dev-box 2026-08-02 | -| FED-08 | Phase 1 | Code complete + unit-pinned; post-OTA check on the user device pending | -| FED-09 | Phase 1 | Complete — 15h Tor uptime / 0 permission-fixes on archi-dev-box; onion-resolution check post-OTA | -| UIFIX-01 | Phase 1 | Pending | -| UIFIX-02 | Phase 1 | Complete | -| UIFIX-03 | Phase 1 | Complete | -| UIFIX-04 | Phase 1 | Complete | -| UIFIX-05 | Phase 1 | Complete | -| UIFIX-06 | Phase 1 | Complete | -| PERF-01 | Phase 2 | Complete | -| PERF-02 | Phase 2 | Complete. 02-11 (`02-FINDINGS.md` § Client-Side Render Cost Root Cause + § Task 3) named and fixed the real cause of Web5/Server's revisit-ms regressions — three leaked background pollers (`useFleetData.ts`, `FipsNetworkCard.vue`, `Web5Monitoring.vue`) armed in `onMounted` and never disarmed once their owning views joined `KEEP_ALIVE_PATHS`, gated to activate/deactivate. Web5 now fixed (275ms, below both its 566ms pre-phase-2 baseline and the 300ms pass bar); Server's regression is closed (574ms, below its 738ms baseline) though not yet under the 300ms stretch target — residual named as real, un-eliminated per-resource reactivation cost, not a new defect | -| PERF-03 | Phase 2 | Complete. 02-11 fixed Fleet's leaked `useFleetData.ts` poll (790ms, down from a 2631ms regression, substantially closing the gap to its 330ms baseline). AppDetails restored to at/near its own baseline (1231ms vs. 1204ms) — residual is the already-documented `useCachedResource` per-mount setup cost, not fixed further. Discover (1389ms) has a SECOND, distinct, evidenced cause found this session (CSS entrance-animation replay on KeepAlive reactivation, `card-stagger`/`showStagger` never removed from the DOM) — named with full profiling/diagnostic evidence but NOT fixed (blast radius spans 5+ files outside this plan's scope, needs its own real-device verification budget) — recommended as a dedicated follow-up. OpenWrtGateway: not measurable this pass (Chromium crash cascading from an unrelated surface); prior numbers stand, confirmed to reflect a real (not empty) disconnected-device UI render, not retracted | -| MNODE-01 | Phase 3 | Pending | -| MNODE-02 | Phase 3 | Pending | -| MNODE-03 | Phase 3 | Pending | -| LIFE-01 | Phase 4 | Pending | -| LIFE-02 | Phase 4 | Pending | -| LIFE-03 | Phase 4 | Pending | -| LIFE-04 | Phase 4 | Pending | -| LIFE-05 | Phase 4 | Pending | -| REG-01 | Phase 5 | Pending | -| REG-02 | Phase 5 | Pending | -| SEC-01 | Phase 6 | Pending | -| SEC-02 | Phase 6 | Pending | -| DEV-01 | Phase 7 | Pending | -| DEV-02 | Phase 7 | Pending | -| DEV-03 | Phase 7 | Pending | -| DEV-04 | Phase 7 | Pending | -| MKT-01 | Phase 8 | Pending | -| MKT-02 | Phase 8 | Pending | -| MKT-03 | Phase 8 | Pending | -| MKT-04 | Phase 8 | Pending | -| AIUI-01 | Phase 13 | Pending | -| AIUI-02 | Phase 13 | Pending | -| AIUI-03 | Phase 13 | Pending | -| AIUI-04 | Phase 13 | Pending | -| AIUI-05 | Phase 13 | Pending | -| AIUI-06 | Phase 13 | Pending | - -**Coverage:** - -- v1 requirements: 35 total -- Mapped to phases: 35 -- Unmapped: 0 - ---- -*Requirements defined: 2026-07-29* -*Last updated: 2026-07-29 — added FED (federation/mesh hardening) and PERF (UI performance) requirement groups; phases renumbered after inserting them as Phases 1–2* diff --git a/.planning/RESUME-2026-08-05-appgate-fixes.md b/.planning/RESUME-2026-08-05-appgate-fixes.md deleted file mode 100644 index 250900e9..00000000 --- a/.planning/RESUME-2026-08-05-appgate-fixes.md +++ /dev/null @@ -1,113 +0,0 @@ -# Resume — 2026-08-05 (app gate, releases .122–.125) - -Paste the block at the bottom into a new session. - -## Where things stand - -- **v1.7.124-alpha is SHIPPED** (signed with the NEW root, published, verified). -- **Signed catalog is LIVE** carrying two hotfixes made after .124: - the repaired bitcoin start script and the fedimint 8175 removal. - Last commit: `4ace62fa`. -- **Release-root rotation is COMPLETE.** .122 was the last release signed with - the old key; .123/.124 and all catalogs use the new one. No override needed. - -## Two bugs I introduced in .124 (both fixed, both instructive) - -1. **Bitcoin vanished from every node.** I put a `#` comment INSIDE the - manifest's folded YAML scalar (`>-`), where `#` is not a comment — it - reaches the shell, and folding joins lines with spaces so it commented out - the `if ... then` while the more-indented `echo` survived, leaving an orphan - `fi`. Container exited instantly; app detection is container-based so the - app disappeared. **Guard added:** `scripts/check-manifest-shell.py` runs - `sh -n` over every embedded manifest script and rejects `#` in these - scalars; wired into `tests/release/run.sh`. -2. **Fedimint crash-looped.** I declared port 8175 on the `fedimint` app so the - gate could name it — but 8175 is served by the separate `archy-fedimint-ui` - companion. The orchestrator then tried to publish 8175 from fedimintd, - collided, and `start_container` failed forever. Removed. **Rule: never - declare a port on an app whose container does not actually serve it.** - -Also: I published an UNSIGNED catalog at one point, which nodes correctly -reject — they silently keep their old cached copy. **Always verify -`'signature' in catalog` on the live URL after publishing.** - -## OPEN TASKS - -1. **indeedhub crash-loop — NOT mine, needs a real fix.** `indeedhub-minio` is - **absent** on `.38` and `.88`, so nginx fails with - `host not found in upstream "minio"` and both `indeedhub` and - `indeedhub-api` exit(1). The stack member never gets created. Look at - `api/rpc/package/stacks.rs` + `dependencies.rs`. -2. **Verify `.38` refetched the signed catalog** and bitcoin-knots starts. - `.88` already did (signed: True, script fixed). -3. **Deploy the .125 build to archi-dev-box for operator confirmation.** - Binary is built at `core/target/release/archipelago` with: app-login page - using the sidebar **A mark** (`favico-black-v2.svg`) not the wordmark; - page pinned to `100svh` + `position:fixed` so mobile stays centred and the - keyboard overlays instead of scrolling; install-version modal icon uses - `object-contain` so non-square icons are not cropped. **Operator has not - seen these yet.** -4. **Cut v1.7.125-alpha** once confirmed. Sign with the **NEW** mnemonic. - -## Traps that cost time today - -- `create-release.sh` says "sign, then re-run" — **re-running regenerates the - manifest and DESTROYS the signature**, and its clean-tree check blocks - anyway. Do steps 7/8 by hand: `git add` version+changelog+manifest → - commit `chore: release vX` → `git tag -a vX` → push main → **push the tag - explicitly** → `git ls-remote --tags` to prove it → `publish-release-assets.sh`. -- The release gate's `cargo-test-weekly` times out on the **compile** after any - version bump. Pre-warm: `CARGO_INCREMENTAL=0 cargo test --manifest-path - core/Cargo.toml -p archipelago --no-run`. -- The frontend version check fails until the in-app **What's New** block for - that version exists (`neode-ui/src/views/settings/AccountInfoSection.vue`) — - that string is what it greps for. -- `generate-app-catalog.py` writes `APP_LAUNCH_PORTS` one-per-line; rustfmt - packs it, so run `cargo fmt` after any catalog sync or the gate fails. -- **Manifest changes reach nodes via the SIGNED CATALOG, not the binary.** A - manifest hotfix needs only a catalog re-sign — no release. - -## Fleet - -SSH: `sshpass -p 'ThisIsWeb54321!' ssh archipelago@` (note the `!`; `@` -is older and still works on some). RPC/node password differs per node — the -`!` one failed RPC login on `.38`. - -- `100.69.68.39` archi-dev-box — dev target -- `100.82.34.38` archipelago-1 -- `100.70.96.88` austin-sapien -- `100.64.204.114` .228 shorty-s — **in real use, treat carefully** - -**Force a catalog refresh on a node:** Settings → App Updates → Check for -updates, or `sudo rm -f /var/lib/archipelago/app-catalog.json && sudo -systemctl restart archipelago`. - -**All fleet nodes were repaired** from `Restart=on-failure` → -`Restart=always`; a node with the old value stays DEAD after an in-process -update (the updater exits cleanly and systemd reads that as success). -`bootstrap::ensure_restart_policy()` now self-heals it. - ---- - -## PASTE THIS INTO THE NEW SESSION - -Resume the archy work from 2026-08-05. Read -`.planning/RESUME-2026-08-05-appgate-fixes.md` and the memory notes -`project_fleet_ota_restart_policy_incident` and -`project_v1_7_121_shipped_appgate` first. - -v1.7.124-alpha is shipped and the signed catalog is live with two hotfixes -(bitcoin start script, fedimint 8175). Four things are open, in order: - -1. Fix the indeedhub crash-loop: `indeedhub-minio` is absent on .38 and .88 so - nginx fails on upstream "minio" and indeedhub + indeedhub-api exit(1). This - one is pre-existing, not from the port work. -2. Verify .38 refetched the signed catalog and bitcoin-knots starts (.88 - already did). -3. Deploy the built .125 binary + frontend to archi-dev-box (100.69.68.39) so - I can confirm the app-login page (A mark, mobile centring, keyboard - behaviour) and the install-modal icon. -4. Then cut v1.7.125-alpha — I sign with the new mnemonic. - -Do not re-run create-release.sh after signing; it destroys the signature — -do the commit/tag/publish steps by hand as the resume doc describes. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md deleted file mode 100644 index 5b42df9f..00000000 --- a/.planning/ROADMAP.md +++ /dev/null @@ -1,395 +0,0 @@ -# Roadmap: Archipelago — v1.8.0 Developer-Ready App Platform - -## Overview - -Brownfield milestone starting from a green single-node production gate (5/5 on .228, -2026-06-23). The journey: make federation and mesh rock-solid (node removal, sync, -messaging parity), fix the UI slowness users feel on every tab switch, prove the platform -across the fleet (multinode pass), make the container lifecycle bulletproof (Quadlet -default, self-healing, honest progress, no ghosts), flip manifest distribution from OTA -disk files to the signed registry, harden manifest security enforcement to the full -ADR-009 bar, ship the `archy app` developer CLI, and land the decentralized Nostr -marketplace — ending at the north star: a third-party developer publishes an app via the -signed/decentralized registry and a user installs it on their node. - -## Phases - -**Phase Numbering:** - -- Integer phases (1, 2, 3): Planned milestone work -- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) - -- [ ] **Phase 1: Federation & Mesh Hardening** - Deep review of federation/fleet + mesh code; node removal sticks, sync converges, mesh messaging behaves identically on demo and real nodes -- [x] **Phase 2: UI Performance** - Tab switches and secondary screens render fast; worst transitions measured and fixed (completed 2026-07-31) -- [ ] **Phase 3: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks -- [ ] **Phase 4: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress -- [ ] **Phase 5: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution -- [ ] **Phase 6: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied -- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide -- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end -- [ ] **Phase 9: BotFights Platform Upgrade** - Native nostr signer login, one self-contained AI bot-setup prompt, shared public VPS2 match endpoint so all nodes see all fighters, registry updated -- [ ] **Phase 12: Bitcoin Node Settings & Core/Knots Parity** - Every bitcoind option reachable in the UI, Knots-only options gated to Knots, network mode a setting defaulting to Tor -- [ ] **Phase 13: AIUI — Conversational Node Control & Content Surfaces** - Human-language node control and settings in AIUI chat, its designed content surfaces wired to real peer/music/movie data, all inside a user-granted capability sandbox that keeps keys and secrets away from the browser and the model - -## Phase Details - -### Phase 1: Federation & Mesh Hardening - -**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs -**Depends on**: Nothing (first phase) -**Requirements**: FED-01, FED-02, FED-03, FED-04, FED-05, FED-06, FED-07, UIFIX-01, UIFIX-02, UIFIX-03, UIFIX-04, UIFIX-05, UIFIX-06, FED-08, FED-09 -**Success Criteria** (what must be TRUE): - - 1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason - 2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping - 3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator - 4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal) - 5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy - 6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears - 7. Fedimint gateway installs have no pre-set password (BLOCKER, added 2026-07-30): a fresh install generates its gateway credentials per-install via manifest-declared `generated_secrets` (per the repo secrets invariant) or requires the user to set one — never a baked-in default; existing installs carrying the default password are migrated or flagged. NOTE: phase 1's 10 plans predate this criterion — an additional gap plan is required before phase 1 execution completes - 8. The FIPS/Tor pills on cloud files are kept and visible at mobile widths (UIFIX-01, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-keep-fips-tor-pills-on-cloud-files-and-show-them-on-mobile.md`) - 9. The connected-nodes list scrolls at row-matched height instead of growing to fit (UIFIX-02, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md`) - 10. The onboarding tickbox is discoverably visible on short viewports via an on-brand affordance (UIFIX-03, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-onboarding-tickbox-hidden-below-fold-on-short-screens.md`) - 11. Paid Files pictures open in the app lightbox, not a browser tab (UIFIX-04, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-peer-files-pictures-open-in-tab-not-lightbox.md`) - 12. Picture-in-picture closes the lightbox with a fluid on-brand animation (UIFIX-05, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-pip-should-close-lightbox-with-fluid-animation.md`) - 13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory) - NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes) - -**Plans**: 11/20 plans executed - -Plans: - -- [x] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09) -- [x] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08) -- [x] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01) -- [x] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04) -- [x] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06) -- [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05) -- [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02) -- [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05) -- [ ] 01-07-PLAN.md — Channel-open request messaging over the mesh (FED-05) -- [ ] 01-08-PLAN.md — Channel-open UX: own URI, trusted-node picker, meshed-peer requests (FED-05) -- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03) -- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06) - -**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)* - -- [x] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07) -- [x] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02) -- [x] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03) -- [x] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06) -- [x] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05) - -**Wave 8** *(blocked on Wave 7 completion)* - -- [x] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07) -- [ ] 01-17-PLAN.md — FIPS/Tor pills pinned against removal and readable at phone widths (UIFIX-01) - -**Wave 9** *(blocked on Wave 8 completion)* - -- [ ] 01-18-PLAN.md — Six-fix sign-off on archi-dev-box (UIFIX-01/02/03/04/05/06) - -**UI hint**: yes - -### Phase 2: UI Performance - -**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts -**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1) -**Requirements**: PERF-01, PERF-02, PERF-03 -**Success Criteria** (what must be TRUE): - - 1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land - 2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session - 3. Secondary screens open without a blocking full reload; repeat visits are instant - 4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device - -**Plans**: 11/11 plans executed - -Plans: -**Wave 1** - -- [x] 02-01-PLAN.md — Profile every D-09 surface on archi-dev-box and commit the findings doc (PERF-01) - -**Wave 2** *(blocked on Wave 1 completion)* - -- [x] 02-02-PLAN.md — TRACER: KeepAlive host, hook reactivation, app-store tab, refresh indicator (PERF-02) -- [x] 02-03-PLAN.md — Secondary screens: per-item cache, parallel loads, purge on logout (PERF-03) - -**Wave 3** *(blocked on Wave 2 completion)* - -- [x] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02) - -**Wave 4** *(blocked on Wave 3 completion)* - -- [x] 02-05-PLAN.md — Mesh: cache the six fetch groups, bound the D3 graph and Leaflet map (PERF-02) -- [x] 02-06-PLAN.md — Server and Home: cache the uncached fan-out, guarantee wallet freshness (PERF-02) -- [x] 02-07-PLAN.md — Chat/AIUI: stable embed URL + the two D-14 UX defaults (PERF-02) - -**Wave 5** *(blocked on Wave 4 completion)* - -- [x] 02-08-PLAN.md — Dev-pair deploy, on-device re-measure, D-11 pass bar (PERF-01/02/03) - -**Wave 6** *(gap closure — blocked on Wave 5 completion)* - -- [x] 02-09-PLAN.md — Server.vue KeepAlive remount: name the cause, fix it, pin it (PERF-02) - -**Wave 7** *(gap closure — blocked on Wave 6 completion)* - -- [x] 02-10-PLAN.md — Timing-regression verdict: three-way re-measure, clear or name each surface (PERF-02/03) - -**Wave 8** *(gap closure — blocked on Wave 7 completion)* - -- [x] 02-11-PLAN.md — Profile the real cause of the six confirmed regressions, fix what's fixable, re-measure (PERF-02/03) - -**UI hint**: yes - -### Phase 3: Multinode Verification Pass - -**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion -**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide) -**Requirements**: MNODE-01, MNODE-02, MNODE-03 -**Success Criteria** (what must be TRUE): - - 1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node - 2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang - 3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed - 4. The on-air mesh suite passes between two radio-equipped nodes over real RF - -**Plans**: TBD - -### Phase 4: Lifecycle Perfection & Quadlet Default - -**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery -**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean) -**Requirements**: LIFE-01, LIFE-02, LIFE-03, LIFE-04, LIFE-05 -**Success Criteria** (what must be TRUE): - - 1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild - 2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention - 3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible - 4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier - 5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes - -**Plans**: TBD -**UI hint**: yes - -### Phase 5: Registry-Distributed Manifests - -**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change -**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel) -**Requirements**: REG-01, REG-02 -**Success Criteria** (what must be TRUE): - - 1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps) - 2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest - 3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet - -**Plans**: TBD - -### Phase 6: Manifest Security Enforcement - -**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime -**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through) -**Requirements**: SEC-01, SEC-02 -**Success Criteria** (what must be TRUE): - - 1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation - 2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail - 3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored - 4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides) - -**Plans**: TBD - -### Phase 7: Developer Tooling CLI - -**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals -**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set) -**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04 -**Success Criteria** (what must be TRUE): - - 1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install - 2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node - 3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI - 4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required - -**Plans**: TBD - -### Phase 8: Decentralized Marketplace - -**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes -**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6) -**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04 -**Success Criteria** (what must be TRUE): - - 1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling - 2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring - 3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed - 4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end - -**Plans**: TBD -**UI hint**: yes - -## Progress - -**Execution Order:** -Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 -(Phases 1 and 2 are independent and may be worked in parallel.) - -| Phase | Plans Complete | Status | Completed | -|-------|----------------|--------|-----------| -| 1. Federation & Mesh Hardening | 11/20 | In Progress| | -| 2. UI Performance | 11/12 | Complete | 2026-07-31 | -| 3. Multinode Verification Pass | 0/TBD | Not started | - | -| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | -| 5. Registry-Distributed Manifests | 0/TBD | Not started | - | -| 6. Manifest Security Enforcement | 0/TBD | Not started | - | -| 7. Developer Tooling CLI | 0/TBD | Not started | - | -| 8. Decentralized Marketplace | 0/TBD | Not started | - | -| 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 | -| 10. Key-Material Hardening | 0/5 | Planned — **priority override, see phase note** | - | -| 11. Wallet Experience & LND UI Parity | 0/TBD | Not started — gated on 10-05's watch-only verdict | - | - -### Phase 9: BotFights Platform Upgrade - -**Goal:** BotFights (app + registry) works great on every node: users sign in with the native nostr signer, a single self-contained AI prompt sets up their bot (replacing the confusing docs page), and every node's instance talks to a shared public match endpoint on VPS2 so all fighters are visible and battle across all nodes. -**Requirements**: BOT-01 native nostr signer login; BOT-02 unified AI bot-setup prompt (one copy-paste prompt, no doc-hopping); BOT-03 public shared match/fighter endpoint hosted on VPS2, node instances federate to it by default; BOT-04 registry/manifest + signed catalog updated and republished for the new version -**Depends on:** Nothing (independent app work; parallelizable with Phases 1–8) -**Plans:** 7 plans - -Plans: - -- [x] 09-01-PLAN.md — Arena reverse-proxy tracer: node instances become thin clients of one shared arena (BOT-03) -- [x] 09-02-PLAN.md — Finish native nostr signer login: JWT-only GET /api/auth/me, bare-pubkey path retired (BOT-01) -- [x] 09-03-PLAN.md — One self-contained AI bot-setup prompt served at /api/docs/prompt (BOT-02) -- [x] 09-04-PLAN.md — Canonical public arena on VPS2 + DNS/TLS via nginx-proxy-manager (BOT-03) -- [x] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04) -- [x] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04) -- [x] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04) - -### Phase 10: Key-Material Hardening - -**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope. -**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint); KEY-05 (F-10a, **Medium**, added 2026-08-02) **a defaulted RNG cannot be inherited anywhere in the crate**. The audit's F-10 recorded this as 2 call sites; it is **41 raw matches across 15 files** (`session.rs` 16 → 4 prod + 12 test, `pine_ha.rs` 6, `wallet/bdhke.rs` 4 → 2 prod — *Cashu proof secret + blinding factor, genuine key material*, `storage_crypto.rs` 1 — *AEAD nonce*, `mesh/x3dh.rs` 2 — *prekey identifiers, **not** key material, corrected 2026-08-02*, +10 more; full table in the audit's §F-10a. Per-site prod/test classification is KEY-05's Task 1, not an assumption). Nothing is broken today — `rand::random()`/`thread_rng()` are ChaCha12 seeded from `getrandom(2)` — but this is the exact T1 structural shape that produced the 2026-07-30 COLDCARD defect, now with key material in its blast radius. Five layers, all required: (a) **sealed allowlist trait** at key-generation seams (private supertrait, so no other module *or crate* can implement it; exactly one production impl, `OsRng`) — this also retires the `impl rand::CryptoRng for CountingRng` false promise at `seed.rs:656`; (b) **`clippy.toml` `disallowed-methods`** banning `rand::thread_rng`/`rand::random` crate-wide, so enforcement is a compile failure in CI rather than a review convention (no `clippy.toml` exists today; CI already runs clippy); (c) **`cargo-deny`** failing on duplicate `rand` majors — two coexist today, which is the mechanism by which a bump could silently rebind (absorbs R-05); (d) **degenerate-entropy runtime check** before key generation (rejects all-zero / counter-like draws — the one layer that would catch the Coldcard failure *on the device* rather than in review); (e) **persist the CSPRNG-readiness verdict** that `seed.rs:59` already computes and discards, so a node can answer after the fact "was the pool seeded when this key was born?" (absorbs R-09). Supersedes R-13 -**Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.** -**Plans:** 6 plans - -> **EXECUTION GATE (user instruction, 2026-08-02):** do **not** begin executing this phase until -> (a) the concurrent agent working Phase 1 has finished, and (b) their changes are synced and -> accounted for. Rationale: Phase 10 edits `middleware.rs`, `identity.rs`, `seed_rpc.rs`, -> `bitcoin.rs` and — under KEY-05 — ~15 further files across the same crate that agent is -> actively committing to. Verify a clean tree and a fetched `gitea-ai/main` before starting. -> -> **KEY-05 is planned** as `10-06` (added 2026-08-02). The other 5 plans predate KEY-05 and -> are unchanged by it. `10-06` is wave 2 because it shares `seed.rs` with `10-05` and -> `api/rpc/auth.rs` with `10-01`; see its ``. - -Plans: - -**Wave 1** *(parallel — no shared files)* - -- [ ] 10-01-PLAN.md — Identity-mutating unauthenticated RPCs hard-refuse on a provisioned node, with the byte-identity regression suite (KEY-01) -- [ ] 10-03-PLAN.md — First-boot secret regeneration retries then fails closed, and the rootfs tar ships identity-free (KEY-02/KEY-04 C-4) -- [ ] 10-05-PLAN.md — Delete the Bitcoin Core xprv-import path; make LND's PSBT round trip first-class, tested and honestly documented (KEY-03) - -**Wave 2** *(each blocked on its wave-1 sibling)* - -- [ ] 10-02-PLAN.md — On-node C-6 exposure measurement, live refusal proof, and the fresh-node onboarding non-regression (KEY-01/KEY-04) — depends on 10-01 -- [ ] 10-04-PLAN.md — Fleet detection of image-baked host secrets, guarded one-time rotation, and C-3 two-node verification (KEY-02/KEY-04) — depends on 10-03 -- [ ] 10-06-PLAN.md — A defaulted RNG cannot be inherited anywhere in the crate: sealed allowlist, clippy ban, cargo-deny, degenerate-entropy check, persisted CSPRNG verdict (KEY-05) — depends on 10-01 and 10-05 - -### Phase 11: Wallet Experience & LND UI Parity - -**Goal:** The wallet is something a user chooses and understands, not something that just appears. A first-run wallet screen lets them pick a wallet type and route accordingly; seed handling reuses the SeedQR + seed-words patterns already shipped; and the day-to-day Lightning interface offers what umbrelOS's LND UI offers, so nothing is missing for someone arriving from Umbrel. -**Requirements**: WALLET-01 first-run wallet-type chooser (an intro/initial screen presenting the available wallet types with plain-language trade-offs, routing into the matching setup flow) — the available types depend on Phase 10's `10-05` watch-only verdict, so this requirement is **gated on that evidence**, not on assumption; WALLET-02 seed handling in the wallet flow reuses the existing SeedQR + seed-words components rather than reimplementing them (`neode-ui/src/utils/seedqr.ts`, `OnboardingSeedGenerate.vue`, `SeedRevealPanel.vue`, `WalletScanModal.vue`) — including the standing constraint that the LND aezeed is text-only by design and has no SeedQR; WALLET-03 evidence-based umbrelOS LND UI parity — produce a feature-by-feature comparison matrix from the actual Umbrel interface (researched, not assumed), classify each row as already-shipped / gap / deliberately-not-wanted, and close the gaps worth closing; WALLET-04 the resulting interface is house-style (Teleport-to-body modals, existing design system) and verified on the :8100 dev preview against archi-dev before any deploy; WALLET-05 **the PSBT air-gap round trip is a real, usable flow** — the standard two-scan dance (node displays the unsigned PSBT as an animated QR → offline signer scans and signs → signer displays the signed PSBT → node scans it back with the camera → finalize + broadcast). Three sub-gaps, all verified 2026-08-01: (a) **no UI exists** — `lnd.create-psbt`/`lnd.finalize-psbt` and their `rpc-client.ts:417` wrappers are called by nothing but unit tests; (b) **no animated-QR encoder** — `qrcode`/`qrloop` are dependencies and `useAnimatedQRDecoder.ts` + `WalletScanModal.vue` already handle the *inbound* scan, but nothing encodes a PSBT for display; (c) **format interop is wrong for real signers** — the animated format in use is `qrloop` (Ledger's), while Passport/SeedSigner speak **BC-UR** (`ur:crypto-psbt`) and Coldcard Q speaks **BBQr**; BC-UR is the priority given the existing Passport-Prime-compatible SeedQR work. **WALLET-05 is meaningless until 10-05's watch-only verdict lands** — `lnd.create-psbt` funds from LND's own wallet whose keys LND holds, so until LND is watch-only against the external signer the offline device would produce a signature the node does not need -**Depends on:** **Phase 10** — specifically `10-05`, which produces the evidence-backed verdict on whether LND can be provisioned watch-only against an external signer. WALLET-01's list of offerable wallet types is a direct consequence of that verdict; building the chooser first would mean guessing at what it can offer. `10-05` also deletes the dead Core wallet path, so this phase never has to represent it in the UI. -**Plans:** 0 plans - -**Already shipped — do not rebuild (verified 2026-08-01):** `LightningChannelsPanel.vue`, `SendBitcoinModal.vue`, `ReceiveBitcoinModal.vue`, `WalletScanModal.vue`, `WalletSettingsModal.vue`, `SeedRevealPanel.vue`, `LndSeedBackupPrompt.vue`, `utils/seedqr.ts`, and the channels All/Active/Pending/Closed tabs. The parity matrix (WALLET-03) must start from this inventory so the phase closes real gaps instead of re-implementing existing surfaces. - -Plans: - -- [ ] TBD (run /gsd-plan-phase 11 to break down) - -### Phase 12: Bitcoin Node Settings & Core/Knots Parity - -**Goal:** The Bitcoin node's configuration is something the operator chooses in the UI, not something baked into three shell scripts. Every option umbrelOS surfaces for its Bitcoin app is reachable, the options that exist **only** on Knots are surfaced separately from the ones Core shares, and the node's network mode is a first-class setting whose **default is Tor, not clearnet**. - -**Requirements**: BTCSET-01 **a single source of truth for bitcoind arguments** — today they are hardcoded and duplicated across `scripts/first-boot-containers.sh:666`, `scripts/container-specs.sh:193-202` and `apps/bitcoin-knots/manifest.yml:43`, which is the exact triplication that produced the lnd-ui bridge/host defect (`HTTP 000`, found 2026-08-02); a persisted settings model must replace it, with those three call sites rendering FROM it rather than restating it; BTCSET-02 **network mode is a setting, defaulting to Tor** — Tor / clearnet / both, wired to the archy-net SOCKS listener shipped in `f0494193` via `-onion=:9050` (onion-only) or `-proxy=` (everything), with the operator's 2026-08-02 choice of onion-only as the shipped default for the "both" mode; **inbound onion is out of scope and must be stated as such in the UI** — it needs Tor's ControlPort, deliberately disabled for security, so the node can reach .onion peers but stays unlisted; BTCSET-03 **Core options surfaced** (prune, dbcache, txindex, maxconnections, maxmempool, mempoolexpiry, persistmempool, blocksonly, peerbloomfilters, blockfilterindex, and the rest of the umbrelOS set, researched from `getumbrel/umbrel-bitcoin` rather than assumed); BTCSET-04 **Knots-only options surfaced separately and gated to Knots** (`datacarrier`, `datacarriersize`, `permitbaremultisig`, `rejectparasites`, `maxscriptsize`, the spam-filter family) — offering a Knots-only flag on Core would produce a node that refuses to start, so the gate is a correctness requirement, not a cosmetic one; BTCSET-05 house-style UI verified on the `:8100` dev preview against archi-dev before any deploy, mobile included. - -**The hazard this phase must not get wrong:** several of these options are **not freely reversible**. Turning `txindex` on forces a full reindex; turning `prune` on is destructive to block data and cannot be undone without a full resync; lowering `prune` below what is already pruned is meaningless. Any setting in that class must be labelled, confirmed, and — where it implies hours of resync on a node that is somebody's wallet backend — refused or gated rather than silently applied. Changing any option at all requires a bitcoind restart, which interrupts LND, electrs and the fedimint gateways that depend on it. - -**Depends on:** `f0494193` (the archy-net SOCKS listener) for BTCSET-02's Tor path to exist at all. Independent of Phases 1–11 otherwise. - -**Plans:** 0 plans - -Plans: - -- [ ] TBD (run /gsd-plan-phase 12 to break down) - -### Phase 13: AIUI — Conversational Node Control & Content Surfaces - -**Goal:** AIUI stops being a beautiful shell and becomes the node's conversational front door. Today it is embedded in `neode-ui/src/views/Chat.vue` as an iframe, its D-14 embed defaults are honoured, and its surfaces are designed — but the chat cannot *do* anything to the node, and the content views are not wired to real data. This phase makes it functional in three directions at once: (1) **ask the node in human language and have it act** — the capability Pine already demonstrates through voice becomes reachable from typed chat; (2) **talk to the system's settings** conversationally instead of hunting through screens; (3) **surface the node's content beautifully** — peer files, music, IndeeHub movies, owned/paid content — in the design AIUI already has but does not yet fill. - -**Requirements**: AIUI-01, AIUI-02, AIUI-03, AIUI-04, AIUI-05, AIUI-06 - -**Requirement detail**: -- **AIUI-01 — human-language node control.** A typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result. The Pine stack (`core/archipelago/src/api/rpc/pine_status.rs`, `.../package/pine_ha.rs`, the wyoming/Home-Assistant voice pipeline) already proves the intent→action path exists for voice; this requirement is about exposing that capability over a **permissioned tool-calling bridge** the browser can reach — not about handing the chat raw RPC. Whether a text entry point exists today or must be built is the first thing the phase research must settle. -- **AIUI-02 — conversational settings.** The system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted. -- **AIUI-03 — content surfaces made real.** AIUI's designed-but-empty content views render live node data: **peer files** (the `/content`, `/content/`, `/api/peer-content//` subsystem and the `content.*` RPCs), **music** (today only a MIME branch and a hardcoded `Music` folder — there is no library domain, so scope must be honest about what "music" means here), **IndeeHub movies**, and owned/paid content. Playback must respect the existing rules: audio belongs to the global bottom-bar player, never the lightbox; media streams via Range requests, never base64 blobs. -- **AIUI-04 — sandboxed by construction, permissioned by the user.** *(see hazard below — this is the gating requirement, not a nice-to-have)* -- **AIUI-05 — delivery and build.** AIUI is a `*-ui` app outside the signed catalog; it reaches nodes on the frontend rsync, which is how the `/assets` 404 happened (fixed in `fbec7006`). A functional AIUI needs a delivery path an operator can actually receive updates through, and the `VITE_BASE_PATH=/aiui/` build requirement pinned so a hand-built bundle cannot ship a black page. -- **AIUI-06 — verified on device**, in the real embedded iframe on archi-dev-box, mobile included — not only in the local `dev:mock` loop. - -**The hazard this phase must not get wrong — an LLM is now touching a node that holds keys.** AIUI runs in the browser and talks to a model. The node holds wallet keys, LND macaroons, Fedimint credentials, node identity and per-app secrets, and Phase 10 is currently hardening exactly that material. So: **secrets never reach the browser or the model context** — the existing pattern where credentials stay server-side and the client gets a scoped token (`app.filebrowser-token`) is the model to follow, not an exception to it. The chat gets an **explicit, user-granted capability scope** — it can reach only what the user has allowed, defaults closed, and the grant is visible and revocable. **Destructive and identity-touching operations are confirmed by the human**, never executed on model say-so alone; the Phase-10 hard-refuse gates and the loopback/auth boundaries must hold with AIUI on the other side of them, not be widened to accommodate it. Prompt injection is in the threat model: peer-supplied content (filenames, descriptions, chat) will enter the model's context, so tool authority must not be derivable from anything a peer controls. Note also the known leak to resolve rather than propagate: `filebrowser-client.ts` puts a JWT in the media URL query string. - -**Depends on:** Independent of Phases 1–12 for its UI and content work. Its security model must not contradict Phase 10 (Key-Material Hardening) — coordinate rather than widen. AIUI's own source lives in a **separate repository** (`git.tx1138.com/lfg2025/AIUI`, branch `development`, cloned at `~/Projects/AIUI`), so this phase spans two repos and needs push access to both. - -**Plans:** 15 plans in 8 waves - -Plans: - -**Wave 1** *(tracer + the two independent security/spike tracks)* - -- [ ] 13-01-PLAN.md — TRACER: typed AIUI chat reaches a real node tool and returns a real result (AIUI-01) -- [ ] 13-02-PLAN.md — Close the live unauthenticated model proxies: session-gated Rust forwarder, port-3142 sidecar deleted (AIUI-04) -- [ ] 13-03-PLAN.md — Routstr protocol spike + capability coverage matrix (AIUI-01) - -**Wave 2** - -- [ ] 13-04-PLAN.md — Music library: one-way entity-model decision + lofty legitimacy gate + tag extraction (AIUI-03) -- [ ] 13-05-PLAN.md — Curated tool registry, D-09 authority ceiling, D-16 default-closed grants, conversational settings (AIUI-01/02) -- [ ] 13-06-PLAN.md — Content surfaces: ContentItem → Film/Song/Podcast adapter, AIUI grids fed from Archy (AIUI-03) - -**Wave 3** - -- [ ] 13-07-PLAN.md — Music index + music.* RPCs + freshness (AIUI-03) -- [ ] 13-08-PLAN.md — D-11 confirm gate: node-authored, nonce-bound, rendered in trusted chrome (AIUI-01/04) - -**Wave 4** - -- [ ] 13-09-PLAN.md — AIUI delivery: enforced build, pinned commit, live-asset verify, iframe sandbox mechanism (AIUI-04/05) -- [ ] 13-10-PLAN.md — D-04 chain: Ollama tool-calling + D-08 node-side history (AIUI-01) -- [ ] 13-11-PLAN.md — SongGrid lit from the real library + the m4a/aac/opus/wma share-mime fix (AIUI-03) - -**Wave 5** - -- [ ] 13-12-PLAN.md — D-10 untrusted-content boundary + cloud-egress guardrails + rate limiting (AIUI-04) - -**Wave 6** - -- [ ] 13-13-PLAN.md — Routstr backend + D-05 hard budget ceiling (AIUI-01) - -**Wave 7** - -- [ ] 13-14-PLAN.md — Eval harness: ScriptedBackend suite, EV-01..EV-18, cross-backend parity (AIUI-01/04) - -**Wave 8** - -- [ ] 13-15-PLAN.md — On-device sign-off: archi-dev-box, embedded iframe, desktop + mobile (AIUI-06) - -**Track note (D-13):** the music-library track (13-04 → 13-07 → 13-11) is independent — no plan -on the control or content track depends on any music plan, **and neither does the phase-closing -gate**. 13-15 depends on 13-06, 13-09 and 13-14 only, so there is no path from it to 13-04, -13-07 or 13-11: if the music track slips or is deferred, 13-15 records that at its step 7b and -the control and content work still closes and ships. 13-11 is therefore a terminal plan of the -phase rather than a gate on it. diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index 60c41e5f..00000000 --- a/.planning/STATE.md +++ /dev/null @@ -1,225 +0,0 @@ ---- -gsd_state_version: 1.0 -milestone: v1.8.0 -milestone_name: milestone -current_phase: 09 -current_phase_name: BotFights Platform Upgrade -status: executing -stopped_at: v1.7.120-alpha SHIPPED; 1.7.121 queue open — see .planning/RELEASE-1.7.121-TASKS.md (12 items, RESUME HERE section at the end) -last_updated: "2026-08-03T15:15:58.798Z" -last_activity: 2026-07-31 -last_activity_desc: Phase 02 complete, transitioned to Phase 09 -progress: - total_phases: 13 - completed_phases: 2 - total_plans: 60 - completed_plans: 38 - percent: 15 ---- - -# Project State - -## Project Reference - -See: .planning/PROJECT.md (updated 2026-07-29) - -**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust. -**Current focus:** Phase 02 — ui-performance - -## Current Position - -Phase: 09 — BotFights Platform Upgrade -Plan: Not started -Status: Ready to execute -Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09 - -Progress: [█████░░░░░] 54% - -## Performance Metrics - -**Velocity:** - -- Total plans completed: 11 -- Average duration: — -- Total execution time: — - -**By Phase:** - -| Phase | Plans | Total | Avg/Plan | -|-------|-------|-------|----------| -| 02 | 11 | - | - | -**Per-Plan Metrics:** - -| Plan | Duration | Tasks | Files | -|------|----------|-------|-------| -| Phase 02 P01 | 100min | 3 tasks | 5 files | -| Phase 02 P03 | 45min | 3 tasks | 5 files | -| Phase 02 P02 | 105min | 3 tasks | 11 files | -| Phase 02 P04 | 150min | 3 tasks | 12 files | -| Phase 02 P05 | 50min | 2 tasks | 8 files | -| Phase 02 P06 | 73min | 2 tasks | 7 files | -| Phase 02 P07 | 75min | 3 tasks | 5 files | -| Phase 02 P08 | ~190min | 3 tasks | 4 files | -| Phase 02 P09 | 130min | 3 tasks | 3 files | -| Phase 02 P10 | 55min | 2 tasks | 3 files | -| Phase 02 P11 | ~150min | 3 tasks | 8 files | -| Phase 01 P01 | n/a-continuation | 2 tasks | 1 files | - -## Accumulated Context - -### Roadmap Evolution - -- Phase 1 added (2026-07-29): Federation & Mesh Hardening — user-directed top priority (node removal/sync issues, mesh attachment parity incl. demo); prior phases shifted down -- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down -- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy -- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue -- Phase 9 added (2026-07-30): BotFights Platform Upgrade — native nostr signer login, unified AI bot-setup prompt replacing docs page, shared public match endpoint on VPS2 (all nodes see all fighters), registry/manifest update. Independent of Phases 1–8. -- Phase 13 added (2026-08-03): AIUI — Conversational Node Control & Content Surfaces. User-directed: AIUI is embedded and styled but non-functional — chat cannot act on the node, content surfaces are unwired. Scope is (a) Pine's human-language intent→action capability reachable from typed chat, (b) conversational settings, (c) peer files / music / IndeeHub movies / node content rendered live, (d) **a user-granted capability sandbox** keeping keys, secrets and identity material away from the browser and the model — the user called this out explicitly as non-negotiable. Spans two repos: this one and `git.tx1138.com/lfg2025/AIUI` (branch `development`, clone at `~/Projects/AIUI`). Appended, not inserted — numeric position is append order, not priority. -- Phase 10 added (2026-08-01): Key-Material Hardening — KEY-01/F-01 (Critical: unauthenticated `seed.generate`/`seed.restore` overwrite a live node's identity keys), KEY-02/F-03 (fail-open first-boot secret regeneration over a fleet-shared rootfs), KEY-03/F-13 (BIP-84 private key imported into Bitcoin Core), KEY-04 (on-node verification of the audit's UNVERIFIED checklist). Sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (quick task 260731-upz). Appended rather than inserted to avoid renumbering a roadmap with concurrent uncommitted edits — **numeric position is append order, not priority; F-01 is Critical and live on the fleet.** - -### Decisions - -Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + milestone decisions table). Recent decisions affecting current work: - -- Milestone version = 1.8.0-alpha (decided 2026-07-08) -- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted) -- Workstream D (DHT distribution) deferred to v2 — design-only backlog -- Canonical manifest schema = `core/container/src/manifest.rs` (code wins over spec doc) -- [Phase ?]: Marketplace is the 02-02 tracer tab (worst-measured main tab, 2033ms revisit) matching the user's own 'often app store' complaint -- [Phase ?]: ContainerAppDetails.vue confirmed fully unreachable dead code (no importer, no route) — no serial-RPC-waterfall target exists in the measured D-09 surface set -- [Phase ?]: archi-dev-box UI password was unknown/undiscoverable from this environment — paused at a checkpoint:human-action rather than guessing or falling back to a mock baseline silently -- [Phase ?]: Purged the resource cache on logout via clearAll() + a generation guard, so no in-flight fetch from an ending session can repopulate memory or sessionStorage (T-02-02) -- [Phase ?]: AppDetails/MarketplaceAppDetails/OpenWrtGateway converted to per-item (or single-key) keyed useCachedResource; CloudFolder.vue's existing store-level cache left as-is (cloud.ts TTL gate is a follow-up, out of this plan's file scope) -- [Phase ?]: Wallet/send flow (SendBitcoinModal.vue) reported as an unplanned-item gap — named by findings as owned by 02-03 but not in files_modified; its cost is pure client-side remount, not a caching problem -- [Phase ?]: PERF-03 reverted to Pending in REQUIREMENTS.md after an initial mark-complete was premature — its own text requires real-node-hardware verification, which is 02-08's job (also declares PERF-03); 02-03 delivers the code-level portion only -- [Phase ?]: 02-02: DashboardRouterView final shape uses statically-named per-route KeepAlive wrapper components (dashboardViewWrappers.ts) with :include name-matching, restoring pre-restructure view-wrapper DOM/animations byte-for-byte after a checkpoint-caught regression -- [Phase ?]: 02-02: HARD RULE for rest of Phase 02 — perf work must be visually invisible; verify against the real dev preview before considering a checkpoint satisfied -- [Phase ?]: 02-02: app-catalog persist:true ttl 300000ms; bitcoin.prune-status persist:true ttl 30000ms — both explicit per T-02-01, no default relied on -- [Phase ?]: 02-02: PERF-02 reverted to Pending/In-Progress in REQUIREMENTS.md after an automated mark-complete run — PERF-02 also spans 02-04..02-07 (extending KeepAlive caching to every remaining main tab); this plan proves the architecture on the tracer tab only -- [Phase ?]: 02-04: KEEP_ALIVE_PATHS widened to every audited main tab (10 paths) derived from TAB_ORDER + /dashboard/discover; /dashboard/settings deliberately withheld — its child sections (SystemDangerZone reboot poll, several onMounted-only fetches) were never audited by this plan -- [Phase ?]: 02-04: onActivated is a documented no-op outside a KeepAlive boundary — every arm function now runs from both onMounted and onActivated (fresh-mount guards on Home/Web5/Mesh/Server avoid doubling first-load RPC cost); caught by CloudPeersRefresh.test.ts -- [Phase ?]: 02-04: useCachedResource.ts's onActivated no longer eagerly force-loads a never-fetched immediate:false resource, so tab-gated lazy data (Cloud.vue Paid Files/My Files) isn't force-loaded merely by its view entering the KeepAlive cache -- [Phase ?]: 02-04: AIUI blank-screen-and-loading symptom reported at Task 3 checkpoint diagnosed as pre-existing (local mock-backend dev mode sets VITE_AIUI_URL=http://localhost:5173 unconditionally with no AIUI repo checked out) — not a regression, left for 02-07 (Chat/AIUI) to address -- [Phase ?]: 02-05: mesh.refreshAll()/transport.fetchStatus() stay uncached at the store level (other callers need guaranteed-fresh reads); the useCachedResource wrapper around each lives in Mesh.vue instead, since Pinia's defineStore(id,setup) runs in a bare effectScope where onActivated() silently no-ops -- [Phase ?]: 02-05: FLAGGED - RESEARCH.md's premise that Mesh.vue owns a D3 force simulation is incorrect for this codebase (verified via grep); only NetworkMap.vue/Federation.vue has one. Task 2's D3 truths are vacuously satisfied; only the real Leaflet map lifecycle (MeshMap.vue, added to scope) was implemented -- [Phase ?]: 02-05: per-group TTL/persist table - mesh.refresh-all/federation-nodes/self-onion/self-did/contacts all persist:false (identity payload); transport-status persists (aggregate, non-identity); reachability groups get 10s TTL, identity groups 300s -- [Phase ?]: 02-06: RESEARCH A3 settled — none of Server's seven load-group loaders consumes another's result; concurrent fan-out is correct as-is -- [Phase ?]: 02-06: Five of Server's seven groups were already on useCachedResource from a pre-phase legacy commit (ea254f63) with only composable defaults (30s TTL, persist:true) — this plan's work was explicit TTL/persist/dedup, not initial conversion; only loadDiskStatus was a genuinely uncached plain fetch -- [Phase ?]: 02-06: Home's wallet composite does NOT share a cache key with Web5.vue's web5.lnd-info — sharing would either corrupt Web5's typed entry.data or fail to close the sessionStorage gap since Web5.vue's own hook (out of scope) defaults persist:true -- [Phase ?]: 02-06: homeStatus.refresh() wrapped by useCachedResource at Home.vue (the view), not inside the homeStatus Pinia store — defineStore(id,setup) runs in a bare effectScope where onActivated() silently no-ops, same finding as 02-05's Mesh.vue -- [Phase ?]: 02-07: AIUI source located mid-plan at git.tx1138.com/lfg2025/AIUI (base branch development, not stale main); AIUI-side D-14 commit 900c0b9 initially local-only (anonymous push 403) then pushed/merged upstream onto development by the orchestrator using a user-supplied write token -- [Phase ?]: 02-07: D-14a fixed via new ?chatExpanded param overriding chat.ts's chatCollapsed default (never persisted to localStorage); D-14b fixed via new ?mobileChat param re-asserting ChatPage.vue's mobileTab='chat' once on mount, guarding against module-singleton content-panel state surviving an internal AIUI remount -- [Phase ?]: 02-07: PERF-02 marked Complete in REQUIREMENTS.md — 02-02 through 02-07 extended KeepAlive/useCachedResource to every main tab, each dev-preview-verified against archi-dev-box per D-11 -- [Phase ?]: 02-08: KEEP_ALIVE_MAX left at 6, now backed by an on-device memory reading (4 cycles, 11 tabs, JS heap fluctuating 10-21MB, no monotonic growth) rather than the FA-D estimate -- [Phase ?]: 02-08: archy-x250-dev offline for the entire plan (checked 3x); archi-dev-box (D-11's named target) is the only dev-pair node this phase reached -- [Phase ?]: 02-08: the harness's remount-probe field is confounded for main tabs once real KeepAlive keeps multiple instances alive simultaneously; corrected via an independent, reproduced-twice verification rather than editing the frozen 02-01 harness — revealed Server.vue genuinely does not survive a round-trip (open gap, not hidden) -- [Phase ?]: 02-08: a user-reported Cloud first-visit navigation regression was treated as release-blocking, not known-open, per explicit direction — root-caused to content.browse-peer's unbounded, untimed-enough per-peer RPC fan-out starving Chromium's connection pool; fixed via a concurrency cap + shortened timeout, verified 5/5, user-approved on-node -- [Phase ?]: 02-08: 4 other user-reported UX issues (Paid Files window.open, PiP not closing lightbox, missing loader on Paid Files item-open, PiP not surviving tab changes) classified as pre-existing (predate phase 2 via git history) and captured into UIFIX-04/05/06, not fixed -- [Phase ?]: 02-09: /dashboard/server's (and Web5's) 'genuinely remounts' reading was a proven probe-measurement artifact (generic .view-container selector can't disambiguate the foreground tab from other still-connected cached tabs) — confirmed via document.elementFromPoint() hit-test contradicting the naive verdict across device runs; no source change needed, pinned with vm.$.uid-based regression tests instead -- [Phase ?]: 02-09: committed neode-ui/e2e/perf/keepalive-remount-probe.spec.ts as a re-runnable, instrumented probe covering every KEEP_ALIVE_PATHS tab, replacing the ad-hoc 02-08 probe so this class of false positive cannot recur -- [Phase ?]: [Phase 2, gap closure 02-10]: Wallet/send-flow's timing regression cleared as environmental noise (re-measure at/below baseline); Discover/Server/Web5/AppDetails/OpenWrtGateway confirmed as real, phase-2-caused client-side render/reactivation regressions via 3-run dispersion + git bisection, recorded as accepted deviations (not fixed — deploy blocked mid-session by a shared-tree hazard with concurrent security-follow-up and BotFights sessions) -- [Phase 2, gap closure 02-11]: Real cause of the six regressions was NOT compute-bound render cost (CPU profile: 86-99% idle/program, <10% JS self-time everywhere) — it was three background pollers (useFleetData.ts 60s, FipsNetworkCard.vue 15s, Web5Monitoring.vue 30s) armed in onMounted and never disarmed once their owning views joined KEEP_ALIVE_PATHS in 02-04, invisible to that audit because it grepped the top-level view files, not the child composables they delegate to. Gated to onActivated/onDeactivated, mirroring 02-04's own established pattern. Fixed: web5 275ms (was 566ms baseline/1329ms regressed), server 574ms (was 738/1239), fleet 790ms (was 330/2631) -- [Phase 2, gap closure 02-11]: Discover (1389ms, worst remaining) has a SECOND, distinct cause: card-stagger/showStagger entrance-animation classes are baked into the DOM at first mount and never programmatically removed, so every KeepAlive detach/reattach cycle restarts the CSS animation on reactivation — replaying the full entrance cascade on every revisit. Confirmed via a diagnostic (DOM card count doubling transiently on every revisit) and an extended animation-event log. NOT fixed — blast radius spans 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards), needs its own real-device verification budget; recommended as a dedicated follow-up -- [Phase 2, gap closure 02-11]: openwrt-gateway unmeasurable in the final re-measure (Chromium "Target crashed" cascading from an unrelated surface, cloud-folder, earlier in the same harness run) — recorded as not-measurable, not written in as data. Separately confirmed the prior baseline/after/remeasure numbers were measuring a real, substantive disconnected-state UI (OpenWrtGateway.vue's h1 is unconditional; a "No router configured" RPC error deterministically renders a real Connect-to-Router form, not a blank/error page) — the six-surface regression count is not retracted, but the numbers reflect one specific code branch (no OpenWrt device has ever been connected to archi-dev-box) -- [Phase ?]: 01-01: record_peer_transport and update_node routed through FEDERATION_STORE_LOCK via *_inner; tombstone-write-failure test added; full-suite verify blocked by a concurrent agent's uncommitted install.rs edit (unrelated file, not fixed per scope boundary) -- [Phase ?]: UIFIX-02: connected-nodes card height tracks row sibling via xl:flex-1 xl:basis-0 (zero-basis flex-grow) instead of flex-auto, with an xl:min-h-[40rem] floor for a short sibling (discovery disabled), tuned from an initial 20rem guess per Dorian's live feedback - -### Pending Todos - -- [blocker/ui] Keep FIPS/Tor pills on cloud files and show them on mobile (`.planning/todos/pending/2026-07-30-keep-fips-tor-pills-on-cloud-files-and-show-them-on-mobile.md`) -- [blocker/security] Fedimint gateway must not install with a pre-set password — tracked as FED-07 / Phase 1 gap plan (`.planning/todos/pending/2026-07-30-fedimint-gateway-must-not-install-with-preset-password.md`) -- [blocker/ui] Connected-nodes list must scroll at row-matched height, not grow to fit (`.planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md`) -- [blocker/ui] Onboarding tickbox hidden below fold on short screens — make it beautifully obvious (`.planning/todos/pending/2026-07-30-onboarding-tickbox-hidden-below-fold-on-short-screens.md`) -- [major/ui] Paid Files pictures open in browser tab, not the app lightbox — UIFIX-04 (`.planning/todos/pending/2026-07-30-peer-files-pictures-open-in-tab-not-lightbox.md`) -- [major/ui] PiP should close the lightbox with a fluid animation — UIFIX-05 (`.planning/todos/pending/2026-07-30-pip-should-close-lightbox-with-fluid-animation.md`) -- [major/ui] Missing loader states on slow opens — UIFIX-06 (`.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`) - -### Blockers/Concerns - -- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind -- [Phase 3] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running -- [Phase 5] Fleet registry flip awaits explicit user authorization + timing call -- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands -- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests) -- cloud.ts's navigate() needs a TTL gate to fully satisfy 'no new RPC within TTL' for CloudFolder.vue — currently always re-fetches on revisit (just doesn't block paint) -- [Phase 2, RESOLVED by 02-09] ~~Server.vue does not survive a tab round-trip despite KEEP_ALIVE_PATHS registration~~ — retracted: proven a probe-measurement artifact (shared generic `.view-container` selector couldn't disambiguate the foreground tab from other cached tabs), not a real defect. Server.vue's (and Web5.vue's) instance genuinely survives; pinned with `vm.$.uid`-based regression tests immune to the same ambiguity. Checkpoint approved on real hardware. -- [Phase 2, RESOLVED by 02-11] ~~Timing regressions on Discover/Web5/Fleet/AppDetails/OpenWrtGateway~~ — root cause found (three leaked background pollers, not compute-bound render cost) and fixed for web5/server/fleet, each proven with a real before/after number on archi-dev-box. AppDetails restored to at/near its own pre-phase-2 baseline (pre-existing per-mount cost, not a new defect). OpenWrtGateway not measurable this pass (browser crash); prior numbers stand with a data-integrity note (measuring a real disconnected-device UI, not an empty page). -- [Phase 2, follow-up needed] Discover (1389ms, worst remaining named surface) has a second, evidenced, phase-2-caused defect: KeepAlive'd entrance-stagger animations (`card-stagger`/`showStagger`) never get their class removed from the DOM after first play, so every reactivation replays the full CSS animation cascade. Fix requires touching 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, Web5Wallet.vue/Web5Identities.vue/Web5NodeVisibility.vue/Web5NostrRelays.vue) with its own real-device visual-regression verification budget (the same class of risk 02-02's original KeepAlive rollout hit on its first checkpoint attempt) — needs a dedicated follow-up plan, not squeezed into a gap-closure pass. - -### Quick Tasks Completed - -| # | Description | Date | Commit | Directory | -|---|-------------|------|--------|-----------| -| 260729-fw7 | improve mesh message hop graphic/animation: balanced desktop sizing, vertical mobile layout, archipelago branding | 2026-07-29 | ac09fc5d | [260729-fw7-improve-mesh-message-hop-graphic-animati](./quick/260729-fw7-improve-mesh-message-hop-graphic-animati/) | -| 260729-gjd | demo: indee.tx1138.com in app iframe (:2101 whole-origin proxy), auto nostr signer sign-in, IndeeHub pre-installed on fresh session | 2026-07-29 | d00ca624 | [260729-gjd-demo-make-indee-tx1138-com-work-in-the-a](./quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/) | -| 260729-hj1 | peer-files media batch: Wavlake paid tracks + purchases + dedupe + real photos (demo); lightbox/player open routing + free-image lightbox fix (both builds) | 2026-07-29 | f52c5407 | [260729-hj1-peer-files-media-batch-wavlake-paid-trac](./quick/260729-hj1-peer-files-media-batch-wavlake-paid-trac/) | -| 260729-je5 | connected-nodes list fills card height (constant footer gap); companion app skips demo intro | 2026-07-29 | d54517cf | [260729-je5-ui-fixes-connected-nodes-scrollable-list](./quick/260729-je5-ui-fixes-connected-nodes-scrollable-list/) | - -## Deferred Items - -| Category | Item | Status | Deferred At | -|----------|------|--------|-------------| -| Distribution | DIST-01 DHT/iroh backbone (workstream D) | v2 | 2026-07-29 | -| Fleet | FLEET-01 Bitcoin multi-version fleet OTA (user-gated) | v2 | 2026-07-29 | -| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 | -| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 | - -## Release SHIPPED — v1.7.120-alpha (2026-08-03) - -**LIVE.** signature PRESENT (did:key:z6Mkkid…q7ur), both assets HTTP 200 at exactly their -manifest byte counts, tag pushed. Two release-process traps hit and documented in memory: -create-release.sh commits the manifest BEFORE signing (fleet refuses unsigned), and -gitea-vps2 is the SAME server as gitea-ai (vps2 token is dead). - -### Staging record (kept for the evidence trail) - -Built from `4d67f56b` (release profile, 15m15s, exit 0), deployed to archi-dev-box, -`.bak` rollback at /opt/archipelago/rollback/archipelago.bak. - -Verified on the node: both security gates 401 unauthenticated from a non-loopback -address; CORS origin-scoped; AIUI assets 200 AFTER the frontend rsync (the deploy that -would have wiped a copied-file fix); mesh.lightning-peers/send-lightning-info answer -correctly; system.stats host_secrets = per-node; served bundle sha256-matches the build -on all three chunks; 31 containers up, none down, no restart loop. - -NOT verified, deliberately: the new torrc SocksPort/SocksPolicy block. regenerate_torrc -only fires on a Tor services change, so the live torrc still reads only `SocksPort 9050`. -Gateway detection was proven in isolation (10.89.0.1 10.89.0.0/24; missing network exits -non-zero -> stays loopback-only). The change is INERT this release since bitcoind has no --onion flag yet (Phase 12), so forcing a torrc regeneration would risk bouncing every -onion service for zero benefit. - -Frontend is a proven no-op this cycle — built chunks are byte-identical to those already -served — so a fleet node only changes binary + the two app-UI images + nginx. - -Remaining to ship: operator go/no-go, then `scripts/create-release.sh 1.7.120-alpha` -(stops at the signing prompt — reads the master mnemonic interactively, operator-only), -then publish-release-assets.sh to gitea-vps2, then push tags. CHANGELOG.md already -carries curated v1.7.120-alpha notes (create-release.sh hard-fails without them). -The 5x lifecycle gate was NOT run. - -## Session Continuity - -Last session: 2026-08-03T12:57:50.980Z -Stopped at: Phase 13 context gathered -Resume file: .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md - -Open on this thread (all recorded as broken windows, none blocking): - -- Window 15 CLOSED 2026-08-02 20:02 — f6b5245b's reconcile path proven on archi-dev-box by - a controlled test: stale conf installed + container restarted (probe 200, genuinely - re-exposed), daemon started, reconcile repaired it unaided at 20:02:19 with the expected - warn line, probe 401, conf byte-identical to the known-good. Both halves now proven on - hardware. - -- Windows 11/12: host-secret rotation on three fleet nodes sharing SSH host keys — - detect-only so far; rotation is USER-GATED and deliberately not actioned. - -- Credential rotation DECIDED AGAINST 2026-08-02 (operator): no LND macaroon rotation, no - Bitcoin RPC password rotation — no evidence of exploitation and the vulnerability is - being closed rather than lived with. rotate-lnd-macaroon.sh stays as a tool, exercised in - detect mode only, never run against a node. Do not re-litigate; see - docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md. - -- Dev-pair verification is archi-dev-box ONLY, by operator instruction 2026-08-02. Do not - raise archy-x250-dev as a blocker again. diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md deleted file mode 100644 index 553df6af..00000000 --- a/.planning/WINDOWS.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -schema_version: 1 -open_count: 11 -waived_count: 0 -fixed_count: 4 -total_count: 15 -last_updated: 2026-08-03T00:06:03.112Z ---- - -# Broken Windows Ledger - -> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`. -> Waive with `gsd-tools windows waive ""` (reason required). -> Mark fixed with `gsd-tools windows fixed `. - -| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at | -|----|-------|------|------|------|-------------|--------|--------|-------------|-------------| -| 1 | 02 | deviation | neode-ui/src/stores/cloud.ts | | CloudFolder.vue's file listing cache lacks a TTL gate in cloudStore.navigate() — always re-issues the RPC on revisit (paints from cache instantly first, but still refetches unconditionally). Needs a TTL check added to navigate() to fully satisfy 'no new RPC within TTL'. | open | | 2026-07-30T12:25:22.301Z | | -| 2 | 02 | deviation | neode-ui/src/views/Home.vue | | Wallet/send flow (SendBitcoinModal.vue via Home.vue) named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit, 2607ms) but not in 02-03-PLAN.md's files_modified — reported as an unplanned-item gap, not converted. Cause is pure client-side remount cost (0 RPC), not a caching problem. | open | | 2026-07-30T12:25:22.450Z | | -| 3 | 02 | deviation | neode-ui/src/views/PeerFiles.vue | | 02-03-PLAN.md assumed PeerFiles.vue already used useCachedResource; it actually uses the raw resources store directly (correctly per-item-keyed) with no TTL gate and the same loading/refreshing conflation bug fixed in OpenWrtGateway.vue this plan. Left untouched (out of files_modified scope) — candidate for the same fix in a future plan. | open | | 2026-07-30T12:25:22.605Z | | -| 4 | 02 | deviation | neode-ui/src/views/Chat.vue | | AIUI-side D-14 commit (900c0b9, branch feat/d14-embed-defaults in local clone /home/archipelago/Projects/AIUI, based on development) is NOT pushed upstream to git.tx1138.com/lfg2025/AIUI — anonymous push returned 403 Forbidden. neode-ui's two new query params (chatExpanded, mobileChat) are inert no-ops against any currently-deployed AIUI build until a maintainer with push rights merges and it is rebuilt/redeployed. 02-08 (deploy) or the user must resolve push access. | fixed | | 2026-07-30T22:37:25.565Z | 2026-07-30T22:37:44.642Z | -| 5 | 09 | unrun-verify | botfight/e2e/signup-bot.spec.ts | | pnpm test:e2e -- e2e/signup-bot.spec.ts not run: local backend dev port 9100 is occupied by the live archi-dev-box botfights container (podman, 42h uptime) needed for tomorrow's demo — could not free it to run a local dev server. Task-level automated verify (vue-tsc + grep sweep) passed; vitest server suite passed with only pre-existing unrelated flaky failures. | open | | 2026-07-31T02:35:00.391Z | | -| 6 | 02 | deviation | neode-ui/src/views/Discover.vue | | Discover revisit-ms regression (1083->1257->1453ms across 3 runs), confirmed phase-2-caused split-signal client-side render cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.089Z | | -| 7 | 02 | deviation | neode-ui/src/views/Server.vue | | Server revisit-ms regression (738->849->1239ms across 3 runs) despite confirmed instance survival and improved RPC count; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.305Z | | -| 8 | 02 | deviation | neode-ui/src/views/web5/Web5.vue | | Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.570Z | | -| 9 | 02 | deviation | neode-ui/src/views/AppDetails.vue | | AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.751Z | | -| 10 | 02 | deviation | neode-ui/src/views/server/OpenWrtGateway.vue | | OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.933Z | | -| 11 | 10 | unrun-verify | docs/security/KEY-02-FLEET-ROTATION.md | | C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node. | open | | 2026-08-02T19:07:39.861Z | | -| 12 | 10 | unrun-verify | scripts/security/host-secrets-audit.sh | | Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose. | open | | 2026-08-02T19:07:40.217Z | | -| 13 | 10 | unrun-verify | core/archipelago/src/api/rpc/system/handlers.rs | | system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call. | fixed | | 2026-08-02T19:07:40.522Z | 2026-08-02T23:00:30.894Z | -| 14 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched. | fixed | | 2026-08-02T22:44:15.215Z | 2026-08-02T23:16:04.071Z | -| 15 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one. | fixed | | 2026-08-02T23:16:04.510Z | 2026-08-03T00:06:03.112Z | - -````json -[ - { - "id": 1, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/stores/cloud.ts", - "line": null, - "description": "CloudFolder.vue's file listing cache lacks a TTL gate in cloudStore.navigate() — always re-issues the RPC on revisit (paints from cache instantly first, but still refetches unconditionally). Needs a TTL check added to navigate() to fully satisfy 'no new RPC within TTL'.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-30T12:25:22.301Z", - "resolved_at": null - }, - { - "id": 2, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Home.vue", - "line": null, - "description": "Wallet/send flow (SendBitcoinModal.vue via Home.vue) named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit, 2607ms) but not in 02-03-PLAN.md's files_modified — reported as an unplanned-item gap, not converted. Cause is pure client-side remount cost (0 RPC), not a caching problem.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-30T12:25:22.450Z", - "resolved_at": null - }, - { - "id": 3, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/PeerFiles.vue", - "line": null, - "description": "02-03-PLAN.md assumed PeerFiles.vue already used useCachedResource; it actually uses the raw resources store directly (correctly per-item-keyed) with no TTL gate and the same loading/refreshing conflation bug fixed in OpenWrtGateway.vue this plan. Left untouched (out of files_modified scope) — candidate for the same fix in a future plan.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-30T12:25:22.605Z", - "resolved_at": null - }, - { - "id": 4, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Chat.vue", - "line": null, - "description": "AIUI-side D-14 commit (900c0b9, branch feat/d14-embed-defaults in local clone /home/archipelago/Projects/AIUI, based on development) is NOT pushed upstream to git.tx1138.com/lfg2025/AIUI — anonymous push returned 403 Forbidden. neode-ui's two new query params (chatExpanded, mobileChat) are inert no-ops against any currently-deployed AIUI build until a maintainer with push rights merges and it is rebuilt/redeployed. 02-08 (deploy) or the user must resolve push access.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-07-30T22:37:25.565Z", - "resolved_at": "2026-07-30T22:37:44.642Z" - }, - { - "id": 5, - "kind": "unrun-verify", - "phase": "09", - "file": "botfight/e2e/signup-bot.spec.ts", - "line": null, - "description": "pnpm test:e2e -- e2e/signup-bot.spec.ts not run: local backend dev port 9100 is occupied by the live archi-dev-box botfights container (podman, 42h uptime) needed for tomorrow's demo — could not free it to run a local dev server. Task-level automated verify (vue-tsc + grep sweep) passed; vitest server suite passed with only pre-existing unrelated flaky failures.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T02:35:00.391Z", - "resolved_at": null - }, - { - "id": 6, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Discover.vue", - "line": null, - "description": "Discover revisit-ms regression (1083->1257->1453ms across 3 runs), confirmed phase-2-caused split-signal client-side render cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.089Z", - "resolved_at": null - }, - { - "id": 7, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Server.vue", - "line": null, - "description": "Server revisit-ms regression (738->849->1239ms across 3 runs) despite confirmed instance survival and improved RPC count; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.305Z", - "resolved_at": null - }, - { - "id": 8, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/web5/Web5.vue", - "line": null, - "description": "Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.570Z", - "resolved_at": null - }, - { - "id": 9, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/AppDetails.vue", - "line": null, - "description": "AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.751Z", - "resolved_at": null - }, - { - "id": 10, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/server/OpenWrtGateway.vue", - "line": null, - "description": "OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.933Z", - "resolved_at": null - }, - { - "id": 11, - "kind": "unrun-verify", - "phase": "10", - "file": "docs/security/KEY-02-FLEET-ROTATION.md", - "line": null, - "description": "C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node.", - "status": "open", - "reason": "", - "recorded_at": "2026-08-02T19:07:39.861Z", - "resolved_at": null - }, - { - "id": 12, - "kind": "unrun-verify", - "phase": "10", - "file": "scripts/security/host-secrets-audit.sh", - "line": null, - "description": "Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose.", - "status": "open", - "reason": "", - "recorded_at": "2026-08-02T19:07:40.217Z", - "resolved_at": null - }, - { - "id": 13, - "kind": "unrun-verify", - "phase": "10", - "file": "core/archipelago/src/api/rpc/system/handlers.rs", - "line": null, - "description": "system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-02T19:07:40.522Z", - "resolved_at": "2026-08-02T23:00:30.894Z" - }, - { - "id": 14, - "kind": "unrun-verify", - "phase": "10", - "file": "core/archipelago/src/container/prod_orchestrator.rs", - "line": null, - "description": "LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-02T22:44:15.215Z", - "resolved_at": "2026-08-02T23:16:04.071Z" - }, - { - "id": 15, - "kind": "unrun-verify", - "phase": "10", - "file": "core/archipelago/src/container/prod_orchestrator.rs", - "line": null, - "description": "The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-02T23:16:04.510Z", - "resolved_at": "2026-08-03T00:06:03.112Z" - } -] -```` diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md deleted file mode 100644 index 904c84b6..00000000 --- a/.planning/codebase/ARCHITECTURE.md +++ /dev/null @@ -1,333 +0,0 @@ - -# Architecture - -**Analysis Date:** 2026-07-29 - -## System Overview - -```text -┌────────────────────────────────────────────────────────────────┐ -│ Frontend Layer (Vue 3) │ -│ `neode-ui/src` (TypeScript + SPA) │ -│ Routes → Views → Components → Composables → RPC Client │ -└────────────────┬─────────────────────────────────────────────┘ - │ WebSocket + HTTP(S) - │ JSON-RPC 2.0 protocol - ▼ -┌────────────────────────────────────────────────────────────────┐ -│ HTTP Server Layer (Hyper) │ -│ `core/archipelago/src/server.rs` │ -│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │ -└────────────────┬─────────────────────────────────────────────┘ - │ - ┌──────────┴──────────┬──────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌─────────┐ ┌──────────┐ ┌────────────┐ - │ WebSocket │ RPC │ │ Content │ - │ Handler │ Handler │ │ Proxy │ - │ (state sync) │ (methods)│ │ (app URIs) │ - └─────────┘ └──────────┘ └────────────┘ - │ │ - └──────────┬───────┘ - │ - ▼ - ┌─────────────────────────────────────┐ - │ Service Layer (Async Tasks) │ - │ `core/archipelago/src/api/rpc/*` │ - │ │ - │ • auth, identity, secrets │ - │ • container orchestration │ - │ • bitcoin, lightning, wallet │ - │ • mesh, federation, FIPS │ - │ • content, backup, settings │ - └─────────────┬───────────────────────┘ - │ - ┌───────────┼───────────┬──────────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ - ┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ - │Container│ │ State │ │BlobStore - │Orch. │ │Manager │ │ │ │Identity │ - │(Podman) │ │(Broadcast - │ │ │ channels)│ │ ContentClient Manager │ - └─────────┘ └──────────┘ └────────┘ └──────────┘ - │ │ │ │ - └───────────┼───────────┼─────────┘ - │ - ▼ - ┌─────────────────────────────────────┐ - │ Persistent Storage Layer │ - │ │ - │ • Data directory files (YAML/JSON) │ - │ • SQLite (session store) │ - │ • Blob store (content-addressed) │ - │ • Podman container state │ - │ • Secret vaults (encrypted) │ - └─────────────────────────────────────┘ -``` - -## Component Responsibilities - -| Component | Responsibility | File | -|-----------|----------------|------| -| **Server** | HTTP listener, connection multiplexing, TLS/encryption | `core/archipelago/src/server.rs` | -| **ApiHandler** | HTTP request routing, authentication, response formatting | `core/archipelago/src/api/handler/mod.rs` | -| **RpcHandler** | JSON-RPC 2.0 dispatch, method registration, rate limiting | `core/archipelago/src/api/rpc/mod.rs` | -| **ContainerOrchestrator** | Podman lifecycle, manifest reconciliation, adoption | `core/archipelago/src/container/prod_orchestrator.rs` | -| **StateManager** | Central state broadcast channel, revision tracking | `core/archipelago/src/state.rs` | -| **AuthManager** | User credentials, session validation, password hashing | `core/archipelago/src/auth.rs` | -| **Identity Manager** | Node Ed25519 keys, seed derivation, Tor address | `core/archipelago/src/identity_manager.rs` | -| **BootReconciler** | Periodic manifest sync loop, adoption, remediation | `core/archipelago/src/container/boot_reconciler.rs` | -| **Frontend Router** | Vue Router, page navigation, deep linking | `neode-ui/src/router/index.ts` | -| **Frontend Stores** | Pinia state (apps, settings, user, mesh) | `neode-ui/src/stores/` | -| **Frontend Components** | UI elements, modals, cards, layout primitives | `neode-ui/src/components/` | - -## Pattern Overview - -**Overall:** Multi-tier async architecture with centralized request dispatch and broadcast state synchronization. - -**Key Characteristics:** -- **Async-first (Tokio)** - All I/O operations are non-blocking; task spawning for background work -- **RPC-driven API** - Frontend communicates via JSON-RPC 2.0 (not REST); single `/api/v0` WebSocket + HTTP endpoint -- **State as broadcast** - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients -- **Manifest-driven containers** - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions) -- **Plugin architecture** - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets) - -## Layers - -**HTTP/Transport Layer:** -- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket -- Location: `core/archipelago/src/server.rs` -- Contains: Hyper listener, TCP accept loop, connection state tracking -- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl) -- Used by: All external clients (web UI, companion app, API consumers) - -**Request Routing & Auth Layer:** -- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login -- Location: `core/archipelago/src/api/` (handler + rpc submodules) -- Contains: Route matching, middleware chain, cookie extraction, error formatting -- Depends on: Server, StateManager, SessionStore -- Used by: All request paths; gates API access - -**RPC Dispatch Layer:** -- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses -- Location: `core/archipelago/src/api/rpc/mod.rs` + subdirectories (auth.rs, container.rs, bitcoin.rs, etc.) -- Contains: Method table, parameter validation, response formatting, rate limit checks -- Depends on: All service modules -- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools - -**Service Layer:** -- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery -- Location: `core/archipelago/src/api/rpc/*` (one RPC module per domain), plus `core/archipelago/src/` (background tasks) -- Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.) -- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS) -- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks) - -**State Management Layer:** -- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots -- Location: `core/archipelago/src/state.rs` (StateManager + data_model.rs) -- Contains: RwLock, broadcast channel, revision counter -- Depends on: DataModel (serde-serializable struct tree) -- Used by: All services that mutate state (container ops, auth, settings) - -**Container Orchestration Layer:** -- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption -- Location: `core/archipelago/src/container/prod_orchestrator.rs` (1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.) -- Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking -- Depends on: Podman CLI + socket, config parser, image registries, local filesystem -- Used by: RPC container.* methods, BootReconciler loop, crash recovery - -**Frontend Layer (Vue 3):** -- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input -- Location: `neode-ui/src/` -- Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router -- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps) -- Used by: Browser clients (desktop, mobile, companion app via WebView) - -## Data Flow - -### Primary Request Path (User Action → Backend → State Sync) - -1. **Frontend user interaction** (click button, type input) → Vue component event handler - - Location: `neode-ui/src/views/*.vue` or `neode-ui/src/components/*.vue` - -2. **Composable dispatches RPC** (e.g., `useContainerInstall()` calls `rpc.container.install()`) - - Location: `neode-ui/src/composables/` (custom or imported from `api/rpc-client.ts`) - -3. **RPC client serializes → HTTP/WebSocket POST to /api/v0** - - Location: `neode-ui/src/api/rpc-client.ts` - - Payload: `{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }` - -4. **HTTP Server receives, routes to ApiHandler** - - Location: `core/archipelago/src/server.rs` (listener) → `core/archipelago/src/api/handler/mod.rs` (dispatch) - -5. **ApiHandler checks auth**, extracts body, calls RpcHandler - - Location: `core/archipelago/src/api/handler/mod.rs:handle_request()` - -6. **RpcHandler dispatches by method name** to specific RPC module - - Location: `core/archipelago/src/api/rpc/mod.rs:call()` → routing to `core/archipelago/src/api/rpc/container.rs:install()` - -7. **Service method executes** (e.g., `container.rs:install()` calls orchestrator, updates state) - - Location: `core/archipelago/src/api/rpc/container.rs` (calls methods on ContainerOrchestrator) - -8. **StateManager.update_data()** broadcasts the new state to all WebSocket subscribers - - Location: `core/archipelago/src/state.rs:update_data()` → broadcast channel - - All connected WebSocket clients receive `{ rev: N, data: {...} }` update - -9. **Frontend receives state update**, updates Pinia stores, re-renders UI - - Location: `neode-ui/src/stores/` (Pinia stores mutate) → Vue reactivity chain → DOM update - -**State Management:** -- All reads from `StateManager` go through `get_snapshot()` which acquires read-lock -- All writes go through `update_data()` which acquires write-lock + increments revision -- Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest) -- WebSocket clients re-sync on reconnect via `get_snapshot()` call (full state transfer) - -### Secondary Flow: Scheduled Reconciliation (Convergence Loop) - -1. **BootReconciler spawned at startup** in `main.rs` - - Location: `core/archipelago/src/main.rs` (line ~338-348) - -2. **Reconciler runs every `RECONCILER_DEFAULT_INTERVAL`** (~30s typical) - - Location: `core/archipelago/src/container/boot_reconciler.rs:run_forever()` - -3. **Compares desired manifests (disk + registry catalog) vs actual Podman state** - - Looks for: containers missing, containers orphaned, image updates, secret changes - -4. **Applies remediation** (create, delete, restart containers) - - Calls: orchestrator.reconcile_*() methods - -5. **Logs changes, broadcasts state update if anything changed** - - Frontend receives update, shows user the reconciled app state - -This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges. - -## Key Abstractions - -**ContainerOrchestrator trait:** -- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist -- Examples: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/container/dev_orchestrator.rs` -- Pattern: Trait-based strategy; RpcHandler holds `Arc`, switches at runtime -- Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade - -**Manifest (YAML-based declarative app):** -- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file -- Examples: `/opt/archipelago/apps/*/manifest.yml` (on-disk) or registry-delivered catalogs -- Pattern: Custom extensions over OCI/Docker Compose (e.g., `interfaces.main.ui`, `generated_secrets`) -- Parsed into: `container::manifest::Manifest` struct, consumed by orchestrator - -**RPC Method Modules:** -- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.) -- Examples: `core/archipelago/src/api/rpc/auth.rs`, `core/archipelago/src/api/rpc/bitcoin.rs` -- Pattern: Each module exports `pub async fn method_name(handler, params) -> Result` -- Registration: Hardcoded dispatch in `RpcHandler::call()` (no reflection; methods are explicit) - -**BlobStore (Content-Addressed):** -- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access -- Examples: Used by mesh.send-content, federation attachments, backup archives -- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash -- Located: `core/archipelago/src/blobs.rs` + `core/archipelago/src/content_server.rs` - -**StateManager + DataModel:** -- Purpose: Single source of truth for UI state; broadcast updates to all clients -- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency -- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately -- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug - -**Session Store:** -- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping) -- Examples: SQLite-backed or in-memory store -- Pattern: Session token issued at login, validated on each request, expires after TTL -- Used by: ApiHandler auth check, rate limiter (per IP + per user) - -## Entry Points - -**Backend Daemon (Binary):** -- Location: `core/archipelago/src/main.rs` -- Triggers: `systemd start archipelago.service` or manual `./archipelago` on development node -- Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks -- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server - -**Frontend SPA:** -- Location: `neode-ui/src/main.ts` -- Triggers: Browser loads `/index.html` (served by HTTP server from `/opt/archipelago/web-ui/`) -- Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend -- Key setup: Mount app → router ready → fetch initial state → subscribe to updates - -**RPC Endpoints (HTTP + WebSocket):** -- Location: `core/archipelago/src/api/` (handler routes requests here) -- Endpoint: `/api/v0` (JSON-RPC 2.0 POST or WebSocket upgrade) -- Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.) -- Example: `POST /api/v0` with body `{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}` - -**Background Tasks (Spawned at startup):** -- BootReconciler: Periodic manifest reconciliation loop -- Health Monitor: Periodic app health checks + restart -- Update Scheduler: Periodic app update checks -- Mesh Service: P2P mesh listener + sender (federation, LoRa) -- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers -- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber -- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks) - -## Architectural Constraints - -- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking) -- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design) -- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge -- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks) -- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory) -- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies) -- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler - -## Anti-Patterns - -### Circular RPC Dispatches - -**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login) -**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug -**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount). - -### Synchronous blocking in RPC handlers - -**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop -**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users -**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples. - -### Hardcoding paths in app RPC modules - -**What happens:** `bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir` -**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found" -**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern. - -### Frontend state outside Pinia stores - -**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data -**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall -**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`. - -### Not handling WebSocket reconnection - -**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state -**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens -**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop. - -## Error Handling - -**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages. - -**Patterns:** -- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes) -- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec -- Service layer: Use `anyhow::Result` + `?` operator for early exit; convert to `RpcError` at handler boundary -- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app) - -**Critical paths:** -- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames) -- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon) -- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries) -- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting") - ---- - -*Architecture analysis: 2026-07-29* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md deleted file mode 100644 index 33b335c3..00000000 --- a/.planning/codebase/CONCERNS.md +++ /dev/null @@ -1,195 +0,0 @@ -# Codebase Concerns - -**Analysis Date:** 2026-07-29 - -## Tech Debt - -**Federation node removal tombstone gap:** -- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it. -- Files: `core/archipelago/src/federation/storage.rs:180-197`, `core/archipelago/src/api/rpc/federation/handlers.rs:272-300` -- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections. -- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles. - -**Container reconciler observability gap:** -- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes. -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconciler loop), `core/archipelago/src/health_monitor.rs` -- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop. -- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output. - -**Failed systemd unit self-healing gap:** -- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts. -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconcile loop) -- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator. -- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed ` + `start`; add backoff to avoid busy-loop on persistent failures. - -**Bitcoin RPC credentials not retrieved from config/secrets:** -- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store. -- Files: `core/container/src/bitcoin_simulator.rs:155-165` -- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail. -- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation. - -**Container security policies not wired in:** -- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers. -- Files: `core/security/src/container_policies.rs:63-75` -- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints. -- Fix approach: Pass `--security-opt apparmor=` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly. - -**Dynamic resource adjustment not implemented:** -- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift. -- Files: `core/performance/src/resource_manager.rs:86-88` -- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically. -- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+). - -## Known Bugs - -**Multinode RPC robustness gap:** -- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback. -- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`) -- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever. -- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery. -- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify. - -## Security Considerations - -**Secrets environment variable exposure risk:** -- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc//environ` and potentially logged. -- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/container/src/manifest.rs`, `core/archipelago/src/api/rpc/package/config.rs` -- Current mitigation: Secrets are declared as `generated_secrets` in manifests and materialized 0600/rootless; the orchestrator avoids logging values. -- Recommendations: Audit all env-var passing to containers; consider switching high-sensitivity secrets (bitcoin RPC, LND macaroons) to file-based secrets mounted read-only; add audit logging for secret access. - -**Federation DID validation incomplete:** -- Risk: Federation peer DIDs are added via the RPC without cryptographic verification of ownership. A compromised peer could advertise arbitrary DIDs. -- Files: `core/archipelago/src/api/rpc/federation/handlers.rs` (add-node path), `core/archipelago/src/federation/storage.rs` -- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers. -- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers. - -**AppArmor profiles overly permissive:** -- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs. -- Files: `core/security/src/container_policies.rs:46-54` -- Current mitigation: None (profiles not applied). -- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod. - -## Performance Bottlenecks - -**Container thrashing during reconcile:** -- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts. -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine) -- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units. -- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup. - -**Reconciler churn on boot:** -- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability. -- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/bootstrap.rs` -- Cause: Multi-pass reconciliation + no incremental diff detection. -- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path. - -**Bitcoin IBD on .198 stalled (disk I/O):** -- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~3–5). -- Files: `tests/multinode-testing-plan.md` (documented issue) -- Cause: Undersized/slow disk; concurrent workload. -- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue. - -## Fragile Areas - -**Uninstall + reinstall lifecycle:** -- Files: `core/archipelago/src/api/rpc/package/install.rs`, `core/archipelago/src/container/quadlet.rs:disable_remove()`, `neode-ui/src/components/AppCard.vue` -- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in. -- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll. -- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`. - -**Production orchestrator state machine:** -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (6291 lines) -- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved. -- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution. -- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate. - -**Mesh radio configuration + boot race:** -- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats` -- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware. -- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues. -- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate). - -**Lightning payment state machine:** -- Files: `core/archipelago/src/api/rpc/lnd/wallet.rs:payinvoice()` -- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop. -- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release. -- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate. - -## Scaling Limits - -**Uninstall progress bar truthfulness:** -- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity). -- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration. -- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app). - -**Federation node list deduplication on disk bloat:** -- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation. -- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible. -- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load). - -**Lifecycle gate iteration count:** -- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~8–12 hours on .228. -- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual. -- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention). - -## Dependencies at Risk - -**Reticulum transport daemon process group:** -- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions. -- Impact: Stale reticulum processes accumulating over time; resource leaks on node. -- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment. - -**Podman socket mount security model:** -- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied). -- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer) -- Impact: A compromised app with podman socket access can start/stop/delete any container on the node. -- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired. - -**Bitcoin version multi-version branch not fleet-wide:** -- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version. -- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation. -- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing. - -## Missing Critical Features - -**Developer tooling CLI suite:** -- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches. -- Blocks: External marketplace (workstream C); external developer onboarding. -- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5. - -**Manifest-distributed registry flip:** -- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed. -- Blocks: Cannot confidently add/bump apps without re-signing the catalog. -- Status: Code ready; flip awaits authorization + timing call from user. - -**Phase-3 Quadlet default-flip:** -- Problem: Orchestrator still uses legacy cgroup-based container management; Phase-3 `use_quadlet_backends` switch exists but is opt-in only. -- Blocks: Resolves container thrashing; unlocks independent app restarts; unblocks lifecycle perfection (workstream F). -- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes. - -## Test Coverage Gaps - -**~30 apps with zero app-specific assertions:** -- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running." -- Files: `tests/lifecycle/bats/all-apps-matrix.bats`, `tests/lifecycle/bats/all-apps-lifecycle.bats` (generic baseline coverage) -- Risk: App-specific bugs (API down, data corruption, dependency failure) go unnoticed until user encounters them. -- Priority: Medium — baseline coverage is a real safety net; app-specific assertions are a "nice to harden" backlog item, not a gate blocker. -- Approach: Add per-app health RPC endpoints or HTTP probes; wire into the gate as opt-in per-app test suites. - -**Progress UI assertions incomplete:** -- What's not tested: Install + uninstall must report monotonic, truthful progress. No stage/percentage assertions in the gate. -- Files: `neode-ui/src/components/AppCard.vue`, `core/archipelago/src/api/rpc/package/install.rs` (backend progress events) -- Risk: Silent hangs or fake progress bars are invisible to the gate. -- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F. -- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in). - -**All-apps matrix in cascade gate:** -- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps. -- Files: `tests/lifecycle/bats/cascade-uninstall.bats` (single-app variant) -- Risk: Multi-container app uninstall bugs (e.g., orphan postgres container) go undetected. -- Priority: High — part of workstream F definition of done. -- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall. - ---- - -*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md deleted file mode 100644 index 2cd4f060..00000000 --- a/.planning/codebase/CONVENTIONS.md +++ /dev/null @@ -1,159 +0,0 @@ -# Coding Conventions - -**Analysis Date:** 2026-07-29 - -## Naming Patterns - -**Files:** -- TypeScript/Vue: PascalCase for components (e.g., `ToggleSwitch.vue`, `SendBitcoinModal.vue`), camelCase for composables and stores (e.g., `useFileType.ts`, `controller.ts`) -- Rust: snake_case for modules and files (e.g., `bitcoin_rpc.rs`, `storage_crypto.rs`) -- Test files: co-located with source in `__tests__/` subdirectories with `.test.ts` or `.spec.ts` suffix for Vitest, `.bats` for shell tests -- Constants in TypeScript use UPPER_SNAKE_CASE within modules (e.g., `IMAGE_EXTS`, `CATEGORY_COLORS` in `useFileType.ts`) - -**Functions:** -- TypeScript/Vue: camelCase for all functions (e.g., `getFileCategory`, `formatSize`, `useFileType`) -- Composables: `use` prefix for Vue composables (e.g., `useFileType`, `useToast`, `useMessageToast`) — exported as named exports or default exports -- Store functions (Pinia): defined with snake_case action names, exported from `defineStore` factory -- Rust: snake_case for all functions and methods (e.g., `doesnt_reallocate`, following Rust conventions) - -**Variables:** -- TypeScript: camelCase for local variables and reactive refs (e.g., `modelValue`, `isActive`, `gamepadCount`) -- Refs (Vue 3): prefix not required, but convention is lowercase start (e.g., `const ext = ref('jpg')`) -- Computed properties: camelCase, explicit `.value` suffix in templates when needed -- Parameters: camelCase, typed explicitly in TypeScript (e.g., `password: string`, `isDir: Ref`) - -**Types:** -- TypeScript: PascalCase for type aliases and interfaces (e.g., `RPCOptions`, `FileCategory`, `CatalogVersionInfo`) -- Union types: PascalCase (e.g., `PendingState = 'pending' | 'sent' | 'approved'`) -- Component props: typed with `defineProps<{ ... }>()` syntax in `';` - — a classic (non-module) script injected at end of head still executes - BEFORE the SPA's deferred module bundle, which is what the seeding needs. - Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }` - (or alias) inside the 2101 server so the seed script is served same-origin - to the iframe. Update the comment block that currently explains why - IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design. - - Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic - script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and - that the embedded key is a freshly generated THROWAWAY demo identity, not - a real secret. Generate ONE fresh secp256k1 keypair at implementation time - (e.g. `node -e` with a tiny script using any available schnorr/secp lib, or - a one-off `npx` of nostr-tools in the scratchpad — the generator itself is - not committed) and embed hex sk + hex pk as constants. The script: if - `localStorage.getItem('indeedhub-accounts')` is empty/absent, write the - two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array - with ONE serialized private-key account: verify the exact `type` string - and common-field shape against the live bundle per verified_findings, shape - `{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields` - round-trips, give it a friendly name/metadata like "Archy Demo" if the - shape supports it) and `indeedhub-active-account` (that account's id). - Because the script runs on the :2101 origin inside the iframe, this - touches only the proxied app's isolated storage. IndeeHub then restores - the account on boot and self-signs with its own bundled signer — no - window.nostr and no parent bridge required. Do NOT define a partial - `window.nostr` in this approach (a pubkey-only shim with a broken - signEvent causes worse failures than no shim). - - FALLBACK (only if live testing in Task-3 verification shows the seeded - account shape is not accepted): seed an `"extension"`-type account - instead, define a `window.nostr` postMessage client in this same script - (request/response protocol matching useNostrBridge: post - `{type:'nostr-request', id, method, params}` to `window.parent`, resolve on - `{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` / - `identity.nostr-sign` in mock-backend.js with real schnorr signatures over - the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is - pure JS and Dockerfile.backend runs `npm install` over package.json). - Prefer the primary approach; only fall back with evidence. - - Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is - already inside `neode-ui/` so the existing `COPY neode-ui/ ./` + - dist copy do NOT ship it — add an explicit - `COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js` - in the nginx stage of Dockerfile.web; it lands only in the demo web image, - never in real-node artifacts). Publish the port in docker-compose.demo.yml - (`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an - env-overridable mapping consistent with its existing `DEMO_WEB_PORT` - style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that - file's header comment). Read docker-entrypoint.sh first and make sure the - new server block survives its template substitution exactly like the - existing blocks (same escaping convention for nginx `$` variables); touch - the entrypoint only if its substitution list needs it. - - Do not put any host IP in any of these files; upstream hostname - indee.tx1138.com is fine. - - - docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js - - nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere. - - - - Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall - neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts - - In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete - the map entirely if it becomes empty, simplifying `isDemoExternal` to - return false — keep the exported function so call sites in appLauncher.ts - and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')` - return the proxied origin built at runtime: - `${window.location.protocol}//${window.location.hostname}:2101/` - (hostname, never a hardcoded host/IP — works on any deploy host). Keep - `isDemoApp('indeedhub')` true (it must stay in the demoable set so the - NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab - keeps routing it into the in-app iframe session, and so the install - button stays enabled). Update the file-header comment block that - currently documents the external-tab workaround to describe the :2101 - whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA) - but guard `typeof window !== 'undefined'` if other tests import the module - in node context — check the existing unit tests under - src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions - about indeedhub being demo-external and update them to the new behavior. - - In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from - useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`, - when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor - must not be interrupted by an identity modal (the embedded IndeeHub is - already signed in via the seeded account from Task 1, and `sendIdentity`'s - `identity.sign` RPC is not what logs it in). Real-node behavior - (picker on first launch) is untouched because IS_DEMO is compile-time - false there. - - - cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK - - Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO. - - - - Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions - neode-ui/mock-backend.js - - Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the - existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub` - (match the existing title map at ~line 537 and APP_TITLES), a short/long - description consistent with the marketplace copy ("Bitcoin documentary - streaming platform" per the existing entry), `state: 'running'`, - `lanPort: 8190` (matches the existing port map), icon - `/assets/img/app-icons/indeedhub.png`. Because per-session demo state is - `structuredClone(staticDevApps)`, this alone makes it installed+running on - every fresh session. Then reconcile the rest of the mock so nothing - contradicts installed status: check the marketplace/available-apps mock - responses and any install/uninstall handlers (~lines 540-740, 1900-1960, - 4900+) for `indeedhub` entries that would render it as not-installed or - double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub - placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the - iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey` - mock as-is unless Task 1's fallback path was taken (in which case align - its pubkey with the throwaway demo key and add the sign handlers described - there). - - - cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine) - - A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1. - - - - - -## Trust Boundaries - -| Boundary | Description | -|----------|-------------| -| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host | -| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path | -| public visitors → demo host | anyone can drive the proxy | - -## STRIDE Threat Register - -| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | -|-----------|----------|-----------|----------|-------------|-----------------| -| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" | -| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop | -| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy | -| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config | -| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) | - - - -Local (executor, before commit): -1. nginx config parses (Task 1 verify). -2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build - unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of - the plain build — that is EXPECTED; the bundle-grep for demo strings must be - done on the VITE_DEMO=1 build, which is exactly what the demo Docker image - builds (Dockerfile.web defaults ARG VITE_DEMO=1). -3. Fresh-session mock package-data includes indeedhub (Task 3 verify). -4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up - --build` locally, browse http://localhost:2100 in a private window → - login `entertoexit` → IndeeHub installed → launch → iframe renders the - proxied site from http://localhost:2101 with a signed-in account. -5. `git status` — confirm nothing under indeedhub/ is staged, ever. - -Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100): -1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options - header and the injected `indee-demo-signin.js` tag in the HTML body - (`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the - port is unreachable, the vps2 firewall needs 2101 opened — flag to - orchestrator. -2. Fresh private browser window → :2100 → login → IndeeHub shows installed/ - running on the dashboard/My Apps without any install action. -3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a - new tab; content browsable; no identity-picker modal. -4. Signed-in check: IndeeHub header shows an active account (avatar/profile - instead of a sign-in button). If the seeded account shape was rejected - (login wall still visible), execute the documented fallback (extension - account + window.nostr shim + mock signer) and redeploy. -5. View-source/network spot-check: no occurrence of the private - release-server IP in any served response. -6. Repeat-visit check: reload the iframe once — a service worker registered by - IndeeHub may serve cached HTML without the injected tag on later loads; - that is acceptable because localStorage is already seeded on first load, - but confirm sign-in persists. - - - -- Demo visitor on a fresh browser sees IndeeHub installed, launches it into - the in-app iframe, and browses indee.tx1138.com content signed in — zero - clicks spent on install/login/identity modals. -- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated). -- No secrets committed beyond the labelled throwaway demo key; nothing staged - under indeedhub/; demo serves no private release-server IP. -- Work committed in focused commits (infra / frontend / mock) with the - Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to - the orchestrator. - - - -Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done. - diff --git a/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md b/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md deleted file mode 100644 index 15f50ae7..00000000 --- a/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -phase: quick-260729-gjd -plan: 01 -subsystem: public-demo -tags: [demo, indeedhub, nginx, reverse-proxy, nostr, mock-backend] -requires: [] -provides: - - "IndeeHub whole-origin demo proxy on :2101 (framing headers stripped, sign-in seeded)" - - "Demo iframe launch of indeedhub via demoAppUrl → :2101/" - - "IndeeHub pre-installed/running on every fresh demo session" -affects: [demo-deploy, neode-ui demo image] -tech-stack: - added: [] - patterns: - - "Whole-origin per-port reverse proxy for frame-busting external SPAs (vs broken path-prefix sub_filter)" - - "localStorage seeding via injected classic script on the proxied origin (applesauce-accounts nsec account)" -key-files: - created: - - neode-ui/docker/indee-demo-signin.js - modified: - - neode-ui/docker/nginx-demo.conf - - neode-ui/Dockerfile.web - - docker-compose.demo.yml - - demo-deploy/docker-compose.yml - - neode-ui/src/composables/useDemoIntro.ts - - neode-ui/src/views/appSession/useAppIdentity.ts - - neode-ui/mock-backend.js -decisions: - - "Primary sign-in path used (seeded nsec account, self-signing) — NIP-07 bridge fallback NOT needed; verified against the live bundle" - - "Dropped `sub_filter_types text/html` (text/html is nginx's default sub_filter type; explicit listing produced a duplicate-MIME warning)" -metrics: - duration: "~50 min" - completed: 2026-07-29 -status: complete ---- - -# Quick Task 260729-gjd: IndeeHub in the Demo Summary - -**One-liner:** Whole-origin nginx proxy of indee.tx1138.com on :2101 with an injected throwaway-nsec sign-in seeder, demo iframe launch via same-host :2101, and IndeeHub pre-installed in every fresh mock-backend session. - -## Commits - -| Task | Commit | Scope | -|------|--------|-------| -| 1 | 69bc3d3f | nginx :2101 whole-origin proxy + indee-demo-signin.js seeder + Dockerfile.web COPY/EXPOSE + both compose files publish 2101 | -| 2 | 66d540f8 | useDemoIntro: DEMO_EXTERNAL_URLS → DEMO_PROXY_PORTS, demoAppUrl builds `//:2101/`; useAppIdentity: picker suppressed under IS_DEMO | -| 3 | d00ca624 | mock-backend.js staticDevApps gains indeedhub (running, lanPort 8190) → installed on every fresh session | - -## What was verified at exec time (live-bundle facts) - -- Live site still serves `X-Frame-Options: SAMEORIGIN`, no CSP; bundle `assets/index-BMWtjRCn.js`. -- Account serialization confirmed by de-minifying the live bundle: private-key account class has `static type="nsec"`, `toJSON` → `{ signer: { key: }, id, pubkey, metadata, type }`; the manager registers the nsec type (`MM(Fe)` registers `mr`) and restores from `indeedhub-accounts` + activates by id from `indeedhub-active-account`. `Vn`/`je` confirmed hex decode/encode. -- Pubkey math independently validated against BIP340 test vectors (sk=1 → Gx, sk=3 → F9308A01…) before embedding the generated pair. Mismatch would trigger the bundle's "Account signer mismatch" guard, so this was load-bearing. - -## Throwaway demo identity - -Freshly generated 2026-07-29 for this task (generator ran in scratchpad, not committed): -- pk `7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31` -- sk embedded in `neode-ui/docker/indee-demo-signin.js`, clearly labelled PUBLIC-DEMO-ONLY / not a secret (threat T-gjd-01: accepted by design). Never a real user key. - -## Local verification results - -1. **nginx parse:** `nginx -t` clean in `nginx:alpine` (podman, with `--add-host neode-backend:127.0.0.1` to satisfy the pre-existing upstream reference). -2. **Live proxy smoke (podman, config + seeder mounted):** `curl` through :2101 → 200, **no X-Frame-Options / CSP**, injected ` + + ``` + +2. Wrap in ChatPage.vue: + - Wrap chat column (around ``) + - Wrap content panel column (around ``) + - Wrap detail view column (around ``) + +3. Wrap in ChatWindow.vue: + - Wrap the message loop (v-for of ChatMessage) + +4. Wrap in ContentPanel.vue: + - Wrap each grid component render + - Wrap detail component render + +**Acceptance criteria**: +- A failing renderer shows error card with retry button +- Chat continues working if content panel errors +- Content panel continues if one card errors +- `pnpm typecheck` passes + +#### Task M1.3: Unit Tests for contentExtraction + +**Why**: `contentExtraction.ts` is 967 lines of regex parsing with zero tests. It's the most critical composable. + +**File to create**: `packages/app/src/__tests__/contentExtraction.test.ts` + +**Tests to write** (use vitest): +```ts +describe('contentExtraction', () => { + describe('extractAllFilms', () => { + it('extracts film_ext tags with title, year, director') + it('extracts film:id tags and looks up from library') + it('returns empty array for text with no film tags') + it('handles multiple films in one message') + it('handles malformed tags gracefully') + }) + + describe('extractAllSongs', () => { + it('extracts song_ext tags with title, artist, year') + it('extracts song:id tags from library') + it('extracts songs from markdown bold patterns') + it('deduplicates songs by title+artist') + }) + + describe('extractAllPodcasts', () => { + it('extracts podcast_ext tags') + it('extracts podcast:id from library') + }) + + describe('extractAllBooks', () => { + it('extracts book_ext tags with title, author, year') + it('handles optional fields') + }) + + describe('extractAllTVSeries', () => { + it('extracts tv_ext tags') + it('parses creator and network fields') + }) + + describe('extractAllPlaces', () => { + it('extracts place_ext tags with all fields') + it('handles missing optional fields (rating, price)') + }) + + describe('extractMagazineSections', () => { + it('extracts sections from markdown headings') + it('captures content between headings') + it('extracts hero images') + }) + + describe('stripContentTags', () => { + it('removes all tag types from text') + it('preserves non-tag content') + it('handles nested/adjacent tags') + }) + + describe('extractBoldDomainLinks', () => { + it('extracts **domain.com** patterns with URLs') + it('extracts markdown links') + }) +}) +``` + +**How to run**: `pnpm test` (vitest via turbo) + +**Acceptance criteria**: +- All tests pass +- Covers the 10 main extraction functions +- Tests edge cases (empty input, malformed tags, duplicates) +- `pnpm test` exits 0 + +#### Task M1.4: Unit Tests for useAI + +**File to create**: `packages/app/src/__tests__/useAI.test.ts` + +**Tests to write**: +```ts +describe('useAI', () => { + describe('provider selection', () => { + it('defaults to first available provider') + it('switches provider via setActiveProvider') + it('lists available models for active provider') + }) + + describe('context injection', () => { + it('includes film library in system prompt') + it('includes song library in system prompt') + it('includes content tag format instructions') + }) + + describe('sendMessage', () => { + it('adds user message to store') + it('creates assistant message placeholder') + it('sets isStreaming to true during stream') + it('sets isStreaming to false after completion') + it('handles stream errors gracefully') + }) + + describe('stopGeneration', () => { + it('aborts active stream') + it('sets isStreaming to false') + }) +}) +``` + +**Note**: Will need to mock `fetch` for streaming tests. Use vitest's `vi.fn()`. + +**Acceptance criteria**: +- All tests pass with mocked fetch/SSE +- `pnpm test` exits 0 + +#### Task M1.5: E2E Test Expansion + +**File to modify**: `packages/app/e2e/content-surfaces.spec.ts` + +**Tests to add**: +```ts +test('sends a message and receives streaming response') +test('content panel shows film cards when AI mentions films') +test('clicking a film card opens detail view') +test('mobile viewport shows full-screen overlay for content') +test('stop button halts generation') +test('web search toggle works') +test('new conversation clears messages') +test('panel side toggle switches layout') +``` + +**Acceptance criteria**: +- `pnpm test:e2e` passes (needs dev server running) + +--- + +### M2: Content Experience (UX) + +#### Task M2.1: Markdown Rendering in Chat + +**Why**: Chat messages display plain text. Markdown (bold, italic, links, code blocks, lists) should render properly. + +**Files to modify**: +- `packages/app/src/components/chat/ChatMessage.vue` (333 lines) +- Add `markdown-it` as dependency + +**Implementation**: +1. `pnpm add markdown-it` + `pnpm add -D @types/markdown-it` in `packages/app` +2. In ChatMessage.vue: + - Import and configure markdown-it with safe defaults (no HTML) + - After `stripContentTags()`, render remaining text through markdown-it + - Use `v-html` with the sanitized markdown output + - Add CSS for rendered markdown (code blocks, lists, links) in main.css + - Ensure content tags are extracted BEFORE markdown rendering + +**Security**: markdown-it with `html: false` prevents XSS. No raw HTML passthrough. + +**Acceptance criteria**: +- Bold, italic, links, code blocks, lists render in chat +- Content tags still extract correctly (films, songs, etc.) +- No XSS possible +- `pnpm typecheck` passes + +#### Task M2.2: Virtual Scrolling for Chat + +**Why**: Long conversations with many messages cause scroll jank. + +**Files to modify**: +- `packages/app/src/components/chat/ChatWindow.vue` +- Add `@tanstack/vue-virtual` dependency + +**Implementation**: +1. `pnpm add @tanstack/vue-virtual` in `packages/app` +2. Replace the message `v-for` loop with `useVirtualizer`: + - Estimate row heights (user messages ~60px, assistant ~200px) + - Use dynamic measurement for actual heights + - Maintain scroll-to-bottom behavior during streaming + - Keep overscan at 5 items + +**Acceptance criteria**: +- Scrolling is smooth with 100+ messages +- Auto-scroll to bottom during streaming still works +- `pnpm typecheck` passes + +#### Task M2.3: Music Source Resolution + +**Why**: PlayerBar exists but music source resolution is incomplete. Iframe embedding untested. + +**Files to modify**: +- `packages/app/src/composables/usePlayer.ts` (185 lines) +- `packages/app/src/components/player/PlayerBar.vue` (165 lines) + +**Implementation**: +1. In usePlayer.ts: + - Add queue management: `queue: ShallowRef`, `currentIndex: Ref` + - Add `playNext()`, `playPrevious()`, `addToQueue(song)` methods + - Fix iframe playback (lines 72-83): create Plyr instance for iframes too + - Add retry logic for failed music searches (try next source) + +2. In PlayerBar.vue: + - Add next/previous buttons + - Show queue count + - Add queue panel (slide-up from player) + +**Acceptance criteria**: +- Can play songs from search results +- Next/previous navigation works +- Queue persists across song changes +- `pnpm typecheck` passes + +#### Task M2.4: Nostr Feed Integration + +**Why**: NostrGrid.vue exists but is non-functional. No relay connection. + +**Files to modify**: +- `packages/app/src/components/content/NostrGrid.vue` +- Create `packages/app/src/composables/useNostr.ts` + +**Implementation**: +1. Create `useNostr.ts`: + - Connect to public relays (wss://relay.damus.io, wss://nos.lol, wss://relay.snort.social) + - Use raw WebSocket (no nostr-tools dependency to keep bundle small) + - Subscribe to kind:1 (text notes) with limit 50 + - Parse NIP-01 event format manually + - Export `useNostr()` returning `{ events, isConnected, connect, disconnect }` + +2. Update NostrGrid.vue: + - Use `useNostr()` composable + - Display events as cards with author npub (truncated), content, timestamp + - Lazy-load on tab activation only + +**Acceptance criteria**: +- Nostr tab shows real posts from public relays +- Connection/disconnection is clean (no leaked WebSockets) +- Handles relay errors gracefully +- `pnpm typecheck` passes + +--- + +### M3: Plugin System (Infrastructure) + +#### Task M3.1: Activate Plugin Registry at Runtime + +**Why**: `packages/core/src/plugins/registry.ts` exists with `registerPlugin()` but nothing calls it. + +**Files to modify**: +- `packages/app/src/main.ts` (26 lines) — add plugin initialization +- Create `packages/app/src/plugins/index.ts` — plugin bootstrap +- Create `packages/app/src/plugins/claude-provider.ts` — first AI provider plugin + +**Implementation**: +1. Create `plugins/index.ts`: + ```ts + export async function initializePlugins() { + // Register built-in plugins + const { claudeProvider } = await import('./claude-provider') + registerPlugin(claudeProvider) + } + ``` + +2. Create `plugins/claude-provider.ts`: + - Implement `AIProviderAdapter` interface from `@aiui/core` + - Wrap existing `useAI.ts` streaming logic as a plugin + - Export as a Tier 1 (trusted) plugin + +3. In `main.ts`: + - Call `initializePlugins()` before app mount + - Make it async with error handling + +**Acceptance criteria**: +- Plugin registry has at least 1 registered plugin at runtime +- Chat still works through the plugin adapter +- `getPluginsByType('ai-provider')` returns the Claude provider +- `pnpm typecheck` passes + +#### Task M3.2: Renderer Plugin Registration + +**Why**: Content renderers are hardcoded. Making them pluggable enables community extensions. + +**Files to modify**: +- Create `packages/app/src/plugins/renderers/film-renderer.ts` +- Create `packages/app/src/plugins/renderers/song-renderer.ts` +- Modify `packages/app/src/plugins/index.ts` — register renderers +- Modify `packages/app/src/components/content/ContentPanel.vue` — use registry lookups + +**Implementation**: +1. Create renderer plugins for film and song (as examples): + ```ts + const filmRenderer: RendererDefinition = { + id: 'film', + name: 'Film Renderer', + contentType: 'film', + surfaces: ['chat-preview', 'panel-preview', 'panel-play'], + chatPreview: FilmCard, + panelPreview: FilmGrid, + panelPlay: FilmDetail, + } + ``` + +2. Register in `plugins/index.ts` via `registerRenderer()` + +3. In ContentPanel.vue, look up renderers via `getRendererForContentType()` instead of hardcoded imports (gradual migration — start with film/song, keep others hardcoded) + +**Acceptance criteria**: +- Film and song renderers registered via plugin system +- `getAllRenderers()` returns registered renderers +- Content panel still renders correctly +- `pnpm typecheck` passes + +--- + +### M4: Social & Discovery (UX) + +#### Task M4.1: Social Embeds + +**Why**: Nostr notes referenced in chat should render as rich embeds, not raw text. + +**Files to create/modify**: +- Create `packages/app/src/components/chat/NostrEmbed.vue` +- Modify `packages/app/src/components/chat/ChatMessage.vue` — detect and render nostr: URIs + +**Implementation**: +1. Create `NostrEmbed.vue`: + - Accept `noteId` or `npub` prop + - Fetch note from relays (reuse `useNostr` composable from M2.4) + - Display: author npub (truncated), content, timestamp, relay source + - Glass card styling matching existing design system + - Loading skeleton while fetching + - Error state if note not found + +2. In ChatMessage.vue: + - Regex detect `nostr:note1...`, `nostr:npub1...`, `nostr:nevent1...` patterns + - Replace with `` component inline + - Handle bech32 decoding (NIP-19) for note/npub/nevent + +**Acceptance criteria**: +- `nostr:note1...` in chat renders as embedded card +- `nostr:npub1...` renders as profile card +- Graceful fallback if relay unreachable +- `pnpm typecheck` passes + +#### Task M4.2: Federated Search + +**Why**: Search currently only queries web. Should search across all content types simultaneously. + +**Files to create/modify**: +- Create `packages/app/src/composables/useFederatedSearch.ts` +- Modify `packages/app/src/components/chat/ChatInput.vue` — add search mode +- Create `packages/app/src/components/ui/SearchResults.vue` + +**Implementation**: +1. Create `useFederatedSearch.ts`: + ```ts + interface SearchResult { + type: 'film' | 'song' | 'podcast' | 'book' | 'article' | 'place' | 'web' + title: string + subtitle: string + thumbnail?: string + data: unknown // type-specific payload + } + export function useFederatedSearch() { + // Search across: film library, song library, podcast library, web (DDG/SearXNG) + // Return unified results sorted by relevance + // Debounce input (150ms) + // Cancel previous searches on new input + } + ``` + +2. In ChatInput.vue: + - Add `/search` command prefix detection + - When typing after `/search`, show SearchResults overlay above input + - Selecting a result inserts it as a content reference in the message + +3. Create SearchResults.vue: + - Grouped by content type with type icons + - Keyboard navigation (arrow keys + enter) + - Glass morphism dropdown styling + +**Acceptance criteria**: +- `/search matrix` returns films, songs, articles matching "matrix" +- Results grouped by type +- Selecting a result works +- `pnpm typecheck` passes + +#### Task M4.3: Bookmarks/Favorites + +**Why**: Users can't save interesting content items for later. + +**Files to create/modify**: +- Create `packages/app/src/stores/favorites.ts` — Pinia store +- Create `packages/app/src/components/ui/FavoriteButton.vue` +- Create `packages/app/src/components/content/FavoritesGrid.vue` +- Modify content card components (FilmCard, SongCard, etc.) — add favorite button +- Modify `packages/app/src/components/content/ContentPanel.vue` — add Favorites tab + +**Implementation**: +1. Create `favorites.ts` Pinia store: + ```ts + interface FavoriteItem { + id: string + type: 'film' | 'song' | 'podcast' | 'book' | 'tv' | 'place' | 'article' + title: string + data: unknown + savedAt: number + } + // Persist to IndexedDB (reuse idb-storage from M1.1) + // Methods: addFavorite, removeFavorite, isFavorited, getFavoritesByType + ``` + +2. Create `FavoriteButton.vue`: + - Heart icon toggle (outline = not saved, filled = saved) + - Animate on toggle (scale bounce) + - Bitcoin orange when favorited + +3. Create `FavoritesGrid.vue`: + - Tab in ContentPanel showing all saved items + - Filter by content type + - Sort by date saved + - Remove from favorites via swipe or button + +4. Add FavoriteButton to existing cards: FilmCard, SongCard, BookCard, etc. + +**Acceptance criteria**: +- Can favorite/unfavorite any content item +- Favorites persist across page refresh (IndexedDB) +- Favorites tab shows all saved items +- Filter by type works +- `pnpm typecheck` passes + +--- + +### M5: Security & Privacy (Infrastructure) + +#### Task M5.1: E2E Encryption + +**Why**: Conversations stored in IndexedDB are plaintext. Need encryption at rest. + +**Files to create/modify**: +- Create `packages/app/src/utils/crypto.ts` +- Modify `packages/app/src/utils/idb-storage.ts` — encrypt before write, decrypt on read + +**Implementation**: +1. Create `crypto.ts`: + ```ts + // Use Web Crypto API (no external dependencies) + export async function deriveKey(password: string, salt: Uint8Array): Promise + // PBKDF2, 100K iterations, SHA-256 + + export async function encrypt(data: string, key: CryptoKey): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> + // AES-256-GCM, random 12-byte IV + + export async function decrypt(ciphertext: ArrayBuffer, iv: Uint8Array, key: CryptoKey): Promise + // AES-256-GCM decrypt + + export async function generateSalt(): Promise + // 16 random bytes + ``` + +2. Modify `idb-storage.ts`: + - Add optional encryption parameter to save/load functions + - When `VITE_DISABLE_CRYPTO=true` (dev mode), skip encryption + - Store salt alongside encrypted data + - Key derived from user passphrase (prompted on first use) + +**Acceptance criteria**: +- Conversations encrypted in IndexedDB when crypto enabled +- Dev mode (`VITE_DISABLE_CRYPTO=true`) bypasses encryption +- Decryption with wrong passphrase fails gracefully +- `pnpm typecheck` passes +- Unit tests for encrypt/decrypt round-trip + +#### Task M5.2: Encrypted Storage Layer + +**Why**: All IndexedDB data (conversations, favorites, settings) should use the encryption layer. + +**Files to modify**: +- Modify `packages/app/src/stores/favorites.ts` — use encrypted storage +- Create `packages/app/src/components/ui/PassphraseDialog.vue` +- Modify `packages/app/src/main.ts` — prompt for passphrase on startup + +**Implementation**: +1. Create `PassphraseDialog.vue`: + - Modal dialog with passphrase input + - "Remember for this session" checkbox (holds key in memory) + - Create new / enter existing passphrase flow + - Glass card styling, min 16px font (no iOS zoom) + +2. Wire encryption into all storage operations: + - Conversations (chat.ts store) + - Favorites (favorites.ts store) + - Future: settings, API keys + +**Acceptance criteria**: +- First launch prompts for passphrase creation +- Subsequent launches prompt for passphrase entry +- Wrong passphrase shows error, does not corrupt data +- Session key held in memory (not persisted) +- `pnpm typecheck` passes + +#### Task M5.3: API Key Vault + +**Why**: API keys (Claude, OpenRouter) are currently stored in plaintext localStorage. + +**Files to create/modify**: +- Create `packages/app/src/utils/key-vault.ts` +- Create `packages/app/src/components/settings/ApiKeyManager.vue` +- Modify `packages/app/src/composables/useAI.ts` — read keys from vault + +**Implementation**: +1. Create `key-vault.ts`: + ```ts + // Encrypted storage for API keys using crypto.ts + export async function storeApiKey(provider: string, key: string): Promise + export async function getApiKey(provider: string): Promise + export async function deleteApiKey(provider: string): Promise + export async function listProviders(): Promise + // Keys encrypted with session-derived key from passphrase + // Stored in dedicated IndexedDB object store: 'api-keys' + ``` + +2. Create `ApiKeyManager.vue`: + - List configured providers + - Add/remove API keys + - Keys masked in UI (show last 4 chars) + - Test connection button per provider + +3. In `useAI.ts`: + - Replace direct env var / localStorage reads with vault lookups + - Fallback to env vars for dev mode + +**Acceptance criteria**: +- API keys encrypted at rest +- Keys never appear in console/logs +- UI shows masked keys +- Test connection verifies key works +- `pnpm typecheck` passes + +--- + +### M6: Payments & Identity (UX + Infrastructure) + +#### Task M6.1: Lightning Wallet Deep-links + +**Why**: AIUI is Bitcoin-only. Need to deep-link to external Lightning wallets for payments. + +**Files to create/modify**: +- Create `packages/app/src/utils/lightning.ts` +- Create `packages/app/src/components/ui/PaymentButton.vue` +- Create `packages/app/src/components/ui/LightningInvoice.vue` + +**Implementation**: +1. Create `lightning.ts`: + ```ts + // Generate LNURL-pay links, BIP21 URIs, Lightning: URIs + export function createLightningUri(invoice: string): string + export function createBip21Uri(address: string, amount?: number, label?: string): string + export function detectWallet(): 'strike' | 'muun' | 'phoenix' | 'zeus' | 'generic' + // Deep-link formats: lightning:BOLT11, bitcoin:?lightning=BOLT11 + ``` + +2. Create `PaymentButton.vue`: + - Bitcoin orange gradient button + - Shows sat amount + - On click: generates deep-link URI, opens wallet + - Fallback: show QR code with invoice string + - Copy invoice to clipboard button + +3. Create `LightningInvoice.vue`: + - Display BOLT11 invoice as QR code (use `qrcode` lib or canvas) + - Show amount in sats + - Expiry countdown + - Copy button + +**Acceptance criteria**: +- Payment button generates valid Lightning URIs +- Deep-link opens system wallet picker on mobile +- QR fallback for desktop +- `pnpm typecheck` passes + +#### Task M6.2: Cashu Token Support + +**Why**: Cashu ecash tokens enable offline micropayments. Display and copy Cashu tokens in chat. + +**Files to create/modify**: +- Create `packages/app/src/utils/cashu.ts` +- Create `packages/app/src/components/chat/CashuToken.vue` +- Modify `packages/app/src/components/chat/ChatMessage.vue` — detect cashu tokens + +**Implementation**: +1. Create `cashu.ts`: + ```ts + // Parse Cashu token format (cashuA...) + export function parseCashuToken(token: string): { mint: string; amount: number; unit: string } | null + export function isCashuToken(text: string): boolean + // No wallet functionality — AIUI is never a wallet + // Just parse, display, and deep-link to external wallet + ``` + +2. Create `CashuToken.vue`: + - Detect `cashuA...` strings in chat messages + - Display as card: amount, mint URL (truncated), copy button + - "Open in wallet" deep-link button + - Glass card styling with Bitcoin orange accent + +3. In ChatMessage.vue: + - Regex detect Cashu tokens + - Replace inline with `` component + +**Acceptance criteria**: +- Cashu tokens in chat render as rich cards +- Copy token to clipboard works +- Deep-link to wallet works +- Invalid tokens show graceful fallback +- `pnpm typecheck` passes + +#### Task M6.3: Nostr Identity (NIP-07) + +**Why**: Enable login via Nostr browser extension (nos2x, Alby, etc.) for identity. + +**Files to create/modify**: +- Create `packages/app/src/composables/useNostrIdentity.ts` +- Create `packages/app/src/components/settings/NostrLogin.vue` +- Modify `packages/app/src/stores/` — add user identity store + +**Implementation**: +1. Create `useNostrIdentity.ts`: + ```ts + // NIP-07: window.nostr API + export function useNostrIdentity() { + const isAvailable: Ref // window.nostr exists + const pubkey: Ref + const npub: Ref // bech32 encoded + async function login(): Promise // calls window.nostr.getPublicKey() + async function sign(event: NostrEvent): Promise // calls window.nostr.signEvent() + function logout(): void + } + ``` + +2. Create `NostrLogin.vue`: + - "Login with Nostr" button (purple/Nostr brand color) + - Shows npub when logged in (truncated with copy) + - Logout button + - Detects if NIP-07 extension is installed + +**Acceptance criteria**: +- Login with nos2x/Alby extension works +- Public key displayed as npub +- Sign events for Nostr posting +- Graceful message if no extension installed +- `pnpm typecheck` passes + +--- + +### M7: Platform (Infrastructure) + +#### Task M7.1: MCP Server Integration + +**Why**: Model Context Protocol enables rich tool use. AIUI should expose content surfaces as MCP tools. + +**Files to create/modify**: +- Create `packages/app/src/plugins/mcp-server.ts` +- Modify `packages/app/src/composables/useAI.ts` — add MCP tool handling + +**Implementation**: +1. Create `mcp-server.ts`: + ```ts + // Expose AIUI capabilities as MCP tools + const tools = [ + { name: 'search_films', description: 'Search film library', inputSchema: {...} }, + { name: 'search_songs', description: 'Search song library', inputSchema: {...} }, + { name: 'search_web', description: 'Search the web', inputSchema: {...} }, + { name: 'get_nostr_feed', description: 'Fetch Nostr notes', inputSchema: {...} }, + ] + // Handle tool_use responses from AI and route to appropriate composable + ``` + +2. In useAI.ts: + - Parse tool_use blocks from Claude responses + - Route to appropriate handler (film search, web search, etc.) + - Return tool results back in conversation + +**Acceptance criteria**: +- Claude can call tools via MCP format +- Tool results display as content in panel +- `pnpm typecheck` passes + +#### Task M7.2: Multi-provider AI Normalization + +**Why**: Different AI providers (Claude, OpenRouter, Ollama) have different APIs. Normalize them. + +**Files to create/modify**: +- Create `packages/app/src/adapters/claude-adapter.ts` +- Create `packages/app/src/adapters/openrouter-adapter.ts` +- Create `packages/app/src/adapters/ollama-adapter.ts` +- Create `packages/app/src/adapters/types.ts` — unified interface +- Modify `packages/app/src/composables/useAI.ts` — use adapter pattern + +**Implementation**: +1. Create `types.ts`: + ```ts + interface AIAdapter { + id: string + name: string + chat(messages: Message[], options: ChatOptions): AsyncIterable + models(): Promise + supportsStreaming: boolean + supportsVision: boolean + supportsTools: boolean + } + ``` + +2. Create adapters for Claude (existing logic), OpenRouter (OpenAI-compatible), Ollama (local). + +3. Refactor useAI.ts to select adapter by provider setting. + +**Acceptance criteria**: +- Can switch between Claude/OpenRouter/Ollama +- Streaming works with all providers +- Content extraction works regardless of provider +- `pnpm typecheck` passes + +#### Task M7.3: Tauri Desktop Build + +**Why**: Desktop app via Tauri for native experience with system tray, global shortcuts. + +**Files to create/modify**: +- Create `src-tauri/` directory with Tauri config +- Create `src-tauri/tauri.conf.json` +- Create `src-tauri/src/main.rs` +- Modify `packages/app/package.json` — add tauri scripts + +**Implementation**: +1. Initialize Tauri in the app package: + - `pnpm add -D @tauri-apps/cli @tauri-apps/api` in packages/app + - Configure window: frameless with custom titlebar, transparent background + - System tray with quick-access menu + - Global shortcut (Cmd+Shift+A) to show/hide window + +2. Tauri config: + - Window size: 1200x800, min 800x600 + - Transparent background (for glass morphism) + - Auto-updater enabled + - File system scope: app data directory only + +**Acceptance criteria**: +- `pnpm tauri dev` launches desktop app +- Glass morphism renders correctly with transparent window +- System tray works +- `pnpm tauri build` produces .dmg/.app +- `pnpm typecheck` passes + +#### Task M7.4: Offline Mode + +**Why**: AIUI should work without internet for browsing saved content. + +**Files to create/modify**: +- Modify `packages/app/src/sw.ts` or PWA config — cache strategies +- Create `packages/app/src/composables/useOffline.ts` +- Modify UI components — offline indicators + +**Implementation**: +1. Create `useOffline.ts`: + ```ts + export function useOffline() { + const isOnline: Ref // navigator.onLine + event listeners + const pendingSync: Ref // count of items waiting to sync + function queueForSync(action: SyncAction): void + function processSyncQueue(): Promise + } + ``` + +2. PWA cache strategies: + - App shell: cache-first (HTML, JS, CSS, fonts) + - API responses: network-first with cache fallback + - Images: cache-first with stale-while-revalidate + - IndexedDB data: always available offline + +3. UI indicators: + - Subtle banner when offline ("Offline — browsing saved content") + - Disable AI chat input when offline (grey out with tooltip) + - Show cached content (favorites, saved conversations) + +**Acceptance criteria**: +- App loads without internet +- Saved conversations and favorites accessible offline +- Chat disabled with clear offline indicator +- Reconnection triggers sync +- `pnpm typecheck` passes + +--- + +## Part 3: Automated Session Execution Order + +For automated late-night Claude sessions, execute tasks in this order: + +### Priority Queue (each session picks next incomplete task): + +**M1: Stability & Polish** +1. **Task M1.2** — Error boundaries *(verify existing ErrorBoundary.vue, wrap remaining components)* +2. **Task M1.3** — Unit tests for contentExtraction *(create __tests__/contentExtraction.test.ts)* +3. **Task M1.1** — IndexedDB persistent storage *(create idb-storage.ts, modify chat.ts)* +4. **Task M1.4** — Unit tests for useAI *(create __tests__/useAI.test.ts with mocked fetch)* +5. **Task M1.5** — E2E test expansion *(8 new tests in content-surfaces.spec.ts)* + +**M2: Content Experience** +6. **Task M2.1** — Markdown rendering in chat *(add markdown-it, modify ChatMessage.vue)* +7. **Task M2.3** — Music source resolution + queue *(fix usePlayer.ts, update PlayerBar.vue)* +8. **Task M2.2** — Virtual scrolling for chat *(add @tanstack/vue-virtual to ChatWindow.vue)* +9. **Task M2.4** — Nostr feed integration *(create useNostr.ts, update NostrGrid.vue)* + +**M3: Plugin System** +10. **Task M3.1** — Activate plugin registry *(create plugins/index.ts, claude-provider.ts)* +11. **Task M3.2** — Renderer plugin registration *(film/song renderer plugins)* + +**M4: Social & Discovery** +12. **Task M4.1** — Social embeds *(NostrEmbed.vue, nostr: URI detection in chat)* +13. **Task M4.2** — Federated search *(useFederatedSearch.ts, /search command)* +14. **Task M4.3** — Bookmarks/favorites *(favorites.ts store, FavoriteButton, FavoritesGrid)* + +**M5: Security & Privacy** +15. **Task M5.1** — E2E encryption *(crypto.ts with Web Crypto API AES-256-GCM)* +16. **Task M5.2** — Encrypted storage layer *(PassphraseDialog, wire encryption to all stores)* +17. **Task M5.3** — API key vault *(key-vault.ts, ApiKeyManager.vue)* + +**M6: Payments & Identity** +18. **Task M6.1** — Lightning wallet deep-links *(lightning.ts, PaymentButton, LightningInvoice)* +19. **Task M6.2** — Cashu token support *(cashu.ts, CashuToken.vue inline in chat)* +20. **Task M6.3** — Nostr identity NIP-07 *(useNostrIdentity.ts, NostrLogin.vue)* + +**M7: Platform** +21. **Task M7.1** — MCP server integration *(mcp-server.ts, tool routing in useAI)* +22. **Task M7.2** — Multi-provider AI normalization *(adapter pattern for Claude/OpenRouter/Ollama)* +23. **Task M7.3** — Tauri desktop build *(src-tauri config, transparent window, system tray)* +24. **Task M7.4** — Offline mode *(useOffline.ts, cache strategies, offline UI indicators)* + +### Session Protocol + +Each automated session should: +1. Read `PROGRESS.md` to find the next incomplete task +2. Read this plan file for the task's detailed spec +3. Execute the task following the spec exactly +4. Run `pnpm typecheck` after changes +5. Run `pnpm lint` after changes +6. Run `pnpm test` if unit tests exist +7. Commit with conventional format: `type(scope): description` +8. Push to current branch +9. Update PROGRESS.md session log (triggered by hook, or manually) + +--- + +## Verification + +After implementing Part 1 (progress automation): +1. Run `pnpm typecheck && pnpm lint` — should pass +2. Commit a change and push — hook should fire +3. Verify PROGRESS.md gets a session log entry +4. Test from a different worktree — should work identically + +After each M1–M3 task: +1. `pnpm typecheck` passes +2. `pnpm lint` passes +3. `pnpm test` passes (if tests exist) +4. Dev server runs without errors (`pnpm dev`) +5. Manual smoke test: send a message, see content render diff --git a/aiui/PLAN2.md b/aiui/PLAN2.md new file mode 100644 index 00000000..76c3b583 --- /dev/null +++ b/aiui/PLAN2.md @@ -0,0 +1,443 @@ +# AIUI Plan 2 — Extended Roadmap + +## Context & Philosophy + +This plan continues from M0–M7 (all complete). Every item below must honour the core philosophy: +- **Glass morphism only** — `glass`, `glass-card`, `glass-button`. No light mode, no gray-900 hacks. +- **Open source / MIT/Apache-2.0** — no proprietary dependencies +- **Decentralised-first** — no vendor lock-in, pluggable everything +- **Bitcoin only** — sats, Lightning, Cashu, Fedimint. AIUI is never a wallet — always deep-link +- **Privacy-first** — no telemetry, no tracking, E2E encryption +- **Mobile-first, everywhere-perfect** — desktop enhances mobile, never replaces it +- **Plugin-everything** — all integrations go through typed plugin interfaces +- **< 250 KB gzipped initial load** — everything else lazy-loaded + +--- + +## M8: Chat UX Polish + +### M8.1 — Message Editing & Regeneration +Edit any sent message in place; all messages after it are cleared and AI regenerates from that point. Pencil icon appears on hover. Textarea replaces bubble on click. `Escape` cancels, `Enter` submits. + +### M8.2 — Conversation Branching +Fork from any assistant message. Branch indicator in chat header (e.g. "Branch 2 of 3"). Branch switcher as a compact glass pill above the forked message. Each branch stored as a separate conversation in IDB. + +### M8.3 — Reply-to Threading +Click any message → "Reply" option. Reply shows a quoted excerpt of the target message above the input. Thread line connects quoted block to source. Visual only — does not send separate context to AI, just prepends `> quote` to the user message. + +### M8.4 — Conversation Search +`Cmd+F` / search icon opens a slide-down glass panel above chat. Real-time filtering highlights matching messages. Up/down arrows jump between matches. `Escape` closes. + +### M8.5 — Auto-Title Generation +After the first AI response in a new conversation, send a background request: `"Give a 4-word title for this conversation: {first user message}"`. Replace "New Chat" silently. No loading state — title updates smoothly. + +### M8.6 — Context Window Visualiser +Slim progress bar at top of chat column. Estimates token count from message lengths (1 token ≈ 4 chars). Shows percentage of model's context window used. Bitcoin-orange fill → red when > 80%. Tooltip: "~12,400 / 200,000 tokens used". + +### M8.7 — Conversation Export +Three-dot menu on each conversation → Export. Options: Markdown (download .md), JSON (full data), Plain text. Uses File System Access API when available, falls back to ``. No server involved. + +### M8.8 — Import Conversations +Settings → Import → drag-and-drop or file picker for AIUI JSON export or Claude.ai export JSON. Merges into existing conversations without overwriting. Shows import summary (N conversations added). + +### M8.9 — Long-press / Right-click Context Menus +Messages: Copy, Edit, Delete, Reply, Branch from here. Content cards: Favourite, Share, Open detail, Copy title. Uses a reusable `ContextMenu.vue` glass-card component positioned at cursor. Closes on outside click or `Escape`. + +### M8.10 — Scroll Position Memory +When switching between conversations, restore the previous scroll position. Store position per conversation ID in a `Map` (not persisted — session only). Virtual scroller should seek to the stored offset on mount. + +--- + +## M9: AI Experience + +### M9.1 — Multi-Model Comparison Mode +Split-screen: same prompt sent to two models simultaneously. Side-by-side layout on desktop, swipeable tabs on mobile. Model selector per pane. Shows streaming output in both. Useful for comparing Claude vs OpenRouter models. + +### M9.2 — System Prompt Editor +Settings → Personas. Create named personas (e.g. "Film Critic", "Bitcoin Analyst"). Each has a system prompt, model preference, and accent colour. Select persona per conversation via a pill menu above the input. Default persona applies to all new conversations. + +### M9.3 — Prompt Template Library +`/` in chat input opens a command palette (glass dropdown). Templates listed with title + preview. Variables in templates use `{{variable}}` syntax — on selection, a mini form appears to fill them. Templates stored in IDB, importable/exportable as JSON. + +### M9.4 — Vision Input +Drag-and-drop or paste image into chat input. Image preview appears as a thumbnail above the input. On send, image encoded as base64 and included in the message content array (Claude vision format). Only enabled when active model supports vision. Max 4 images per message. + +### M9.5 — Response Feedback +Thumbs up / thumbs down on each AI message (appears on hover). Stored locally in IDB per message ID. Shown in conversation export. Future: aggregate across sessions for personal preference tracking. Never sent anywhere. + +### M9.6 — Token & Cost Estimator +Settings toggle to show token counts. Each message shows estimated token count in a tiny badge (bottom-right of bubble). Running total shown in context window bar. Cost estimate based on current model's pricing (hardcoded table, updated with model releases). + +### M9.7 — AI Memory Panel +Settings → Memory. A list of "always remember" facts injected into every system prompt. e.g. "I live in London", "I prefer sats over fiat". Edit/delete/add. Max 20 items. Stored encrypted in IDB. Shown as a collapsed "Memory" section in the system prompt. + +### M9.8 — Model Capabilities Badge +Model selector shows capability badges: Vision 👁, Tools 🔧, Long context 📄. Tooltip explains each. Greys out vision input button when selected model doesn't support it. Updates dynamically when switching providers. + +### M9.9 — Temperature & Params Slider +Advanced settings section (collapsed by default) beneath the model selector. Sliders for: Temperature (0–1), Max tokens (256–8192), Top-P. Values persisted per conversation in IDB. Reset to defaults button. + +### M9.10 — Stop Sequence Configuration +Advanced settings: configurable stop sequences (comma-separated). Applied to all requests for that conversation. Useful for structured output tasks. Shown as a small tag list below the slider panel. + +--- + +## M10: Advanced Content Renderers + +### M10.1 — Full Article Renderer +When AI returns a long-form article (> 800 words with headings), render it in the panel as a paginated article view. Features: auto-generated table of contents (sticky left sidebar on desktop), estimated reading time, font-size control, print mode. Uses existing markdown-it instance. + +### M10.2 — PDF Viewer +Content type `pdf` renders via `pdfjs-dist` (lazy loaded, ~400 KB). Page navigation, zoom, text selection, search within PDF. Chat preview: thumbnail of page 1. Panel play: full viewer. Files loaded from URL (no local file upload in v1). + +### M10.3 — Map Renderer +Content type `place` upgrades from static card to interactive Leaflet map (lazy loaded). OpenStreetMap tiles (no API key needed). Pins for all places mentioned in conversation. Cluster pins when > 10 places. Panel play: fullscreen map with place list sidebar. + +### M10.4 — Recipe Renderer +New content type `recipe`. Tag: ``. Structured display: ingredients checklist (tap to strike through), numbered steps, metadata chips (time, servings, calories). "Scale recipe" slider (0.5×–4×) recalculates quantities. + +### M10.5 — Event Renderer +New content type `event`. Tag: ``. Shows: date chip, location, countdown. Add to calendar buttons: ICS download, Google Calendar URL, Apple Calendar. Glass card in chat, full detail in panel. + +### M10.6 — Math Renderer +Detect `$...$` (inline) and `$$...$$` (block) LaTeX in chat messages. Render using KaTeX (lazy loaded, ~70 KB). Fallback: display raw LaTeX in a code block. No re-renders during streaming — batch render on stream end. + +### M10.7 — Mermaid Diagram Renderer +Detect ` ```mermaid ` fenced code blocks. Render using Mermaid.js (lazy loaded, ~500 KB). Support: flowchart, sequence, gantt, entity-relationship. Dark theme matching glass design. Copy SVG button. Pan/zoom on mobile. + +### M10.8 — Audio Waveform Player +Upgrade PlayerBar for locally-loaded audio. Use WaveSurfer.js (lazy loaded) to show waveform visualization. Waveform rendered in Bitcoin orange on dark background. Click to seek. Existing queue/next/prev preserved. + +### M10.9 — Table Renderer +Markdown tables rendered as interactive tables: column sort (click header), row filter (search input above table), CSV export button. Uses existing markdown-it but overrides the table token renderer. Max 500 rows before virtualisation kicks in. + +### M10.10 — Timeline Renderer +New content type `timeline`. AI returns a series of `` tags. Panel renders them as a vertical timeline: date on left, event card on right, connecting line. Animate entries in as they appear during streaming. + +### M10.11 — Code Runner +Fenced code blocks with a "Run" button for HTML/CSS/JS. Opens a sandboxed ``},"putIntoIFrame"),Wl=p((e,t,r,i,n)=>{const a=e.append("div");a.attr("id",r),i&&a.attr("style",i);const s=a.append("svg").attr("id",t).attr("width","100%").attr("xmlns",sT);return n&&s.attr("xmlns:xlink",n),s.append("g"),e},"appendDivSvgG");function us(e,t){return e.append("iframe").attr("id",t).attr("style","width: 100%; height: 100%;").attr("sandbox","")}p(us,"sandboxedIframe");var CT=p((e,t,r,i)=>{e.getElementById(t)?.remove(),e.getElementById(r)?.remove(),e.getElementById(i)?.remove()},"removeExistingElements"),wT=p(async function(e,t,r){ta();const i=po(t);t=i.code;const n=vt();A.debug(n),t.length>(n?.maxTextSize??rT)&&(t=iT);const a="#"+e,s="i"+e,o="#"+s,l="d"+e,c="#"+l,h=p(()=>{const O=it(f?o:c).node();O&&"remove"in O&&O.remove()},"removeTempElements");let u=it("body");const f=n.securityLevel===nT,d=n.securityLevel===aT,g=n.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=""),f){const R=us(it(r),s);u=it(R.nodes()[0].contentDocument.body),u.node().style.margin=0}else u=it(r);Wl(u,e,l,`font-family: ${g}`,oT)}else{if(CT(document,e,l,s),f){const R=us(it("body"),s);u=it(R.nodes()[0].contentDocument.body),u.node().style.margin=0}else u=it("body");Wl(u,e,l)}let m,y;try{m=await hs.fromText(t,{title:i.title})}catch(R){if(n.suppressErrorRendering)throw h(),R;m=await hs.fromText("error"),y=R}const x=u.select(c).node(),b=m.type,_=x.firstChild,k=_.firstChild,C=m.renderer.getClasses?.(t,m),B=xT(n,b,C,a),v=document.createElement("style");v.innerHTML=B,_.insertBefore(v,k);try{await m.renderer.draw(t,e,go.version,m)}catch(R){throw n.suppressErrorRendering?h():cS.draw(t,e,go.version),R}const E=u.select(`${c} svg`),P=m.db.getAccTitle?.(),D=m.db.getAccDescription?.();Qp(b,E,P,D),u.select(`[id="${e}"]`).selectAll("foreignobject > *").attr("xmlns",lT);let F=u.select(c).node().innerHTML;if(A.debug("config.arrowMarkerAbsolute",n.arrowMarkerAbsolute),F=bT(F,f,yt(n.arrowMarkerAbsolute)),f){const R=u.select(c+" svg").node();F=_T(F,R)}else d||(F=dr.sanitize(F,{ADD_TAGS:gT,ADD_ATTR:mT,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(KS(),y)throw y;return h(),{diagramType:b,svg:F,bindFunctions:m.db.bindFunctions}},"render");function Zp(e={}){const t=gt({},e);t?.fontFamily&&!t.themeVariables?.fontFamily&&(t.themeVariables||(t.themeVariables={}),t.themeVariables.fontFamily=t.fontFamily),Fg(t),t?.theme&&t.theme in oe?t.themeVariables=oe[t.theme].getThemeVariables(t.themeVariables):t&&(t.themeVariables=oe.default.getThemeVariables(t.themeVariables));const r=typeof t=="object"?Ag(t):Ql();fs(r.logLevel),ta()}p(Zp,"initialize");var Kp=p((e,t={})=>{const{code:r}=fo(e);return hs.fromText(r,t)},"getDiagramFromText");function Qp(e,t,r,i){Yp(t,e),Gp(t,r,i,t.attr("id"))}p(Qp,"addA11yInfo");var Ye=Object.freeze({render:wT,parse:Xp,getDiagramFromText:Kp,initialize:Zp,getConfig:vt,setConfig:Jl,getSiteConfig:Ql,updateSiteConfig:Eg,reset:p(()=>{Vi()},"reset"),globalReset:p(()=>{Vi(pr)},"globalReset"),defaultConfig:pr});fs(vt().logLevel);Vi(vt());var kT=p((e,t,r)=>{A.warn(e),Us(e)?(r&&r(e.str,e.hash),t.push({...e,message:e.str,error:e})):(r&&r(e),e instanceof Error&&t.push({str:e.message,message:e.message,hash:e.name,error:e}))},"handleError"),Jp=p(async function(e={querySelector:".mermaid"}){try{await vT(e)}catch(t){if(Us(t)&&A.error(t.str),ue.parseError&&ue.parseError(t),!e.suppressErrors)throw A.error("Use the suppressErrors option to suppress these errors"),t}},"run"),vT=p(async function({postRenderCallback:e,querySelector:t,nodes:r}={querySelector:".mermaid"}){const i=Ye.getConfig();A.debug(`${e?"":"No "}Callback function found`);let n;if(r)n=r;else if(t)n=document.querySelectorAll(t);else throw new Error("Nodes and querySelector are both undefined");A.debug(`Found ${n.length} diagrams`),i?.startOnLoad!==void 0&&(A.debug("Start On Load: "+i?.startOnLoad),Ye.updateSiteConfig({startOnLoad:i?.startOnLoad}));const a=new jt.InitIDGenerator(i.deterministicIds,i.deterministicIDSeed);let s;const o=[];for(const l of Array.from(n)){if(A.info("Rendering diagram: "+l.id),l.getAttribute("data-processed"))continue;l.setAttribute("data-processed","true");const c=`mermaid-${a.next()}`;s=l.innerHTML,s=_f(jt.entityDecode(s)).trim().replace(//gi,"
");const h=jt.detectInit(s);h&&A.debug("Detected early reinit: ",h);try{const{svg:u,bindFunctions:f}=await ig(c,s,l);l.innerHTML=u,e&&await e(c),f&&f(l)}catch(u){kT(u,o,ue.parseError)}}if(o.length>0)throw o[0]},"runThrowsErrors"),tg=p(function(e){Ye.initialize(e)},"initialize"),ST=p(async function(e,t,r){A.warn("mermaid.init is deprecated. Please use run instead."),e&&tg(e);const i={postRenderCallback:r,querySelector:".mermaid"};typeof t=="string"?i.querySelector=t:t&&(t instanceof HTMLElement?i.nodes=[t]:i.nodes=t),await Jp(i)},"init"),TT=p(async(e,{lazyLoad:t=!0}={})=>{ta(),ma(...e),t===!1&&await XS()},"registerExternalDiagrams"),eg=p(function(){if(ue.startOnLoad){const{startOnLoad:e}=Ye.getConfig();e&&ue.run().catch(t=>A.error("Mermaid failed to initialize",t))}},"contentLoaded");typeof document<"u"&&window.addEventListener("load",eg,!1);var BT=p(function(e){ue.parseError=e},"setParseErrorHandler"),Bn=[],ga=!1,rg=p(async()=>{if(!ga){for(ga=!0;Bn.length>0;){const e=Bn.shift();if(e)try{await e()}catch(t){A.error("Error executing queue",t)}}ga=!1}},"executeQueue"),LT=p(async(e,t)=>new Promise((r,i)=>{const n=p(()=>new Promise((a,s)=>{Ye.parse(e,t).then(o=>{a(o),r(o)},o=>{A.error("Error parsing",o),ue.parseError?.(o),s(o),i(o)})}),"performCall");Bn.push(n),rg().catch(i)}),"parse"),ig=p((e,t,r)=>new Promise((i,n)=>{const a=p(()=>new Promise((s,o)=>{Ye.render(e,t,r).then(l=>{s(l),i(l)},l=>{A.error("Error parsing",l),ue.parseError?.(l),o(l),n(l)})}),"performCall");Bn.push(a),rg().catch(n)}),"render"),$T=p(()=>Object.keys(Ne).map(e=>({id:e})),"getRegisteredDiagramsMetadata"),ue={startOnLoad:!0,mermaidAPI:Ye,parse:LT,render:ig,init:ST,run:Jp,registerExternalDiagrams:TT,registerLayoutLoaders:hp,initialize:tg,parseError:void 0,contentLoaded:eg,setParseErrorHandler:BT,detectType:ds,registerIconPacks:QC,getRegisteredDiagramsMetadata:$T},MT=ue;const oB=Object.freeze(Object.defineProperty({__proto__:null,default:MT},Symbol.toStringTag,{value:"Module"}));export{qT as $,Yr as A,cg as B,c0 as C,Xs as D,Kl as E,vt as F,z_ as G,Wx as H,go as I,Ub as J,vg as K,hi as L,Jt as M,$x as N,Bs as O,zT as P,HT as Q,Ke as R,No as S,Io as T,J as U,YT as V,jT as W,WT as X,IT as Y,NT as Z,p as _,i0 as a,Ru as a$,VT as a0,GT as a1,gr as a2,DT as a3,jn as a4,A_ as a5,Hg as a6,ps as a7,bo as a8,Ex as a9,Y as aA,eB as aB,Qw as aC,Vw as aD,Gw as aE,tw as aF,Mx as aG,ET as aH,ng as aI,yi as aJ,QC as aK,KC as aL,Ge as aM,L_ as aN,Xu as aO,Nn as aP,Hn as aQ,mn as aR,Ku as aS,Uu as aT,n_ as aU,ks as aV,ye as aW,Qr as aX,Fo as aY,cy as aZ,j as a_,Ri as aa,N_ as ab,Kg as ac,ci as ad,z as ae,U as af,sc as ag,Sw as ah,sp as ai,iB as aj,Zb as ak,yt as al,Se as am,Ns as an,Ff as ao,Xe as ap,nf as aq,B_ as ar,b2 as as,__ as at,zs as au,Il as av,mk as aw,rB as ax,nB as ay,tB as az,r0 as b,Ot as b0,ry as b1,ws as b2,_c as b3,fi as b4,kc as b5,RT as b6,lg as b7,T_ as b8,b_ as b9,ls as bA,$_ as bB,Wn as bC,FT as bD,oB as bE,s2 as ba,qs as bb,t_ as bc,M_ as bd,gi as be,vr as bf,dn as bg,u_ as bh,Nk as bi,pi as bj,gn as bk,a_ as bl,Wu as bm,c2 as bn,h2 as bo,Ae as bp,al as bq,u2 as br,Ws as bs,l2 as bt,p2 as bu,Sr as bv,ve as bw,tl as bx,Hs as by,ju as bz,st as c,it as d,ac as e,gt as f,a0 as g,he as h,Nt as i,r1 as j,wr as k,A as l,sf as m,OT as n,sB as o,s0 as p,o0 as q,aB as r,n0 as s,Xb as t,jt as u,Hw as v,H_ as w,UT as x,e0 as y,PT as z}; +`},"putIntoIFrame"),Wl=p((e,t,r,i,n)=>{const a=e.append("div");a.attr("id",r),i&&a.attr("style",i);const s=a.append("svg").attr("id",t).attr("width","100%").attr("xmlns",sT);return n&&s.attr("xmlns:xlink",n),s.append("g"),e},"appendDivSvgG");function us(e,t){return e.append("iframe").attr("id",t).attr("style","width: 100%; height: 100%;").attr("sandbox","")}p(us,"sandboxedIframe");var CT=p((e,t,r,i)=>{e.getElementById(t)?.remove(),e.getElementById(r)?.remove(),e.getElementById(i)?.remove()},"removeExistingElements"),wT=p(async function(e,t,r){ta();const i=po(t);t=i.code;const n=vt();A.debug(n),t.length>(n?.maxTextSize??rT)&&(t=iT);const a="#"+e,s="i"+e,o="#"+s,l="d"+e,c="#"+l,h=p(()=>{const O=it(f?o:c).node();O&&"remove"in O&&O.remove()},"removeTempElements");let u=it("body");const f=n.securityLevel===nT,d=n.securityLevel===aT,g=n.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=""),f){const R=us(it(r),s);u=it(R.nodes()[0].contentDocument.body),u.node().style.margin=0}else u=it(r);Wl(u,e,l,`font-family: ${g}`,oT)}else{if(CT(document,e,l,s),f){const R=us(it("body"),s);u=it(R.nodes()[0].contentDocument.body),u.node().style.margin=0}else u=it("body");Wl(u,e,l)}let m,y;try{m=await hs.fromText(t,{title:i.title})}catch(R){if(n.suppressErrorRendering)throw h(),R;m=await hs.fromText("error"),y=R}const x=u.select(c).node(),b=m.type,_=x.firstChild,k=_.firstChild,C=m.renderer.getClasses?.(t,m),B=xT(n,b,C,a),v=document.createElement("style");v.innerHTML=B,_.insertBefore(v,k);try{await m.renderer.draw(t,e,go.version,m)}catch(R){throw n.suppressErrorRendering?h():cS.draw(t,e,go.version),R}const E=u.select(`${c} svg`),P=m.db.getAccTitle?.(),D=m.db.getAccDescription?.();Qp(b,E,P,D),u.select(`[id="${e}"]`).selectAll("foreignobject > *").attr("xmlns",lT);let F=u.select(c).node().innerHTML;if(A.debug("config.arrowMarkerAbsolute",n.arrowMarkerAbsolute),F=bT(F,f,yt(n.arrowMarkerAbsolute)),f){const R=u.select(c+" svg").node();F=_T(F,R)}else d||(F=dr.sanitize(F,{ADD_TAGS:gT,ADD_ATTR:mT,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(KS(),y)throw y;return h(),{diagramType:b,svg:F,bindFunctions:m.db.bindFunctions}},"render");function Zp(e={}){const t=gt({},e);t?.fontFamily&&!t.themeVariables?.fontFamily&&(t.themeVariables||(t.themeVariables={}),t.themeVariables.fontFamily=t.fontFamily),Fg(t),t?.theme&&t.theme in oe?t.themeVariables=oe[t.theme].getThemeVariables(t.themeVariables):t&&(t.themeVariables=oe.default.getThemeVariables(t.themeVariables));const r=typeof t=="object"?Ag(t):Ql();fs(r.logLevel),ta()}p(Zp,"initialize");var Kp=p((e,t={})=>{const{code:r}=fo(e);return hs.fromText(r,t)},"getDiagramFromText");function Qp(e,t,r,i){Yp(t,e),Gp(t,r,i,t.attr("id"))}p(Qp,"addA11yInfo");var Ye=Object.freeze({render:wT,parse:Xp,getDiagramFromText:Kp,initialize:Zp,getConfig:vt,setConfig:Jl,getSiteConfig:Ql,updateSiteConfig:Eg,reset:p(()=>{Vi()},"reset"),globalReset:p(()=>{Vi(pr)},"globalReset"),defaultConfig:pr});fs(vt().logLevel);Vi(vt());var kT=p((e,t,r)=>{A.warn(e),Us(e)?(r&&r(e.str,e.hash),t.push({...e,message:e.str,error:e})):(r&&r(e),e instanceof Error&&t.push({str:e.message,message:e.message,hash:e.name,error:e}))},"handleError"),Jp=p(async function(e={querySelector:".mermaid"}){try{await vT(e)}catch(t){if(Us(t)&&A.error(t.str),ue.parseError&&ue.parseError(t),!e.suppressErrors)throw A.error("Use the suppressErrors option to suppress these errors"),t}},"run"),vT=p(async function({postRenderCallback:e,querySelector:t,nodes:r}={querySelector:".mermaid"}){const i=Ye.getConfig();A.debug(`${e?"":"No "}Callback function found`);let n;if(r)n=r;else if(t)n=document.querySelectorAll(t);else throw new Error("Nodes and querySelector are both undefined");A.debug(`Found ${n.length} diagrams`),i?.startOnLoad!==void 0&&(A.debug("Start On Load: "+i?.startOnLoad),Ye.updateSiteConfig({startOnLoad:i?.startOnLoad}));const a=new jt.InitIDGenerator(i.deterministicIds,i.deterministicIDSeed);let s;const o=[];for(const l of Array.from(n)){if(A.info("Rendering diagram: "+l.id),l.getAttribute("data-processed"))continue;l.setAttribute("data-processed","true");const c=`mermaid-${a.next()}`;s=l.innerHTML,s=_f(jt.entityDecode(s)).trim().replace(//gi,"
");const h=jt.detectInit(s);h&&A.debug("Detected early reinit: ",h);try{const{svg:u,bindFunctions:f}=await ig(c,s,l);l.innerHTML=u,e&&await e(c),f&&f(l)}catch(u){kT(u,o,ue.parseError)}}if(o.length>0)throw o[0]},"runThrowsErrors"),tg=p(function(e){Ye.initialize(e)},"initialize"),ST=p(async function(e,t,r){A.warn("mermaid.init is deprecated. Please use run instead."),e&&tg(e);const i={postRenderCallback:r,querySelector:".mermaid"};typeof t=="string"?i.querySelector=t:t&&(t instanceof HTMLElement?i.nodes=[t]:i.nodes=t),await Jp(i)},"init"),TT=p(async(e,{lazyLoad:t=!0}={})=>{ta(),ma(...e),t===!1&&await XS()},"registerExternalDiagrams"),eg=p(function(){if(ue.startOnLoad){const{startOnLoad:e}=Ye.getConfig();e&&ue.run().catch(t=>A.error("Mermaid failed to initialize",t))}},"contentLoaded");typeof document<"u"&&window.addEventListener("load",eg,!1);var BT=p(function(e){ue.parseError=e},"setParseErrorHandler"),Bn=[],ga=!1,rg=p(async()=>{if(!ga){for(ga=!0;Bn.length>0;){const e=Bn.shift();if(e)try{await e()}catch(t){A.error("Error executing queue",t)}}ga=!1}},"executeQueue"),LT=p(async(e,t)=>new Promise((r,i)=>{const n=p(()=>new Promise((a,s)=>{Ye.parse(e,t).then(o=>{a(o),r(o)},o=>{A.error("Error parsing",o),ue.parseError?.(o),s(o),i(o)})}),"performCall");Bn.push(n),rg().catch(i)}),"parse"),ig=p((e,t,r)=>new Promise((i,n)=>{const a=p(()=>new Promise((s,o)=>{Ye.render(e,t,r).then(l=>{s(l),i(l)},l=>{A.error("Error parsing",l),ue.parseError?.(l),o(l),n(l)})}),"performCall");Bn.push(a),rg().catch(n)}),"render"),$T=p(()=>Object.keys(Ne).map(e=>({id:e})),"getRegisteredDiagramsMetadata"),ue={startOnLoad:!0,mermaidAPI:Ye,parse:LT,render:ig,init:ST,run:Jp,registerExternalDiagrams:TT,registerLayoutLoaders:hp,initialize:tg,parseError:void 0,contentLoaded:eg,setParseErrorHandler:BT,detectType:ds,registerIconPacks:QC,getRegisteredDiagramsMetadata:$T},MT=ue;const oB=Object.freeze(Object.defineProperty({__proto__:null,default:MT},Symbol.toStringTag,{value:"Module"}));export{qT as $,Yr as A,cg as B,c0 as C,Xs as D,Kl as E,vt as F,z_ as G,Wx as H,go as I,Ub as J,vg as K,hi as L,Jt as M,$x as N,Bs as O,zT as P,HT as Q,Ke as R,No as S,Io as T,J as U,YT as V,jT as W,WT as X,IT as Y,NT as Z,p as _,i0 as a,Ru as a$,VT as a0,GT as a1,gr as a2,DT as a3,jn as a4,A_ as a5,Hg as a6,ps as a7,bo as a8,Ex as a9,Y as aA,eB as aB,Qw as aC,Vw as aD,Gw as aE,tw as aF,Mx as aG,ET as aH,ng as aI,yi as aJ,QC as aK,KC as aL,ks as aM,ye as aN,Qr as aO,Fo as aP,cy as aQ,Ge as aR,L_ as aS,Xu as aT,Nn as aU,Hn as aV,mn as aW,Ku as aX,Uu as aY,n_ as aZ,j as a_,Ri as aa,N_ as ab,Kg as ac,ci as ad,z as ae,U as af,sc as ag,Sw as ah,sp as ai,iB as aj,Zb as ak,yt as al,Se as am,Ns as an,Ff as ao,Xe as ap,nf as aq,B_ as ar,b2 as as,__ as at,zs as au,Il as av,mk as aw,rB as ax,nB as ay,tB as az,r0 as b,Ot as b0,ry as b1,ws as b2,_c as b3,fi as b4,kc as b5,RT as b6,lg as b7,T_ as b8,b_ as b9,ls as bA,$_ as bB,Wn as bC,FT as bD,oB as bE,s2 as ba,qs as bb,t_ as bc,M_ as bd,gi as be,vr as bf,dn as bg,u_ as bh,Nk as bi,pi as bj,gn as bk,a_ as bl,Wu as bm,c2 as bn,h2 as bo,Ae as bp,al as bq,u2 as br,Ws as bs,l2 as bt,p2 as bu,Sr as bv,ve as bw,tl as bx,Hs as by,ju as bz,st as c,it as d,ac as e,gt as f,a0 as g,he as h,Nt as i,r1 as j,wr as k,A as l,sf as m,OT as n,sB as o,s0 as p,o0 as q,aB as r,n0 as s,Xb as t,jt as u,Hw as v,H_ as w,UT as x,e0 as y,PT as z}; diff --git a/demo/aiui/assets/mindmap-definition-VGOIOE7T-BbRYcaHR.js b/demo/aiui/assets/mindmap-definition-VGOIOE7T-DQT8gnk8.js similarity index 99% rename from demo/aiui/assets/mindmap-definition-VGOIOE7T-BbRYcaHR.js rename to demo/aiui/assets/mindmap-definition-VGOIOE7T-DQT8gnk8.js index fcfa28aa..d594e3df 100644 --- a/demo/aiui/assets/mindmap-definition-VGOIOE7T-BbRYcaHR.js +++ b/demo/aiui/assets/mindmap-definition-VGOIOE7T-DQT8gnk8.js @@ -1,4 +1,4 @@ -import{g as le}from"./chunk-55IACEB6-CWcaiZ1g.js";import{s as he}from"./chunk-QN33PNHL-C8Gh8Kbh.js";import{_ as l,l as I,o as de,r as ge,E as B,c as z,i as V,aH as ue,ad as pe,ae as fe,af as ye}from"./mermaid.core-DaNhpuX9.js";import"./index-Lh5NfTCq.js";const E=[];for(let t=0;t<256;++t)E.push((t+256).toString(16).slice(1));function me(t,e=0){return(E[t[e+0]]+E[t[e+1]]+E[t[e+2]]+E[t[e+3]]+"-"+E[t[e+4]]+E[t[e+5]]+"-"+E[t[e+6]]+E[t[e+7]]+"-"+E[t[e+8]]+E[t[e+9]]+"-"+E[t[e+10]]+E[t[e+11]]+E[t[e+12]]+E[t[e+13]]+E[t[e+14]]+E[t[e+15]]).toLowerCase()}let W;const Ee=new Uint8Array(16);function _e(){if(!W){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");W=crypto.getRandomValues.bind(crypto)}return W(Ee)}const be=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),ne={randomUUID:be};function Se(t,e,n){if(ne.randomUUID&&!t)return ne.randomUUID();t=t||{};const c=t.random??t.rng?.()??_e();if(c.length<16)throw new Error("Random bytes length must be >= 16");return c[6]=c[6]&15|64,c[8]=c[8]&63|128,me(c)}var X=(function(){var t=l(function(x,s,i,o){for(i=i||{},o=x.length;o--;i[x[o]]=s);return i},"o"),e=[1,4],n=[1,13],c=[1,12],f=[1,15],h=[1,16],p=[1,20],m=[1,19],u=[6,7,8],N=[1,26],Y=[1,24],q=[1,25],b=[6,7,11],J=[1,6,13,15,16,19,22],K=[1,33],Q=[1,34],R=[1,6,7,11,13,15,16,19,22],F={trace:l(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:l(function(s,i,o,a,g,r,w){var d=r.length-1;switch(g){case 6:case 7:return a;case 8:a.getLogger().trace("Stop NL ");break;case 9:a.getLogger().trace("Stop EOF ");break;case 11:a.getLogger().trace("Stop NL2 ");break;case 12:a.getLogger().trace("Stop EOF2 ");break;case 15:a.getLogger().info("Node: ",r[d].id),a.addNode(r[d-1].length,r[d].id,r[d].descr,r[d].type);break;case 16:a.getLogger().trace("Icon: ",r[d]),a.decorateNode({icon:r[d]});break;case 17:case 21:a.decorateNode({class:r[d]});break;case 18:a.getLogger().trace("SPACELIST");break;case 19:a.getLogger().trace("Node: ",r[d].id),a.addNode(0,r[d].id,r[d].descr,r[d].type);break;case 20:a.decorateNode({icon:r[d]});break;case 25:a.getLogger().trace("node found ..",r[d-2]),this.$={id:r[d-1],descr:r[d-1],type:a.getType(r[d-2],r[d])};break;case 26:this.$={id:r[d],descr:r[d],type:a.nodeType.DEFAULT};break;case 27:a.getLogger().trace("node found ..",r[d-3]),this.$={id:r[d-3],descr:r[d-1],type:a.getType(r[d-2],r[d])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:n,7:[1,10],9:9,12:11,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:n,12:21,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},{6:n,9:22,12:11,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},{6:N,7:Y,10:23,11:q},t(b,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:p,22:m}),t(b,[2,18]),t(b,[2,19]),t(b,[2,20]),t(b,[2,21]),t(b,[2,23]),t(b,[2,24]),t(b,[2,26],{19:[1,30]}),{20:[1,31]},{6:N,7:Y,10:32,11:q},{1:[2,7],6:n,12:21,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},t(J,[2,14],{7:K,11:Q}),t(R,[2,8]),t(R,[2,9]),t(R,[2,10]),t(b,[2,15]),t(b,[2,16]),t(b,[2,17]),{20:[1,35]},{21:[1,36]},t(J,[2,13],{7:K,11:Q}),t(R,[2,11]),t(R,[2,12]),{21:[1,37]},t(b,[2,25]),t(b,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:l(function(s,i){if(i.recoverable)this.trace(s);else{var o=new Error(s);throw o.hash=i,o}},"parseError"),parse:l(function(s){var i=this,o=[0],a=[],g=[null],r=[],w=this.table,d="",U=0,Z=0,re=2,ee=1,oe=r.slice.call(arguments,1),y=Object.create(this.lexer),v={yy:{}};for(var j in this.yy)Object.prototype.hasOwnProperty.call(this.yy,j)&&(v.yy[j]=this.yy[j]);y.setInput(s,v.yy),v.yy.lexer=y,v.yy.parser=this,typeof y.yylloc>"u"&&(y.yylloc={});var G=y.yylloc;r.push(G);var ae=y.options&&y.options.ranges;typeof v.yy.parseError=="function"?this.parseError=v.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ce(S){o.length=o.length-2*S,g.length=g.length-S,r.length=r.length-S}l(ce,"popStack");function te(){var S;return S=a.pop()||y.lex()||ee,typeof S!="number"&&(S instanceof Array&&(a=S,S=a.pop()),S=i.symbols_[S]||S),S}l(te,"lex");for(var _,T,D,H,O={},P,k,ie,M;;){if(T=o[o.length-1],this.defaultActions[T]?D=this.defaultActions[T]:((_===null||typeof _>"u")&&(_=te()),D=w[T]&&w[T][_]),typeof D>"u"||!D.length||!D[0]){var $="";M=[];for(P in w[T])this.terminals_[P]&&P>re&&M.push("'"+this.terminals_[P]+"'");y.showPosition?$="Parse error on line "+(U+1)+`: +import{g as le}from"./chunk-55IACEB6-CtULfmDo.js";import{s as he}from"./chunk-QN33PNHL-DSThOC6-.js";import{_ as l,l as I,o as de,r as ge,E as B,c as z,i as V,aH as ue,ad as pe,ae as fe,af as ye}from"./mermaid.core-v0oo9NRr.js";import"./index-8cIrvc8q.js";const E=[];for(let t=0;t<256;++t)E.push((t+256).toString(16).slice(1));function me(t,e=0){return(E[t[e+0]]+E[t[e+1]]+E[t[e+2]]+E[t[e+3]]+"-"+E[t[e+4]]+E[t[e+5]]+"-"+E[t[e+6]]+E[t[e+7]]+"-"+E[t[e+8]]+E[t[e+9]]+"-"+E[t[e+10]]+E[t[e+11]]+E[t[e+12]]+E[t[e+13]]+E[t[e+14]]+E[t[e+15]]).toLowerCase()}let W;const Ee=new Uint8Array(16);function _e(){if(!W){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");W=crypto.getRandomValues.bind(crypto)}return W(Ee)}const be=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),ne={randomUUID:be};function Se(t,e,n){if(ne.randomUUID&&!t)return ne.randomUUID();t=t||{};const c=t.random??t.rng?.()??_e();if(c.length<16)throw new Error("Random bytes length must be >= 16");return c[6]=c[6]&15|64,c[8]=c[8]&63|128,me(c)}var X=(function(){var t=l(function(x,s,i,o){for(i=i||{},o=x.length;o--;i[x[o]]=s);return i},"o"),e=[1,4],n=[1,13],c=[1,12],f=[1,15],h=[1,16],p=[1,20],m=[1,19],u=[6,7,8],N=[1,26],Y=[1,24],q=[1,25],b=[6,7,11],J=[1,6,13,15,16,19,22],K=[1,33],Q=[1,34],R=[1,6,7,11,13,15,16,19,22],F={trace:l(function(){},"trace"),yy:{},symbols_:{error:2,start:3,mindMap:4,spaceLines:5,SPACELINE:6,NL:7,MINDMAP:8,document:9,stop:10,EOF:11,statement:12,SPACELIST:13,node:14,ICON:15,CLASS:16,nodeWithId:17,nodeWithoutId:18,NODE_DSTART:19,NODE_DESCR:20,NODE_DEND:21,NODE_ID:22,$accept:0,$end:1},terminals_:{2:"error",6:"SPACELINE",7:"NL",8:"MINDMAP",11:"EOF",13:"SPACELIST",15:"ICON",16:"CLASS",19:"NODE_DSTART",20:"NODE_DESCR",21:"NODE_DEND",22:"NODE_ID"},productions_:[0,[3,1],[3,2],[5,1],[5,2],[5,2],[4,2],[4,3],[10,1],[10,1],[10,1],[10,2],[10,2],[9,3],[9,2],[12,2],[12,2],[12,2],[12,1],[12,1],[12,1],[12,1],[12,1],[14,1],[14,1],[18,3],[17,1],[17,4]],performAction:l(function(s,i,o,a,g,r,w){var d=r.length-1;switch(g){case 6:case 7:return a;case 8:a.getLogger().trace("Stop NL ");break;case 9:a.getLogger().trace("Stop EOF ");break;case 11:a.getLogger().trace("Stop NL2 ");break;case 12:a.getLogger().trace("Stop EOF2 ");break;case 15:a.getLogger().info("Node: ",r[d].id),a.addNode(r[d-1].length,r[d].id,r[d].descr,r[d].type);break;case 16:a.getLogger().trace("Icon: ",r[d]),a.decorateNode({icon:r[d]});break;case 17:case 21:a.decorateNode({class:r[d]});break;case 18:a.getLogger().trace("SPACELIST");break;case 19:a.getLogger().trace("Node: ",r[d].id),a.addNode(0,r[d].id,r[d].descr,r[d].type);break;case 20:a.decorateNode({icon:r[d]});break;case 25:a.getLogger().trace("node found ..",r[d-2]),this.$={id:r[d-1],descr:r[d-1],type:a.getType(r[d-2],r[d])};break;case 26:this.$={id:r[d],descr:r[d],type:a.nodeType.DEFAULT};break;case 27:a.getLogger().trace("node found ..",r[d-3]),this.$={id:r[d-3],descr:r[d-1],type:a.getType(r[d-2],r[d])};break}},"anonymous"),table:[{3:1,4:2,5:3,6:[1,5],8:e},{1:[3]},{1:[2,1]},{4:6,6:[1,7],7:[1,8],8:e},{6:n,7:[1,10],9:9,12:11,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},t(u,[2,3]),{1:[2,2]},t(u,[2,4]),t(u,[2,5]),{1:[2,6],6:n,12:21,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},{6:n,9:22,12:11,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},{6:N,7:Y,10:23,11:q},t(b,[2,22],{17:17,18:18,14:27,15:[1,28],16:[1,29],19:p,22:m}),t(b,[2,18]),t(b,[2,19]),t(b,[2,20]),t(b,[2,21]),t(b,[2,23]),t(b,[2,24]),t(b,[2,26],{19:[1,30]}),{20:[1,31]},{6:N,7:Y,10:32,11:q},{1:[2,7],6:n,12:21,13:c,14:14,15:f,16:h,17:17,18:18,19:p,22:m},t(J,[2,14],{7:K,11:Q}),t(R,[2,8]),t(R,[2,9]),t(R,[2,10]),t(b,[2,15]),t(b,[2,16]),t(b,[2,17]),{20:[1,35]},{21:[1,36]},t(J,[2,13],{7:K,11:Q}),t(R,[2,11]),t(R,[2,12]),{21:[1,37]},t(b,[2,25]),t(b,[2,27])],defaultActions:{2:[2,1],6:[2,2]},parseError:l(function(s,i){if(i.recoverable)this.trace(s);else{var o=new Error(s);throw o.hash=i,o}},"parseError"),parse:l(function(s){var i=this,o=[0],a=[],g=[null],r=[],w=this.table,d="",U=0,Z=0,re=2,ee=1,oe=r.slice.call(arguments,1),y=Object.create(this.lexer),v={yy:{}};for(var j in this.yy)Object.prototype.hasOwnProperty.call(this.yy,j)&&(v.yy[j]=this.yy[j]);y.setInput(s,v.yy),v.yy.lexer=y,v.yy.parser=this,typeof y.yylloc>"u"&&(y.yylloc={});var G=y.yylloc;r.push(G);var ae=y.options&&y.options.ranges;typeof v.yy.parseError=="function"?this.parseError=v.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ce(S){o.length=o.length-2*S,g.length=g.length-S,r.length=r.length-S}l(ce,"popStack");function te(){var S;return S=a.pop()||y.lex()||ee,typeof S!="number"&&(S instanceof Array&&(a=S,S=a.pop()),S=i.symbols_[S]||S),S}l(te,"lex");for(var _,T,D,H,O={},P,k,ie,M;;){if(T=o[o.length-1],this.defaultActions[T]?D=this.defaultActions[T]:((_===null||typeof _>"u")&&(_=te()),D=w[T]&&w[T][_]),typeof D>"u"||!D.length||!D[0]){var $="";M=[];for(P in w[T])this.terminals_[P]&&P>re&&M.push("'"+this.terminals_[P]+"'");y.showPosition?$="Parse error on line "+(U+1)+`: `+y.showPosition()+` Expecting `+M.join(", ")+", got '"+(this.terminals_[_]||_)+"'":$="Parse error on line "+(U+1)+": Unexpected "+(_==ee?"end of input":"'"+(this.terminals_[_]||_)+"'"),this.parseError($,{text:y.match,token:this.terminals_[_]||_,line:y.yylineno,loc:G,expected:M})}if(D[0]instanceof Array&&D.length>1)throw new Error("Parse Error: multiple actions possible at state: "+T+", token: "+_);switch(D[0]){case 1:o.push(_),g.push(y.yytext),r.push(y.yylloc),o.push(D[1]),_=null,Z=y.yyleng,d=y.yytext,U=y.yylineno,G=y.yylloc;break;case 2:if(k=this.productions_[D[1]][1],O.$=g[g.length-k],O._$={first_line:r[r.length-(k||1)].first_line,last_line:r[r.length-1].last_line,first_column:r[r.length-(k||1)].first_column,last_column:r[r.length-1].last_column},ae&&(O._$.range=[r[r.length-(k||1)].range[0],r[r.length-1].range[1]]),H=this.performAction.apply(O,[d,Z,U,v.yy,D[1],g,r].concat(oe)),typeof H<"u")return H;k&&(o=o.slice(0,-1*k*2),g=g.slice(0,-1*k),r=r.slice(0,-1*k)),o.push(this.productions_[D[1]][0]),g.push(O.$),r.push(O._$),ie=w[o[o.length-2]][o[o.length-1]],o.push(ie);break;case 3:return!0}}return!0},"parse")},se=(function(){var x={EOF:1,parseError:l(function(i,o){if(this.yy.parser)this.yy.parser.parseError(i,o);else throw new Error(i)},"parseError"),setInput:l(function(s,i){return this.yy=i||this.yy||{},this._input=s,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:l(function(){var s=this._input[0];this.yytext+=s,this.yyleng++,this.offset++,this.match+=s,this.matched+=s;var i=s.match(/(?:\r\n?|\n).*/g);return i?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),s},"input"),unput:l(function(s){var i=s.length,o=s.split(/(?:\r\n?|\n)/g);this._input=s+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-i),this.offset-=i;var a=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),o.length-1&&(this.yylineno-=o.length-1);var g=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:o?(o.length===a.length?this.yylloc.first_column:0)+a[a.length-o.length].length-o[0].length:this.yylloc.first_column-i},this.options.ranges&&(this.yylloc.range=[g[0],g[0]+this.yyleng-i]),this.yyleng=this.yytext.length,this},"unput"),more:l(function(){return this._more=!0,this},"more"),reject:l(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:l(function(s){this.unput(this.match.slice(s))},"less"),pastInput:l(function(){var s=this.matched.substr(0,this.matched.length-this.match.length);return(s.length>20?"...":"")+s.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:l(function(){var s=this.match;return s.length<20&&(s+=this._input.substr(0,20-s.length)),(s.substr(0,20)+(s.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:l(function(){var s=this.pastInput(),i=new Array(s.length+1).join("-");return s+this.upcomingInput()+` diff --git a/demo/aiui/assets/nodeDemoPrompts-DjnuaxJP.js b/demo/aiui/assets/nodeDemoPrompts-ByvlmttR.js similarity index 99% rename from demo/aiui/assets/nodeDemoPrompts-DjnuaxJP.js rename to demo/aiui/assets/nodeDemoPrompts-ByvlmttR.js index 551981c5..842c1d80 100644 --- a/demo/aiui/assets/nodeDemoPrompts-DjnuaxJP.js +++ b/demo/aiui/assets/nodeDemoPrompts-ByvlmttR.js @@ -73,7 +73,7 @@ txindex=1 # RPC Settings rpcuser=archipelago -rpcpassword=archipelago123 +rpcpassword=EXAMPLE-ONLY-not-a-real-password rpcallowip=127.0.0.1 rpcbind=127.0.0.1 rpcport=8332 diff --git a/demo/aiui/assets/ollama-provider-Ck1Tq0Ld.js b/demo/aiui/assets/ollama-provider-Ck1Tq0Ld.js deleted file mode 100644 index 26563b90..00000000 --- a/demo/aiui/assets/ollama-provider-Ck1Tq0Ld.js +++ /dev/null @@ -1,2 +0,0 @@ -const v="/aiui/",u=`${v}api/ollama`;async function*w(o,t,a){const i=o.filter(e=>e.role!=="system").map(e=>({role:e.role,content:typeof e.content=="string"?e.content:e.content.map(d=>d.text??"").join("")})),r=o.find(e=>e.role==="system"),m=r?typeof r.content=="string"?r.content:r.content.map(e=>e.text??"").join(""):void 0;m&&i.unshift({role:"system",content:m});const y={model:t.model||"qwen2.5-coder:3b",messages:i,stream:!0};t.temperature!==void 0&&(y.temperature=t.temperature);let s;try{s=await fetch(`${u}/api/chat`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)})}catch{yield{type:"error",error:"Cannot connect to Ollama. Is it running on this node?"};return}if(!s.ok){const e=await s.text().catch(()=>"Could not read error body");yield{type:"error",error:`Ollama error ${s.status}: ${e}`};return}const l=s.body?.getReader();if(!l){yield{type:"error",error:"No response body"};return}const h=new TextDecoder;let c="";try{for(;;){const{done:e,value:d}=await l.read();if(e)break;c+=h.decode(d,{stream:!0});const f=c.split(` -`);c=f.pop()??"";for(const g of f)if(g.trim())try{const n=JSON.parse(g);if(n.message?.content&&(yield{type:"text",text:n.message.content}),n.done){yield{type:"done",usage:n.eval_count?{promptTokens:n.prompt_eval_count??0,completionTokens:n.eval_count??0}:void 0};return}}catch{}}}finally{l.cancel().catch(()=>{})}yield{type:"done"}}const x={id:"ollama",name:"Local AI (Ollama)",version:"1.0.0",type:"ai-provider",description:"Local AI model via Ollama — fully private, no data leaves your node",supportsStreaming:!0,supportsVision:!1,supportsTools:!1,async init(o){},async destroy(){},async isAvailable(){try{return(await fetch(`${u}/api/tags`,{signal:AbortSignal.timeout(3e3)})).ok}catch{return!1}},chat(o,t){return w(o,t)},async models(){try{const o=await fetch(`${u}/api/tags`,{signal:AbortSignal.timeout(5e3)});if(!o.ok)return p();const t=await o.json();return t.models?.length?t.models.map(a=>({id:a.name,name:b(a.name),provider:"ollama",supportsVision:!1,supportsTools:!1,contextWindow:8192})):p()}catch{return p()}}};function p(){return[{id:"qwen2.5-coder:3b",name:"Qwen 2.5 Coder 3B",provider:"ollama",supportsVision:!1,supportsTools:!1,contextWindow:8192}]}function b(o){const t=o.split(":"),a=t[0].replace(/[.-]/g," ").replace(/\b\w/g,r=>r.toUpperCase()),i=t[1]?` (${t[1]})`:"";return`${a}${i}`}export{x as ollamaProvider}; diff --git a/demo/aiui/assets/pieDiagram-ADFJNKIX-DskAbgnA.js b/demo/aiui/assets/pieDiagram-ADFJNKIX-1zGTmPKE.js similarity index 92% rename from demo/aiui/assets/pieDiagram-ADFJNKIX-DskAbgnA.js rename to demo/aiui/assets/pieDiagram-ADFJNKIX-1zGTmPKE.js index 94215706..6e6652f1 100644 --- a/demo/aiui/assets/pieDiagram-ADFJNKIX-DskAbgnA.js +++ b/demo/aiui/assets/pieDiagram-ADFJNKIX-1zGTmPKE.js @@ -1,4 +1,4 @@ -import{R as S,V as z,aG as j,g as q,s as H,a as Z,b as J,q as K,p as Q,_ as p,l as F,c as X,D as Y,H as ee,a4 as te,e as ae,y as re,E as ne}from"./mermaid.core-DaNhpuX9.js";import{p as ie}from"./chunk-4BX2VUAB-WOh8BXBb.js";import{p as se}from"./treemap-GDKQZRPO-yRLasM0b.js";import{d as I}from"./arc-M-sFvFvX.js";import{o as le}from"./ordinal-Cboi1Yqb.js";import"./index-Lh5NfTCq.js";import"./_baseUniq-C5dU7AKy.js";import"./_basePickBy-BlfxZvco.js";import"./clone-CJT8Sng7.js";import"./init-Gi6I4Gst.js";function oe(e,a){return ae?1:a>=e?0:NaN}function ce(e){return e}function ue(){var e=ce,a=oe,f=null,y=S(0),s=S(z),o=S(0);function l(t){var n,c=(t=j(t)).length,d,x,h=0,u=new Array(c),i=new Array(c),v=+y.apply(this,arguments),w=Math.min(z,Math.max(-z,s.apply(this,arguments)-v)),m,C=Math.min(Math.abs(w)/c,o.apply(this,arguments)),$=C*(w<0?-1:1),g;for(n=0;n0&&(h+=g);for(a!=null?u.sort(function(A,D){return a(i[A],i[D])}):f!=null&&u.sort(function(A,D){return f(t[A],t[D])}),n=0,x=h?(w-c*$)/h:0;n0?g*x:0)+$,i[d]={data:t[d],index:n,value:g,startAngle:v,endAngle:m,padAngle:C};return i}return l.value=function(t){return arguments.length?(e=typeof t=="function"?t:S(+t),l):e},l.sortValues=function(t){return arguments.length?(a=t,f=null,l):a},l.sort=function(t){return arguments.length?(f=t,a=null,l):f},l.startAngle=function(t){return arguments.length?(y=typeof t=="function"?t:S(+t),l):y},l.endAngle=function(t){return arguments.length?(s=typeof t=="function"?t:S(+t),l):s},l.padAngle=function(t){return arguments.length?(o=typeof t=="function"?t:S(+t),l):o},l}var pe=ne.pie,G={sections:new Map,showData:!1},T=G.sections,N=G.showData,de=structuredClone(pe),ge=p(()=>structuredClone(de),"getConfig"),fe=p(()=>{T=new Map,N=G.showData,re()},"clear"),me=p(({label:e,value:a})=>{if(a<0)throw new Error(`"${e}" has invalid value: ${a}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);T.has(e)||(T.set(e,a),F.debug(`added new section: ${e}, with value: ${a}`))},"addSection"),he=p(()=>T,"getSections"),ve=p(e=>{N=e},"setShowData"),Se=p(()=>N,"getShowData"),L={getConfig:ge,clear:fe,setDiagramTitle:Q,getDiagramTitle:K,setAccTitle:J,getAccTitle:Z,setAccDescription:H,getAccDescription:q,addSection:me,getSections:he,setShowData:ve,getShowData:Se},ye=p((e,a)=>{ie(e,a),a.setShowData(e.showData),e.sections.map(a.addSection)},"populateDb"),xe={parse:p(async e=>{const a=await se("pie",e);F.debug(a),ye(a,L)},"parse")},we=p(e=>` +import{R as S,V as z,aG as j,g as q,s as H,a as Z,b as J,q as K,p as Q,_ as p,l as F,c as X,D as Y,H as ee,a4 as te,e as ae,y as re,E as ne}from"./mermaid.core-v0oo9NRr.js";import{p as ie}from"./chunk-4BX2VUAB-DWDvTYfd.js";import{p as se}from"./treemap-GDKQZRPO-DJjQsbt8.js";import{d as I}from"./arc-UjuE1bPP.js";import{o as le}from"./ordinal-Cboi1Yqb.js";import"./index-8cIrvc8q.js";import"./_baseUniq-DAOs4kUj.js";import"./_basePickBy-CL4iQUG-.js";import"./clone-C1u3K6Fy.js";import"./init-Gi6I4Gst.js";function oe(e,a){return ae?1:a>=e?0:NaN}function ce(e){return e}function ue(){var e=ce,a=oe,f=null,y=S(0),s=S(z),o=S(0);function l(t){var n,c=(t=j(t)).length,d,x,h=0,u=new Array(c),i=new Array(c),v=+y.apply(this,arguments),w=Math.min(z,Math.max(-z,s.apply(this,arguments)-v)),m,C=Math.min(Math.abs(w)/c,o.apply(this,arguments)),$=C*(w<0?-1:1),g;for(n=0;n0&&(h+=g);for(a!=null?u.sort(function(A,D){return a(i[A],i[D])}):f!=null&&u.sort(function(A,D){return f(t[A],t[D])}),n=0,x=h?(w-c*$)/h:0;n0?g*x:0)+$,i[d]={data:t[d],index:n,value:g,startAngle:v,endAngle:m,padAngle:C};return i}return l.value=function(t){return arguments.length?(e=typeof t=="function"?t:S(+t),l):e},l.sortValues=function(t){return arguments.length?(a=t,f=null,l):a},l.sort=function(t){return arguments.length?(f=t,a=null,l):f},l.startAngle=function(t){return arguments.length?(y=typeof t=="function"?t:S(+t),l):y},l.endAngle=function(t){return arguments.length?(s=typeof t=="function"?t:S(+t),l):s},l.padAngle=function(t){return arguments.length?(o=typeof t=="function"?t:S(+t),l):o},l}var pe=ne.pie,G={sections:new Map,showData:!1},T=G.sections,N=G.showData,de=structuredClone(pe),ge=p(()=>structuredClone(de),"getConfig"),fe=p(()=>{T=new Map,N=G.showData,re()},"clear"),me=p(({label:e,value:a})=>{if(a<0)throw new Error(`"${e}" has invalid value: ${a}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);T.has(e)||(T.set(e,a),F.debug(`added new section: ${e}, with value: ${a}`))},"addSection"),he=p(()=>T,"getSections"),ve=p(e=>{N=e},"setShowData"),Se=p(()=>N,"getShowData"),L={getConfig:ge,clear:fe,setDiagramTitle:Q,getDiagramTitle:K,setAccTitle:J,getAccTitle:Z,setAccDescription:H,getAccDescription:q,addSection:me,getSections:he,setShowData:ve,getShowData:Se},ye=p((e,a)=>{ie(e,a),a.setShowData(e.showData),e.sections.map(a.addSection)},"populateDb"),xe={parse:p(async e=>{const a=await se("pie",e);F.debug(a),ye(a,L)},"parse")},we=p(e=>` .pieCircle{ stroke: ${e.pieStrokeColor}; stroke-width : ${e.pieStrokeWidth}; diff --git a/demo/aiui/assets/quadrantDiagram-AYHSOK5B-Bp8ks7mP.js b/demo/aiui/assets/quadrantDiagram-AYHSOK5B-D5eqUBKn.js similarity index 99% rename from demo/aiui/assets/quadrantDiagram-AYHSOK5B-Bp8ks7mP.js rename to demo/aiui/assets/quadrantDiagram-AYHSOK5B-D5eqUBKn.js index ee496768..76a21ac7 100644 --- a/demo/aiui/assets/quadrantDiagram-AYHSOK5B-Bp8ks7mP.js +++ b/demo/aiui/assets/quadrantDiagram-AYHSOK5B-D5eqUBKn.js @@ -1,4 +1,4 @@ -import{s as _e,g as Ae,q as ie,p as ke,a as Fe,b as Pe,_ as o,c as zt,l as bt,d as Lt,e as ve,y as Ce,E as D,i as Le,K as Ee}from"./mermaid.core-DaNhpuX9.js";import{l as ee}from"./linear-BI0BnS_D.js";import"./index-Lh5NfTCq.js";import"./init-Gi6I4Gst.js";import"./defaultLocale-DX6XiGOO.js";var Et=(function(){var t=o(function(M,r,l,x){for(l=l||{},x=M.length;x--;l[M[x]]=r);return l},"o"),n=[1,3],f=[1,4],d=[1,5],h=[1,6],p=[1,7],y=[1,4,5,10,12,13,14,18,25,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],_=[1,4,5,10,12,13,14,18,25,28,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],a=[55,56,57],A=[2,36],u=[1,37],T=[1,36],q=[1,38],m=[1,35],b=[1,43],g=[1,41],G=[1,14],ht=[1,23],xt=[1,18],ft=[1,19],gt=[1,20],ct=[1,21],_t=[1,22],dt=[1,24],i=[1,25],Vt=[1,26],It=[1,27],wt=[1,28],Bt=[1,29],W=[1,32],U=[1,33],k=[1,34],F=[1,39],P=[1,40],v=[1,42],C=[1,44],O=[1,62],H=[1,61],L=[4,5,8,10,12,13,14,18,44,47,49,55,56,57,63,64,65,66,67],Rt=[1,65],Nt=[1,66],Wt=[1,67],Ut=[1,68],Qt=[1,69],Ot=[1,70],Ht=[1,71],Xt=[1,72],Mt=[1,73],Yt=[1,74],jt=[1,75],Gt=[1,76],I=[4,5,6,7,8,9,10,11,12,13,14,15,18],K=[1,90],Z=[1,91],J=[1,92],$=[1,99],tt=[1,93],et=[1,96],it=[1,94],at=[1,95],nt=[1,97],st=[1,98],At=[1,102],Kt=[10,55,56,57],R=[4,5,6,8,10,11,13,17,18,19,20,55,56,57],kt={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,idStringToken:3,ALPHA:4,NUM:5,NODE_STRING:6,DOWN:7,MINUS:8,DEFAULT:9,COMMA:10,COLON:11,AMP:12,BRKT:13,MULT:14,UNICODE_TEXT:15,styleComponent:16,UNIT:17,SPACE:18,STYLE:19,PCT:20,idString:21,style:22,stylesOpt:23,classDefStatement:24,CLASSDEF:25,start:26,eol:27,QUADRANT:28,document:29,line:30,statement:31,axisDetails:32,quadrantDetails:33,points:34,title:35,title_value:36,acc_title:37,acc_title_value:38,acc_descr:39,acc_descr_value:40,acc_descr_multiline_value:41,section:42,text:43,point_start:44,point_x:45,point_y:46,class_name:47,"X-AXIS":48,"AXIS-TEXT-DELIMITER":49,"Y-AXIS":50,QUADRANT_1:51,QUADRANT_2:52,QUADRANT_3:53,QUADRANT_4:54,NEWLINE:55,SEMI:56,EOF:57,alphaNumToken:58,textNoTagsToken:59,STR:60,MD_STR:61,alphaNum:62,PUNCTUATION:63,PLUS:64,EQUALS:65,DOT:66,UNDERSCORE:67,$accept:0,$end:1},terminals_:{2:"error",4:"ALPHA",5:"NUM",6:"NODE_STRING",7:"DOWN",8:"MINUS",9:"DEFAULT",10:"COMMA",11:"COLON",12:"AMP",13:"BRKT",14:"MULT",15:"UNICODE_TEXT",17:"UNIT",18:"SPACE",19:"STYLE",20:"PCT",25:"CLASSDEF",28:"QUADRANT",35:"title",36:"title_value",37:"acc_title",38:"acc_title_value",39:"acc_descr",40:"acc_descr_value",41:"acc_descr_multiline_value",42:"section",44:"point_start",45:"point_x",46:"point_y",47:"class_name",48:"X-AXIS",49:"AXIS-TEXT-DELIMITER",50:"Y-AXIS",51:"QUADRANT_1",52:"QUADRANT_2",53:"QUADRANT_3",54:"QUADRANT_4",55:"NEWLINE",56:"SEMI",57:"EOF",60:"STR",61:"MD_STR",63:"PUNCTUATION",64:"PLUS",65:"EQUALS",66:"DOT",67:"UNDERSCORE"},productions_:[0,[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[21,1],[21,2],[22,1],[22,2],[23,1],[23,3],[24,5],[26,2],[26,2],[26,2],[29,0],[29,2],[30,2],[31,0],[31,1],[31,2],[31,1],[31,1],[31,1],[31,2],[31,2],[31,2],[31,1],[31,1],[34,4],[34,5],[34,5],[34,6],[32,4],[32,3],[32,2],[32,4],[32,3],[32,2],[33,2],[33,2],[33,2],[33,2],[27,1],[27,1],[27,1],[43,1],[43,2],[43,1],[43,1],[62,1],[62,2],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[59,1],[59,1],[59,1]],performAction:o(function(r,l,x,c,S,e,ut){var s=e.length-1;switch(S){case 23:this.$=e[s];break;case 24:this.$=e[s-1]+""+e[s];break;case 26:this.$=e[s-1]+e[s];break;case 27:this.$=[e[s].trim()];break;case 28:e[s-2].push(e[s].trim()),this.$=e[s-2];break;case 29:this.$=e[s-4],c.addClass(e[s-2],e[s]);break;case 37:this.$=[];break;case 42:this.$=e[s].trim(),c.setDiagramTitle(this.$);break;case 43:this.$=e[s].trim(),c.setAccTitle(this.$);break;case 44:case 45:this.$=e[s].trim(),c.setAccDescription(this.$);break;case 46:c.addSection(e[s].substr(8)),this.$=e[s].substr(8);break;case 47:c.addPoint(e[s-3],"",e[s-1],e[s],[]);break;case 48:c.addPoint(e[s-4],e[s-3],e[s-1],e[s],[]);break;case 49:c.addPoint(e[s-4],"",e[s-2],e[s-1],e[s]);break;case 50:c.addPoint(e[s-5],e[s-4],e[s-2],e[s-1],e[s]);break;case 51:c.setXAxisLeftText(e[s-2]),c.setXAxisRightText(e[s]);break;case 52:e[s-1].text+=" ⟶ ",c.setXAxisLeftText(e[s-1]);break;case 53:c.setXAxisLeftText(e[s]);break;case 54:c.setYAxisBottomText(e[s-2]),c.setYAxisTopText(e[s]);break;case 55:e[s-1].text+=" ⟶ ",c.setYAxisBottomText(e[s-1]);break;case 56:c.setYAxisBottomText(e[s]);break;case 57:c.setQuadrant1Text(e[s]);break;case 58:c.setQuadrant2Text(e[s]);break;case 59:c.setQuadrant3Text(e[s]);break;case 60:c.setQuadrant4Text(e[s]);break;case 64:this.$={text:e[s],type:"text"};break;case 65:this.$={text:e[s-1].text+""+e[s],type:e[s-1].type};break;case 66:this.$={text:e[s],type:"text"};break;case 67:this.$={text:e[s],type:"markdown"};break;case 68:this.$=e[s];break;case 69:this.$=e[s-1]+""+e[s];break}},"anonymous"),table:[{18:n,26:1,27:2,28:f,55:d,56:h,57:p},{1:[3]},{18:n,26:8,27:2,28:f,55:d,56:h,57:p},{18:n,26:9,27:2,28:f,55:d,56:h,57:p},t(y,[2,33],{29:10}),t(_,[2,61]),t(_,[2,62]),t(_,[2,63]),{1:[2,30]},{1:[2,31]},t(a,A,{30:11,31:12,24:13,32:15,33:16,34:17,43:30,58:31,1:[2,32],4:u,5:T,10:q,12:m,13:b,14:g,18:G,25:ht,35:xt,37:ft,39:gt,41:ct,42:_t,48:dt,50:i,51:Vt,52:It,53:wt,54:Bt,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),t(y,[2,34]),{27:45,55:d,56:h,57:p},t(a,[2,37]),t(a,A,{24:13,32:15,33:16,34:17,43:30,58:31,31:46,4:u,5:T,10:q,12:m,13:b,14:g,18:G,25:ht,35:xt,37:ft,39:gt,41:ct,42:_t,48:dt,50:i,51:Vt,52:It,53:wt,54:Bt,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),t(a,[2,39]),t(a,[2,40]),t(a,[2,41]),{36:[1,47]},{38:[1,48]},{40:[1,49]},t(a,[2,45]),t(a,[2,46]),{18:[1,50]},{4:u,5:T,10:q,12:m,13:b,14:g,43:51,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:52,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:53,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:54,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:55,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:56,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,44:[1,57],47:[1,58],58:60,59:59,63:k,64:F,65:P,66:v,67:C},t(L,[2,64]),t(L,[2,66]),t(L,[2,67]),t(L,[2,70]),t(L,[2,71]),t(L,[2,72]),t(L,[2,73]),t(L,[2,74]),t(L,[2,75]),t(L,[2,76]),t(L,[2,77]),t(L,[2,78]),t(L,[2,79]),t(L,[2,80]),t(y,[2,35]),t(a,[2,38]),t(a,[2,42]),t(a,[2,43]),t(a,[2,44]),{3:64,4:Rt,5:Nt,6:Wt,7:Ut,8:Qt,9:Ot,10:Ht,11:Xt,12:Mt,13:Yt,14:jt,15:Gt,21:63},t(a,[2,53],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,49:[1,77],63:k,64:F,65:P,66:v,67:C}),t(a,[2,56],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,49:[1,78],63:k,64:F,65:P,66:v,67:C}),t(a,[2,57],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,58],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,59],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,60],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),{45:[1,79]},{44:[1,80]},t(L,[2,65]),t(L,[2,81]),t(L,[2,82]),t(L,[2,83]),{3:82,4:Rt,5:Nt,6:Wt,7:Ut,8:Qt,9:Ot,10:Ht,11:Xt,12:Mt,13:Yt,14:jt,15:Gt,18:[1,81]},t(I,[2,23]),t(I,[2,1]),t(I,[2,2]),t(I,[2,3]),t(I,[2,4]),t(I,[2,5]),t(I,[2,6]),t(I,[2,7]),t(I,[2,8]),t(I,[2,9]),t(I,[2,10]),t(I,[2,11]),t(I,[2,12]),t(a,[2,52],{58:31,43:83,4:u,5:T,10:q,12:m,13:b,14:g,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),t(a,[2,55],{58:31,43:84,4:u,5:T,10:q,12:m,13:b,14:g,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),{46:[1,85]},{45:[1,86]},{4:K,5:Z,6:J,8:$,11:tt,13:et,16:89,17:it,18:at,19:nt,20:st,22:88,23:87},t(I,[2,24]),t(a,[2,51],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,54],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,47],{22:88,16:89,23:100,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st}),{46:[1,101]},t(a,[2,29],{10:At}),t(Kt,[2,27],{16:103,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st}),t(R,[2,25]),t(R,[2,13]),t(R,[2,14]),t(R,[2,15]),t(R,[2,16]),t(R,[2,17]),t(R,[2,18]),t(R,[2,19]),t(R,[2,20]),t(R,[2,21]),t(R,[2,22]),t(a,[2,49],{10:At}),t(a,[2,48],{22:88,16:89,23:104,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st}),{4:K,5:Z,6:J,8:$,11:tt,13:et,16:89,17:it,18:at,19:nt,20:st,22:105},t(R,[2,26]),t(a,[2,50],{10:At}),t(Kt,[2,28],{16:103,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st})],defaultActions:{8:[2,30],9:[2,31]},parseError:o(function(r,l){if(l.recoverable)this.trace(r);else{var x=new Error(r);throw x.hash=l,x}},"parseError"),parse:o(function(r){var l=this,x=[0],c=[],S=[null],e=[],ut=this.table,s="",yt=0,Zt=0,qe=2,Jt=1,me=e.slice.call(arguments,1),E=Object.create(this.lexer),Y={yy:{}};for(var Ft in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ft)&&(Y.yy[Ft]=this.yy[Ft]);E.setInput(r,Y.yy),Y.yy.lexer=E,Y.yy.parser=this,typeof E.yylloc>"u"&&(E.yylloc={});var Pt=E.yylloc;e.push(Pt);var be=E.options&&E.options.ranges;typeof Y.yy.parseError=="function"?this.parseError=Y.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Se(B){x.length=x.length-2*B,S.length=S.length-B,e.length=e.length-B}o(Se,"popStack");function $t(){var B;return B=c.pop()||E.lex()||Jt,typeof B!="number"&&(B instanceof Array&&(c=B,B=c.pop()),B=l.symbols_[B]||B),B}o($t,"lex");for(var w,j,N,vt,rt={},Tt,X,te,qt;;){if(j=x[x.length-1],this.defaultActions[j]?N=this.defaultActions[j]:((w===null||typeof w>"u")&&(w=$t()),N=ut[j]&&ut[j][w]),typeof N>"u"||!N.length||!N[0]){var Ct="";qt=[];for(Tt in ut[j])this.terminals_[Tt]&&Tt>qe&&qt.push("'"+this.terminals_[Tt]+"'");E.showPosition?Ct="Parse error on line "+(yt+1)+`: +import{s as _e,g as Ae,q as ie,p as ke,a as Fe,b as Pe,_ as o,c as zt,l as bt,d as Lt,e as ve,y as Ce,E as D,i as Le,K as Ee}from"./mermaid.core-v0oo9NRr.js";import{l as ee}from"./linear-Bzk-L7jX.js";import"./index-8cIrvc8q.js";import"./init-Gi6I4Gst.js";import"./defaultLocale-DX6XiGOO.js";var Et=(function(){var t=o(function(M,r,l,x){for(l=l||{},x=M.length;x--;l[M[x]]=r);return l},"o"),n=[1,3],f=[1,4],d=[1,5],h=[1,6],p=[1,7],y=[1,4,5,10,12,13,14,18,25,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],_=[1,4,5,10,12,13,14,18,25,28,35,37,39,41,42,48,50,51,52,53,54,55,56,57,60,61,63,64,65,66,67],a=[55,56,57],A=[2,36],u=[1,37],T=[1,36],q=[1,38],m=[1,35],b=[1,43],g=[1,41],G=[1,14],ht=[1,23],xt=[1,18],ft=[1,19],gt=[1,20],ct=[1,21],_t=[1,22],dt=[1,24],i=[1,25],Vt=[1,26],It=[1,27],wt=[1,28],Bt=[1,29],W=[1,32],U=[1,33],k=[1,34],F=[1,39],P=[1,40],v=[1,42],C=[1,44],O=[1,62],H=[1,61],L=[4,5,8,10,12,13,14,18,44,47,49,55,56,57,63,64,65,66,67],Rt=[1,65],Nt=[1,66],Wt=[1,67],Ut=[1,68],Qt=[1,69],Ot=[1,70],Ht=[1,71],Xt=[1,72],Mt=[1,73],Yt=[1,74],jt=[1,75],Gt=[1,76],I=[4,5,6,7,8,9,10,11,12,13,14,15,18],K=[1,90],Z=[1,91],J=[1,92],$=[1,99],tt=[1,93],et=[1,96],it=[1,94],at=[1,95],nt=[1,97],st=[1,98],At=[1,102],Kt=[10,55,56,57],R=[4,5,6,8,10,11,13,17,18,19,20,55,56,57],kt={trace:o(function(){},"trace"),yy:{},symbols_:{error:2,idStringToken:3,ALPHA:4,NUM:5,NODE_STRING:6,DOWN:7,MINUS:8,DEFAULT:9,COMMA:10,COLON:11,AMP:12,BRKT:13,MULT:14,UNICODE_TEXT:15,styleComponent:16,UNIT:17,SPACE:18,STYLE:19,PCT:20,idString:21,style:22,stylesOpt:23,classDefStatement:24,CLASSDEF:25,start:26,eol:27,QUADRANT:28,document:29,line:30,statement:31,axisDetails:32,quadrantDetails:33,points:34,title:35,title_value:36,acc_title:37,acc_title_value:38,acc_descr:39,acc_descr_value:40,acc_descr_multiline_value:41,section:42,text:43,point_start:44,point_x:45,point_y:46,class_name:47,"X-AXIS":48,"AXIS-TEXT-DELIMITER":49,"Y-AXIS":50,QUADRANT_1:51,QUADRANT_2:52,QUADRANT_3:53,QUADRANT_4:54,NEWLINE:55,SEMI:56,EOF:57,alphaNumToken:58,textNoTagsToken:59,STR:60,MD_STR:61,alphaNum:62,PUNCTUATION:63,PLUS:64,EQUALS:65,DOT:66,UNDERSCORE:67,$accept:0,$end:1},terminals_:{2:"error",4:"ALPHA",5:"NUM",6:"NODE_STRING",7:"DOWN",8:"MINUS",9:"DEFAULT",10:"COMMA",11:"COLON",12:"AMP",13:"BRKT",14:"MULT",15:"UNICODE_TEXT",17:"UNIT",18:"SPACE",19:"STYLE",20:"PCT",25:"CLASSDEF",28:"QUADRANT",35:"title",36:"title_value",37:"acc_title",38:"acc_title_value",39:"acc_descr",40:"acc_descr_value",41:"acc_descr_multiline_value",42:"section",44:"point_start",45:"point_x",46:"point_y",47:"class_name",48:"X-AXIS",49:"AXIS-TEXT-DELIMITER",50:"Y-AXIS",51:"QUADRANT_1",52:"QUADRANT_2",53:"QUADRANT_3",54:"QUADRANT_4",55:"NEWLINE",56:"SEMI",57:"EOF",60:"STR",61:"MD_STR",63:"PUNCTUATION",64:"PLUS",65:"EQUALS",66:"DOT",67:"UNDERSCORE"},productions_:[0,[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[3,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[16,1],[21,1],[21,2],[22,1],[22,2],[23,1],[23,3],[24,5],[26,2],[26,2],[26,2],[29,0],[29,2],[30,2],[31,0],[31,1],[31,2],[31,1],[31,1],[31,1],[31,2],[31,2],[31,2],[31,1],[31,1],[34,4],[34,5],[34,5],[34,6],[32,4],[32,3],[32,2],[32,4],[32,3],[32,2],[33,2],[33,2],[33,2],[33,2],[27,1],[27,1],[27,1],[43,1],[43,2],[43,1],[43,1],[62,1],[62,2],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[58,1],[59,1],[59,1],[59,1]],performAction:o(function(r,l,x,c,S,e,ut){var s=e.length-1;switch(S){case 23:this.$=e[s];break;case 24:this.$=e[s-1]+""+e[s];break;case 26:this.$=e[s-1]+e[s];break;case 27:this.$=[e[s].trim()];break;case 28:e[s-2].push(e[s].trim()),this.$=e[s-2];break;case 29:this.$=e[s-4],c.addClass(e[s-2],e[s]);break;case 37:this.$=[];break;case 42:this.$=e[s].trim(),c.setDiagramTitle(this.$);break;case 43:this.$=e[s].trim(),c.setAccTitle(this.$);break;case 44:case 45:this.$=e[s].trim(),c.setAccDescription(this.$);break;case 46:c.addSection(e[s].substr(8)),this.$=e[s].substr(8);break;case 47:c.addPoint(e[s-3],"",e[s-1],e[s],[]);break;case 48:c.addPoint(e[s-4],e[s-3],e[s-1],e[s],[]);break;case 49:c.addPoint(e[s-4],"",e[s-2],e[s-1],e[s]);break;case 50:c.addPoint(e[s-5],e[s-4],e[s-2],e[s-1],e[s]);break;case 51:c.setXAxisLeftText(e[s-2]),c.setXAxisRightText(e[s]);break;case 52:e[s-1].text+=" ⟶ ",c.setXAxisLeftText(e[s-1]);break;case 53:c.setXAxisLeftText(e[s]);break;case 54:c.setYAxisBottomText(e[s-2]),c.setYAxisTopText(e[s]);break;case 55:e[s-1].text+=" ⟶ ",c.setYAxisBottomText(e[s-1]);break;case 56:c.setYAxisBottomText(e[s]);break;case 57:c.setQuadrant1Text(e[s]);break;case 58:c.setQuadrant2Text(e[s]);break;case 59:c.setQuadrant3Text(e[s]);break;case 60:c.setQuadrant4Text(e[s]);break;case 64:this.$={text:e[s],type:"text"};break;case 65:this.$={text:e[s-1].text+""+e[s],type:e[s-1].type};break;case 66:this.$={text:e[s],type:"text"};break;case 67:this.$={text:e[s],type:"markdown"};break;case 68:this.$=e[s];break;case 69:this.$=e[s-1]+""+e[s];break}},"anonymous"),table:[{18:n,26:1,27:2,28:f,55:d,56:h,57:p},{1:[3]},{18:n,26:8,27:2,28:f,55:d,56:h,57:p},{18:n,26:9,27:2,28:f,55:d,56:h,57:p},t(y,[2,33],{29:10}),t(_,[2,61]),t(_,[2,62]),t(_,[2,63]),{1:[2,30]},{1:[2,31]},t(a,A,{30:11,31:12,24:13,32:15,33:16,34:17,43:30,58:31,1:[2,32],4:u,5:T,10:q,12:m,13:b,14:g,18:G,25:ht,35:xt,37:ft,39:gt,41:ct,42:_t,48:dt,50:i,51:Vt,52:It,53:wt,54:Bt,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),t(y,[2,34]),{27:45,55:d,56:h,57:p},t(a,[2,37]),t(a,A,{24:13,32:15,33:16,34:17,43:30,58:31,31:46,4:u,5:T,10:q,12:m,13:b,14:g,18:G,25:ht,35:xt,37:ft,39:gt,41:ct,42:_t,48:dt,50:i,51:Vt,52:It,53:wt,54:Bt,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),t(a,[2,39]),t(a,[2,40]),t(a,[2,41]),{36:[1,47]},{38:[1,48]},{40:[1,49]},t(a,[2,45]),t(a,[2,46]),{18:[1,50]},{4:u,5:T,10:q,12:m,13:b,14:g,43:51,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:52,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:53,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:54,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:55,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,10:q,12:m,13:b,14:g,43:56,58:31,60:W,61:U,63:k,64:F,65:P,66:v,67:C},{4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,44:[1,57],47:[1,58],58:60,59:59,63:k,64:F,65:P,66:v,67:C},t(L,[2,64]),t(L,[2,66]),t(L,[2,67]),t(L,[2,70]),t(L,[2,71]),t(L,[2,72]),t(L,[2,73]),t(L,[2,74]),t(L,[2,75]),t(L,[2,76]),t(L,[2,77]),t(L,[2,78]),t(L,[2,79]),t(L,[2,80]),t(y,[2,35]),t(a,[2,38]),t(a,[2,42]),t(a,[2,43]),t(a,[2,44]),{3:64,4:Rt,5:Nt,6:Wt,7:Ut,8:Qt,9:Ot,10:Ht,11:Xt,12:Mt,13:Yt,14:jt,15:Gt,21:63},t(a,[2,53],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,49:[1,77],63:k,64:F,65:P,66:v,67:C}),t(a,[2,56],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,49:[1,78],63:k,64:F,65:P,66:v,67:C}),t(a,[2,57],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,58],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,59],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,60],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),{45:[1,79]},{44:[1,80]},t(L,[2,65]),t(L,[2,81]),t(L,[2,82]),t(L,[2,83]),{3:82,4:Rt,5:Nt,6:Wt,7:Ut,8:Qt,9:Ot,10:Ht,11:Xt,12:Mt,13:Yt,14:jt,15:Gt,18:[1,81]},t(I,[2,23]),t(I,[2,1]),t(I,[2,2]),t(I,[2,3]),t(I,[2,4]),t(I,[2,5]),t(I,[2,6]),t(I,[2,7]),t(I,[2,8]),t(I,[2,9]),t(I,[2,10]),t(I,[2,11]),t(I,[2,12]),t(a,[2,52],{58:31,43:83,4:u,5:T,10:q,12:m,13:b,14:g,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),t(a,[2,55],{58:31,43:84,4:u,5:T,10:q,12:m,13:b,14:g,60:W,61:U,63:k,64:F,65:P,66:v,67:C}),{46:[1,85]},{45:[1,86]},{4:K,5:Z,6:J,8:$,11:tt,13:et,16:89,17:it,18:at,19:nt,20:st,22:88,23:87},t(I,[2,24]),t(a,[2,51],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,54],{59:59,58:60,4:u,5:T,8:O,10:q,12:m,13:b,14:g,18:H,63:k,64:F,65:P,66:v,67:C}),t(a,[2,47],{22:88,16:89,23:100,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st}),{46:[1,101]},t(a,[2,29],{10:At}),t(Kt,[2,27],{16:103,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st}),t(R,[2,25]),t(R,[2,13]),t(R,[2,14]),t(R,[2,15]),t(R,[2,16]),t(R,[2,17]),t(R,[2,18]),t(R,[2,19]),t(R,[2,20]),t(R,[2,21]),t(R,[2,22]),t(a,[2,49],{10:At}),t(a,[2,48],{22:88,16:89,23:104,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st}),{4:K,5:Z,6:J,8:$,11:tt,13:et,16:89,17:it,18:at,19:nt,20:st,22:105},t(R,[2,26]),t(a,[2,50],{10:At}),t(Kt,[2,28],{16:103,4:K,5:Z,6:J,8:$,11:tt,13:et,17:it,18:at,19:nt,20:st})],defaultActions:{8:[2,30],9:[2,31]},parseError:o(function(r,l){if(l.recoverable)this.trace(r);else{var x=new Error(r);throw x.hash=l,x}},"parseError"),parse:o(function(r){var l=this,x=[0],c=[],S=[null],e=[],ut=this.table,s="",yt=0,Zt=0,qe=2,Jt=1,me=e.slice.call(arguments,1),E=Object.create(this.lexer),Y={yy:{}};for(var Ft in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ft)&&(Y.yy[Ft]=this.yy[Ft]);E.setInput(r,Y.yy),Y.yy.lexer=E,Y.yy.parser=this,typeof E.yylloc>"u"&&(E.yylloc={});var Pt=E.yylloc;e.push(Pt);var be=E.options&&E.options.ranges;typeof Y.yy.parseError=="function"?this.parseError=Y.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Se(B){x.length=x.length-2*B,S.length=S.length-B,e.length=e.length-B}o(Se,"popStack");function $t(){var B;return B=c.pop()||E.lex()||Jt,typeof B!="number"&&(B instanceof Array&&(c=B,B=c.pop()),B=l.symbols_[B]||B),B}o($t,"lex");for(var w,j,N,vt,rt={},Tt,X,te,qt;;){if(j=x[x.length-1],this.defaultActions[j]?N=this.defaultActions[j]:((w===null||typeof w>"u")&&(w=$t()),N=ut[j]&&ut[j][w]),typeof N>"u"||!N.length||!N[0]){var Ct="";qt=[];for(Tt in ut[j])this.terminals_[Tt]&&Tt>qe&&qt.push("'"+this.terminals_[Tt]+"'");E.showPosition?Ct="Parse error on line "+(yt+1)+`: `+E.showPosition()+` Expecting `+qt.join(", ")+", got '"+(this.terminals_[w]||w)+"'":Ct="Parse error on line "+(yt+1)+": Unexpected "+(w==Jt?"end of input":"'"+(this.terminals_[w]||w)+"'"),this.parseError(Ct,{text:E.match,token:this.terminals_[w]||w,line:E.yylineno,loc:Pt,expected:qt})}if(N[0]instanceof Array&&N.length>1)throw new Error("Parse Error: multiple actions possible at state: "+j+", token: "+w);switch(N[0]){case 1:x.push(w),S.push(E.yytext),e.push(E.yylloc),x.push(N[1]),w=null,Zt=E.yyleng,s=E.yytext,yt=E.yylineno,Pt=E.yylloc;break;case 2:if(X=this.productions_[N[1]][1],rt.$=S[S.length-X],rt._$={first_line:e[e.length-(X||1)].first_line,last_line:e[e.length-1].last_line,first_column:e[e.length-(X||1)].first_column,last_column:e[e.length-1].last_column},be&&(rt._$.range=[e[e.length-(X||1)].range[0],e[e.length-1].range[1]]),vt=this.performAction.apply(rt,[s,Zt,yt,Y.yy,N[1],S,e].concat(me)),typeof vt<"u")return vt;X&&(x=x.slice(0,-1*X*2),S=S.slice(0,-1*X),e=e.slice(0,-1*X)),x.push(this.productions_[N[1]][0]),S.push(rt.$),e.push(rt._$),te=ut[x[x.length-2]][x[x.length-1]],x.push(te);break;case 3:return!0}}return!0},"parse")},Te=(function(){var M={EOF:1,parseError:o(function(l,x){if(this.yy.parser)this.yy.parser.parseError(l,x);else throw new Error(l)},"parseError"),setInput:o(function(r,l){return this.yy=l||this.yy||{},this._input=r,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:o(function(){var r=this._input[0];this.yytext+=r,this.yyleng++,this.offset++,this.match+=r,this.matched+=r;var l=r.match(/(?:\r\n?|\n).*/g);return l?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),r},"input"),unput:o(function(r){var l=r.length,x=r.split(/(?:\r\n?|\n)/g);this._input=r+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-l),this.offset-=l;var c=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),x.length-1&&(this.yylineno-=x.length-1);var S=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:x?(x.length===c.length?this.yylloc.first_column:0)+c[c.length-x.length].length-x[0].length:this.yylloc.first_column-l},this.options.ranges&&(this.yylloc.range=[S[0],S[0]+this.yyleng-l]),this.yyleng=this.yytext.length,this},"unput"),more:o(function(){return this._more=!0,this},"more"),reject:o(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:o(function(r){this.unput(this.match.slice(r))},"less"),pastInput:o(function(){var r=this.matched.substr(0,this.matched.length-this.match.length);return(r.length>20?"...":"")+r.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:o(function(){var r=this.match;return r.length<20&&(r+=this._input.substr(0,20-r.length)),(r.substr(0,20)+(r.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:o(function(){var r=this.pastInput(),l=new Array(r.length+1).join("-");return r+this.upcomingInput()+` diff --git a/demo/aiui/assets/requirementDiagram-UZGBJVZJ-BhL2HWfZ.js b/demo/aiui/assets/requirementDiagram-UZGBJVZJ-oASOEzEc.js similarity index 99% rename from demo/aiui/assets/requirementDiagram-UZGBJVZJ-BhL2HWfZ.js rename to demo/aiui/assets/requirementDiagram-UZGBJVZJ-oASOEzEc.js index 3a57672b..a8015f0f 100644 --- a/demo/aiui/assets/requirementDiagram-UZGBJVZJ-BhL2HWfZ.js +++ b/demo/aiui/assets/requirementDiagram-UZGBJVZJ-oASOEzEc.js @@ -1,4 +1,4 @@ -import{g as Ge}from"./chunk-55IACEB6-CWcaiZ1g.js";import{s as ze}from"./chunk-QN33PNHL-C8Gh8Kbh.js";import{_ as f,b as Xe,a as Je,s as Ze,g as et,p as tt,q as st,c as Ne,l as qe,y as it,B as rt,o as nt,r as at,u as lt}from"./mermaid.core-DaNhpuX9.js";import"./index-Lh5NfTCq.js";var Ae=(function(){var e=f(function(P,i,n,c){for(n=n||{},c=P.length;c--;n[P[c]]=i);return n},"o"),l=[1,3],u=[1,4],h=[1,5],r=[1,6],o=[5,6,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],m=[1,22],y=[2,7],_=[1,26],b=[1,27],N=[1,28],q=[1,29],A=[1,33],C=[1,34],V=[1,35],v=[1,36],x=[1,37],L=[1,38],D=[1,24],O=[1,31],w=[1,32],M=[1,30],p=[1,39],R=[1,40],d=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],$=[1,61],X=[89,90],Ce=[5,8,9,11,13,21,22,23,24,27,29,41,42,43,44,45,46,54,61,63,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],de=[27,29],Ve=[1,70],ve=[1,71],xe=[1,72],Le=[1,73],De=[1,74],Oe=[1,75],we=[1,76],ee=[1,83],U=[1,80],te=[1,84],se=[1,85],ie=[1,86],re=[1,87],ne=[1,88],ae=[1,89],le=[1,90],ce=[1,91],oe=[1,92],pe=[5,8,9,11,13,21,22,23,24,27,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],Y=[63,64],Me=[1,101],Fe=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,76,77,89,90],T=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],B=[1,110],Q=[1,106],H=[1,107],K=[1,108],W=[1,109],j=[1,111],he=[1,116],ue=[1,117],fe=[1,114],me=[1,115],Se={trace:f(function(){},"trace"),yy:{},symbols_:{error:2,start:3,directive:4,NEWLINE:5,RD:6,diagram:7,EOF:8,acc_title:9,acc_title_value:10,acc_descr:11,acc_descr_value:12,acc_descr_multiline_value:13,requirementDef:14,elementDef:15,relationshipDef:16,direction:17,styleStatement:18,classDefStatement:19,classStatement:20,direction_tb:21,direction_bt:22,direction_rl:23,direction_lr:24,requirementType:25,requirementName:26,STRUCT_START:27,requirementBody:28,STYLE_SEPARATOR:29,idList:30,ID:31,COLONSEP:32,id:33,TEXT:34,text:35,RISK:36,riskLevel:37,VERIFYMTHD:38,verifyType:39,STRUCT_STOP:40,REQUIREMENT:41,FUNCTIONAL_REQUIREMENT:42,INTERFACE_REQUIREMENT:43,PERFORMANCE_REQUIREMENT:44,PHYSICAL_REQUIREMENT:45,DESIGN_CONSTRAINT:46,LOW_RISK:47,MED_RISK:48,HIGH_RISK:49,VERIFY_ANALYSIS:50,VERIFY_DEMONSTRATION:51,VERIFY_INSPECTION:52,VERIFY_TEST:53,ELEMENT:54,elementName:55,elementBody:56,TYPE:57,type:58,DOCREF:59,ref:60,END_ARROW_L:61,relationship:62,LINE:63,END_ARROW_R:64,CONTAINS:65,COPIES:66,DERIVES:67,SATISFIES:68,VERIFIES:69,REFINES:70,TRACES:71,CLASSDEF:72,stylesOpt:73,CLASS:74,ALPHA:75,COMMA:76,STYLE:77,style:78,styleComponent:79,NUM:80,COLON:81,UNIT:82,SPACE:83,BRKT:84,PCT:85,MINUS:86,LABEL:87,SEMICOLON:88,unqString:89,qString:90,$accept:0,$end:1},terminals_:{2:"error",5:"NEWLINE",6:"RD",8:"EOF",9:"acc_title",10:"acc_title_value",11:"acc_descr",12:"acc_descr_value",13:"acc_descr_multiline_value",21:"direction_tb",22:"direction_bt",23:"direction_rl",24:"direction_lr",27:"STRUCT_START",29:"STYLE_SEPARATOR",31:"ID",32:"COLONSEP",34:"TEXT",36:"RISK",38:"VERIFYMTHD",40:"STRUCT_STOP",41:"REQUIREMENT",42:"FUNCTIONAL_REQUIREMENT",43:"INTERFACE_REQUIREMENT",44:"PERFORMANCE_REQUIREMENT",45:"PHYSICAL_REQUIREMENT",46:"DESIGN_CONSTRAINT",47:"LOW_RISK",48:"MED_RISK",49:"HIGH_RISK",50:"VERIFY_ANALYSIS",51:"VERIFY_DEMONSTRATION",52:"VERIFY_INSPECTION",53:"VERIFY_TEST",54:"ELEMENT",57:"TYPE",59:"DOCREF",61:"END_ARROW_L",63:"LINE",64:"END_ARROW_R",65:"CONTAINS",66:"COPIES",67:"DERIVES",68:"SATISFIES",69:"VERIFIES",70:"REFINES",71:"TRACES",72:"CLASSDEF",74:"CLASS",75:"ALPHA",76:"COMMA",77:"STYLE",80:"NUM",81:"COLON",82:"UNIT",83:"SPACE",84:"BRKT",85:"PCT",86:"MINUS",87:"LABEL",88:"SEMICOLON",89:"unqString",90:"qString"},productions_:[0,[3,3],[3,2],[3,4],[4,2],[4,2],[4,1],[7,0],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[17,1],[17,1],[17,1],[17,1],[14,5],[14,7],[28,5],[28,5],[28,5],[28,5],[28,2],[28,1],[25,1],[25,1],[25,1],[25,1],[25,1],[25,1],[37,1],[37,1],[37,1],[39,1],[39,1],[39,1],[39,1],[15,5],[15,7],[56,5],[56,5],[56,2],[56,1],[16,5],[16,5],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[19,3],[20,3],[20,3],[30,1],[30,3],[30,1],[30,3],[18,3],[73,1],[73,3],[78,1],[78,2],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[26,1],[26,1],[33,1],[33,1],[35,1],[35,1],[55,1],[55,1],[58,1],[58,1],[60,1],[60,1]],performAction:f(function(i,n,c,s,E,t,Ee){var a=t.length-1;switch(E){case 4:this.$=t[a].trim(),s.setAccTitle(this.$);break;case 5:case 6:this.$=t[a].trim(),s.setAccDescription(this.$);break;case 7:this.$=[];break;case 17:s.setDirection("TB");break;case 18:s.setDirection("BT");break;case 19:s.setDirection("RL");break;case 20:s.setDirection("LR");break;case 21:s.addRequirement(t[a-3],t[a-4]);break;case 22:s.addRequirement(t[a-5],t[a-6]),s.setClass([t[a-5]],t[a-3]);break;case 23:s.setNewReqId(t[a-2]);break;case 24:s.setNewReqText(t[a-2]);break;case 25:s.setNewReqRisk(t[a-2]);break;case 26:s.setNewReqVerifyMethod(t[a-2]);break;case 29:this.$=s.RequirementType.REQUIREMENT;break;case 30:this.$=s.RequirementType.FUNCTIONAL_REQUIREMENT;break;case 31:this.$=s.RequirementType.INTERFACE_REQUIREMENT;break;case 32:this.$=s.RequirementType.PERFORMANCE_REQUIREMENT;break;case 33:this.$=s.RequirementType.PHYSICAL_REQUIREMENT;break;case 34:this.$=s.RequirementType.DESIGN_CONSTRAINT;break;case 35:this.$=s.RiskLevel.LOW_RISK;break;case 36:this.$=s.RiskLevel.MED_RISK;break;case 37:this.$=s.RiskLevel.HIGH_RISK;break;case 38:this.$=s.VerifyType.VERIFY_ANALYSIS;break;case 39:this.$=s.VerifyType.VERIFY_DEMONSTRATION;break;case 40:this.$=s.VerifyType.VERIFY_INSPECTION;break;case 41:this.$=s.VerifyType.VERIFY_TEST;break;case 42:s.addElement(t[a-3]);break;case 43:s.addElement(t[a-5]),s.setClass([t[a-5]],t[a-3]);break;case 44:s.setNewElementType(t[a-2]);break;case 45:s.setNewElementDocRef(t[a-2]);break;case 48:s.addRelationship(t[a-2],t[a],t[a-4]);break;case 49:s.addRelationship(t[a-2],t[a-4],t[a]);break;case 50:this.$=s.Relationships.CONTAINS;break;case 51:this.$=s.Relationships.COPIES;break;case 52:this.$=s.Relationships.DERIVES;break;case 53:this.$=s.Relationships.SATISFIES;break;case 54:this.$=s.Relationships.VERIFIES;break;case 55:this.$=s.Relationships.REFINES;break;case 56:this.$=s.Relationships.TRACES;break;case 57:this.$=t[a-2],s.defineClass(t[a-1],t[a]);break;case 58:s.setClass(t[a-1],t[a]);break;case 59:s.setClass([t[a-2]],t[a]);break;case 60:case 62:this.$=[t[a]];break;case 61:case 63:this.$=t[a-2].concat([t[a]]);break;case 64:this.$=t[a-2],s.setCssStyle(t[a-1],t[a]);break;case 65:this.$=[t[a]];break;case 66:t[a-2].push(t[a]),this.$=t[a-2];break;case 68:this.$=t[a-1]+t[a];break}},"anonymous"),table:[{3:1,4:2,6:l,9:u,11:h,13:r},{1:[3]},{3:8,4:2,5:[1,7],6:l,9:u,11:h,13:r},{5:[1,9]},{10:[1,10]},{12:[1,11]},e(o,[2,6]),{3:12,4:2,6:l,9:u,11:h,13:r},{1:[2,2]},{4:17,5:m,7:13,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},e(o,[2,4]),e(o,[2,5]),{1:[2,1]},{8:[1,41]},{4:17,5:m,7:42,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:43,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:44,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:45,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:46,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:47,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:48,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:49,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:50,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{26:51,89:[1,52],90:[1,53]},{55:54,89:[1,55],90:[1,56]},{29:[1,59],61:[1,57],63:[1,58]},e(d,[2,17]),e(d,[2,18]),e(d,[2,19]),e(d,[2,20]),{30:60,33:62,75:$,89:p,90:R},{30:63,33:62,75:$,89:p,90:R},{30:64,33:62,75:$,89:p,90:R},e(X,[2,29]),e(X,[2,30]),e(X,[2,31]),e(X,[2,32]),e(X,[2,33]),e(X,[2,34]),e(Ce,[2,81]),e(Ce,[2,82]),{1:[2,3]},{8:[2,8]},{8:[2,9]},{8:[2,10]},{8:[2,11]},{8:[2,12]},{8:[2,13]},{8:[2,14]},{8:[2,15]},{8:[2,16]},{27:[1,65],29:[1,66]},e(de,[2,79]),e(de,[2,80]),{27:[1,67],29:[1,68]},e(de,[2,85]),e(de,[2,86]),{62:69,65:Ve,66:ve,67:xe,68:Le,69:De,70:Oe,71:we},{62:77,65:Ve,66:ve,67:xe,68:Le,69:De,70:Oe,71:we},{30:78,33:62,75:$,89:p,90:R},{73:79,75:ee,76:U,78:81,79:82,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe},e(pe,[2,60]),e(pe,[2,62]),{73:93,75:ee,76:U,78:81,79:82,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe},{30:94,33:62,75:$,76:U,89:p,90:R},{5:[1,95]},{30:96,33:62,75:$,89:p,90:R},{5:[1,97]},{30:98,33:62,75:$,89:p,90:R},{63:[1,99]},e(Y,[2,50]),e(Y,[2,51]),e(Y,[2,52]),e(Y,[2,53]),e(Y,[2,54]),e(Y,[2,55]),e(Y,[2,56]),{64:[1,100]},e(d,[2,59],{76:U}),e(d,[2,64],{76:Me}),{33:103,75:[1,102],89:p,90:R},e(Fe,[2,65],{79:104,75:ee,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe}),e(T,[2,67]),e(T,[2,69]),e(T,[2,70]),e(T,[2,71]),e(T,[2,72]),e(T,[2,73]),e(T,[2,74]),e(T,[2,75]),e(T,[2,76]),e(T,[2,77]),e(T,[2,78]),e(d,[2,57],{76:Me}),e(d,[2,58],{76:U}),{5:B,28:105,31:Q,34:H,36:K,38:W,40:j},{27:[1,112],76:U},{5:he,40:ue,56:113,57:fe,59:me},{27:[1,118],76:U},{33:119,89:p,90:R},{33:120,89:p,90:R},{75:ee,78:121,79:82,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe},e(pe,[2,61]),e(pe,[2,63]),e(T,[2,68]),e(d,[2,21]),{32:[1,122]},{32:[1,123]},{32:[1,124]},{32:[1,125]},{5:B,28:126,31:Q,34:H,36:K,38:W,40:j},e(d,[2,28]),{5:[1,127]},e(d,[2,42]),{32:[1,128]},{32:[1,129]},{5:he,40:ue,56:130,57:fe,59:me},e(d,[2,47]),{5:[1,131]},e(d,[2,48]),e(d,[2,49]),e(Fe,[2,66],{79:104,75:ee,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe}),{33:132,89:p,90:R},{35:133,89:[1,134],90:[1,135]},{37:136,47:[1,137],48:[1,138],49:[1,139]},{39:140,50:[1,141],51:[1,142],52:[1,143],53:[1,144]},e(d,[2,27]),{5:B,28:145,31:Q,34:H,36:K,38:W,40:j},{58:146,89:[1,147],90:[1,148]},{60:149,89:[1,150],90:[1,151]},e(d,[2,46]),{5:he,40:ue,56:152,57:fe,59:me},{5:[1,153]},{5:[1,154]},{5:[2,83]},{5:[2,84]},{5:[1,155]},{5:[2,35]},{5:[2,36]},{5:[2,37]},{5:[1,156]},{5:[2,38]},{5:[2,39]},{5:[2,40]},{5:[2,41]},e(d,[2,22]),{5:[1,157]},{5:[2,87]},{5:[2,88]},{5:[1,158]},{5:[2,89]},{5:[2,90]},e(d,[2,43]),{5:B,28:159,31:Q,34:H,36:K,38:W,40:j},{5:B,28:160,31:Q,34:H,36:K,38:W,40:j},{5:B,28:161,31:Q,34:H,36:K,38:W,40:j},{5:B,28:162,31:Q,34:H,36:K,38:W,40:j},{5:he,40:ue,56:163,57:fe,59:me},{5:he,40:ue,56:164,57:fe,59:me},e(d,[2,23]),e(d,[2,24]),e(d,[2,25]),e(d,[2,26]),e(d,[2,44]),e(d,[2,45])],defaultActions:{8:[2,2],12:[2,1],41:[2,3],42:[2,8],43:[2,9],44:[2,10],45:[2,11],46:[2,12],47:[2,13],48:[2,14],49:[2,15],50:[2,16],134:[2,83],135:[2,84],137:[2,35],138:[2,36],139:[2,37],141:[2,38],142:[2,39],143:[2,40],144:[2,41],147:[2,87],148:[2,88],150:[2,89],151:[2,90]},parseError:f(function(i,n){if(n.recoverable)this.trace(i);else{var c=new Error(i);throw c.hash=n,c}},"parseError"),parse:f(function(i){var n=this,c=[0],s=[],E=[null],t=[],Ee=this.table,a="",ye=0,Pe=0,He=2,$e=1,Ke=t.slice.call(arguments,1),g=Object.create(this.lexer),G={yy:{}};for(var Ie in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ie)&&(G.yy[Ie]=this.yy[Ie]);g.setInput(i,G.yy),G.yy.lexer=g,G.yy.parser=this,typeof g.yylloc>"u"&&(g.yylloc={});var be=g.yylloc;t.push(be);var We=g.options&&g.options.ranges;typeof G.yy.parseError=="function"?this.parseError=G.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function je(I){c.length=c.length-2*I,E.length=E.length-I,t.length=t.length-I}f(je,"popStack");function Ue(){var I;return I=s.pop()||g.lex()||$e,typeof I!="number"&&(I instanceof Array&&(s=I,I=s.pop()),I=n.symbols_[I]||I),I}f(Ue,"lex");for(var S,z,k,Te,J={},ge,F,Ye,_e;;){if(z=c[c.length-1],this.defaultActions[z]?k=this.defaultActions[z]:((S===null||typeof S>"u")&&(S=Ue()),k=Ee[z]&&Ee[z][S]),typeof k>"u"||!k.length||!k[0]){var ke="";_e=[];for(ge in Ee[z])this.terminals_[ge]&&ge>He&&_e.push("'"+this.terminals_[ge]+"'");g.showPosition?ke="Parse error on line "+(ye+1)+`: +import{g as Ge}from"./chunk-55IACEB6-CtULfmDo.js";import{s as ze}from"./chunk-QN33PNHL-DSThOC6-.js";import{_ as f,b as Xe,a as Je,s as Ze,g as et,p as tt,q as st,c as Ne,l as qe,y as it,B as rt,o as nt,r as at,u as lt}from"./mermaid.core-v0oo9NRr.js";import"./index-8cIrvc8q.js";var Ae=(function(){var e=f(function(P,i,n,c){for(n=n||{},c=P.length;c--;n[P[c]]=i);return n},"o"),l=[1,3],u=[1,4],h=[1,5],r=[1,6],o=[5,6,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],m=[1,22],y=[2,7],_=[1,26],b=[1,27],N=[1,28],q=[1,29],A=[1,33],C=[1,34],V=[1,35],v=[1,36],x=[1,37],L=[1,38],D=[1,24],O=[1,31],w=[1,32],M=[1,30],p=[1,39],R=[1,40],d=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,77,89,90],$=[1,61],X=[89,90],Ce=[5,8,9,11,13,21,22,23,24,27,29,41,42,43,44,45,46,54,61,63,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],de=[27,29],Ve=[1,70],ve=[1,71],xe=[1,72],Le=[1,73],De=[1,74],Oe=[1,75],we=[1,76],ee=[1,83],U=[1,80],te=[1,84],se=[1,85],ie=[1,86],re=[1,87],ne=[1,88],ae=[1,89],le=[1,90],ce=[1,91],oe=[1,92],pe=[5,8,9,11,13,21,22,23,24,27,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],Y=[63,64],Me=[1,101],Fe=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,76,77,89,90],T=[5,8,9,11,13,21,22,23,24,41,42,43,44,45,46,54,72,74,75,76,77,80,81,82,83,84,85,86,87,88,89,90],B=[1,110],Q=[1,106],H=[1,107],K=[1,108],W=[1,109],j=[1,111],he=[1,116],ue=[1,117],fe=[1,114],me=[1,115],Se={trace:f(function(){},"trace"),yy:{},symbols_:{error:2,start:3,directive:4,NEWLINE:5,RD:6,diagram:7,EOF:8,acc_title:9,acc_title_value:10,acc_descr:11,acc_descr_value:12,acc_descr_multiline_value:13,requirementDef:14,elementDef:15,relationshipDef:16,direction:17,styleStatement:18,classDefStatement:19,classStatement:20,direction_tb:21,direction_bt:22,direction_rl:23,direction_lr:24,requirementType:25,requirementName:26,STRUCT_START:27,requirementBody:28,STYLE_SEPARATOR:29,idList:30,ID:31,COLONSEP:32,id:33,TEXT:34,text:35,RISK:36,riskLevel:37,VERIFYMTHD:38,verifyType:39,STRUCT_STOP:40,REQUIREMENT:41,FUNCTIONAL_REQUIREMENT:42,INTERFACE_REQUIREMENT:43,PERFORMANCE_REQUIREMENT:44,PHYSICAL_REQUIREMENT:45,DESIGN_CONSTRAINT:46,LOW_RISK:47,MED_RISK:48,HIGH_RISK:49,VERIFY_ANALYSIS:50,VERIFY_DEMONSTRATION:51,VERIFY_INSPECTION:52,VERIFY_TEST:53,ELEMENT:54,elementName:55,elementBody:56,TYPE:57,type:58,DOCREF:59,ref:60,END_ARROW_L:61,relationship:62,LINE:63,END_ARROW_R:64,CONTAINS:65,COPIES:66,DERIVES:67,SATISFIES:68,VERIFIES:69,REFINES:70,TRACES:71,CLASSDEF:72,stylesOpt:73,CLASS:74,ALPHA:75,COMMA:76,STYLE:77,style:78,styleComponent:79,NUM:80,COLON:81,UNIT:82,SPACE:83,BRKT:84,PCT:85,MINUS:86,LABEL:87,SEMICOLON:88,unqString:89,qString:90,$accept:0,$end:1},terminals_:{2:"error",5:"NEWLINE",6:"RD",8:"EOF",9:"acc_title",10:"acc_title_value",11:"acc_descr",12:"acc_descr_value",13:"acc_descr_multiline_value",21:"direction_tb",22:"direction_bt",23:"direction_rl",24:"direction_lr",27:"STRUCT_START",29:"STYLE_SEPARATOR",31:"ID",32:"COLONSEP",34:"TEXT",36:"RISK",38:"VERIFYMTHD",40:"STRUCT_STOP",41:"REQUIREMENT",42:"FUNCTIONAL_REQUIREMENT",43:"INTERFACE_REQUIREMENT",44:"PERFORMANCE_REQUIREMENT",45:"PHYSICAL_REQUIREMENT",46:"DESIGN_CONSTRAINT",47:"LOW_RISK",48:"MED_RISK",49:"HIGH_RISK",50:"VERIFY_ANALYSIS",51:"VERIFY_DEMONSTRATION",52:"VERIFY_INSPECTION",53:"VERIFY_TEST",54:"ELEMENT",57:"TYPE",59:"DOCREF",61:"END_ARROW_L",63:"LINE",64:"END_ARROW_R",65:"CONTAINS",66:"COPIES",67:"DERIVES",68:"SATISFIES",69:"VERIFIES",70:"REFINES",71:"TRACES",72:"CLASSDEF",74:"CLASS",75:"ALPHA",76:"COMMA",77:"STYLE",80:"NUM",81:"COLON",82:"UNIT",83:"SPACE",84:"BRKT",85:"PCT",86:"MINUS",87:"LABEL",88:"SEMICOLON",89:"unqString",90:"qString"},productions_:[0,[3,3],[3,2],[3,4],[4,2],[4,2],[4,1],[7,0],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[7,2],[17,1],[17,1],[17,1],[17,1],[14,5],[14,7],[28,5],[28,5],[28,5],[28,5],[28,2],[28,1],[25,1],[25,1],[25,1],[25,1],[25,1],[25,1],[37,1],[37,1],[37,1],[39,1],[39,1],[39,1],[39,1],[15,5],[15,7],[56,5],[56,5],[56,2],[56,1],[16,5],[16,5],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[62,1],[19,3],[20,3],[20,3],[30,1],[30,3],[30,1],[30,3],[18,3],[73,1],[73,3],[78,1],[78,2],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[79,1],[26,1],[26,1],[33,1],[33,1],[35,1],[35,1],[55,1],[55,1],[58,1],[58,1],[60,1],[60,1]],performAction:f(function(i,n,c,s,E,t,Ee){var a=t.length-1;switch(E){case 4:this.$=t[a].trim(),s.setAccTitle(this.$);break;case 5:case 6:this.$=t[a].trim(),s.setAccDescription(this.$);break;case 7:this.$=[];break;case 17:s.setDirection("TB");break;case 18:s.setDirection("BT");break;case 19:s.setDirection("RL");break;case 20:s.setDirection("LR");break;case 21:s.addRequirement(t[a-3],t[a-4]);break;case 22:s.addRequirement(t[a-5],t[a-6]),s.setClass([t[a-5]],t[a-3]);break;case 23:s.setNewReqId(t[a-2]);break;case 24:s.setNewReqText(t[a-2]);break;case 25:s.setNewReqRisk(t[a-2]);break;case 26:s.setNewReqVerifyMethod(t[a-2]);break;case 29:this.$=s.RequirementType.REQUIREMENT;break;case 30:this.$=s.RequirementType.FUNCTIONAL_REQUIREMENT;break;case 31:this.$=s.RequirementType.INTERFACE_REQUIREMENT;break;case 32:this.$=s.RequirementType.PERFORMANCE_REQUIREMENT;break;case 33:this.$=s.RequirementType.PHYSICAL_REQUIREMENT;break;case 34:this.$=s.RequirementType.DESIGN_CONSTRAINT;break;case 35:this.$=s.RiskLevel.LOW_RISK;break;case 36:this.$=s.RiskLevel.MED_RISK;break;case 37:this.$=s.RiskLevel.HIGH_RISK;break;case 38:this.$=s.VerifyType.VERIFY_ANALYSIS;break;case 39:this.$=s.VerifyType.VERIFY_DEMONSTRATION;break;case 40:this.$=s.VerifyType.VERIFY_INSPECTION;break;case 41:this.$=s.VerifyType.VERIFY_TEST;break;case 42:s.addElement(t[a-3]);break;case 43:s.addElement(t[a-5]),s.setClass([t[a-5]],t[a-3]);break;case 44:s.setNewElementType(t[a-2]);break;case 45:s.setNewElementDocRef(t[a-2]);break;case 48:s.addRelationship(t[a-2],t[a],t[a-4]);break;case 49:s.addRelationship(t[a-2],t[a-4],t[a]);break;case 50:this.$=s.Relationships.CONTAINS;break;case 51:this.$=s.Relationships.COPIES;break;case 52:this.$=s.Relationships.DERIVES;break;case 53:this.$=s.Relationships.SATISFIES;break;case 54:this.$=s.Relationships.VERIFIES;break;case 55:this.$=s.Relationships.REFINES;break;case 56:this.$=s.Relationships.TRACES;break;case 57:this.$=t[a-2],s.defineClass(t[a-1],t[a]);break;case 58:s.setClass(t[a-1],t[a]);break;case 59:s.setClass([t[a-2]],t[a]);break;case 60:case 62:this.$=[t[a]];break;case 61:case 63:this.$=t[a-2].concat([t[a]]);break;case 64:this.$=t[a-2],s.setCssStyle(t[a-1],t[a]);break;case 65:this.$=[t[a]];break;case 66:t[a-2].push(t[a]),this.$=t[a-2];break;case 68:this.$=t[a-1]+t[a];break}},"anonymous"),table:[{3:1,4:2,6:l,9:u,11:h,13:r},{1:[3]},{3:8,4:2,5:[1,7],6:l,9:u,11:h,13:r},{5:[1,9]},{10:[1,10]},{12:[1,11]},e(o,[2,6]),{3:12,4:2,6:l,9:u,11:h,13:r},{1:[2,2]},{4:17,5:m,7:13,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},e(o,[2,4]),e(o,[2,5]),{1:[2,1]},{8:[1,41]},{4:17,5:m,7:42,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:43,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:44,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:45,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:46,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:47,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:48,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:49,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{4:17,5:m,7:50,8:y,9:u,11:h,13:r,14:14,15:15,16:16,17:18,18:19,19:20,20:21,21:_,22:b,23:N,24:q,25:23,33:25,41:A,42:C,43:V,44:v,45:x,46:L,54:D,72:O,74:w,77:M,89:p,90:R},{26:51,89:[1,52],90:[1,53]},{55:54,89:[1,55],90:[1,56]},{29:[1,59],61:[1,57],63:[1,58]},e(d,[2,17]),e(d,[2,18]),e(d,[2,19]),e(d,[2,20]),{30:60,33:62,75:$,89:p,90:R},{30:63,33:62,75:$,89:p,90:R},{30:64,33:62,75:$,89:p,90:R},e(X,[2,29]),e(X,[2,30]),e(X,[2,31]),e(X,[2,32]),e(X,[2,33]),e(X,[2,34]),e(Ce,[2,81]),e(Ce,[2,82]),{1:[2,3]},{8:[2,8]},{8:[2,9]},{8:[2,10]},{8:[2,11]},{8:[2,12]},{8:[2,13]},{8:[2,14]},{8:[2,15]},{8:[2,16]},{27:[1,65],29:[1,66]},e(de,[2,79]),e(de,[2,80]),{27:[1,67],29:[1,68]},e(de,[2,85]),e(de,[2,86]),{62:69,65:Ve,66:ve,67:xe,68:Le,69:De,70:Oe,71:we},{62:77,65:Ve,66:ve,67:xe,68:Le,69:De,70:Oe,71:we},{30:78,33:62,75:$,89:p,90:R},{73:79,75:ee,76:U,78:81,79:82,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe},e(pe,[2,60]),e(pe,[2,62]),{73:93,75:ee,76:U,78:81,79:82,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe},{30:94,33:62,75:$,76:U,89:p,90:R},{5:[1,95]},{30:96,33:62,75:$,89:p,90:R},{5:[1,97]},{30:98,33:62,75:$,89:p,90:R},{63:[1,99]},e(Y,[2,50]),e(Y,[2,51]),e(Y,[2,52]),e(Y,[2,53]),e(Y,[2,54]),e(Y,[2,55]),e(Y,[2,56]),{64:[1,100]},e(d,[2,59],{76:U}),e(d,[2,64],{76:Me}),{33:103,75:[1,102],89:p,90:R},e(Fe,[2,65],{79:104,75:ee,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe}),e(T,[2,67]),e(T,[2,69]),e(T,[2,70]),e(T,[2,71]),e(T,[2,72]),e(T,[2,73]),e(T,[2,74]),e(T,[2,75]),e(T,[2,76]),e(T,[2,77]),e(T,[2,78]),e(d,[2,57],{76:Me}),e(d,[2,58],{76:U}),{5:B,28:105,31:Q,34:H,36:K,38:W,40:j},{27:[1,112],76:U},{5:he,40:ue,56:113,57:fe,59:me},{27:[1,118],76:U},{33:119,89:p,90:R},{33:120,89:p,90:R},{75:ee,78:121,79:82,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe},e(pe,[2,61]),e(pe,[2,63]),e(T,[2,68]),e(d,[2,21]),{32:[1,122]},{32:[1,123]},{32:[1,124]},{32:[1,125]},{5:B,28:126,31:Q,34:H,36:K,38:W,40:j},e(d,[2,28]),{5:[1,127]},e(d,[2,42]),{32:[1,128]},{32:[1,129]},{5:he,40:ue,56:130,57:fe,59:me},e(d,[2,47]),{5:[1,131]},e(d,[2,48]),e(d,[2,49]),e(Fe,[2,66],{79:104,75:ee,80:te,81:se,82:ie,83:re,84:ne,85:ae,86:le,87:ce,88:oe}),{33:132,89:p,90:R},{35:133,89:[1,134],90:[1,135]},{37:136,47:[1,137],48:[1,138],49:[1,139]},{39:140,50:[1,141],51:[1,142],52:[1,143],53:[1,144]},e(d,[2,27]),{5:B,28:145,31:Q,34:H,36:K,38:W,40:j},{58:146,89:[1,147],90:[1,148]},{60:149,89:[1,150],90:[1,151]},e(d,[2,46]),{5:he,40:ue,56:152,57:fe,59:me},{5:[1,153]},{5:[1,154]},{5:[2,83]},{5:[2,84]},{5:[1,155]},{5:[2,35]},{5:[2,36]},{5:[2,37]},{5:[1,156]},{5:[2,38]},{5:[2,39]},{5:[2,40]},{5:[2,41]},e(d,[2,22]),{5:[1,157]},{5:[2,87]},{5:[2,88]},{5:[1,158]},{5:[2,89]},{5:[2,90]},e(d,[2,43]),{5:B,28:159,31:Q,34:H,36:K,38:W,40:j},{5:B,28:160,31:Q,34:H,36:K,38:W,40:j},{5:B,28:161,31:Q,34:H,36:K,38:W,40:j},{5:B,28:162,31:Q,34:H,36:K,38:W,40:j},{5:he,40:ue,56:163,57:fe,59:me},{5:he,40:ue,56:164,57:fe,59:me},e(d,[2,23]),e(d,[2,24]),e(d,[2,25]),e(d,[2,26]),e(d,[2,44]),e(d,[2,45])],defaultActions:{8:[2,2],12:[2,1],41:[2,3],42:[2,8],43:[2,9],44:[2,10],45:[2,11],46:[2,12],47:[2,13],48:[2,14],49:[2,15],50:[2,16],134:[2,83],135:[2,84],137:[2,35],138:[2,36],139:[2,37],141:[2,38],142:[2,39],143:[2,40],144:[2,41],147:[2,87],148:[2,88],150:[2,89],151:[2,90]},parseError:f(function(i,n){if(n.recoverable)this.trace(i);else{var c=new Error(i);throw c.hash=n,c}},"parseError"),parse:f(function(i){var n=this,c=[0],s=[],E=[null],t=[],Ee=this.table,a="",ye=0,Pe=0,He=2,$e=1,Ke=t.slice.call(arguments,1),g=Object.create(this.lexer),G={yy:{}};for(var Ie in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Ie)&&(G.yy[Ie]=this.yy[Ie]);g.setInput(i,G.yy),G.yy.lexer=g,G.yy.parser=this,typeof g.yylloc>"u"&&(g.yylloc={});var be=g.yylloc;t.push(be);var We=g.options&&g.options.ranges;typeof G.yy.parseError=="function"?this.parseError=G.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function je(I){c.length=c.length-2*I,E.length=E.length-I,t.length=t.length-I}f(je,"popStack");function Ue(){var I;return I=s.pop()||g.lex()||$e,typeof I!="number"&&(I instanceof Array&&(s=I,I=s.pop()),I=n.symbols_[I]||I),I}f(Ue,"lex");for(var S,z,k,Te,J={},ge,F,Ye,_e;;){if(z=c[c.length-1],this.defaultActions[z]?k=this.defaultActions[z]:((S===null||typeof S>"u")&&(S=Ue()),k=Ee[z]&&Ee[z][S]),typeof k>"u"||!k.length||!k[0]){var ke="";_e=[];for(ge in Ee[z])this.terminals_[ge]&&ge>He&&_e.push("'"+this.terminals_[ge]+"'");g.showPosition?ke="Parse error on line "+(ye+1)+`: `+g.showPosition()+` Expecting `+_e.join(", ")+", got '"+(this.terminals_[S]||S)+"'":ke="Parse error on line "+(ye+1)+": Unexpected "+(S==$e?"end of input":"'"+(this.terminals_[S]||S)+"'"),this.parseError(ke,{text:g.match,token:this.terminals_[S]||S,line:g.yylineno,loc:be,expected:_e})}if(k[0]instanceof Array&&k.length>1)throw new Error("Parse Error: multiple actions possible at state: "+z+", token: "+S);switch(k[0]){case 1:c.push(S),E.push(g.yytext),t.push(g.yylloc),c.push(k[1]),S=null,Pe=g.yyleng,a=g.yytext,ye=g.yylineno,be=g.yylloc;break;case 2:if(F=this.productions_[k[1]][1],J.$=E[E.length-F],J._$={first_line:t[t.length-(F||1)].first_line,last_line:t[t.length-1].last_line,first_column:t[t.length-(F||1)].first_column,last_column:t[t.length-1].last_column},We&&(J._$.range=[t[t.length-(F||1)].range[0],t[t.length-1].range[1]]),Te=this.performAction.apply(J,[a,Pe,ye,G.yy,k[1],E,t].concat(Ke)),typeof Te<"u")return Te;F&&(c=c.slice(0,-1*F*2),E=E.slice(0,-1*F),t=t.slice(0,-1*F)),c.push(this.productions_[k[1]][0]),E.push(J.$),t.push(J._$),Ye=Ee[c[c.length-2]][c[c.length-1]],c.push(Ye);break;case 3:return!0}}return!0},"parse")},Qe=(function(){var P={EOF:1,parseError:f(function(n,c){if(this.yy.parser)this.yy.parser.parseError(n,c);else throw new Error(n)},"parseError"),setInput:f(function(i,n){return this.yy=n||this.yy||{},this._input=i,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:f(function(){var i=this._input[0];this.yytext+=i,this.yyleng++,this.offset++,this.match+=i,this.matched+=i;var n=i.match(/(?:\r\n?|\n).*/g);return n?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),i},"input"),unput:f(function(i){var n=i.length,c=i.split(/(?:\r\n?|\n)/g);this._input=i+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-n),this.offset-=n;var s=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),c.length-1&&(this.yylineno-=c.length-1);var E=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:c?(c.length===s.length?this.yylloc.first_column:0)+s[s.length-c.length].length-c[0].length:this.yylloc.first_column-n},this.options.ranges&&(this.yylloc.range=[E[0],E[0]+this.yyleng-n]),this.yyleng=this.yytext.length,this},"unput"),more:f(function(){return this._more=!0,this},"more"),reject:f(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:f(function(i){this.unput(this.match.slice(i))},"less"),pastInput:f(function(){var i=this.matched.substr(0,this.matched.length-this.match.length);return(i.length>20?"...":"")+i.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:f(function(){var i=this.match;return i.length<20&&(i+=this._input.substr(0,20-i.length)),(i.substr(0,20)+(i.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:f(function(){var i=this.pastInput(),n=new Array(i.length+1).join("-");return i+this.upcomingInput()+` diff --git a/demo/aiui/assets/sankeyDiagram-TZEHDZUN-DqQOGyyA.js b/demo/aiui/assets/sankeyDiagram-TZEHDZUN-Bdq3WEjh.js similarity index 99% rename from demo/aiui/assets/sankeyDiagram-TZEHDZUN-DqQOGyyA.js rename to demo/aiui/assets/sankeyDiagram-TZEHDZUN-Bdq3WEjh.js index 9cb9a34f..01b80c29 100644 --- a/demo/aiui/assets/sankeyDiagram-TZEHDZUN-DqQOGyyA.js +++ b/demo/aiui/assets/sankeyDiagram-TZEHDZUN-Bdq3WEjh.js @@ -1,4 +1,4 @@ -import{p as _t,q as xt,s as vt,g as bt,b as wt,a as St,_ as g,c as lt,z as Lt,d as H,ac as Et,y as At,k as Tt}from"./mermaid.core-DaNhpuX9.js";import{o as Mt}from"./ordinal-Cboi1Yqb.js";import"./index-Lh5NfTCq.js";import"./init-Gi6I4Gst.js";function Nt(t){for(var e=t.length/6|0,i=new Array(e),a=0;a=a)&&(i=a);else{let a=-1;for(let h of t)(h=e(h,++a,t))!=null&&(i=h)&&(i=h)}return i}function pt(t,e){let i;if(e===void 0)for(const a of t)a!=null&&(i>a||i===void 0&&a>=a)&&(i=a);else{let a=-1;for(let h of t)(h=e(h,++a,t))!=null&&(i>h||i===void 0&&h>=h)&&(i=h)}return i}function nt(t,e){let i=0;if(e===void 0)for(let a of t)(a=+a)&&(i+=a);else{let a=-1;for(let h of t)(h=+e(h,++a,t))&&(i+=h)}return i}function Pt(t){return t.target.depth}function Ct(t){return t.depth}function Ot(t,e){return e-1-t.height}function mt(t,e){return t.sourceLinks.length?t.depth:e-1}function zt(t){return t.targetLinks.length?t.depth:t.sourceLinks.length?pt(t.sourceLinks,Pt)-1:0}function X(t){return function(){return t}}function ut(t,e){return Q(t.source,e.source)||t.index-e.index}function ht(t,e){return Q(t.target,e.target)||t.index-e.index}function Q(t,e){return t.y0-e.y0}function it(t){return t.value}function Dt(t){return t.index}function $t(t){return t.nodes}function jt(t){return t.links}function ft(t,e){const i=t.get(e);if(!i)throw new Error("missing: "+e);return i}function yt({nodes:t}){for(const e of t){let i=e.y0,a=i;for(const h of e.sourceLinks)h.y0=i+h.width/2,i+=h.width;for(const h of e.targetLinks)h.y1=a+h.width/2,a+=h.width}}function Bt(){let t=0,e=0,i=1,a=1,h=24,b=8,p,k=Dt,s=mt,o,l,_=$t,x=jt,y=6;function v(){const n={nodes:_.apply(null,arguments),links:x.apply(null,arguments)};return M(n),T(n),N(n),C(n),S(n),yt(n),n}v.update=function(n){return yt(n),n},v.nodeId=function(n){return arguments.length?(k=typeof n=="function"?n:X(n),v):k},v.nodeAlign=function(n){return arguments.length?(s=typeof n=="function"?n:X(n),v):s},v.nodeSort=function(n){return arguments.length?(o=n,v):o},v.nodeWidth=function(n){return arguments.length?(h=+n,v):h},v.nodePadding=function(n){return arguments.length?(b=p=+n,v):b},v.nodes=function(n){return arguments.length?(_=typeof n=="function"?n:X(n),v):_},v.links=function(n){return arguments.length?(x=typeof n=="function"?n:X(n),v):x},v.linkSort=function(n){return arguments.length?(l=n,v):l},v.size=function(n){return arguments.length?(t=e=0,i=+n[0],a=+n[1],v):[i-t,a-e]},v.extent=function(n){return arguments.length?(t=+n[0][0],i=+n[1][0],e=+n[0][1],a=+n[1][1],v):[[t,e],[i,a]]},v.iterations=function(n){return arguments.length?(y=+n,v):y};function M({nodes:n,links:f}){for(const[c,r]of n.entries())r.index=c,r.sourceLinks=[],r.targetLinks=[];const u=new Map(n.map((c,r)=>[k(c,r,n),c]));for(const[c,r]of f.entries()){r.index=c;let{source:m,target:w}=r;typeof m!="object"&&(m=r.source=ft(u,m)),typeof w!="object"&&(w=r.target=ft(u,w)),m.sourceLinks.push(r),w.targetLinks.push(r)}if(l!=null)for(const{sourceLinks:c,targetLinks:r}of n)c.sort(l),r.sort(l)}function T({nodes:n}){for(const f of n)f.value=f.fixedValue===void 0?Math.max(nt(f.sourceLinks,it),nt(f.targetLinks,it)):f.fixedValue}function N({nodes:n}){const f=n.length;let u=new Set(n),c=new Set,r=0;for(;u.size;){for(const m of u){m.depth=r;for(const{target:w}of m.sourceLinks)c.add(w)}if(++r>f)throw new Error("circular link");u=c,c=new Set}}function C({nodes:n}){const f=n.length;let u=new Set(n),c=new Set,r=0;for(;u.size;){for(const m of u){m.height=r;for(const{source:w}of m.targetLinks)c.add(w)}if(++r>f)throw new Error("circular link");u=c,c=new Set}}function D({nodes:n}){const f=ct(n,r=>r.depth)+1,u=(i-t-h)/(f-1),c=new Array(f);for(const r of n){const m=Math.max(0,Math.min(f-1,Math.floor(s.call(null,r,f))));r.layer=m,r.x0=t+m*u,r.x1=r.x0+h,c[m]?c[m].push(r):c[m]=[r]}if(o)for(const r of c)r.sort(o);return c}function R(n){const f=pt(n,u=>(a-e-(u.length-1)*p)/nt(u,it));for(const u of n){let c=e;for(const r of u){r.y0=c,r.y1=c+r.value*f,c=r.y1+p;for(const m of r.sourceLinks)m.width=m.value*f}c=(a-c+p)/(u.length+1);for(let r=0;ru.length)-1)),R(f);for(let u=0;u0))continue;let G=(L/F-w.y0)*f;w.y0+=G,w.y1+=G,E(w)}o===void 0&&m.sort(Q),O(m,u)}}function B(n,f,u){for(let c=n.length,r=c-2;r>=0;--r){const m=n[r];for(const w of m){let L=0,F=0;for(const{target:Y,value:et}of w.sourceLinks){let q=et*(Y.layer-w.layer);L+=I(w,Y)*q,F+=q}if(!(F>0))continue;let G=(L/F-w.y0)*f;w.y0+=G,w.y1+=G,E(w)}o===void 0&&m.sort(Q),O(m,u)}}function O(n,f){const u=n.length>>1,c=n[u];d(n,c.y0-p,u-1,f),z(n,c.y1+p,u+1,f),d(n,a,n.length-1,f),z(n,e,0,f)}function z(n,f,u,c){for(;u1e-6&&(r.y0+=m,r.y1+=m),f=r.y1+p}}function d(n,f,u,c){for(;u>=0;--u){const r=n[u],m=(r.y1-f)*c;m>1e-6&&(r.y0-=m,r.y1-=m),f=r.y0-p}}function E({sourceLinks:n,targetLinks:f}){if(l===void 0){for(const{source:{sourceLinks:u}}of f)u.sort(ht);for(const{target:{targetLinks:u}}of n)u.sort(ut)}}function A(n){if(l===void 0)for(const{sourceLinks:f,targetLinks:u}of n)f.sort(ht),u.sort(ut)}function $(n,f){let u=n.y0-(n.sourceLinks.length-1)*p/2;for(const{target:c,width:r}of n.sourceLinks){if(c===f)break;u+=r+p}for(const{source:c,width:r}of f.targetLinks){if(c===n)break;u-=r}return u}function I(n,f){let u=f.y0-(f.targetLinks.length-1)*p/2;for(const{source:c,width:r}of f.targetLinks){if(c===n)break;u+=r+p}for(const{target:c,width:r}of n.sourceLinks){if(c===f)break;u-=r}return u}return v}var st=Math.PI,rt=2*st,V=1e-6,Rt=rt-V;function ot(){this._x0=this._y0=this._x1=this._y1=null,this._=""}function kt(){return new ot}ot.prototype=kt.prototype={constructor:ot,moveTo:function(t,e){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)},closePath:function(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")},lineTo:function(t,e){this._+="L"+(this._x1=+t)+","+(this._y1=+e)},quadraticCurveTo:function(t,e,i,a){this._+="Q"+ +t+","+ +e+","+(this._x1=+i)+","+(this._y1=+a)},bezierCurveTo:function(t,e,i,a,h,b){this._+="C"+ +t+","+ +e+","+ +i+","+ +a+","+(this._x1=+h)+","+(this._y1=+b)},arcTo:function(t,e,i,a,h){t=+t,e=+e,i=+i,a=+a,h=+h;var b=this._x1,p=this._y1,k=i-t,s=a-e,o=b-t,l=p-e,_=o*o+l*l;if(h<0)throw new Error("negative radius: "+h);if(this._x1===null)this._+="M"+(this._x1=t)+","+(this._y1=e);else if(_>V)if(!(Math.abs(l*k-s*o)>V)||!h)this._+="L"+(this._x1=t)+","+(this._y1=e);else{var x=i-b,y=a-p,v=k*k+s*s,M=x*x+y*y,T=Math.sqrt(v),N=Math.sqrt(_),C=h*Math.tan((st-Math.acos((v+_-M)/(2*T*N)))/2),D=C/N,R=C/T;Math.abs(D-1)>V&&(this._+="L"+(t+D*o)+","+(e+D*l)),this._+="A"+h+","+h+",0,0,"+ +(l*x>o*y)+","+(this._x1=t+R*k)+","+(this._y1=e+R*s)}},arc:function(t,e,i,a,h,b){t=+t,e=+e,i=+i,b=!!b;var p=i*Math.cos(a),k=i*Math.sin(a),s=t+p,o=e+k,l=1^b,_=b?a-h:h-a;if(i<0)throw new Error("negative radius: "+i);this._x1===null?this._+="M"+s+","+o:(Math.abs(this._x1-s)>V||Math.abs(this._y1-o)>V)&&(this._+="L"+s+","+o),i&&(_<0&&(_=_%rt+rt),_>Rt?this._+="A"+i+","+i+",0,1,"+l+","+(t-p)+","+(e-k)+"A"+i+","+i+",0,1,"+l+","+(this._x1=s)+","+(this._y1=o):_>V&&(this._+="A"+i+","+i+",0,"+ +(_>=st)+","+l+","+(this._x1=t+i*Math.cos(h))+","+(this._y1=e+i*Math.sin(h))))},rect:function(t,e,i,a){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +i+"v"+ +a+"h"+-i+"Z"},toString:function(){return this._}};function dt(t){return function(){return t}}function Ft(t){return t[0]}function Vt(t){return t[1]}var Wt=Array.prototype.slice;function Ut(t){return t.source}function Gt(t){return t.target}function Yt(t){var e=Ut,i=Gt,a=Ft,h=Vt,b=null;function p(){var k,s=Wt.call(arguments),o=e.apply(this,s),l=i.apply(this,s);if(b||(b=k=kt()),t(b,+a.apply(this,(s[0]=o,s)),+h.apply(this,s),+a.apply(this,(s[0]=l,s)),+h.apply(this,s)),k)return b=null,k+""||null}return p.source=function(k){return arguments.length?(e=k,p):e},p.target=function(k){return arguments.length?(i=k,p):i},p.x=function(k){return arguments.length?(a=typeof k=="function"?k:dt(+k),p):a},p.y=function(k){return arguments.length?(h=typeof k=="function"?k:dt(+k),p):h},p.context=function(k){return arguments.length?(b=k??null,p):b},p}function qt(t,e,i,a,h){t.moveTo(e,i),t.bezierCurveTo(e=(e+a)/2,i,e,h,a,h)}function Ht(){return Yt(qt)}function Xt(t){return[t.source.x1,t.y0]}function Qt(t){return[t.target.x0,t.y1]}function Kt(){return Ht().source(Xt).target(Qt)}var at=(function(){var t=g(function(k,s,o,l){for(o=o||{},l=k.length;l--;o[k[l]]=s);return o},"o"),e=[1,9],i=[1,10],a=[1,5,10,12],h={trace:g(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SANKEY:4,NEWLINE:5,csv:6,opt_eof:7,record:8,csv_tail:9,EOF:10,"field[source]":11,COMMA:12,"field[target]":13,"field[value]":14,field:15,escaped:16,non_escaped:17,DQUOTE:18,ESCAPED_TEXT:19,NON_ESCAPED_TEXT:20,$accept:0,$end:1},terminals_:{2:"error",4:"SANKEY",5:"NEWLINE",10:"EOF",11:"field[source]",12:"COMMA",13:"field[target]",14:"field[value]",18:"DQUOTE",19:"ESCAPED_TEXT",20:"NON_ESCAPED_TEXT"},productions_:[0,[3,4],[6,2],[9,2],[9,0],[7,1],[7,0],[8,5],[15,1],[15,1],[16,3],[17,1]],performAction:g(function(s,o,l,_,x,y,v){var M=y.length-1;switch(x){case 7:const T=_.findOrCreateNode(y[M-4].trim().replaceAll('""','"')),N=_.findOrCreateNode(y[M-2].trim().replaceAll('""','"')),C=parseFloat(y[M].trim());_.addLink(T,N,C);break;case 8:case 9:case 11:this.$=y[M];break;case 10:this.$=y[M-1];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},{5:[1,3]},{6:4,8:5,15:6,16:7,17:8,18:e,20:i},{1:[2,6],7:11,10:[1,12]},t(i,[2,4],{9:13,5:[1,14]}),{12:[1,15]},t(a,[2,8]),t(a,[2,9]),{19:[1,16]},t(a,[2,11]),{1:[2,1]},{1:[2,5]},t(i,[2,2]),{6:17,8:5,15:6,16:7,17:8,18:e,20:i},{15:18,16:7,17:8,18:e,20:i},{18:[1,19]},t(i,[2,3]),{12:[1,20]},t(a,[2,10]),{15:21,16:7,17:8,18:e,20:i},t([1,5,10],[2,7])],defaultActions:{11:[2,1],12:[2,5]},parseError:g(function(s,o){if(o.recoverable)this.trace(s);else{var l=new Error(s);throw l.hash=o,l}},"parseError"),parse:g(function(s){var o=this,l=[0],_=[],x=[null],y=[],v=this.table,M="",T=0,N=0,C=2,D=1,R=y.slice.call(arguments,1),S=Object.create(this.lexer),P={yy:{}};for(var B in this.yy)Object.prototype.hasOwnProperty.call(this.yy,B)&&(P.yy[B]=this.yy[B]);S.setInput(s,P.yy),P.yy.lexer=S,P.yy.parser=this,typeof S.yylloc>"u"&&(S.yylloc={});var O=S.yylloc;y.push(O);var z=S.options&&S.options.ranges;typeof P.yy.parseError=="function"?this.parseError=P.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function d(L){l.length=l.length-2*L,x.length=x.length-L,y.length=y.length-L}g(d,"popStack");function E(){var L;return L=_.pop()||S.lex()||D,typeof L!="number"&&(L instanceof Array&&(_=L,L=_.pop()),L=o.symbols_[L]||L),L}g(E,"lex");for(var A,$,I,n,f={},u,c,r,m;;){if($=l[l.length-1],this.defaultActions[$]?I=this.defaultActions[$]:((A===null||typeof A>"u")&&(A=E()),I=v[$]&&v[$][A]),typeof I>"u"||!I.length||!I[0]){var w="";m=[];for(u in v[$])this.terminals_[u]&&u>C&&m.push("'"+this.terminals_[u]+"'");S.showPosition?w="Parse error on line "+(T+1)+`: +import{p as _t,q as xt,s as vt,g as bt,b as wt,a as St,_ as g,c as lt,z as Lt,d as H,ac as Et,y as At,k as Tt}from"./mermaid.core-v0oo9NRr.js";import{o as Mt}from"./ordinal-Cboi1Yqb.js";import"./index-8cIrvc8q.js";import"./init-Gi6I4Gst.js";function Nt(t){for(var e=t.length/6|0,i=new Array(e),a=0;a=a)&&(i=a);else{let a=-1;for(let h of t)(h=e(h,++a,t))!=null&&(i=h)&&(i=h)}return i}function pt(t,e){let i;if(e===void 0)for(const a of t)a!=null&&(i>a||i===void 0&&a>=a)&&(i=a);else{let a=-1;for(let h of t)(h=e(h,++a,t))!=null&&(i>h||i===void 0&&h>=h)&&(i=h)}return i}function nt(t,e){let i=0;if(e===void 0)for(let a of t)(a=+a)&&(i+=a);else{let a=-1;for(let h of t)(h=+e(h,++a,t))&&(i+=h)}return i}function Pt(t){return t.target.depth}function Ct(t){return t.depth}function Ot(t,e){return e-1-t.height}function mt(t,e){return t.sourceLinks.length?t.depth:e-1}function zt(t){return t.targetLinks.length?t.depth:t.sourceLinks.length?pt(t.sourceLinks,Pt)-1:0}function X(t){return function(){return t}}function ut(t,e){return Q(t.source,e.source)||t.index-e.index}function ht(t,e){return Q(t.target,e.target)||t.index-e.index}function Q(t,e){return t.y0-e.y0}function it(t){return t.value}function Dt(t){return t.index}function $t(t){return t.nodes}function jt(t){return t.links}function ft(t,e){const i=t.get(e);if(!i)throw new Error("missing: "+e);return i}function yt({nodes:t}){for(const e of t){let i=e.y0,a=i;for(const h of e.sourceLinks)h.y0=i+h.width/2,i+=h.width;for(const h of e.targetLinks)h.y1=a+h.width/2,a+=h.width}}function Bt(){let t=0,e=0,i=1,a=1,h=24,b=8,p,k=Dt,s=mt,o,l,_=$t,x=jt,y=6;function v(){const n={nodes:_.apply(null,arguments),links:x.apply(null,arguments)};return M(n),T(n),N(n),C(n),S(n),yt(n),n}v.update=function(n){return yt(n),n},v.nodeId=function(n){return arguments.length?(k=typeof n=="function"?n:X(n),v):k},v.nodeAlign=function(n){return arguments.length?(s=typeof n=="function"?n:X(n),v):s},v.nodeSort=function(n){return arguments.length?(o=n,v):o},v.nodeWidth=function(n){return arguments.length?(h=+n,v):h},v.nodePadding=function(n){return arguments.length?(b=p=+n,v):b},v.nodes=function(n){return arguments.length?(_=typeof n=="function"?n:X(n),v):_},v.links=function(n){return arguments.length?(x=typeof n=="function"?n:X(n),v):x},v.linkSort=function(n){return arguments.length?(l=n,v):l},v.size=function(n){return arguments.length?(t=e=0,i=+n[0],a=+n[1],v):[i-t,a-e]},v.extent=function(n){return arguments.length?(t=+n[0][0],i=+n[1][0],e=+n[0][1],a=+n[1][1],v):[[t,e],[i,a]]},v.iterations=function(n){return arguments.length?(y=+n,v):y};function M({nodes:n,links:f}){for(const[c,r]of n.entries())r.index=c,r.sourceLinks=[],r.targetLinks=[];const u=new Map(n.map((c,r)=>[k(c,r,n),c]));for(const[c,r]of f.entries()){r.index=c;let{source:m,target:w}=r;typeof m!="object"&&(m=r.source=ft(u,m)),typeof w!="object"&&(w=r.target=ft(u,w)),m.sourceLinks.push(r),w.targetLinks.push(r)}if(l!=null)for(const{sourceLinks:c,targetLinks:r}of n)c.sort(l),r.sort(l)}function T({nodes:n}){for(const f of n)f.value=f.fixedValue===void 0?Math.max(nt(f.sourceLinks,it),nt(f.targetLinks,it)):f.fixedValue}function N({nodes:n}){const f=n.length;let u=new Set(n),c=new Set,r=0;for(;u.size;){for(const m of u){m.depth=r;for(const{target:w}of m.sourceLinks)c.add(w)}if(++r>f)throw new Error("circular link");u=c,c=new Set}}function C({nodes:n}){const f=n.length;let u=new Set(n),c=new Set,r=0;for(;u.size;){for(const m of u){m.height=r;for(const{source:w}of m.targetLinks)c.add(w)}if(++r>f)throw new Error("circular link");u=c,c=new Set}}function D({nodes:n}){const f=ct(n,r=>r.depth)+1,u=(i-t-h)/(f-1),c=new Array(f);for(const r of n){const m=Math.max(0,Math.min(f-1,Math.floor(s.call(null,r,f))));r.layer=m,r.x0=t+m*u,r.x1=r.x0+h,c[m]?c[m].push(r):c[m]=[r]}if(o)for(const r of c)r.sort(o);return c}function R(n){const f=pt(n,u=>(a-e-(u.length-1)*p)/nt(u,it));for(const u of n){let c=e;for(const r of u){r.y0=c,r.y1=c+r.value*f,c=r.y1+p;for(const m of r.sourceLinks)m.width=m.value*f}c=(a-c+p)/(u.length+1);for(let r=0;ru.length)-1)),R(f);for(let u=0;u0))continue;let G=(L/F-w.y0)*f;w.y0+=G,w.y1+=G,E(w)}o===void 0&&m.sort(Q),O(m,u)}}function B(n,f,u){for(let c=n.length,r=c-2;r>=0;--r){const m=n[r];for(const w of m){let L=0,F=0;for(const{target:Y,value:et}of w.sourceLinks){let q=et*(Y.layer-w.layer);L+=I(w,Y)*q,F+=q}if(!(F>0))continue;let G=(L/F-w.y0)*f;w.y0+=G,w.y1+=G,E(w)}o===void 0&&m.sort(Q),O(m,u)}}function O(n,f){const u=n.length>>1,c=n[u];d(n,c.y0-p,u-1,f),z(n,c.y1+p,u+1,f),d(n,a,n.length-1,f),z(n,e,0,f)}function z(n,f,u,c){for(;u1e-6&&(r.y0+=m,r.y1+=m),f=r.y1+p}}function d(n,f,u,c){for(;u>=0;--u){const r=n[u],m=(r.y1-f)*c;m>1e-6&&(r.y0-=m,r.y1-=m),f=r.y0-p}}function E({sourceLinks:n,targetLinks:f}){if(l===void 0){for(const{source:{sourceLinks:u}}of f)u.sort(ht);for(const{target:{targetLinks:u}}of n)u.sort(ut)}}function A(n){if(l===void 0)for(const{sourceLinks:f,targetLinks:u}of n)f.sort(ht),u.sort(ut)}function $(n,f){let u=n.y0-(n.sourceLinks.length-1)*p/2;for(const{target:c,width:r}of n.sourceLinks){if(c===f)break;u+=r+p}for(const{source:c,width:r}of f.targetLinks){if(c===n)break;u-=r}return u}function I(n,f){let u=f.y0-(f.targetLinks.length-1)*p/2;for(const{source:c,width:r}of f.targetLinks){if(c===n)break;u+=r+p}for(const{target:c,width:r}of n.sourceLinks){if(c===f)break;u-=r}return u}return v}var st=Math.PI,rt=2*st,V=1e-6,Rt=rt-V;function ot(){this._x0=this._y0=this._x1=this._y1=null,this._=""}function kt(){return new ot}ot.prototype=kt.prototype={constructor:ot,moveTo:function(t,e){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)},closePath:function(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")},lineTo:function(t,e){this._+="L"+(this._x1=+t)+","+(this._y1=+e)},quadraticCurveTo:function(t,e,i,a){this._+="Q"+ +t+","+ +e+","+(this._x1=+i)+","+(this._y1=+a)},bezierCurveTo:function(t,e,i,a,h,b){this._+="C"+ +t+","+ +e+","+ +i+","+ +a+","+(this._x1=+h)+","+(this._y1=+b)},arcTo:function(t,e,i,a,h){t=+t,e=+e,i=+i,a=+a,h=+h;var b=this._x1,p=this._y1,k=i-t,s=a-e,o=b-t,l=p-e,_=o*o+l*l;if(h<0)throw new Error("negative radius: "+h);if(this._x1===null)this._+="M"+(this._x1=t)+","+(this._y1=e);else if(_>V)if(!(Math.abs(l*k-s*o)>V)||!h)this._+="L"+(this._x1=t)+","+(this._y1=e);else{var x=i-b,y=a-p,v=k*k+s*s,M=x*x+y*y,T=Math.sqrt(v),N=Math.sqrt(_),C=h*Math.tan((st-Math.acos((v+_-M)/(2*T*N)))/2),D=C/N,R=C/T;Math.abs(D-1)>V&&(this._+="L"+(t+D*o)+","+(e+D*l)),this._+="A"+h+","+h+",0,0,"+ +(l*x>o*y)+","+(this._x1=t+R*k)+","+(this._y1=e+R*s)}},arc:function(t,e,i,a,h,b){t=+t,e=+e,i=+i,b=!!b;var p=i*Math.cos(a),k=i*Math.sin(a),s=t+p,o=e+k,l=1^b,_=b?a-h:h-a;if(i<0)throw new Error("negative radius: "+i);this._x1===null?this._+="M"+s+","+o:(Math.abs(this._x1-s)>V||Math.abs(this._y1-o)>V)&&(this._+="L"+s+","+o),i&&(_<0&&(_=_%rt+rt),_>Rt?this._+="A"+i+","+i+",0,1,"+l+","+(t-p)+","+(e-k)+"A"+i+","+i+",0,1,"+l+","+(this._x1=s)+","+(this._y1=o):_>V&&(this._+="A"+i+","+i+",0,"+ +(_>=st)+","+l+","+(this._x1=t+i*Math.cos(h))+","+(this._y1=e+i*Math.sin(h))))},rect:function(t,e,i,a){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +i+"v"+ +a+"h"+-i+"Z"},toString:function(){return this._}};function dt(t){return function(){return t}}function Ft(t){return t[0]}function Vt(t){return t[1]}var Wt=Array.prototype.slice;function Ut(t){return t.source}function Gt(t){return t.target}function Yt(t){var e=Ut,i=Gt,a=Ft,h=Vt,b=null;function p(){var k,s=Wt.call(arguments),o=e.apply(this,s),l=i.apply(this,s);if(b||(b=k=kt()),t(b,+a.apply(this,(s[0]=o,s)),+h.apply(this,s),+a.apply(this,(s[0]=l,s)),+h.apply(this,s)),k)return b=null,k+""||null}return p.source=function(k){return arguments.length?(e=k,p):e},p.target=function(k){return arguments.length?(i=k,p):i},p.x=function(k){return arguments.length?(a=typeof k=="function"?k:dt(+k),p):a},p.y=function(k){return arguments.length?(h=typeof k=="function"?k:dt(+k),p):h},p.context=function(k){return arguments.length?(b=k??null,p):b},p}function qt(t,e,i,a,h){t.moveTo(e,i),t.bezierCurveTo(e=(e+a)/2,i,e,h,a,h)}function Ht(){return Yt(qt)}function Xt(t){return[t.source.x1,t.y0]}function Qt(t){return[t.target.x0,t.y1]}function Kt(){return Ht().source(Xt).target(Qt)}var at=(function(){var t=g(function(k,s,o,l){for(o=o||{},l=k.length;l--;o[k[l]]=s);return o},"o"),e=[1,9],i=[1,10],a=[1,5,10,12],h={trace:g(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SANKEY:4,NEWLINE:5,csv:6,opt_eof:7,record:8,csv_tail:9,EOF:10,"field[source]":11,COMMA:12,"field[target]":13,"field[value]":14,field:15,escaped:16,non_escaped:17,DQUOTE:18,ESCAPED_TEXT:19,NON_ESCAPED_TEXT:20,$accept:0,$end:1},terminals_:{2:"error",4:"SANKEY",5:"NEWLINE",10:"EOF",11:"field[source]",12:"COMMA",13:"field[target]",14:"field[value]",18:"DQUOTE",19:"ESCAPED_TEXT",20:"NON_ESCAPED_TEXT"},productions_:[0,[3,4],[6,2],[9,2],[9,0],[7,1],[7,0],[8,5],[15,1],[15,1],[16,3],[17,1]],performAction:g(function(s,o,l,_,x,y,v){var M=y.length-1;switch(x){case 7:const T=_.findOrCreateNode(y[M-4].trim().replaceAll('""','"')),N=_.findOrCreateNode(y[M-2].trim().replaceAll('""','"')),C=parseFloat(y[M].trim());_.addLink(T,N,C);break;case 8:case 9:case 11:this.$=y[M];break;case 10:this.$=y[M-1];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},{5:[1,3]},{6:4,8:5,15:6,16:7,17:8,18:e,20:i},{1:[2,6],7:11,10:[1,12]},t(i,[2,4],{9:13,5:[1,14]}),{12:[1,15]},t(a,[2,8]),t(a,[2,9]),{19:[1,16]},t(a,[2,11]),{1:[2,1]},{1:[2,5]},t(i,[2,2]),{6:17,8:5,15:6,16:7,17:8,18:e,20:i},{15:18,16:7,17:8,18:e,20:i},{18:[1,19]},t(i,[2,3]),{12:[1,20]},t(a,[2,10]),{15:21,16:7,17:8,18:e,20:i},t([1,5,10],[2,7])],defaultActions:{11:[2,1],12:[2,5]},parseError:g(function(s,o){if(o.recoverable)this.trace(s);else{var l=new Error(s);throw l.hash=o,l}},"parseError"),parse:g(function(s){var o=this,l=[0],_=[],x=[null],y=[],v=this.table,M="",T=0,N=0,C=2,D=1,R=y.slice.call(arguments,1),S=Object.create(this.lexer),P={yy:{}};for(var B in this.yy)Object.prototype.hasOwnProperty.call(this.yy,B)&&(P.yy[B]=this.yy[B]);S.setInput(s,P.yy),P.yy.lexer=S,P.yy.parser=this,typeof S.yylloc>"u"&&(S.yylloc={});var O=S.yylloc;y.push(O);var z=S.options&&S.options.ranges;typeof P.yy.parseError=="function"?this.parseError=P.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function d(L){l.length=l.length-2*L,x.length=x.length-L,y.length=y.length-L}g(d,"popStack");function E(){var L;return L=_.pop()||S.lex()||D,typeof L!="number"&&(L instanceof Array&&(_=L,L=_.pop()),L=o.symbols_[L]||L),L}g(E,"lex");for(var A,$,I,n,f={},u,c,r,m;;){if($=l[l.length-1],this.defaultActions[$]?I=this.defaultActions[$]:((A===null||typeof A>"u")&&(A=E()),I=v[$]&&v[$][A]),typeof I>"u"||!I.length||!I[0]){var w="";m=[];for(u in v[$])this.terminals_[u]&&u>C&&m.push("'"+this.terminals_[u]+"'");S.showPosition?w="Parse error on line "+(T+1)+`: `+S.showPosition()+` Expecting `+m.join(", ")+", got '"+(this.terminals_[A]||A)+"'":w="Parse error on line "+(T+1)+": Unexpected "+(A==D?"end of input":"'"+(this.terminals_[A]||A)+"'"),this.parseError(w,{text:S.match,token:this.terminals_[A]||A,line:S.yylineno,loc:O,expected:m})}if(I[0]instanceof Array&&I.length>1)throw new Error("Parse Error: multiple actions possible at state: "+$+", token: "+A);switch(I[0]){case 1:l.push(A),x.push(S.yytext),y.push(S.yylloc),l.push(I[1]),A=null,N=S.yyleng,M=S.yytext,T=S.yylineno,O=S.yylloc;break;case 2:if(c=this.productions_[I[1]][1],f.$=x[x.length-c],f._$={first_line:y[y.length-(c||1)].first_line,last_line:y[y.length-1].last_line,first_column:y[y.length-(c||1)].first_column,last_column:y[y.length-1].last_column},z&&(f._$.range=[y[y.length-(c||1)].range[0],y[y.length-1].range[1]]),n=this.performAction.apply(f,[M,N,T,P.yy,I[1],x,y].concat(R)),typeof n<"u")return n;c&&(l=l.slice(0,-1*c*2),x=x.slice(0,-1*c),y=y.slice(0,-1*c)),l.push(this.productions_[I[1]][0]),x.push(f.$),y.push(f._$),r=v[l[l.length-2]][l[l.length-1]],l.push(r);break;case 3:return!0}}return!0},"parse")},b=(function(){var k={EOF:1,parseError:g(function(o,l){if(this.yy.parser)this.yy.parser.parseError(o,l);else throw new Error(o)},"parseError"),setInput:g(function(s,o){return this.yy=o||this.yy||{},this._input=s,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:g(function(){var s=this._input[0];this.yytext+=s,this.yyleng++,this.offset++,this.match+=s,this.matched+=s;var o=s.match(/(?:\r\n?|\n).*/g);return o?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),s},"input"),unput:g(function(s){var o=s.length,l=s.split(/(?:\r\n?|\n)/g);this._input=s+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-o),this.offset-=o;var _=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),l.length-1&&(this.yylineno-=l.length-1);var x=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:l?(l.length===_.length?this.yylloc.first_column:0)+_[_.length-l.length].length-l[0].length:this.yylloc.first_column-o},this.options.ranges&&(this.yylloc.range=[x[0],x[0]+this.yyleng-o]),this.yyleng=this.yytext.length,this},"unput"),more:g(function(){return this._more=!0,this},"more"),reject:g(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:g(function(s){this.unput(this.match.slice(s))},"less"),pastInput:g(function(){var s=this.matched.substr(0,this.matched.length-this.match.length);return(s.length>20?"...":"")+s.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:g(function(){var s=this.match;return s.length<20&&(s+=this._input.substr(0,20-s.length)),(s.substr(0,20)+(s.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:g(function(){var s=this.pastInput(),o=new Array(s.length+1).join("-");return s+this.upcomingInput()+` diff --git a/demo/aiui/assets/sequenceDiagram-WL72ISMW-BukSiqtq.js b/demo/aiui/assets/sequenceDiagram-WL72ISMW-VBErL0-f.js similarity index 99% rename from demo/aiui/assets/sequenceDiagram-WL72ISMW-BukSiqtq.js rename to demo/aiui/assets/sequenceDiagram-WL72ISMW-VBErL0-f.js index 9d9a7abe..6d0a83d4 100644 --- a/demo/aiui/assets/sequenceDiagram-WL72ISMW-BukSiqtq.js +++ b/demo/aiui/assets/sequenceDiagram-WL72ISMW-VBErL0-f.js @@ -1,4 +1,4 @@ -import{a as we,b as Xt,g as ct,d as ve,c as Jt,e as Qt}from"./chunk-TZMSLE5B-Did4v35P.js";import{_ as f,n as Ie,c as st,d as St,l as Q,j as re,e as Le,f as _e,k as I,b as se,s as Ae,p as ke,a as Pe,g as Ne,q as Se,t as Me,J as Re,y as De,i as Mt,u as W,a2 as z,a3 as _t,a4 as ie,a5 as Ce,a6 as Oe,a7 as ne,F as Ht}from"./mermaid.core-DaNhpuX9.js";import{I as Be}from"./chunk-QZHKN3VN-BpY3MN1h.js";import"./index-Lh5NfTCq.js";var Ut=(function(){var e=f(function(pt,v,A,L){for(A=A||{},L=pt.length;L--;A[pt[L]]=v);return A},"o"),t=[1,2],n=[1,3],s=[1,4],r=[2,4],i=[1,9],c=[1,11],h=[1,13],o=[1,14],a=[1,16],p=[1,17],g=[1,18],x=[1,24],y=[1,25],m=[1,26],w=[1,27],k=[1,28],N=[1,29],S=[1,30],O=[1,31],B=[1,32],q=[1,33],H=[1,34],Z=[1,35],at=[1,36],U=[1,37],G=[1,38],F=[1,39],D=[1,41],$=[1,42],K=[1,43],j=[1,44],rt=[1,45],R=[1,46],E=[1,4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,48,49,50,52,53,55,60,61,62,63,71],_=[2,71],X=[4,5,16,50,52,53],tt=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],M=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,49,50,52,53,55,60,61,62,63,71],Vt=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,48,50,52,53,55,60,61,62,63,71],Zt=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,50,52,53,55,60,61,62,63,71],ot=[69,70,71],lt=[1,127],Yt={trace:f(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NEWLINE:5,SD:6,document:7,line:8,statement:9,box_section:10,box_line:11,participant_statement:12,create:13,box:14,restOfLine:15,end:16,signal:17,autonumber:18,NUM:19,off:20,activate:21,actor:22,deactivate:23,note_statement:24,links_statement:25,link_statement:26,properties_statement:27,details_statement:28,title:29,legacy_title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,loop:36,rect:37,opt:38,alt:39,else_sections:40,par:41,par_sections:42,par_over:43,critical:44,option_sections:45,break:46,option:47,and:48,else:49,participant:50,AS:51,participant_actor:52,destroy:53,actor_with_config:54,note:55,placement:56,text2:57,over:58,actor_pair:59,links:60,link:61,properties:62,details:63,spaceList:64,",":65,left_of:66,right_of:67,signaltype:68,"+":69,"-":70,ACTOR:71,config_object:72,CONFIG_START:73,CONFIG_CONTENT:74,CONFIG_END:75,SOLID_OPEN_ARROW:76,DOTTED_OPEN_ARROW:77,SOLID_ARROW:78,BIDIRECTIONAL_SOLID_ARROW:79,DOTTED_ARROW:80,BIDIRECTIONAL_DOTTED_ARROW:81,SOLID_CROSS:82,DOTTED_CROSS:83,SOLID_POINT:84,DOTTED_POINT:85,TXT:86,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NEWLINE",6:"SD",13:"create",14:"box",15:"restOfLine",16:"end",18:"autonumber",19:"NUM",20:"off",21:"activate",23:"deactivate",29:"title",30:"legacy_title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"loop",37:"rect",38:"opt",39:"alt",41:"par",43:"par_over",44:"critical",46:"break",47:"option",48:"and",49:"else",50:"participant",51:"AS",52:"participant_actor",53:"destroy",55:"note",58:"over",60:"links",61:"link",62:"properties",63:"details",65:",",66:"left_of",67:"right_of",69:"+",70:"-",71:"ACTOR",73:"CONFIG_START",74:"CONFIG_CONTENT",75:"CONFIG_END",76:"SOLID_OPEN_ARROW",77:"DOTTED_OPEN_ARROW",78:"SOLID_ARROW",79:"BIDIRECTIONAL_SOLID_ARROW",80:"DOTTED_ARROW",81:"BIDIRECTIONAL_DOTTED_ARROW",82:"SOLID_CROSS",83:"DOTTED_CROSS",84:"SOLID_POINT",85:"DOTTED_POINT",86:"TXT"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[10,0],[10,2],[11,2],[11,1],[11,1],[9,1],[9,2],[9,4],[9,2],[9,4],[9,3],[9,3],[9,2],[9,3],[9,3],[9,2],[9,2],[9,2],[9,2],[9,2],[9,1],[9,1],[9,2],[9,2],[9,1],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[45,1],[45,4],[42,1],[42,4],[40,1],[40,4],[12,5],[12,3],[12,5],[12,3],[12,3],[12,3],[24,4],[24,4],[25,3],[26,3],[27,3],[28,3],[64,2],[64,1],[59,3],[59,1],[56,1],[56,1],[17,5],[17,5],[17,4],[54,2],[72,3],[22,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[57,1]],performAction:f(function(v,A,L,b,C,d,It){var u=d.length-1;switch(C){case 3:return b.apply(d[u]),d[u];case 4:case 9:this.$=[];break;case 5:case 10:d[u-1].push(d[u]),this.$=d[u-1];break;case 6:case 7:case 11:case 12:this.$=d[u];break;case 8:case 13:this.$=[];break;case 15:d[u].type="createParticipant",this.$=d[u];break;case 16:d[u-1].unshift({type:"boxStart",boxData:b.parseBoxData(d[u-2])}),d[u-1].push({type:"boxEnd",boxText:d[u-2]}),this.$=d[u-1];break;case 18:this.$={type:"sequenceIndex",sequenceIndex:Number(d[u-2]),sequenceIndexStep:Number(d[u-1]),sequenceVisible:!0,signalType:b.LINETYPE.AUTONUMBER};break;case 19:this.$={type:"sequenceIndex",sequenceIndex:Number(d[u-1]),sequenceIndexStep:1,sequenceVisible:!0,signalType:b.LINETYPE.AUTONUMBER};break;case 20:this.$={type:"sequenceIndex",sequenceVisible:!1,signalType:b.LINETYPE.AUTONUMBER};break;case 21:this.$={type:"sequenceIndex",sequenceVisible:!0,signalType:b.LINETYPE.AUTONUMBER};break;case 22:this.$={type:"activeStart",signalType:b.LINETYPE.ACTIVE_START,actor:d[u-1].actor};break;case 23:this.$={type:"activeEnd",signalType:b.LINETYPE.ACTIVE_END,actor:d[u-1].actor};break;case 29:b.setDiagramTitle(d[u].substring(6)),this.$=d[u].substring(6);break;case 30:b.setDiagramTitle(d[u].substring(7)),this.$=d[u].substring(7);break;case 31:this.$=d[u].trim(),b.setAccTitle(this.$);break;case 32:case 33:this.$=d[u].trim(),b.setAccDescription(this.$);break;case 34:d[u-1].unshift({type:"loopStart",loopText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.LOOP_START}),d[u-1].push({type:"loopEnd",loopText:d[u-2],signalType:b.LINETYPE.LOOP_END}),this.$=d[u-1];break;case 35:d[u-1].unshift({type:"rectStart",color:b.parseMessage(d[u-2]),signalType:b.LINETYPE.RECT_START}),d[u-1].push({type:"rectEnd",color:b.parseMessage(d[u-2]),signalType:b.LINETYPE.RECT_END}),this.$=d[u-1];break;case 36:d[u-1].unshift({type:"optStart",optText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.OPT_START}),d[u-1].push({type:"optEnd",optText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.OPT_END}),this.$=d[u-1];break;case 37:d[u-1].unshift({type:"altStart",altText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.ALT_START}),d[u-1].push({type:"altEnd",signalType:b.LINETYPE.ALT_END}),this.$=d[u-1];break;case 38:d[u-1].unshift({type:"parStart",parText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.PAR_START}),d[u-1].push({type:"parEnd",signalType:b.LINETYPE.PAR_END}),this.$=d[u-1];break;case 39:d[u-1].unshift({type:"parStart",parText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.PAR_OVER_START}),d[u-1].push({type:"parEnd",signalType:b.LINETYPE.PAR_END}),this.$=d[u-1];break;case 40:d[u-1].unshift({type:"criticalStart",criticalText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.CRITICAL_START}),d[u-1].push({type:"criticalEnd",signalType:b.LINETYPE.CRITICAL_END}),this.$=d[u-1];break;case 41:d[u-1].unshift({type:"breakStart",breakText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.BREAK_START}),d[u-1].push({type:"breakEnd",optText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.BREAK_END}),this.$=d[u-1];break;case 43:this.$=d[u-3].concat([{type:"option",optionText:b.parseMessage(d[u-1]),signalType:b.LINETYPE.CRITICAL_OPTION},d[u]]);break;case 45:this.$=d[u-3].concat([{type:"and",parText:b.parseMessage(d[u-1]),signalType:b.LINETYPE.PAR_AND},d[u]]);break;case 47:this.$=d[u-3].concat([{type:"else",altText:b.parseMessage(d[u-1]),signalType:b.LINETYPE.ALT_ELSE},d[u]]);break;case 48:d[u-3].draw="participant",d[u-3].type="addParticipant",d[u-3].description=b.parseMessage(d[u-1]),this.$=d[u-3];break;case 49:d[u-1].draw="participant",d[u-1].type="addParticipant",this.$=d[u-1];break;case 50:d[u-3].draw="actor",d[u-3].type="addParticipant",d[u-3].description=b.parseMessage(d[u-1]),this.$=d[u-3];break;case 51:d[u-1].draw="actor",d[u-1].type="addParticipant",this.$=d[u-1];break;case 52:d[u-1].type="destroyParticipant",this.$=d[u-1];break;case 53:d[u-1].draw="participant",d[u-1].type="addParticipant",this.$=d[u-1];break;case 54:this.$=[d[u-1],{type:"addNote",placement:d[u-2],actor:d[u-1].actor,text:d[u]}];break;case 55:d[u-2]=[].concat(d[u-1],d[u-1]).slice(0,2),d[u-2][0]=d[u-2][0].actor,d[u-2][1]=d[u-2][1].actor,this.$=[d[u-1],{type:"addNote",placement:b.PLACEMENT.OVER,actor:d[u-2].slice(0,2),text:d[u]}];break;case 56:this.$=[d[u-1],{type:"addLinks",actor:d[u-1].actor,text:d[u]}];break;case 57:this.$=[d[u-1],{type:"addALink",actor:d[u-1].actor,text:d[u]}];break;case 58:this.$=[d[u-1],{type:"addProperties",actor:d[u-1].actor,text:d[u]}];break;case 59:this.$=[d[u-1],{type:"addDetails",actor:d[u-1].actor,text:d[u]}];break;case 62:this.$=[d[u-2],d[u]];break;case 63:this.$=d[u];break;case 64:this.$=b.PLACEMENT.LEFTOF;break;case 65:this.$=b.PLACEMENT.RIGHTOF;break;case 66:this.$=[d[u-4],d[u-1],{type:"addMessage",from:d[u-4].actor,to:d[u-1].actor,signalType:d[u-3],msg:d[u],activate:!0},{type:"activeStart",signalType:b.LINETYPE.ACTIVE_START,actor:d[u-1].actor}];break;case 67:this.$=[d[u-4],d[u-1],{type:"addMessage",from:d[u-4].actor,to:d[u-1].actor,signalType:d[u-3],msg:d[u]},{type:"activeEnd",signalType:b.LINETYPE.ACTIVE_END,actor:d[u-4].actor}];break;case 68:this.$=[d[u-3],d[u-1],{type:"addMessage",from:d[u-3].actor,to:d[u-1].actor,signalType:d[u-2],msg:d[u]}];break;case 69:this.$={type:"addParticipant",actor:d[u-1],config:d[u]};break;case 70:this.$=d[u-1].trim();break;case 71:this.$={type:"addParticipant",actor:d[u]};break;case 72:this.$=b.LINETYPE.SOLID_OPEN;break;case 73:this.$=b.LINETYPE.DOTTED_OPEN;break;case 74:this.$=b.LINETYPE.SOLID;break;case 75:this.$=b.LINETYPE.BIDIRECTIONAL_SOLID;break;case 76:this.$=b.LINETYPE.DOTTED;break;case 77:this.$=b.LINETYPE.BIDIRECTIONAL_DOTTED;break;case 78:this.$=b.LINETYPE.SOLID_CROSS;break;case 79:this.$=b.LINETYPE.DOTTED_CROSS;break;case 80:this.$=b.LINETYPE.SOLID_POINT;break;case 81:this.$=b.LINETYPE.DOTTED_POINT;break;case 82:this.$=b.parseMessage(d[u].trim().substring(1));break}},"anonymous"),table:[{3:1,4:t,5:n,6:s},{1:[3]},{3:5,4:t,5:n,6:s},{3:6,4:t,5:n,6:s},e([1,4,5,13,14,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],r,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:i,5:c,8:8,9:10,12:12,13:h,14:o,17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},e(E,[2,5]),{9:47,12:12,13:h,14:o,17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},e(E,[2,7]),e(E,[2,8]),e(E,[2,14]),{12:48,50:U,52:G,53:F},{15:[1,49]},{5:[1,50]},{5:[1,53],19:[1,51],20:[1,52]},{22:54,71:R},{22:55,71:R},{5:[1,56]},{5:[1,57]},{5:[1,58]},{5:[1,59]},{5:[1,60]},e(E,[2,29]),e(E,[2,30]),{32:[1,61]},{34:[1,62]},e(E,[2,33]),{15:[1,63]},{15:[1,64]},{15:[1,65]},{15:[1,66]},{15:[1,67]},{15:[1,68]},{15:[1,69]},{15:[1,70]},{22:71,54:72,71:[1,73]},{22:74,71:R},{22:75,71:R},{68:76,76:[1,77],77:[1,78],78:[1,79],79:[1,80],80:[1,81],81:[1,82],82:[1,83],83:[1,84],84:[1,85],85:[1,86]},{56:87,58:[1,88],66:[1,89],67:[1,90]},{22:91,71:R},{22:92,71:R},{22:93,71:R},{22:94,71:R},e([5,51,65,76,77,78,79,80,81,82,83,84,85,86],_),e(E,[2,6]),e(E,[2,15]),e(X,[2,9],{10:95}),e(E,[2,17]),{5:[1,97],19:[1,96]},{5:[1,98]},e(E,[2,21]),{5:[1,99]},{5:[1,100]},e(E,[2,24]),e(E,[2,25]),e(E,[2,26]),e(E,[2,27]),e(E,[2,28]),e(E,[2,31]),e(E,[2,32]),e(tt,r,{7:101}),e(tt,r,{7:102}),e(tt,r,{7:103}),e(M,r,{40:104,7:105}),e(Vt,r,{42:106,7:107}),e(Vt,r,{7:107,42:108}),e(Zt,r,{45:109,7:110}),e(tt,r,{7:111}),{5:[1,113],51:[1,112]},{5:[1,114]},e([5,51],_,{72:115,73:[1,116]}),{5:[1,118],51:[1,117]},{5:[1,119]},{22:122,69:[1,120],70:[1,121],71:R},e(ot,[2,72]),e(ot,[2,73]),e(ot,[2,74]),e(ot,[2,75]),e(ot,[2,76]),e(ot,[2,77]),e(ot,[2,78]),e(ot,[2,79]),e(ot,[2,80]),e(ot,[2,81]),{22:123,71:R},{22:125,59:124,71:R},{71:[2,64]},{71:[2,65]},{57:126,86:lt},{57:128,86:lt},{57:129,86:lt},{57:130,86:lt},{4:[1,133],5:[1,135],11:132,12:134,16:[1,131],50:U,52:G,53:F},{5:[1,136]},e(E,[2,19]),e(E,[2,20]),e(E,[2,22]),e(E,[2,23]),{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,137],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,138],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,139],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{16:[1,140]},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[2,46],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,49:[1,141],50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{16:[1,142]},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[2,44],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,48:[1,143],50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{16:[1,144]},{16:[1,145]},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[2,42],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,47:[1,146],50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,147],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{15:[1,148]},e(E,[2,49]),e(E,[2,53]),{5:[2,69]},{74:[1,149]},{15:[1,150]},e(E,[2,51]),e(E,[2,52]),{22:151,71:R},{22:152,71:R},{57:153,86:lt},{57:154,86:lt},{57:155,86:lt},{65:[1,156],86:[2,63]},{5:[2,56]},{5:[2,82]},{5:[2,57]},{5:[2,58]},{5:[2,59]},e(E,[2,16]),e(X,[2,10]),{12:157,50:U,52:G,53:F},e(X,[2,12]),e(X,[2,13]),e(E,[2,18]),e(E,[2,34]),e(E,[2,35]),e(E,[2,36]),e(E,[2,37]),{15:[1,158]},e(E,[2,38]),{15:[1,159]},e(E,[2,39]),e(E,[2,40]),{15:[1,160]},e(E,[2,41]),{5:[1,161]},{75:[1,162]},{5:[1,163]},{57:164,86:lt},{57:165,86:lt},{5:[2,68]},{5:[2,54]},{5:[2,55]},{22:166,71:R},e(X,[2,11]),e(M,r,{7:105,40:167}),e(Vt,r,{7:107,42:168}),e(Zt,r,{7:110,45:169}),e(E,[2,48]),{5:[2,70]},e(E,[2,50]),{5:[2,66]},{5:[2,67]},{86:[2,62]},{16:[2,47]},{16:[2,45]},{16:[2,43]}],defaultActions:{5:[2,1],6:[2,2],89:[2,64],90:[2,65],115:[2,69],126:[2,56],127:[2,82],128:[2,57],129:[2,58],130:[2,59],153:[2,68],154:[2,54],155:[2,55],162:[2,70],164:[2,66],165:[2,67],166:[2,62],167:[2,47],168:[2,45],169:[2,43]},parseError:f(function(v,A){if(A.recoverable)this.trace(v);else{var L=new Error(v);throw L.hash=A,L}},"parseError"),parse:f(function(v){var A=this,L=[0],b=[],C=[null],d=[],It=this.table,u="",kt=0,$t=0,Te=2,jt=1,Ee=d.slice.call(arguments,1),Y=Object.create(this.lexer),ft={yy:{}};for(var Wt in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Wt)&&(ft.yy[Wt]=this.yy[Wt]);Y.setInput(v,ft.yy),ft.yy.lexer=Y,ft.yy.parser=this,typeof Y.yylloc>"u"&&(Y.yylloc={});var Ft=Y.yylloc;d.push(Ft);var be=Y.options&&Y.options.ranges;typeof ft.yy.parseError=="function"?this.parseError=ft.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function me(et){L.length=L.length-2*et,C.length=C.length-et,d.length=d.length-et}f(me,"popStack");function te(){var et;return et=b.pop()||Y.lex()||jt,typeof et!="number"&&(et instanceof Array&&(b=et,et=b.pop()),et=A.symbols_[et]||et),et}f(te,"lex");for(var J,yt,it,qt,bt={},Pt,ht,ee,Nt;;){if(yt=L[L.length-1],this.defaultActions[yt]?it=this.defaultActions[yt]:((J===null||typeof J>"u")&&(J=te()),it=It[yt]&&It[yt][J]),typeof it>"u"||!it.length||!it[0]){var zt="";Nt=[];for(Pt in It[yt])this.terminals_[Pt]&&Pt>Te&&Nt.push("'"+this.terminals_[Pt]+"'");Y.showPosition?zt="Parse error on line "+(kt+1)+`: +import{a as we,b as Xt,g as ct,d as ve,c as Jt,e as Qt}from"./chunk-TZMSLE5B-93PKdbpb.js";import{_ as f,n as Ie,c as st,d as St,l as Q,j as re,e as Le,f as _e,k as I,b as se,s as Ae,p as ke,a as Pe,g as Ne,q as Se,t as Me,J as Re,y as De,i as Mt,u as W,a2 as z,a3 as _t,a4 as ie,a5 as Ce,a6 as Oe,a7 as ne,F as Ht}from"./mermaid.core-v0oo9NRr.js";import{I as Be}from"./chunk-QZHKN3VN-Diifi0zg.js";import"./index-8cIrvc8q.js";var Ut=(function(){var e=f(function(pt,v,A,L){for(A=A||{},L=pt.length;L--;A[pt[L]]=v);return A},"o"),t=[1,2],n=[1,3],s=[1,4],r=[2,4],i=[1,9],c=[1,11],h=[1,13],o=[1,14],a=[1,16],p=[1,17],g=[1,18],x=[1,24],y=[1,25],m=[1,26],w=[1,27],k=[1,28],N=[1,29],S=[1,30],O=[1,31],B=[1,32],q=[1,33],H=[1,34],Z=[1,35],at=[1,36],U=[1,37],G=[1,38],F=[1,39],D=[1,41],$=[1,42],K=[1,43],j=[1,44],rt=[1,45],R=[1,46],E=[1,4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,48,49,50,52,53,55,60,61,62,63,71],_=[2,71],X=[4,5,16,50,52,53],tt=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],M=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,49,50,52,53,55,60,61,62,63,71],Vt=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,48,50,52,53,55,60,61,62,63,71],Zt=[4,5,13,14,16,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,47,50,52,53,55,60,61,62,63,71],ot=[69,70,71],lt=[1,127],Yt={trace:f(function(){},"trace"),yy:{},symbols_:{error:2,start:3,SPACE:4,NEWLINE:5,SD:6,document:7,line:8,statement:9,box_section:10,box_line:11,participant_statement:12,create:13,box:14,restOfLine:15,end:16,signal:17,autonumber:18,NUM:19,off:20,activate:21,actor:22,deactivate:23,note_statement:24,links_statement:25,link_statement:26,properties_statement:27,details_statement:28,title:29,legacy_title:30,acc_title:31,acc_title_value:32,acc_descr:33,acc_descr_value:34,acc_descr_multiline_value:35,loop:36,rect:37,opt:38,alt:39,else_sections:40,par:41,par_sections:42,par_over:43,critical:44,option_sections:45,break:46,option:47,and:48,else:49,participant:50,AS:51,participant_actor:52,destroy:53,actor_with_config:54,note:55,placement:56,text2:57,over:58,actor_pair:59,links:60,link:61,properties:62,details:63,spaceList:64,",":65,left_of:66,right_of:67,signaltype:68,"+":69,"-":70,ACTOR:71,config_object:72,CONFIG_START:73,CONFIG_CONTENT:74,CONFIG_END:75,SOLID_OPEN_ARROW:76,DOTTED_OPEN_ARROW:77,SOLID_ARROW:78,BIDIRECTIONAL_SOLID_ARROW:79,DOTTED_ARROW:80,BIDIRECTIONAL_DOTTED_ARROW:81,SOLID_CROSS:82,DOTTED_CROSS:83,SOLID_POINT:84,DOTTED_POINT:85,TXT:86,$accept:0,$end:1},terminals_:{2:"error",4:"SPACE",5:"NEWLINE",6:"SD",13:"create",14:"box",15:"restOfLine",16:"end",18:"autonumber",19:"NUM",20:"off",21:"activate",23:"deactivate",29:"title",30:"legacy_title",31:"acc_title",32:"acc_title_value",33:"acc_descr",34:"acc_descr_value",35:"acc_descr_multiline_value",36:"loop",37:"rect",38:"opt",39:"alt",41:"par",43:"par_over",44:"critical",46:"break",47:"option",48:"and",49:"else",50:"participant",51:"AS",52:"participant_actor",53:"destroy",55:"note",58:"over",60:"links",61:"link",62:"properties",63:"details",65:",",66:"left_of",67:"right_of",69:"+",70:"-",71:"ACTOR",73:"CONFIG_START",74:"CONFIG_CONTENT",75:"CONFIG_END",76:"SOLID_OPEN_ARROW",77:"DOTTED_OPEN_ARROW",78:"SOLID_ARROW",79:"BIDIRECTIONAL_SOLID_ARROW",80:"DOTTED_ARROW",81:"BIDIRECTIONAL_DOTTED_ARROW",82:"SOLID_CROSS",83:"DOTTED_CROSS",84:"SOLID_POINT",85:"DOTTED_POINT",86:"TXT"},productions_:[0,[3,2],[3,2],[3,2],[7,0],[7,2],[8,2],[8,1],[8,1],[10,0],[10,2],[11,2],[11,1],[11,1],[9,1],[9,2],[9,4],[9,2],[9,4],[9,3],[9,3],[9,2],[9,3],[9,3],[9,2],[9,2],[9,2],[9,2],[9,2],[9,1],[9,1],[9,2],[9,2],[9,1],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[9,4],[45,1],[45,4],[42,1],[42,4],[40,1],[40,4],[12,5],[12,3],[12,5],[12,3],[12,3],[12,3],[24,4],[24,4],[25,3],[26,3],[27,3],[28,3],[64,2],[64,1],[59,3],[59,1],[56,1],[56,1],[17,5],[17,5],[17,4],[54,2],[72,3],[22,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[68,1],[57,1]],performAction:f(function(v,A,L,b,C,d,It){var u=d.length-1;switch(C){case 3:return b.apply(d[u]),d[u];case 4:case 9:this.$=[];break;case 5:case 10:d[u-1].push(d[u]),this.$=d[u-1];break;case 6:case 7:case 11:case 12:this.$=d[u];break;case 8:case 13:this.$=[];break;case 15:d[u].type="createParticipant",this.$=d[u];break;case 16:d[u-1].unshift({type:"boxStart",boxData:b.parseBoxData(d[u-2])}),d[u-1].push({type:"boxEnd",boxText:d[u-2]}),this.$=d[u-1];break;case 18:this.$={type:"sequenceIndex",sequenceIndex:Number(d[u-2]),sequenceIndexStep:Number(d[u-1]),sequenceVisible:!0,signalType:b.LINETYPE.AUTONUMBER};break;case 19:this.$={type:"sequenceIndex",sequenceIndex:Number(d[u-1]),sequenceIndexStep:1,sequenceVisible:!0,signalType:b.LINETYPE.AUTONUMBER};break;case 20:this.$={type:"sequenceIndex",sequenceVisible:!1,signalType:b.LINETYPE.AUTONUMBER};break;case 21:this.$={type:"sequenceIndex",sequenceVisible:!0,signalType:b.LINETYPE.AUTONUMBER};break;case 22:this.$={type:"activeStart",signalType:b.LINETYPE.ACTIVE_START,actor:d[u-1].actor};break;case 23:this.$={type:"activeEnd",signalType:b.LINETYPE.ACTIVE_END,actor:d[u-1].actor};break;case 29:b.setDiagramTitle(d[u].substring(6)),this.$=d[u].substring(6);break;case 30:b.setDiagramTitle(d[u].substring(7)),this.$=d[u].substring(7);break;case 31:this.$=d[u].trim(),b.setAccTitle(this.$);break;case 32:case 33:this.$=d[u].trim(),b.setAccDescription(this.$);break;case 34:d[u-1].unshift({type:"loopStart",loopText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.LOOP_START}),d[u-1].push({type:"loopEnd",loopText:d[u-2],signalType:b.LINETYPE.LOOP_END}),this.$=d[u-1];break;case 35:d[u-1].unshift({type:"rectStart",color:b.parseMessage(d[u-2]),signalType:b.LINETYPE.RECT_START}),d[u-1].push({type:"rectEnd",color:b.parseMessage(d[u-2]),signalType:b.LINETYPE.RECT_END}),this.$=d[u-1];break;case 36:d[u-1].unshift({type:"optStart",optText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.OPT_START}),d[u-1].push({type:"optEnd",optText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.OPT_END}),this.$=d[u-1];break;case 37:d[u-1].unshift({type:"altStart",altText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.ALT_START}),d[u-1].push({type:"altEnd",signalType:b.LINETYPE.ALT_END}),this.$=d[u-1];break;case 38:d[u-1].unshift({type:"parStart",parText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.PAR_START}),d[u-1].push({type:"parEnd",signalType:b.LINETYPE.PAR_END}),this.$=d[u-1];break;case 39:d[u-1].unshift({type:"parStart",parText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.PAR_OVER_START}),d[u-1].push({type:"parEnd",signalType:b.LINETYPE.PAR_END}),this.$=d[u-1];break;case 40:d[u-1].unshift({type:"criticalStart",criticalText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.CRITICAL_START}),d[u-1].push({type:"criticalEnd",signalType:b.LINETYPE.CRITICAL_END}),this.$=d[u-1];break;case 41:d[u-1].unshift({type:"breakStart",breakText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.BREAK_START}),d[u-1].push({type:"breakEnd",optText:b.parseMessage(d[u-2]),signalType:b.LINETYPE.BREAK_END}),this.$=d[u-1];break;case 43:this.$=d[u-3].concat([{type:"option",optionText:b.parseMessage(d[u-1]),signalType:b.LINETYPE.CRITICAL_OPTION},d[u]]);break;case 45:this.$=d[u-3].concat([{type:"and",parText:b.parseMessage(d[u-1]),signalType:b.LINETYPE.PAR_AND},d[u]]);break;case 47:this.$=d[u-3].concat([{type:"else",altText:b.parseMessage(d[u-1]),signalType:b.LINETYPE.ALT_ELSE},d[u]]);break;case 48:d[u-3].draw="participant",d[u-3].type="addParticipant",d[u-3].description=b.parseMessage(d[u-1]),this.$=d[u-3];break;case 49:d[u-1].draw="participant",d[u-1].type="addParticipant",this.$=d[u-1];break;case 50:d[u-3].draw="actor",d[u-3].type="addParticipant",d[u-3].description=b.parseMessage(d[u-1]),this.$=d[u-3];break;case 51:d[u-1].draw="actor",d[u-1].type="addParticipant",this.$=d[u-1];break;case 52:d[u-1].type="destroyParticipant",this.$=d[u-1];break;case 53:d[u-1].draw="participant",d[u-1].type="addParticipant",this.$=d[u-1];break;case 54:this.$=[d[u-1],{type:"addNote",placement:d[u-2],actor:d[u-1].actor,text:d[u]}];break;case 55:d[u-2]=[].concat(d[u-1],d[u-1]).slice(0,2),d[u-2][0]=d[u-2][0].actor,d[u-2][1]=d[u-2][1].actor,this.$=[d[u-1],{type:"addNote",placement:b.PLACEMENT.OVER,actor:d[u-2].slice(0,2),text:d[u]}];break;case 56:this.$=[d[u-1],{type:"addLinks",actor:d[u-1].actor,text:d[u]}];break;case 57:this.$=[d[u-1],{type:"addALink",actor:d[u-1].actor,text:d[u]}];break;case 58:this.$=[d[u-1],{type:"addProperties",actor:d[u-1].actor,text:d[u]}];break;case 59:this.$=[d[u-1],{type:"addDetails",actor:d[u-1].actor,text:d[u]}];break;case 62:this.$=[d[u-2],d[u]];break;case 63:this.$=d[u];break;case 64:this.$=b.PLACEMENT.LEFTOF;break;case 65:this.$=b.PLACEMENT.RIGHTOF;break;case 66:this.$=[d[u-4],d[u-1],{type:"addMessage",from:d[u-4].actor,to:d[u-1].actor,signalType:d[u-3],msg:d[u],activate:!0},{type:"activeStart",signalType:b.LINETYPE.ACTIVE_START,actor:d[u-1].actor}];break;case 67:this.$=[d[u-4],d[u-1],{type:"addMessage",from:d[u-4].actor,to:d[u-1].actor,signalType:d[u-3],msg:d[u]},{type:"activeEnd",signalType:b.LINETYPE.ACTIVE_END,actor:d[u-4].actor}];break;case 68:this.$=[d[u-3],d[u-1],{type:"addMessage",from:d[u-3].actor,to:d[u-1].actor,signalType:d[u-2],msg:d[u]}];break;case 69:this.$={type:"addParticipant",actor:d[u-1],config:d[u]};break;case 70:this.$=d[u-1].trim();break;case 71:this.$={type:"addParticipant",actor:d[u]};break;case 72:this.$=b.LINETYPE.SOLID_OPEN;break;case 73:this.$=b.LINETYPE.DOTTED_OPEN;break;case 74:this.$=b.LINETYPE.SOLID;break;case 75:this.$=b.LINETYPE.BIDIRECTIONAL_SOLID;break;case 76:this.$=b.LINETYPE.DOTTED;break;case 77:this.$=b.LINETYPE.BIDIRECTIONAL_DOTTED;break;case 78:this.$=b.LINETYPE.SOLID_CROSS;break;case 79:this.$=b.LINETYPE.DOTTED_CROSS;break;case 80:this.$=b.LINETYPE.SOLID_POINT;break;case 81:this.$=b.LINETYPE.DOTTED_POINT;break;case 82:this.$=b.parseMessage(d[u].trim().substring(1));break}},"anonymous"),table:[{3:1,4:t,5:n,6:s},{1:[3]},{3:5,4:t,5:n,6:s},{3:6,4:t,5:n,6:s},e([1,4,5,13,14,18,21,23,29,30,31,33,35,36,37,38,39,41,43,44,46,50,52,53,55,60,61,62,63,71],r,{7:7}),{1:[2,1]},{1:[2,2]},{1:[2,3],4:i,5:c,8:8,9:10,12:12,13:h,14:o,17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},e(E,[2,5]),{9:47,12:12,13:h,14:o,17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},e(E,[2,7]),e(E,[2,8]),e(E,[2,14]),{12:48,50:U,52:G,53:F},{15:[1,49]},{5:[1,50]},{5:[1,53],19:[1,51],20:[1,52]},{22:54,71:R},{22:55,71:R},{5:[1,56]},{5:[1,57]},{5:[1,58]},{5:[1,59]},{5:[1,60]},e(E,[2,29]),e(E,[2,30]),{32:[1,61]},{34:[1,62]},e(E,[2,33]),{15:[1,63]},{15:[1,64]},{15:[1,65]},{15:[1,66]},{15:[1,67]},{15:[1,68]},{15:[1,69]},{15:[1,70]},{22:71,54:72,71:[1,73]},{22:74,71:R},{22:75,71:R},{68:76,76:[1,77],77:[1,78],78:[1,79],79:[1,80],80:[1,81],81:[1,82],82:[1,83],83:[1,84],84:[1,85],85:[1,86]},{56:87,58:[1,88],66:[1,89],67:[1,90]},{22:91,71:R},{22:92,71:R},{22:93,71:R},{22:94,71:R},e([5,51,65,76,77,78,79,80,81,82,83,84,85,86],_),e(E,[2,6]),e(E,[2,15]),e(X,[2,9],{10:95}),e(E,[2,17]),{5:[1,97],19:[1,96]},{5:[1,98]},e(E,[2,21]),{5:[1,99]},{5:[1,100]},e(E,[2,24]),e(E,[2,25]),e(E,[2,26]),e(E,[2,27]),e(E,[2,28]),e(E,[2,31]),e(E,[2,32]),e(tt,r,{7:101}),e(tt,r,{7:102}),e(tt,r,{7:103}),e(M,r,{40:104,7:105}),e(Vt,r,{42:106,7:107}),e(Vt,r,{7:107,42:108}),e(Zt,r,{45:109,7:110}),e(tt,r,{7:111}),{5:[1,113],51:[1,112]},{5:[1,114]},e([5,51],_,{72:115,73:[1,116]}),{5:[1,118],51:[1,117]},{5:[1,119]},{22:122,69:[1,120],70:[1,121],71:R},e(ot,[2,72]),e(ot,[2,73]),e(ot,[2,74]),e(ot,[2,75]),e(ot,[2,76]),e(ot,[2,77]),e(ot,[2,78]),e(ot,[2,79]),e(ot,[2,80]),e(ot,[2,81]),{22:123,71:R},{22:125,59:124,71:R},{71:[2,64]},{71:[2,65]},{57:126,86:lt},{57:128,86:lt},{57:129,86:lt},{57:130,86:lt},{4:[1,133],5:[1,135],11:132,12:134,16:[1,131],50:U,52:G,53:F},{5:[1,136]},e(E,[2,19]),e(E,[2,20]),e(E,[2,22]),e(E,[2,23]),{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,137],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,138],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,139],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{16:[1,140]},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[2,46],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,49:[1,141],50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{16:[1,142]},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[2,44],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,48:[1,143],50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{16:[1,144]},{16:[1,145]},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[2,42],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,47:[1,146],50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{4:i,5:c,8:8,9:10,12:12,13:h,14:o,16:[1,147],17:15,18:a,21:p,22:40,23:g,24:19,25:20,26:21,27:22,28:23,29:x,30:y,31:m,33:w,35:k,36:N,37:S,38:O,39:B,41:q,43:H,44:Z,46:at,50:U,52:G,53:F,55:D,60:$,61:K,62:j,63:rt,71:R},{15:[1,148]},e(E,[2,49]),e(E,[2,53]),{5:[2,69]},{74:[1,149]},{15:[1,150]},e(E,[2,51]),e(E,[2,52]),{22:151,71:R},{22:152,71:R},{57:153,86:lt},{57:154,86:lt},{57:155,86:lt},{65:[1,156],86:[2,63]},{5:[2,56]},{5:[2,82]},{5:[2,57]},{5:[2,58]},{5:[2,59]},e(E,[2,16]),e(X,[2,10]),{12:157,50:U,52:G,53:F},e(X,[2,12]),e(X,[2,13]),e(E,[2,18]),e(E,[2,34]),e(E,[2,35]),e(E,[2,36]),e(E,[2,37]),{15:[1,158]},e(E,[2,38]),{15:[1,159]},e(E,[2,39]),e(E,[2,40]),{15:[1,160]},e(E,[2,41]),{5:[1,161]},{75:[1,162]},{5:[1,163]},{57:164,86:lt},{57:165,86:lt},{5:[2,68]},{5:[2,54]},{5:[2,55]},{22:166,71:R},e(X,[2,11]),e(M,r,{7:105,40:167}),e(Vt,r,{7:107,42:168}),e(Zt,r,{7:110,45:169}),e(E,[2,48]),{5:[2,70]},e(E,[2,50]),{5:[2,66]},{5:[2,67]},{86:[2,62]},{16:[2,47]},{16:[2,45]},{16:[2,43]}],defaultActions:{5:[2,1],6:[2,2],89:[2,64],90:[2,65],115:[2,69],126:[2,56],127:[2,82],128:[2,57],129:[2,58],130:[2,59],153:[2,68],154:[2,54],155:[2,55],162:[2,70],164:[2,66],165:[2,67],166:[2,62],167:[2,47],168:[2,45],169:[2,43]},parseError:f(function(v,A){if(A.recoverable)this.trace(v);else{var L=new Error(v);throw L.hash=A,L}},"parseError"),parse:f(function(v){var A=this,L=[0],b=[],C=[null],d=[],It=this.table,u="",kt=0,$t=0,Te=2,jt=1,Ee=d.slice.call(arguments,1),Y=Object.create(this.lexer),ft={yy:{}};for(var Wt in this.yy)Object.prototype.hasOwnProperty.call(this.yy,Wt)&&(ft.yy[Wt]=this.yy[Wt]);Y.setInput(v,ft.yy),ft.yy.lexer=Y,ft.yy.parser=this,typeof Y.yylloc>"u"&&(Y.yylloc={});var Ft=Y.yylloc;d.push(Ft);var be=Y.options&&Y.options.ranges;typeof ft.yy.parseError=="function"?this.parseError=ft.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function me(et){L.length=L.length-2*et,C.length=C.length-et,d.length=d.length-et}f(me,"popStack");function te(){var et;return et=b.pop()||Y.lex()||jt,typeof et!="number"&&(et instanceof Array&&(b=et,et=b.pop()),et=A.symbols_[et]||et),et}f(te,"lex");for(var J,yt,it,qt,bt={},Pt,ht,ee,Nt;;){if(yt=L[L.length-1],this.defaultActions[yt]?it=this.defaultActions[yt]:((J===null||typeof J>"u")&&(J=te()),it=It[yt]&&It[yt][J]),typeof it>"u"||!it.length||!it[0]){var zt="";Nt=[];for(Pt in It[yt])this.terminals_[Pt]&&Pt>Te&&Nt.push("'"+this.terminals_[Pt]+"'");Y.showPosition?zt="Parse error on line "+(kt+1)+`: `+Y.showPosition()+` Expecting `+Nt.join(", ")+", got '"+(this.terminals_[J]||J)+"'":zt="Parse error on line "+(kt+1)+": Unexpected "+(J==jt?"end of input":"'"+(this.terminals_[J]||J)+"'"),this.parseError(zt,{text:Y.match,token:this.terminals_[J]||J,line:Y.yylineno,loc:Ft,expected:Nt})}if(it[0]instanceof Array&&it.length>1)throw new Error("Parse Error: multiple actions possible at state: "+yt+", token: "+J);switch(it[0]){case 1:L.push(J),C.push(Y.yytext),d.push(Y.yylloc),L.push(it[1]),J=null,$t=Y.yyleng,u=Y.yytext,kt=Y.yylineno,Ft=Y.yylloc;break;case 2:if(ht=this.productions_[it[1]][1],bt.$=C[C.length-ht],bt._$={first_line:d[d.length-(ht||1)].first_line,last_line:d[d.length-1].last_line,first_column:d[d.length-(ht||1)].first_column,last_column:d[d.length-1].last_column},be&&(bt._$.range=[d[d.length-(ht||1)].range[0],d[d.length-1].range[1]]),qt=this.performAction.apply(bt,[u,$t,kt,ft.yy,it[1],C,d].concat(Ee)),typeof qt<"u")return qt;ht&&(L=L.slice(0,-1*ht*2),C=C.slice(0,-1*ht),d=d.slice(0,-1*ht)),L.push(this.productions_[it[1]][0]),C.push(bt.$),d.push(bt._$),ee=It[L[L.length-2]][L[L.length-1]],L.push(ee);break;case 3:return!0}}return!0},"parse")},ye=(function(){var pt={EOF:1,parseError:f(function(A,L){if(this.yy.parser)this.yy.parser.parseError(A,L);else throw new Error(A)},"parseError"),setInput:f(function(v,A){return this.yy=A||this.yy||{},this._input=v,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:f(function(){var v=this._input[0];this.yytext+=v,this.yyleng++,this.offset++,this.match+=v,this.matched+=v;var A=v.match(/(?:\r\n?|\n).*/g);return A?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),v},"input"),unput:f(function(v){var A=v.length,L=v.split(/(?:\r\n?|\n)/g);this._input=v+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-A),this.offset-=A;var b=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),L.length-1&&(this.yylineno-=L.length-1);var C=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:L?(L.length===b.length?this.yylloc.first_column:0)+b[b.length-L.length].length-L[0].length:this.yylloc.first_column-A},this.options.ranges&&(this.yylloc.range=[C[0],C[0]+this.yyleng-A]),this.yyleng=this.yytext.length,this},"unput"),more:f(function(){return this._more=!0,this},"more"),reject:f(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:f(function(v){this.unput(this.match.slice(v))},"less"),pastInput:f(function(){var v=this.matched.substr(0,this.matched.length-this.match.length);return(v.length>20?"...":"")+v.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:f(function(){var v=this.match;return v.length<20&&(v+=this._input.substr(0,20-v.length)),(v.substr(0,20)+(v.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:f(function(){var v=this.pastInput(),A=new Array(v.length+1).join("-");return v+this.upcomingInput()+` diff --git a/demo/aiui/assets/song-renderer-C7kO7K9V.js b/demo/aiui/assets/song-renderer-C7kO7K9V.js new file mode 100644 index 00000000..ca7db7a2 --- /dev/null +++ b/demo/aiui/assets/song-renderer-C7kO7K9V.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/SongGrid-C568K-c_.js","assets/SongGrid.vue_vue_type_script_setup_true_lang-IvAOIQYW.js","assets/index-8cIrvc8q.js","assets/index-BJkaQ2c4.css","assets/useContentImages-7wLVntsF.js","assets/SongDetail-BiVK4Yzb.js","assets/SongDetail.vue_vue_type_script_setup_true_lang-B3om3Z8v.js"])))=>i.map(i=>d[i]); +import{d as e,_ as r}from"./index-8cIrvc8q.js";const o={id:"song",name:"Song Renderer",contentType:"song",surfaces:["chat-preview","panel-preview","panel-play"],chatPreview:e(()=>r(()=>import("./SongGrid-C568K-c_.js"),__vite__mapDeps([0,1,2,3,4]))),panelPreview:e(()=>r(()=>import("./SongGrid-C568K-c_.js"),__vite__mapDeps([0,1,2,3,4]))),panelPlay:e(()=>r(()=>import("./SongDetail-BiVK4Yzb.js"),__vite__mapDeps([5,6,2,3])))};export{o as songRenderer}; diff --git a/demo/aiui/assets/song-renderer-DRNqTHD0.js b/demo/aiui/assets/song-renderer-DRNqTHD0.js deleted file mode 100644 index 71796138..00000000 --- a/demo/aiui/assets/song-renderer-DRNqTHD0.js +++ /dev/null @@ -1,2 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/SongGrid-BgCYmw2O.js","assets/SongGrid.vue_vue_type_script_setup_true_lang-CW1T9zpX.js","assets/index-Lh5NfTCq.js","assets/index-CHQ7uqBj.css","assets/useContentImages-CagIZs4M.js","assets/SongDetail-DCzBBkzH.js","assets/SongDetail.vue_vue_type_script_setup_true_lang-CvC0ROCb.js"])))=>i.map(i=>d[i]); -import{d as e,_ as r}from"./index-Lh5NfTCq.js";const o={id:"song",name:"Song Renderer",contentType:"song",surfaces:["chat-preview","panel-preview","panel-play"],chatPreview:e(()=>r(()=>import("./SongGrid-BgCYmw2O.js"),__vite__mapDeps([0,1,2,3,4]))),panelPreview:e(()=>r(()=>import("./SongGrid-BgCYmw2O.js"),__vite__mapDeps([0,1,2,3,4]))),panelPlay:e(()=>r(()=>import("./SongDetail-DCzBBkzH.js"),__vite__mapDeps([5,6,2,3])))};export{o as songRenderer}; diff --git a/demo/aiui/assets/stateDiagram-FKZM4ZOC-VzKqVF4W.js b/demo/aiui/assets/stateDiagram-FKZM4ZOC-pVxTgt6V.js similarity index 96% rename from demo/aiui/assets/stateDiagram-FKZM4ZOC-VzKqVF4W.js rename to demo/aiui/assets/stateDiagram-FKZM4ZOC-pVxTgt6V.js index b484f586..c9bf0d51 100644 --- a/demo/aiui/assets/stateDiagram-FKZM4ZOC-VzKqVF4W.js +++ b/demo/aiui/assets/stateDiagram-FKZM4ZOC-pVxTgt6V.js @@ -1 +1 @@ -import{s as R,a as W,S as N}from"./chunk-DI55MBZ5-CqWBFVaC.js";import{_ as f,c as t,d as H,l as S,e as P,k as z,a9 as _,aa as U,a6 as C,u as F}from"./mermaid.core-DaNhpuX9.js";import{G as O}from"./graph-mkJTNBrq.js";import{l as J}from"./layout-T-4jL0RA.js";import"./chunk-55IACEB6-CWcaiZ1g.js";import"./chunk-QN33PNHL-C8Gh8Kbh.js";import"./index-Lh5NfTCq.js";import"./_baseUniq-C5dU7AKy.js";import"./_basePickBy-BlfxZvco.js";var X=f(e=>e.append("circle").attr("class","start-state").attr("r",t().state.sizeUnit).attr("cx",t().state.padding+t().state.sizeUnit).attr("cy",t().state.padding+t().state.sizeUnit),"drawStartState"),D=f(e=>e.append("line").style("stroke","grey").style("stroke-dasharray","3").attr("x1",t().state.textHeight).attr("class","divider").attr("x2",t().state.textHeight*2).attr("y1",0).attr("y2",0),"drawDivider"),Y=f((e,i)=>{const d=e.append("text").attr("x",2*t().state.padding).attr("y",t().state.textHeight+2*t().state.padding).attr("font-size",t().state.fontSize).attr("class","state-title").text(i.id),c=d.node().getBBox();return e.insert("rect",":first-child").attr("x",t().state.padding).attr("y",t().state.padding).attr("width",c.width+2*t().state.padding).attr("height",c.height+2*t().state.padding).attr("rx",t().state.radius),d},"drawSimpleState"),I=f((e,i)=>{const d=f(function(g,B,m){const E=g.append("tspan").attr("x",2*t().state.padding).text(B);m||E.attr("dy",t().state.textHeight)},"addTspan"),n=e.append("text").attr("x",2*t().state.padding).attr("y",t().state.textHeight+1.3*t().state.padding).attr("font-size",t().state.fontSize).attr("class","state-title").text(i.descriptions[0]).node().getBBox(),l=n.height,x=e.append("text").attr("x",t().state.padding).attr("y",l+t().state.padding*.4+t().state.dividerMargin+t().state.textHeight).attr("class","state-description");let a=!0,s=!0;i.descriptions.forEach(function(g){a||(d(x,g,s),s=!1),a=!1});const w=e.append("line").attr("x1",t().state.padding).attr("y1",t().state.padding+l+t().state.dividerMargin/2).attr("y2",t().state.padding+l+t().state.dividerMargin/2).attr("class","descr-divider"),p=x.node().getBBox(),o=Math.max(p.width,n.width);return w.attr("x2",o+3*t().state.padding),e.insert("rect",":first-child").attr("x",t().state.padding).attr("y",t().state.padding).attr("width",o+2*t().state.padding).attr("height",p.height+l+2*t().state.padding).attr("rx",t().state.radius),e},"drawDescrState"),$=f((e,i,d)=>{const c=t().state.padding,n=2*t().state.padding,l=e.node().getBBox(),x=l.width,a=l.x,s=e.append("text").attr("x",0).attr("y",t().state.titleShift).attr("font-size",t().state.fontSize).attr("class","state-title").text(i.id),p=s.node().getBBox().width+n;let o=Math.max(p,x);o===x&&(o=o+n);let g;const B=e.node().getBBox();i.doc,g=a-c,p>x&&(g=(x-o)/2+c),Math.abs(a-B.x)x&&(g=a-(p-x)/2);const m=1-t().state.textHeight;return e.insert("rect",":first-child").attr("x",g).attr("y",m).attr("class",d?"alt-composit":"composit").attr("width",o).attr("height",B.height+t().state.textHeight+t().state.titleShift+1).attr("rx","0"),s.attr("x",g+c),p<=x&&s.attr("x",a+(o-n)/2-p/2+c),e.insert("rect",":first-child").attr("x",g).attr("y",t().state.titleShift-t().state.textHeight-t().state.padding).attr("width",o).attr("height",t().state.textHeight*3).attr("rx",t().state.radius),e.insert("rect",":first-child").attr("x",g).attr("y",t().state.titleShift-t().state.textHeight-t().state.padding).attr("width",o).attr("height",B.height+3+2*t().state.textHeight).attr("rx",t().state.radius),e},"addTitleAndBox"),q=f(e=>(e.append("circle").attr("class","end-state-outer").attr("r",t().state.sizeUnit+t().state.miniPadding).attr("cx",t().state.padding+t().state.sizeUnit+t().state.miniPadding).attr("cy",t().state.padding+t().state.sizeUnit+t().state.miniPadding),e.append("circle").attr("class","end-state-inner").attr("r",t().state.sizeUnit).attr("cx",t().state.padding+t().state.sizeUnit+2).attr("cy",t().state.padding+t().state.sizeUnit+2)),"drawEndState"),Z=f((e,i)=>{let d=t().state.forkWidth,c=t().state.forkHeight;if(i.parentId){let n=d;d=c,c=n}return e.append("rect").style("stroke","black").style("fill","black").attr("width",d).attr("height",c).attr("x",t().state.padding).attr("y",t().state.padding)},"drawForkJoinState"),j=f((e,i,d,c)=>{let n=0;const l=c.append("text");l.style("text-anchor","start"),l.attr("class","noteText");let x=e.replace(/\r\n/g,"
");x=x.replace(/\n/g,"
");const a=x.split(z.lineBreakRegex);let s=1.25*t().state.noteMargin;for(const w of a){const p=w.trim();if(p.length>0){const o=l.append("tspan");if(o.text(p),s===0){const g=o.node().getBBox();s+=g.height}n+=s,o.attr("x",i+t().state.noteMargin),o.attr("y",d+n+1.25*t().state.noteMargin)}}return{textWidth:l.node().getBBox().width,textHeight:n}},"_drawLongText"),K=f((e,i)=>{i.attr("class","state-note");const d=i.append("rect").attr("x",0).attr("y",t().state.padding),c=i.append("g"),{textWidth:n,textHeight:l}=j(e,0,0,c);return d.attr("height",l+2*t().state.noteMargin),d.attr("width",n+t().state.noteMargin*2),d},"drawNote"),L=f(function(e,i){const d=i.id,c={id:d,label:i.id,width:0,height:0},n=e.append("g").attr("id",d).attr("class","stateGroup");i.type==="start"&&X(n),i.type==="end"&&q(n),(i.type==="fork"||i.type==="join")&&Z(n,i),i.type==="note"&&K(i.note.text,n),i.type==="divider"&&D(n),i.type==="default"&&i.descriptions.length===0&&Y(n,i),i.type==="default"&&i.descriptions.length>0&&I(n,i);const l=n.node().getBBox();return c.width=l.width+2*t().state.padding,c.height=l.height+2*t().state.padding,c},"drawState"),A=0,Q=f(function(e,i,d){const c=f(function(s){switch(s){case N.relationType.AGGREGATION:return"aggregation";case N.relationType.EXTENSION:return"extension";case N.relationType.COMPOSITION:return"composition";case N.relationType.DEPENDENCY:return"dependency"}},"getRelationType");i.points=i.points.filter(s=>!Number.isNaN(s.y));const n=i.points,l=_().x(function(s){return s.x}).y(function(s){return s.y}).curve(U),x=e.append("path").attr("d",l(n)).attr("id","edge"+A).attr("class","transition");let a="";if(t().state.arrowMarkerAbsolute&&(a=C(!0)),x.attr("marker-end","url("+a+"#"+c(N.relationType.DEPENDENCY)+"End)"),d.title!==void 0){const s=e.append("g").attr("class","stateLabel"),{x:w,y:p}=F.calcLabelPosition(i.points),o=z.getRows(d.title);let g=0;const B=[];let m=0,E=0;for(let u=0;u<=o.length;u++){const h=s.append("text").attr("text-anchor","middle").text(o[u]).attr("x",w).attr("y",p+g),y=h.node().getBBox();m=Math.max(m,y.width),E=Math.min(E,y.x),S.info(y.x,w,p+g),g===0&&(g=h.node().getBBox().height,S.info("Title height",g,p)),B.push(h)}let k=g*o.length;if(o.length>1){const u=(o.length-1)*g*.5;B.forEach((h,y)=>h.attr("y",p+y*g-u)),k=g*o.length}const r=s.node().getBBox();s.insert("rect",":first-child").attr("class","box").attr("x",w-m/2-t().state.padding/2).attr("y",p-k/2-t().state.padding/2-3.5).attr("width",m+t().state.padding).attr("height",k+t().state.padding),S.info(r)}A++},"drawEdge"),b,T={},V=f(function(){},"setConf"),tt=f(function(e){e.append("defs").append("marker").attr("id","dependencyEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"insertMarkers"),et=f(function(e,i,d,c){b=t().state;const n=t().securityLevel;let l;n==="sandbox"&&(l=H("#i"+i));const x=n==="sandbox"?H(l.nodes()[0].contentDocument.body):H("body"),a=n==="sandbox"?l.nodes()[0].contentDocument:document;S.debug("Rendering diagram "+e);const s=x.select(`[id='${i}']`);tt(s);const w=c.db.getRootDoc();G(w,s,void 0,!1,x,a,c);const p=b.padding,o=s.node().getBBox(),g=o.width+p*2,B=o.height+p*2,m=g*1.75;P(s,B,m,b.useMaxWidth),s.attr("viewBox",`${o.x-b.padding} ${o.y-b.padding} `+g+" "+B)},"draw"),at=f(e=>e?e.length*b.fontSizeFactor:1,"getLabelWidth"),G=f((e,i,d,c,n,l,x)=>{const a=new O({compound:!0,multigraph:!0});let s,w=!0;for(s=0;s{const y=h.parentElement;let v=0,M=0;y&&(y.parentElement&&(v=y.parentElement.getBBox().width),M=parseInt(y.getAttribute("data-x-shift"),10),Number.isNaN(M)&&(M=0)),h.setAttribute("x1",0-M+8),h.setAttribute("x2",v-M-8)})):S.debug("No Node "+r+": "+JSON.stringify(a.node(r)))});let E=m.getBBox();a.edges().forEach(function(r){r!==void 0&&a.edge(r)!==void 0&&(S.debug("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(a.edge(r))),Q(i,a.edge(r),a.edge(r).relation))}),E=m.getBBox();const k={id:d||"root",label:d||"root",width:0,height:0};return k.width=E.width+2*b.padding,k.height=E.height+2*b.padding,S.debug("Doc rendered",k,a),k},"renderDoc"),it={setConf:V,draw:et},pt={parser:W,get db(){return new N(1)},renderer:it,styles:R,init:f(e=>{e.state||(e.state={}),e.state.arrowMarkerAbsolute=e.arrowMarkerAbsolute},"init")};export{pt as diagram}; +import{s as R,a as W,S as N}from"./chunk-DI55MBZ5-BzH7fNN2.js";import{_ as f,c as t,d as H,l as S,e as P,k as z,a9 as _,aa as U,a6 as C,u as F}from"./mermaid.core-v0oo9NRr.js";import{G as O}from"./graph-C1er8lVu.js";import{l as J}from"./layout-D8RlHuWE.js";import"./chunk-55IACEB6-CtULfmDo.js";import"./chunk-QN33PNHL-DSThOC6-.js";import"./index-8cIrvc8q.js";import"./_baseUniq-DAOs4kUj.js";import"./_basePickBy-CL4iQUG-.js";var X=f(e=>e.append("circle").attr("class","start-state").attr("r",t().state.sizeUnit).attr("cx",t().state.padding+t().state.sizeUnit).attr("cy",t().state.padding+t().state.sizeUnit),"drawStartState"),D=f(e=>e.append("line").style("stroke","grey").style("stroke-dasharray","3").attr("x1",t().state.textHeight).attr("class","divider").attr("x2",t().state.textHeight*2).attr("y1",0).attr("y2",0),"drawDivider"),Y=f((e,i)=>{const d=e.append("text").attr("x",2*t().state.padding).attr("y",t().state.textHeight+2*t().state.padding).attr("font-size",t().state.fontSize).attr("class","state-title").text(i.id),c=d.node().getBBox();return e.insert("rect",":first-child").attr("x",t().state.padding).attr("y",t().state.padding).attr("width",c.width+2*t().state.padding).attr("height",c.height+2*t().state.padding).attr("rx",t().state.radius),d},"drawSimpleState"),I=f((e,i)=>{const d=f(function(g,B,m){const E=g.append("tspan").attr("x",2*t().state.padding).text(B);m||E.attr("dy",t().state.textHeight)},"addTspan"),n=e.append("text").attr("x",2*t().state.padding).attr("y",t().state.textHeight+1.3*t().state.padding).attr("font-size",t().state.fontSize).attr("class","state-title").text(i.descriptions[0]).node().getBBox(),l=n.height,x=e.append("text").attr("x",t().state.padding).attr("y",l+t().state.padding*.4+t().state.dividerMargin+t().state.textHeight).attr("class","state-description");let a=!0,s=!0;i.descriptions.forEach(function(g){a||(d(x,g,s),s=!1),a=!1});const w=e.append("line").attr("x1",t().state.padding).attr("y1",t().state.padding+l+t().state.dividerMargin/2).attr("y2",t().state.padding+l+t().state.dividerMargin/2).attr("class","descr-divider"),p=x.node().getBBox(),o=Math.max(p.width,n.width);return w.attr("x2",o+3*t().state.padding),e.insert("rect",":first-child").attr("x",t().state.padding).attr("y",t().state.padding).attr("width",o+2*t().state.padding).attr("height",p.height+l+2*t().state.padding).attr("rx",t().state.radius),e},"drawDescrState"),$=f((e,i,d)=>{const c=t().state.padding,n=2*t().state.padding,l=e.node().getBBox(),x=l.width,a=l.x,s=e.append("text").attr("x",0).attr("y",t().state.titleShift).attr("font-size",t().state.fontSize).attr("class","state-title").text(i.id),p=s.node().getBBox().width+n;let o=Math.max(p,x);o===x&&(o=o+n);let g;const B=e.node().getBBox();i.doc,g=a-c,p>x&&(g=(x-o)/2+c),Math.abs(a-B.x)x&&(g=a-(p-x)/2);const m=1-t().state.textHeight;return e.insert("rect",":first-child").attr("x",g).attr("y",m).attr("class",d?"alt-composit":"composit").attr("width",o).attr("height",B.height+t().state.textHeight+t().state.titleShift+1).attr("rx","0"),s.attr("x",g+c),p<=x&&s.attr("x",a+(o-n)/2-p/2+c),e.insert("rect",":first-child").attr("x",g).attr("y",t().state.titleShift-t().state.textHeight-t().state.padding).attr("width",o).attr("height",t().state.textHeight*3).attr("rx",t().state.radius),e.insert("rect",":first-child").attr("x",g).attr("y",t().state.titleShift-t().state.textHeight-t().state.padding).attr("width",o).attr("height",B.height+3+2*t().state.textHeight).attr("rx",t().state.radius),e},"addTitleAndBox"),q=f(e=>(e.append("circle").attr("class","end-state-outer").attr("r",t().state.sizeUnit+t().state.miniPadding).attr("cx",t().state.padding+t().state.sizeUnit+t().state.miniPadding).attr("cy",t().state.padding+t().state.sizeUnit+t().state.miniPadding),e.append("circle").attr("class","end-state-inner").attr("r",t().state.sizeUnit).attr("cx",t().state.padding+t().state.sizeUnit+2).attr("cy",t().state.padding+t().state.sizeUnit+2)),"drawEndState"),Z=f((e,i)=>{let d=t().state.forkWidth,c=t().state.forkHeight;if(i.parentId){let n=d;d=c,c=n}return e.append("rect").style("stroke","black").style("fill","black").attr("width",d).attr("height",c).attr("x",t().state.padding).attr("y",t().state.padding)},"drawForkJoinState"),j=f((e,i,d,c)=>{let n=0;const l=c.append("text");l.style("text-anchor","start"),l.attr("class","noteText");let x=e.replace(/\r\n/g,"
");x=x.replace(/\n/g,"
");const a=x.split(z.lineBreakRegex);let s=1.25*t().state.noteMargin;for(const w of a){const p=w.trim();if(p.length>0){const o=l.append("tspan");if(o.text(p),s===0){const g=o.node().getBBox();s+=g.height}n+=s,o.attr("x",i+t().state.noteMargin),o.attr("y",d+n+1.25*t().state.noteMargin)}}return{textWidth:l.node().getBBox().width,textHeight:n}},"_drawLongText"),K=f((e,i)=>{i.attr("class","state-note");const d=i.append("rect").attr("x",0).attr("y",t().state.padding),c=i.append("g"),{textWidth:n,textHeight:l}=j(e,0,0,c);return d.attr("height",l+2*t().state.noteMargin),d.attr("width",n+t().state.noteMargin*2),d},"drawNote"),L=f(function(e,i){const d=i.id,c={id:d,label:i.id,width:0,height:0},n=e.append("g").attr("id",d).attr("class","stateGroup");i.type==="start"&&X(n),i.type==="end"&&q(n),(i.type==="fork"||i.type==="join")&&Z(n,i),i.type==="note"&&K(i.note.text,n),i.type==="divider"&&D(n),i.type==="default"&&i.descriptions.length===0&&Y(n,i),i.type==="default"&&i.descriptions.length>0&&I(n,i);const l=n.node().getBBox();return c.width=l.width+2*t().state.padding,c.height=l.height+2*t().state.padding,c},"drawState"),A=0,Q=f(function(e,i,d){const c=f(function(s){switch(s){case N.relationType.AGGREGATION:return"aggregation";case N.relationType.EXTENSION:return"extension";case N.relationType.COMPOSITION:return"composition";case N.relationType.DEPENDENCY:return"dependency"}},"getRelationType");i.points=i.points.filter(s=>!Number.isNaN(s.y));const n=i.points,l=_().x(function(s){return s.x}).y(function(s){return s.y}).curve(U),x=e.append("path").attr("d",l(n)).attr("id","edge"+A).attr("class","transition");let a="";if(t().state.arrowMarkerAbsolute&&(a=C(!0)),x.attr("marker-end","url("+a+"#"+c(N.relationType.DEPENDENCY)+"End)"),d.title!==void 0){const s=e.append("g").attr("class","stateLabel"),{x:w,y:p}=F.calcLabelPosition(i.points),o=z.getRows(d.title);let g=0;const B=[];let m=0,E=0;for(let u=0;u<=o.length;u++){const h=s.append("text").attr("text-anchor","middle").text(o[u]).attr("x",w).attr("y",p+g),y=h.node().getBBox();m=Math.max(m,y.width),E=Math.min(E,y.x),S.info(y.x,w,p+g),g===0&&(g=h.node().getBBox().height,S.info("Title height",g,p)),B.push(h)}let k=g*o.length;if(o.length>1){const u=(o.length-1)*g*.5;B.forEach((h,y)=>h.attr("y",p+y*g-u)),k=g*o.length}const r=s.node().getBBox();s.insert("rect",":first-child").attr("class","box").attr("x",w-m/2-t().state.padding/2).attr("y",p-k/2-t().state.padding/2-3.5).attr("width",m+t().state.padding).attr("height",k+t().state.padding),S.info(r)}A++},"drawEdge"),b,T={},V=f(function(){},"setConf"),tt=f(function(e){e.append("defs").append("marker").attr("id","dependencyEnd").attr("refX",19).attr("refY",7).attr("markerWidth",20).attr("markerHeight",28).attr("orient","auto").append("path").attr("d","M 19,7 L9,13 L14,7 L9,1 Z")},"insertMarkers"),et=f(function(e,i,d,c){b=t().state;const n=t().securityLevel;let l;n==="sandbox"&&(l=H("#i"+i));const x=n==="sandbox"?H(l.nodes()[0].contentDocument.body):H("body"),a=n==="sandbox"?l.nodes()[0].contentDocument:document;S.debug("Rendering diagram "+e);const s=x.select(`[id='${i}']`);tt(s);const w=c.db.getRootDoc();G(w,s,void 0,!1,x,a,c);const p=b.padding,o=s.node().getBBox(),g=o.width+p*2,B=o.height+p*2,m=g*1.75;P(s,B,m,b.useMaxWidth),s.attr("viewBox",`${o.x-b.padding} ${o.y-b.padding} `+g+" "+B)},"draw"),at=f(e=>e?e.length*b.fontSizeFactor:1,"getLabelWidth"),G=f((e,i,d,c,n,l,x)=>{const a=new O({compound:!0,multigraph:!0});let s,w=!0;for(s=0;s{const y=h.parentElement;let v=0,M=0;y&&(y.parentElement&&(v=y.parentElement.getBBox().width),M=parseInt(y.getAttribute("data-x-shift"),10),Number.isNaN(M)&&(M=0)),h.setAttribute("x1",0-M+8),h.setAttribute("x2",v-M-8)})):S.debug("No Node "+r+": "+JSON.stringify(a.node(r)))});let E=m.getBBox();a.edges().forEach(function(r){r!==void 0&&a.edge(r)!==void 0&&(S.debug("Edge "+r.v+" -> "+r.w+": "+JSON.stringify(a.edge(r))),Q(i,a.edge(r),a.edge(r).relation))}),E=m.getBBox();const k={id:d||"root",label:d||"root",width:0,height:0};return k.width=E.width+2*b.padding,k.height=E.height+2*b.padding,S.debug("Doc rendered",k,a),k},"renderDoc"),it={setConf:V,draw:et},pt={parser:W,get db(){return new N(1)},renderer:it,styles:R,init:f(e=>{e.state||(e.state={}),e.state.arrowMarkerAbsolute=e.arrowMarkerAbsolute},"init")};export{pt as diagram}; diff --git a/demo/aiui/assets/stateDiagram-v2-4FDKWEC3-DCr2kVu5.js b/demo/aiui/assets/stateDiagram-v2-4FDKWEC3-DCr2kVu5.js deleted file mode 100644 index 41ade66d..00000000 --- a/demo/aiui/assets/stateDiagram-v2-4FDKWEC3-DCr2kVu5.js +++ /dev/null @@ -1 +0,0 @@ -import{s as e,b as r,a,S as s}from"./chunk-DI55MBZ5-CqWBFVaC.js";import{_ as i}from"./mermaid.core-DaNhpuX9.js";import"./chunk-55IACEB6-CWcaiZ1g.js";import"./chunk-QN33PNHL-C8Gh8Kbh.js";import"./index-Lh5NfTCq.js";var p={parser:a,get db(){return new s(2)},renderer:r,styles:e,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{p as diagram}; diff --git a/demo/aiui/assets/stateDiagram-v2-4FDKWEC3-LzueEDky.js b/demo/aiui/assets/stateDiagram-v2-4FDKWEC3-LzueEDky.js new file mode 100644 index 00000000..3099032c --- /dev/null +++ b/demo/aiui/assets/stateDiagram-v2-4FDKWEC3-LzueEDky.js @@ -0,0 +1 @@ +import{s as e,b as r,a,S as s}from"./chunk-DI55MBZ5-BzH7fNN2.js";import{_ as i}from"./mermaid.core-v0oo9NRr.js";import"./chunk-55IACEB6-CtULfmDo.js";import"./chunk-QN33PNHL-DSThOC6-.js";import"./index-8cIrvc8q.js";var p={parser:a,get db(){return new s(2)},renderer:r,styles:e,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{p as diagram}; diff --git a/demo/aiui/assets/timeline-definition-IT6M3QCI-D5HR-Z95.js b/demo/aiui/assets/timeline-definition-IT6M3QCI-DY7_afEv.js similarity index 99% rename from demo/aiui/assets/timeline-definition-IT6M3QCI-D5HR-Z95.js rename to demo/aiui/assets/timeline-definition-IT6M3QCI-DY7_afEv.js index 8f86502c..a4d83116 100644 --- a/demo/aiui/assets/timeline-definition-IT6M3QCI-D5HR-Z95.js +++ b/demo/aiui/assets/timeline-definition-IT6M3QCI-DY7_afEv.js @@ -1,4 +1,4 @@ -import{_ as s,c as xt,l as E,d as j,ac as kt,ad as vt,ae as _t,af as bt,B as wt,ag as St,y as Et}from"./mermaid.core-DaNhpuX9.js";import{d as nt}from"./arc-M-sFvFvX.js";import"./index-Lh5NfTCq.js";var Q=(function(){var n=s(function(x,r,a,c){for(a=a||{},c=x.length;c--;a[x[c]]=r);return a},"o"),t=[6,8,10,11,12,14,16,17,20,21],e=[1,9],l=[1,10],i=[1,11],d=[1,12],h=[1,13],f=[1,16],m=[1,17],p={trace:s(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,period_statement:18,event_statement:19,period:20,event:21,$accept:0,$end:1},terminals_:{2:"error",4:"timeline",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",20:"period",21:"event"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[18,1],[19,1]],performAction:s(function(r,a,c,u,y,o,w){var v=o.length-1;switch(y){case 1:return o[v-1];case 2:this.$=[];break;case 3:o[v-1].push(o[v]),this.$=o[v-1];break;case 4:case 5:this.$=o[v];break;case 6:case 7:this.$=[];break;case 8:u.getCommonDb().setDiagramTitle(o[v].substr(6)),this.$=o[v].substr(6);break;case 9:this.$=o[v].trim(),u.getCommonDb().setAccTitle(this.$);break;case 10:case 11:this.$=o[v].trim(),u.getCommonDb().setAccDescription(this.$);break;case 12:u.addSection(o[v].substr(8)),this.$=o[v].substr(8);break;case 15:u.addTask(o[v],0,""),this.$=o[v];break;case 16:u.addEvent(o[v].substr(2)),this.$=o[v];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},n(t,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:e,12:l,14:i,16:d,17:h,18:14,19:15,20:f,21:m},n(t,[2,7],{1:[2,1]}),n(t,[2,3]),{9:18,11:e,12:l,14:i,16:d,17:h,18:14,19:15,20:f,21:m},n(t,[2,5]),n(t,[2,6]),n(t,[2,8]),{13:[1,19]},{15:[1,20]},n(t,[2,11]),n(t,[2,12]),n(t,[2,13]),n(t,[2,14]),n(t,[2,15]),n(t,[2,16]),n(t,[2,4]),n(t,[2,9]),n(t,[2,10])],defaultActions:{},parseError:s(function(r,a){if(a.recoverable)this.trace(r);else{var c=new Error(r);throw c.hash=a,c}},"parseError"),parse:s(function(r){var a=this,c=[0],u=[],y=[null],o=[],w=this.table,v="",N=0,P=0,V=2,U=1,H=o.slice.call(arguments,1),g=Object.create(this.lexer),b={yy:{}};for(var L in this.yy)Object.prototype.hasOwnProperty.call(this.yy,L)&&(b.yy[L]=this.yy[L]);g.setInput(r,b.yy),b.yy.lexer=g,b.yy.parser=this,typeof g.yylloc>"u"&&(g.yylloc={});var M=g.yylloc;o.push(M);var W=g.options&&g.options.ranges;typeof b.yy.parseError=="function"?this.parseError=b.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Z(T){c.length=c.length-2*T,y.length=y.length-T,o.length=o.length-T}s(Z,"popStack");function tt(){var T;return T=u.pop()||g.lex()||U,typeof T!="number"&&(T instanceof Array&&(u=T,T=u.pop()),T=a.symbols_[T]||T),T}s(tt,"lex");for(var S,A,I,J,R={},B,$,et,O;;){if(A=c[c.length-1],this.defaultActions[A]?I=this.defaultActions[A]:((S===null||typeof S>"u")&&(S=tt()),I=w[A]&&w[A][S]),typeof I>"u"||!I.length||!I[0]){var K="";O=[];for(B in w[A])this.terminals_[B]&&B>V&&O.push("'"+this.terminals_[B]+"'");g.showPosition?K="Parse error on line "+(N+1)+`: +import{_ as s,c as xt,l as E,d as j,ac as kt,ad as vt,ae as _t,af as bt,B as wt,ag as St,y as Et}from"./mermaid.core-v0oo9NRr.js";import{d as nt}from"./arc-UjuE1bPP.js";import"./index-8cIrvc8q.js";var Q=(function(){var n=s(function(x,r,a,c){for(a=a||{},c=x.length;c--;a[x[c]]=r);return a},"o"),t=[6,8,10,11,12,14,16,17,20,21],e=[1,9],l=[1,10],i=[1,11],d=[1,12],h=[1,13],f=[1,16],m=[1,17],p={trace:s(function(){},"trace"),yy:{},symbols_:{error:2,start:3,timeline:4,document:5,EOF:6,line:7,SPACE:8,statement:9,NEWLINE:10,title:11,acc_title:12,acc_title_value:13,acc_descr:14,acc_descr_value:15,acc_descr_multiline_value:16,section:17,period_statement:18,event_statement:19,period:20,event:21,$accept:0,$end:1},terminals_:{2:"error",4:"timeline",6:"EOF",8:"SPACE",10:"NEWLINE",11:"title",12:"acc_title",13:"acc_title_value",14:"acc_descr",15:"acc_descr_value",16:"acc_descr_multiline_value",17:"section",20:"period",21:"event"},productions_:[0,[3,3],[5,0],[5,2],[7,2],[7,1],[7,1],[7,1],[9,1],[9,2],[9,2],[9,1],[9,1],[9,1],[9,1],[18,1],[19,1]],performAction:s(function(r,a,c,u,y,o,w){var v=o.length-1;switch(y){case 1:return o[v-1];case 2:this.$=[];break;case 3:o[v-1].push(o[v]),this.$=o[v-1];break;case 4:case 5:this.$=o[v];break;case 6:case 7:this.$=[];break;case 8:u.getCommonDb().setDiagramTitle(o[v].substr(6)),this.$=o[v].substr(6);break;case 9:this.$=o[v].trim(),u.getCommonDb().setAccTitle(this.$);break;case 10:case 11:this.$=o[v].trim(),u.getCommonDb().setAccDescription(this.$);break;case 12:u.addSection(o[v].substr(8)),this.$=o[v].substr(8);break;case 15:u.addTask(o[v],0,""),this.$=o[v];break;case 16:u.addEvent(o[v].substr(2)),this.$=o[v];break}},"anonymous"),table:[{3:1,4:[1,2]},{1:[3]},n(t,[2,2],{5:3}),{6:[1,4],7:5,8:[1,6],9:7,10:[1,8],11:e,12:l,14:i,16:d,17:h,18:14,19:15,20:f,21:m},n(t,[2,7],{1:[2,1]}),n(t,[2,3]),{9:18,11:e,12:l,14:i,16:d,17:h,18:14,19:15,20:f,21:m},n(t,[2,5]),n(t,[2,6]),n(t,[2,8]),{13:[1,19]},{15:[1,20]},n(t,[2,11]),n(t,[2,12]),n(t,[2,13]),n(t,[2,14]),n(t,[2,15]),n(t,[2,16]),n(t,[2,4]),n(t,[2,9]),n(t,[2,10])],defaultActions:{},parseError:s(function(r,a){if(a.recoverable)this.trace(r);else{var c=new Error(r);throw c.hash=a,c}},"parseError"),parse:s(function(r){var a=this,c=[0],u=[],y=[null],o=[],w=this.table,v="",N=0,P=0,V=2,U=1,H=o.slice.call(arguments,1),g=Object.create(this.lexer),b={yy:{}};for(var L in this.yy)Object.prototype.hasOwnProperty.call(this.yy,L)&&(b.yy[L]=this.yy[L]);g.setInput(r,b.yy),b.yy.lexer=g,b.yy.parser=this,typeof g.yylloc>"u"&&(g.yylloc={});var M=g.yylloc;o.push(M);var W=g.options&&g.options.ranges;typeof b.yy.parseError=="function"?this.parseError=b.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function Z(T){c.length=c.length-2*T,y.length=y.length-T,o.length=o.length-T}s(Z,"popStack");function tt(){var T;return T=u.pop()||g.lex()||U,typeof T!="number"&&(T instanceof Array&&(u=T,T=u.pop()),T=a.symbols_[T]||T),T}s(tt,"lex");for(var S,A,I,J,R={},B,$,et,O;;){if(A=c[c.length-1],this.defaultActions[A]?I=this.defaultActions[A]:((S===null||typeof S>"u")&&(S=tt()),I=w[A]&&w[A][S]),typeof I>"u"||!I.length||!I[0]){var K="";O=[];for(B in w[A])this.terminals_[B]&&B>V&&O.push("'"+this.terminals_[B]+"'");g.showPosition?K="Parse error on line "+(N+1)+`: `+g.showPosition()+` Expecting `+O.join(", ")+", got '"+(this.terminals_[S]||S)+"'":K="Parse error on line "+(N+1)+": Unexpected "+(S==U?"end of input":"'"+(this.terminals_[S]||S)+"'"),this.parseError(K,{text:g.match,token:this.terminals_[S]||S,line:g.yylineno,loc:M,expected:O})}if(I[0]instanceof Array&&I.length>1)throw new Error("Parse Error: multiple actions possible at state: "+A+", token: "+S);switch(I[0]){case 1:c.push(S),y.push(g.yytext),o.push(g.yylloc),c.push(I[1]),S=null,P=g.yyleng,v=g.yytext,N=g.yylineno,M=g.yylloc;break;case 2:if($=this.productions_[I[1]][1],R.$=y[y.length-$],R._$={first_line:o[o.length-($||1)].first_line,last_line:o[o.length-1].last_line,first_column:o[o.length-($||1)].first_column,last_column:o[o.length-1].last_column},W&&(R._$.range=[o[o.length-($||1)].range[0],o[o.length-1].range[1]]),J=this.performAction.apply(R,[v,P,N,b.yy,I[1],y,o].concat(H)),typeof J<"u")return J;$&&(c=c.slice(0,-1*$*2),y=y.slice(0,-1*$),o=o.slice(0,-1*$)),c.push(this.productions_[I[1]][0]),y.push(R.$),o.push(R._$),et=w[c[c.length-2]][c[c.length-1]],c.push(et);break;case 3:return!0}}return!0},"parse")},k=(function(){var x={EOF:1,parseError:s(function(a,c){if(this.yy.parser)this.yy.parser.parseError(a,c);else throw new Error(a)},"parseError"),setInput:s(function(r,a){return this.yy=a||this.yy||{},this._input=r,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:s(function(){var r=this._input[0];this.yytext+=r,this.yyleng++,this.offset++,this.match+=r,this.matched+=r;var a=r.match(/(?:\r\n?|\n).*/g);return a?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),r},"input"),unput:s(function(r){var a=r.length,c=r.split(/(?:\r\n?|\n)/g);this._input=r+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-a),this.offset-=a;var u=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),c.length-1&&(this.yylineno-=c.length-1);var y=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:c?(c.length===u.length?this.yylloc.first_column:0)+u[u.length-c.length].length-c[0].length:this.yylloc.first_column-a},this.options.ranges&&(this.yylloc.range=[y[0],y[0]+this.yyleng-a]),this.yyleng=this.yytext.length,this},"unput"),more:s(function(){return this._more=!0,this},"more"),reject:s(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:s(function(r){this.unput(this.match.slice(r))},"less"),pastInput:s(function(){var r=this.matched.substr(0,this.matched.length-this.match.length);return(r.length>20?"...":"")+r.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:s(function(){var r=this.match;return r.length<20&&(r+=this._input.substr(0,20-r.length)),(r.substr(0,20)+(r.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:s(function(){var r=this.pastInput(),a=new Array(r.length+1).join("-");return r+this.upcomingInput()+` diff --git a/demo/aiui/assets/treemap-GDKQZRPO-yRLasM0b.js b/demo/aiui/assets/treemap-GDKQZRPO-DJjQsbt8.js similarity index 99% rename from demo/aiui/assets/treemap-GDKQZRPO-yRLasM0b.js rename to demo/aiui/assets/treemap-GDKQZRPO-DJjQsbt8.js index da8cede6..75b68955 100644 --- a/demo/aiui/assets/treemap-GDKQZRPO-yRLasM0b.js +++ b/demo/aiui/assets/treemap-GDKQZRPO-DJjQsbt8.js @@ -1,4 +1,4 @@ -import{_ as En}from"./index-Lh5NfTCq.js";import{bB as Oh,bC as xh,aQ as bd,bl as Dh,aU as Mh,aR as je,ar as Fh,as as bl,bb as Gh,be as _d,bf as Id,bc as Uh,bq as _l,au as Yn,av as ae,aS as qh,aM as jh,bD as zh}from"./mermaid.core-DaNhpuX9.js";import{k as qr,j as tl,g as Hr,S as Bh,w as Wh,x as Vh,c as Pd,v as $e,y as $d,l as Kh,z as Hh,A as Yh,B as Xh,C as Jh,a as Ld,d as q,i as tn,r as Je,f as ht,D as Fe}from"./_baseUniq-C5dU7AKy.js";import{j as nl,m as M,d as Qh,f as yt,g as jr,h as z,i as rl,l as jn,e as Zh}from"./_basePickBy-BlfxZvco.js";import{c as Be}from"./clone-CJT8Sng7.js";var ep=Object.prototype,tp=ep.hasOwnProperty,ft=Oh(function(t,e){if(xh(e)||bd(e)){Dh(e,qr(e),t);return}for(var n in e)tp.call(e,n)&&Mh(t,n,e[n])});function Od(t,e,n){var r=-1,i=t.length;e<0&&(e=-e>i?0:i+e),n=n>i?i:n,n<0&&(n+=i),i=e>n?0:n-e>>>0,e>>>=0;for(var s=Array(i);++r=sp&&(s=Vh,a=!1,e=new Bh(e));e:for(;++i-1:!!i&&$d(t,e,n)>-1}function Il(t,e,n){var r=t==null?0:t.length;if(!r)return-1;var i=0;return $d(t,e,i)}var pp="[object RegExp]";function mp(t){return _d(t)&&Id(t)==pp}var Pl=_l&&_l.isRegExp,nn=Pl?Uh(Pl):mp,gp="Expected a function";function yp(t){if(typeof t!="function")throw new TypeError(gp);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}function Nt(t,e){if(t==null)return{};var n=Kh(Hh(t),function(r){return[r]});return e=Hr(e),Qh(t,n,function(r,i){return e(r,i[0])})}function Ia(t,e){var n=je(t)?Yh:Xh;return n(t,yp(Hr(e)))}function Tp(t,e){var n;return tl(t,function(r,i,s){return n=e(r,i,s),!n}),!!n}function xd(t,e,n){var r=je(t)?Jh:Tp;return r(t,Hr(e))}function il(t){return t&&t.length?Ld(t):[]}function Rp(t,e){return t&&t.length?Ld(t,Hr(e)):[]}function We(t){return typeof t=="object"&&t!==null&&typeof t.$type=="string"}function St(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"&&"ref"in t}function un(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"&&"items"in t}function vp(t){return typeof t=="object"&&t!==null&&typeof t.name=="string"&&typeof t.type=="string"&&typeof t.path=="string"}function fi(t){return typeof t=="object"&&t!==null&&typeof t.info=="object"&&typeof t.message=="string"}class Dd{constructor(){this.subtypes={},this.allSubtypes={}}getAllTypes(){return Object.keys(this.types)}getReferenceType(e){const n=this.types[e.container.$type];if(!n)throw new Error(`Type ${e.container.$type||"undefined"} not found.`);const r=n.properties[e.property]?.referenceType;if(!r)throw new Error(`Property ${e.property||"undefined"} of type ${e.container.$type} is not a reference.`);return r}getTypeMetaData(e){const n=this.types[e];return n||{name:e,properties:{},superTypes:[]}}isInstance(e,n){return We(e)&&this.isSubtype(e.$type,n)}isSubtype(e,n){if(e===n)return!0;let r=this.subtypes[e];r||(r=this.subtypes[e]={});const i=r[n];if(i!==void 0)return i;{const s=this.types[e],a=s?s.superTypes.some(o=>this.isSubtype(o,n)):!1;return r[n]=a,a}}getAllSubTypes(e){const n=this.allSubtypes[e];if(n)return n;{const r=this.getAllTypes(),i=[];for(const s of r)this.isSubtype(s,e)&&i.push(s);return this.allSubtypes[e]=i,i}}}function Bi(t){return typeof t=="object"&&t!==null&&Array.isArray(t.content)}function Md(t){return typeof t=="object"&&t!==null&&typeof t.tokenType=="object"}function Fd(t){return Bi(t)&&typeof t.fullText=="string"}class Ue{constructor(e,n){this.startFn=e,this.nextFn=n}iterator(){const e={state:this.startFn(),next:()=>this.nextFn(e.state),[Symbol.iterator]:()=>e};return e}[Symbol.iterator](){return this.iterator()}isEmpty(){return!!this.iterator().next().done}count(){const e=this.iterator();let n=0,r=e.next();for(;!r.done;)n++,r=e.next();return n}toArray(){const e=[],n=this.iterator();let r;do r=n.next(),r.value!==void 0&&e.push(r.value);while(!r.done);return e}toSet(){return new Set(this)}toMap(e,n){const r=this.map(i=>[e?e(i):i,n?n(i):i]);return new Map(r)}toString(){return this.join()}concat(e){return new Ue(()=>({first:this.startFn(),firstDone:!1,iterator:e[Symbol.iterator]()}),n=>{let r;if(!n.firstDone){do if(r=this.nextFn(n.first),!r.done)return r;while(!r.done);n.firstDone=!0}do if(r=n.iterator.next(),!r.done)return r;while(!r.done);return lt})}join(e=","){const n=this.iterator();let r="",i,s=!1;do i=n.next(),i.done||(s&&(r+=e),r+=Ep(i.value)),s=!0;while(!i.done);return r}indexOf(e,n=0){const r=this.iterator();let i=0,s=r.next();for(;!s.done;){if(i>=n&&s.value===e)return i;s=r.next(),i++}return-1}every(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(!e(r.value))return!1;r=n.next()}return!0}some(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(e(r.value))return!0;r=n.next()}return!1}forEach(e){const n=this.iterator();let r=0,i=n.next();for(;!i.done;)e(i.value,r),i=n.next(),r++}map(e){return new Ue(this.startFn,n=>{const{done:r,value:i}=this.nextFn(n);return r?lt:{done:!1,value:e(i)}})}filter(e){return new Ue(this.startFn,n=>{let r;do if(r=this.nextFn(n),!r.done&&e(r.value))return r;while(!r.done);return lt})}nonNullable(){return this.filter(e=>e!=null)}reduce(e,n){const r=this.iterator();let i=n,s=r.next();for(;!s.done;)i===void 0?i=s.value:i=e(i,s.value),s=r.next();return i}reduceRight(e,n){return this.recursiveReduce(this.iterator(),e,n)}recursiveReduce(e,n,r){const i=e.next();if(i.done)return r;const s=this.recursiveReduce(e,n,r);return s===void 0?i.value:n(s,i.value)}find(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(e(r.value))return r.value;r=n.next()}}findIndex(e){const n=this.iterator();let r=0,i=n.next();for(;!i.done;){if(e(i.value))return r;i=n.next(),r++}return-1}includes(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(r.value===e)return!0;r=n.next()}return!1}flatMap(e){return new Ue(()=>({this:this.startFn()}),n=>{do{if(n.iterator){const s=n.iterator.next();if(s.done)n.iterator=void 0;else return s}const{done:r,value:i}=this.nextFn(n.this);if(!r){const s=e(i);if(aa(s))n.iterator=s[Symbol.iterator]();else return{done:!1,value:s}}}while(n.iterator);return lt})}flat(e){if(e===void 0&&(e=1),e<=0)return this;const n=e>1?this.flat(e-1):this;return new Ue(()=>({this:n.startFn()}),r=>{do{if(r.iterator){const a=r.iterator.next();if(a.done)r.iterator=void 0;else return a}const{done:i,value:s}=n.nextFn(r.this);if(!i)if(aa(s))r.iterator=s[Symbol.iterator]();else return{done:!1,value:s}}while(r.iterator);return lt})}head(){const n=this.iterator().next();if(!n.done)return n.value}tail(e=1){return new Ue(()=>{const n=this.startFn();for(let r=0;r({size:0,state:this.startFn()}),n=>(n.size++,n.size>e?lt:this.nextFn(n.state)))}distinct(e){return new Ue(()=>({set:new Set,internalState:this.startFn()}),n=>{let r;do if(r=this.nextFn(n.internalState),!r.done){const i=e?e(r.value):r.value;if(!n.set.has(i))return n.set.add(i),r}while(!r.done);return lt})}exclude(e,n){const r=new Set;for(const i of e){const s=n?n(i):i;r.add(s)}return this.filter(i=>{const s=n?n(i):i;return!r.has(s)})}}function Ep(t){return typeof t=="string"?t:typeof t>"u"?"undefined":typeof t.toString=="function"?t.toString():Object.prototype.toString.call(t)}function aa(t){return!!t&&typeof t[Symbol.iterator]=="function"}const Gd=new Ue(()=>{},()=>lt),lt=Object.freeze({done:!0,value:void 0});function me(...t){if(t.length===1){const e=t[0];if(e instanceof Ue)return e;if(aa(e))return new Ue(()=>e[Symbol.iterator](),n=>n.next());if(typeof e.length=="number")return new Ue(()=>({index:0}),n=>n.index1?new Ue(()=>({collIndex:0,arrIndex:0}),e=>{do{if(e.iterator){const n=e.iterator.next();if(!n.done)return n;e.iterator=void 0}if(e.array){if(e.arrIndex({iterators:r?.includeRoot?[[e][Symbol.iterator]()]:[n(e)[Symbol.iterator]()],pruned:!1}),i=>{for(i.pruned&&(i.iterators.pop(),i.pruned=!1);i.iterators.length>0;){const a=i.iterators[i.iterators.length-1].next();if(a.done)i.iterators.pop();else return i.iterators.push(n(a.value)[Symbol.iterator]()),a}return lt})}iterator(){const e={state:this.startFn(),next:()=>this.nextFn(e.state),prune:()=>{e.state.pruned=!0},[Symbol.iterator]:()=>e};return e}}var To;(function(t){function e(s){return s.reduce((a,o)=>a+o,0)}t.sum=e;function n(s){return s.reduce((a,o)=>a*o,0)}t.product=n;function r(s){return s.reduce((a,o)=>Math.min(a,o))}t.min=r;function i(s){return s.reduce((a,o)=>Math.max(a,o))}t.max=i})(To||(To={}));function Ro(t,e={}){for(const[n,r]of Object.entries(t))n.startsWith("$")||(Array.isArray(r)?r.forEach((i,s)=>{We(i)&&(i.$container=t,i.$containerProperty=n,i.$containerIndex=s,e.deep&&Ro(i,e))}):We(r)&&(r.$container=t,r.$containerProperty=n,e.deep&&Ro(r,e)))}function Pa(t,e){let n=t;for(;n;){if(e(n))return n;n=n.$container}}function Qt(t){const n=Ws(t).$document;if(!n)throw new Error("AST node has no document.");return n}function Ws(t){for(;t.$container;)t=t.$container;return t}function $l(t){return St(t)?t.ref?[t.ref]:[]:un(t)?t.items.map(e=>e.ref):[]}function al(t,e){if(!t)throw new Error("Node must be an AstNode.");const n=e?.range;return new Ue(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),r=>{for(;r.keyIndexal(n,e))}function Zt(t,e){if(!t)throw new Error("Root node must be an AstNode.");return new sl(t,n=>al(n,e),{includeRoot:!0})}function Ll(t,e){if(!e)return!0;const n=t.$cstNode?.range;return n?Bp(n,e):!1}function oa(t){return new Ue(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),e=>{for(;e.keyIndexBi(e)?e.content:[],{includeRoot:!0})}function jp(t,e){for(;t.container;)if(t=t.container,t===e)return!0;return!1}function No(t){return{start:{character:t.startColumn-1,line:t.startLine-1},end:{character:t.endColumn,line:t.endLine-1}}}function la(t){if(!t)return;const{offset:e,end:n,range:r}=t;return{range:r,offset:e,end:n,length:n-e}}var Xt;(function(t){t[t.Before=0]="Before",t[t.After=1]="After",t[t.OverlapFront=2]="OverlapFront",t[t.OverlapBack=3]="OverlapBack",t[t.Inside=4]="Inside",t[t.Outside=5]="Outside"})(Xt||(Xt={}));function zp(t,e){if(t.end.linee.end.line||t.start.line===e.end.line&&t.start.character>=e.end.character)return Xt.After;const n=t.start.line>e.start.line||t.start.line===e.start.line&&t.start.character>=e.start.character,r=t.end.lineXt.After}const Wp=/^[\w\p{L}]$/u;function Vp(t,e){if(t){const n=Kp(t,!0);if(n&&ql(n,e))return n;if(Fd(t)){const r=t.content.findIndex(i=>!i.hidden);for(let i=r-1;i>=0;i--){const s=t.content[i];if(ql(s,e))return s}}}}function ql(t,e){return Md(t)&&e.includes(t.tokenType.name)}function Kp(t,e=!0){for(;t.container;){const n=t.container;let r=n.content.indexOf(t);for(;r>0;){r--;const i=n.content[r];if(e||!i.hidden)return i}t=n}}class Wd extends Error{constructor(e,n){super(e?`${n} at ${e.range.start.line}:${e.range.start.character}`:n)}}function ss(t,e="Error: Got unexpected value."){throw new Error(e)}function W(t){return t.charCodeAt(0)}function Ya(t,e){Array.isArray(t)?t.forEach(function(n){e.push(n)}):e.push(t)}function gi(t,e){if(t[e]===!0)throw"duplicate flag "+e;t[e],t[e]=!0}function rr(t){if(t===void 0)throw Error("Internal Error - Should never get here!");return!0}function Hp(){throw Error("Internal Error - Should never get here!")}function jl(t){return t.type==="Character"}const ua=[];for(let t=W("0");t<=W("9");t++)ua.push(t);const da=[W("_")].concat(ua);for(let t=W("a");t<=W("z");t++)da.push(t);for(let t=W("A");t<=W("Z");t++)da.push(t);const zl=[W(" "),W("\f"),W(` +import{_ as En}from"./index-8cIrvc8q.js";import{bB as Oh,bC as xh,aV as bd,bl as Dh,aZ as Mh,aW as je,ar as Fh,as as bl,bb as Gh,be as _d,bf as Id,bc as Uh,bq as _l,au as Yn,av as ae,aX as qh,aR as jh,bD as zh}from"./mermaid.core-v0oo9NRr.js";import{k as qr,j as tl,g as Hr,S as Bh,w as Wh,x as Vh,c as Pd,v as $e,y as $d,l as Kh,z as Hh,A as Yh,B as Xh,C as Jh,a as Ld,d as q,i as tn,r as Je,f as ht,D as Fe}from"./_baseUniq-DAOs4kUj.js";import{j as nl,m as M,d as Qh,f as yt,g as jr,h as z,i as rl,l as jn,e as Zh}from"./_basePickBy-CL4iQUG-.js";import{c as Be}from"./clone-C1u3K6Fy.js";var ep=Object.prototype,tp=ep.hasOwnProperty,ft=Oh(function(t,e){if(xh(e)||bd(e)){Dh(e,qr(e),t);return}for(var n in e)tp.call(e,n)&&Mh(t,n,e[n])});function Od(t,e,n){var r=-1,i=t.length;e<0&&(e=-e>i?0:i+e),n=n>i?i:n,n<0&&(n+=i),i=e>n?0:n-e>>>0,e>>>=0;for(var s=Array(i);++r=sp&&(s=Vh,a=!1,e=new Bh(e));e:for(;++i-1:!!i&&$d(t,e,n)>-1}function Il(t,e,n){var r=t==null?0:t.length;if(!r)return-1;var i=0;return $d(t,e,i)}var pp="[object RegExp]";function mp(t){return _d(t)&&Id(t)==pp}var Pl=_l&&_l.isRegExp,nn=Pl?Uh(Pl):mp,gp="Expected a function";function yp(t){if(typeof t!="function")throw new TypeError(gp);return function(){var e=arguments;switch(e.length){case 0:return!t.call(this);case 1:return!t.call(this,e[0]);case 2:return!t.call(this,e[0],e[1]);case 3:return!t.call(this,e[0],e[1],e[2])}return!t.apply(this,e)}}function Nt(t,e){if(t==null)return{};var n=Kh(Hh(t),function(r){return[r]});return e=Hr(e),Qh(t,n,function(r,i){return e(r,i[0])})}function Ia(t,e){var n=je(t)?Yh:Xh;return n(t,yp(Hr(e)))}function Tp(t,e){var n;return tl(t,function(r,i,s){return n=e(r,i,s),!n}),!!n}function xd(t,e,n){var r=je(t)?Jh:Tp;return r(t,Hr(e))}function il(t){return t&&t.length?Ld(t):[]}function Rp(t,e){return t&&t.length?Ld(t,Hr(e)):[]}function We(t){return typeof t=="object"&&t!==null&&typeof t.$type=="string"}function St(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"&&"ref"in t}function un(t){return typeof t=="object"&&t!==null&&typeof t.$refText=="string"&&"items"in t}function vp(t){return typeof t=="object"&&t!==null&&typeof t.name=="string"&&typeof t.type=="string"&&typeof t.path=="string"}function fi(t){return typeof t=="object"&&t!==null&&typeof t.info=="object"&&typeof t.message=="string"}class Dd{constructor(){this.subtypes={},this.allSubtypes={}}getAllTypes(){return Object.keys(this.types)}getReferenceType(e){const n=this.types[e.container.$type];if(!n)throw new Error(`Type ${e.container.$type||"undefined"} not found.`);const r=n.properties[e.property]?.referenceType;if(!r)throw new Error(`Property ${e.property||"undefined"} of type ${e.container.$type} is not a reference.`);return r}getTypeMetaData(e){const n=this.types[e];return n||{name:e,properties:{},superTypes:[]}}isInstance(e,n){return We(e)&&this.isSubtype(e.$type,n)}isSubtype(e,n){if(e===n)return!0;let r=this.subtypes[e];r||(r=this.subtypes[e]={});const i=r[n];if(i!==void 0)return i;{const s=this.types[e],a=s?s.superTypes.some(o=>this.isSubtype(o,n)):!1;return r[n]=a,a}}getAllSubTypes(e){const n=this.allSubtypes[e];if(n)return n;{const r=this.getAllTypes(),i=[];for(const s of r)this.isSubtype(s,e)&&i.push(s);return this.allSubtypes[e]=i,i}}}function Bi(t){return typeof t=="object"&&t!==null&&Array.isArray(t.content)}function Md(t){return typeof t=="object"&&t!==null&&typeof t.tokenType=="object"}function Fd(t){return Bi(t)&&typeof t.fullText=="string"}class Ue{constructor(e,n){this.startFn=e,this.nextFn=n}iterator(){const e={state:this.startFn(),next:()=>this.nextFn(e.state),[Symbol.iterator]:()=>e};return e}[Symbol.iterator](){return this.iterator()}isEmpty(){return!!this.iterator().next().done}count(){const e=this.iterator();let n=0,r=e.next();for(;!r.done;)n++,r=e.next();return n}toArray(){const e=[],n=this.iterator();let r;do r=n.next(),r.value!==void 0&&e.push(r.value);while(!r.done);return e}toSet(){return new Set(this)}toMap(e,n){const r=this.map(i=>[e?e(i):i,n?n(i):i]);return new Map(r)}toString(){return this.join()}concat(e){return new Ue(()=>({first:this.startFn(),firstDone:!1,iterator:e[Symbol.iterator]()}),n=>{let r;if(!n.firstDone){do if(r=this.nextFn(n.first),!r.done)return r;while(!r.done);n.firstDone=!0}do if(r=n.iterator.next(),!r.done)return r;while(!r.done);return lt})}join(e=","){const n=this.iterator();let r="",i,s=!1;do i=n.next(),i.done||(s&&(r+=e),r+=Ep(i.value)),s=!0;while(!i.done);return r}indexOf(e,n=0){const r=this.iterator();let i=0,s=r.next();for(;!s.done;){if(i>=n&&s.value===e)return i;s=r.next(),i++}return-1}every(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(!e(r.value))return!1;r=n.next()}return!0}some(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(e(r.value))return!0;r=n.next()}return!1}forEach(e){const n=this.iterator();let r=0,i=n.next();for(;!i.done;)e(i.value,r),i=n.next(),r++}map(e){return new Ue(this.startFn,n=>{const{done:r,value:i}=this.nextFn(n);return r?lt:{done:!1,value:e(i)}})}filter(e){return new Ue(this.startFn,n=>{let r;do if(r=this.nextFn(n),!r.done&&e(r.value))return r;while(!r.done);return lt})}nonNullable(){return this.filter(e=>e!=null)}reduce(e,n){const r=this.iterator();let i=n,s=r.next();for(;!s.done;)i===void 0?i=s.value:i=e(i,s.value),s=r.next();return i}reduceRight(e,n){return this.recursiveReduce(this.iterator(),e,n)}recursiveReduce(e,n,r){const i=e.next();if(i.done)return r;const s=this.recursiveReduce(e,n,r);return s===void 0?i.value:n(s,i.value)}find(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(e(r.value))return r.value;r=n.next()}}findIndex(e){const n=this.iterator();let r=0,i=n.next();for(;!i.done;){if(e(i.value))return r;i=n.next(),r++}return-1}includes(e){const n=this.iterator();let r=n.next();for(;!r.done;){if(r.value===e)return!0;r=n.next()}return!1}flatMap(e){return new Ue(()=>({this:this.startFn()}),n=>{do{if(n.iterator){const s=n.iterator.next();if(s.done)n.iterator=void 0;else return s}const{done:r,value:i}=this.nextFn(n.this);if(!r){const s=e(i);if(aa(s))n.iterator=s[Symbol.iterator]();else return{done:!1,value:s}}}while(n.iterator);return lt})}flat(e){if(e===void 0&&(e=1),e<=0)return this;const n=e>1?this.flat(e-1):this;return new Ue(()=>({this:n.startFn()}),r=>{do{if(r.iterator){const a=r.iterator.next();if(a.done)r.iterator=void 0;else return a}const{done:i,value:s}=n.nextFn(r.this);if(!i)if(aa(s))r.iterator=s[Symbol.iterator]();else return{done:!1,value:s}}while(r.iterator);return lt})}head(){const n=this.iterator().next();if(!n.done)return n.value}tail(e=1){return new Ue(()=>{const n=this.startFn();for(let r=0;r({size:0,state:this.startFn()}),n=>(n.size++,n.size>e?lt:this.nextFn(n.state)))}distinct(e){return new Ue(()=>({set:new Set,internalState:this.startFn()}),n=>{let r;do if(r=this.nextFn(n.internalState),!r.done){const i=e?e(r.value):r.value;if(!n.set.has(i))return n.set.add(i),r}while(!r.done);return lt})}exclude(e,n){const r=new Set;for(const i of e){const s=n?n(i):i;r.add(s)}return this.filter(i=>{const s=n?n(i):i;return!r.has(s)})}}function Ep(t){return typeof t=="string"?t:typeof t>"u"?"undefined":typeof t.toString=="function"?t.toString():Object.prototype.toString.call(t)}function aa(t){return!!t&&typeof t[Symbol.iterator]=="function"}const Gd=new Ue(()=>{},()=>lt),lt=Object.freeze({done:!0,value:void 0});function me(...t){if(t.length===1){const e=t[0];if(e instanceof Ue)return e;if(aa(e))return new Ue(()=>e[Symbol.iterator](),n=>n.next());if(typeof e.length=="number")return new Ue(()=>({index:0}),n=>n.index1?new Ue(()=>({collIndex:0,arrIndex:0}),e=>{do{if(e.iterator){const n=e.iterator.next();if(!n.done)return n;e.iterator=void 0}if(e.array){if(e.arrIndex({iterators:r?.includeRoot?[[e][Symbol.iterator]()]:[n(e)[Symbol.iterator]()],pruned:!1}),i=>{for(i.pruned&&(i.iterators.pop(),i.pruned=!1);i.iterators.length>0;){const a=i.iterators[i.iterators.length-1].next();if(a.done)i.iterators.pop();else return i.iterators.push(n(a.value)[Symbol.iterator]()),a}return lt})}iterator(){const e={state:this.startFn(),next:()=>this.nextFn(e.state),prune:()=>{e.state.pruned=!0},[Symbol.iterator]:()=>e};return e}}var To;(function(t){function e(s){return s.reduce((a,o)=>a+o,0)}t.sum=e;function n(s){return s.reduce((a,o)=>a*o,0)}t.product=n;function r(s){return s.reduce((a,o)=>Math.min(a,o))}t.min=r;function i(s){return s.reduce((a,o)=>Math.max(a,o))}t.max=i})(To||(To={}));function Ro(t,e={}){for(const[n,r]of Object.entries(t))n.startsWith("$")||(Array.isArray(r)?r.forEach((i,s)=>{We(i)&&(i.$container=t,i.$containerProperty=n,i.$containerIndex=s,e.deep&&Ro(i,e))}):We(r)&&(r.$container=t,r.$containerProperty=n,e.deep&&Ro(r,e)))}function Pa(t,e){let n=t;for(;n;){if(e(n))return n;n=n.$container}}function Qt(t){const n=Ws(t).$document;if(!n)throw new Error("AST node has no document.");return n}function Ws(t){for(;t.$container;)t=t.$container;return t}function $l(t){return St(t)?t.ref?[t.ref]:[]:un(t)?t.items.map(e=>e.ref):[]}function al(t,e){if(!t)throw new Error("Node must be an AstNode.");const n=e?.range;return new Ue(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),r=>{for(;r.keyIndexal(n,e))}function Zt(t,e){if(!t)throw new Error("Root node must be an AstNode.");return new sl(t,n=>al(n,e),{includeRoot:!0})}function Ll(t,e){if(!e)return!0;const n=t.$cstNode?.range;return n?Bp(n,e):!1}function oa(t){return new Ue(()=>({keys:Object.keys(t),keyIndex:0,arrayIndex:0}),e=>{for(;e.keyIndexBi(e)?e.content:[],{includeRoot:!0})}function jp(t,e){for(;t.container;)if(t=t.container,t===e)return!0;return!1}function No(t){return{start:{character:t.startColumn-1,line:t.startLine-1},end:{character:t.endColumn,line:t.endLine-1}}}function la(t){if(!t)return;const{offset:e,end:n,range:r}=t;return{range:r,offset:e,end:n,length:n-e}}var Xt;(function(t){t[t.Before=0]="Before",t[t.After=1]="After",t[t.OverlapFront=2]="OverlapFront",t[t.OverlapBack=3]="OverlapBack",t[t.Inside=4]="Inside",t[t.Outside=5]="Outside"})(Xt||(Xt={}));function zp(t,e){if(t.end.linee.end.line||t.start.line===e.end.line&&t.start.character>=e.end.character)return Xt.After;const n=t.start.line>e.start.line||t.start.line===e.start.line&&t.start.character>=e.start.character,r=t.end.lineXt.After}const Wp=/^[\w\p{L}]$/u;function Vp(t,e){if(t){const n=Kp(t,!0);if(n&&ql(n,e))return n;if(Fd(t)){const r=t.content.findIndex(i=>!i.hidden);for(let i=r-1;i>=0;i--){const s=t.content[i];if(ql(s,e))return s}}}}function ql(t,e){return Md(t)&&e.includes(t.tokenType.name)}function Kp(t,e=!0){for(;t.container;){const n=t.container;let r=n.content.indexOf(t);for(;r>0;){r--;const i=n.content[r];if(e||!i.hidden)return i}t=n}}class Wd extends Error{constructor(e,n){super(e?`${n} at ${e.range.start.line}:${e.range.start.character}`:n)}}function ss(t,e="Error: Got unexpected value."){throw new Error(e)}function W(t){return t.charCodeAt(0)}function Ya(t,e){Array.isArray(t)?t.forEach(function(n){e.push(n)}):e.push(t)}function gi(t,e){if(t[e]===!0)throw"duplicate flag "+e;t[e],t[e]=!0}function rr(t){if(t===void 0)throw Error("Internal Error - Should never get here!");return!0}function Hp(){throw Error("Internal Error - Should never get here!")}function jl(t){return t.type==="Character"}const ua=[];for(let t=W("0");t<=W("9");t++)ua.push(t);const da=[W("_")].concat(ua);for(let t=W("a");t<=W("z");t++)da.push(t);for(let t=W("A");t<=W("Z");t++)da.push(t);const zl=[W(" "),W("\f"),W(` `),W("\r"),W(" "),W("\v"),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W(" "),W("\u2028"),W("\u2029"),W(" "),W(" "),W(" "),W("\uFEFF")],Yp=/[0-9a-fA-F]/,Ls=/[0-9]/,Xp=/[1-9]/;class Vd{constructor(){this.idx=0,this.input="",this.groupIdx=0}saveState(){return{idx:this.idx,input:this.input,groupIdx:this.groupIdx}}restoreState(e){this.idx=e.idx,this.input=e.input,this.groupIdx=e.groupIdx}pattern(e){this.idx=0,this.input=e,this.groupIdx=0,this.consumeChar("/");const n=this.disjunction();this.consumeChar("/");const r={type:"Flags",loc:{begin:this.idx,end:e.length},global:!1,ignoreCase:!1,multiLine:!1,unicode:!1,sticky:!1};for(;this.isRegExpFlag();)switch(this.popChar()){case"g":gi(r,"global");break;case"i":gi(r,"ignoreCase");break;case"m":gi(r,"multiLine");break;case"u":gi(r,"unicode");break;case"y":gi(r,"sticky");break}if(this.idx!==this.input.length)throw Error("Redundant input: "+this.input.substring(this.idx));return{type:"Pattern",flags:r,value:n,loc:this.loc(0)}}disjunction(){const e=[],n=this.idx;for(e.push(this.alternative());this.peekChar()==="|";)this.consumeChar("|"),e.push(this.alternative());return{type:"Disjunction",value:e,loc:this.loc(n)}}alternative(){const e=[],n=this.idx;for(;this.isTerm();)e.push(this.term());return{type:"Alternative",value:e,loc:this.loc(n)}}term(){return this.isAssertion()?this.assertion():this.atom()}assertion(){const e=this.idx;switch(this.popChar()){case"^":return{type:"StartAnchor",loc:this.loc(e)};case"$":return{type:"EndAnchor",loc:this.loc(e)};case"\\":switch(this.popChar()){case"b":return{type:"WordBoundary",loc:this.loc(e)};case"B":return{type:"NonWordBoundary",loc:this.loc(e)}}throw Error("Invalid Assertion Escape");case"(":this.consumeChar("?");let n;switch(this.popChar()){case"=":n="Lookahead";break;case"!":n="NegativeLookahead";break;case"<":{switch(this.popChar()){case"=":n="Lookbehind";break;case"!":n="NegativeLookbehind"}break}}rr(n);const r=this.disjunction();return this.consumeChar(")"),{type:n,value:r,loc:this.loc(e)}}return Hp()}quantifier(e=!1){let n;const r=this.idx;switch(this.popChar()){case"*":n={atLeast:0,atMost:1/0};break;case"+":n={atLeast:1,atMost:1/0};break;case"?":n={atLeast:0,atMost:1};break;case"{":const i=this.integerIncludingZero();switch(this.popChar()){case"}":n={atLeast:i,atMost:i};break;case",":let s;this.isDigit()?(s=this.integerIncludingZero(),n={atLeast:i,atMost:s}):n={atLeast:i,atMost:1/0},this.consumeChar("}");break}if(e===!0&&n===void 0)return;rr(n);break}if(!(e===!0&&n===void 0)&&rr(n))return this.peekChar(0)==="?"?(this.consumeChar("?"),n.greedy=!1):n.greedy=!0,n.type="Quantifier",n.loc=this.loc(r),n}atom(){let e;const n=this.idx;switch(this.peekChar()){case".":e=this.dotAll();break;case"\\":e=this.atomEscape();break;case"[":e=this.characterClass();break;case"(":e=this.group();break}if(e===void 0&&this.isPatternCharacter()&&(e=this.patternCharacter()),rr(e))return e.loc=this.loc(n),this.isQuantifier()&&(e.quantifier=this.quantifier()),e}dotAll(){return this.consumeChar("."),{type:"Set",complement:!0,value:[W(` `),W("\r"),W("\u2028"),W("\u2029")]}}atomEscape(){switch(this.consumeChar("\\"),this.peekChar()){case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":return this.decimalEscapeAtom();case"d":case"D":case"s":case"S":case"w":case"W":return this.characterClassEscape();case"f":case"n":case"r":case"t":case"v":return this.controlEscapeAtom();case"c":return this.controlLetterEscapeAtom();case"0":return this.nulCharacterAtom();case"x":return this.hexEscapeSequenceAtom();case"u":return this.regExpUnicodeEscapeSequenceAtom();default:return this.identityEscapeAtom()}}decimalEscapeAtom(){return{type:"GroupBackReference",value:this.positiveInteger()}}characterClassEscape(){let e,n=!1;switch(this.popChar()){case"d":e=ua;break;case"D":e=ua,n=!0;break;case"s":e=zl;break;case"S":e=zl,n=!0;break;case"w":e=da;break;case"W":e=da,n=!0;break}if(rr(e))return{type:"Set",value:e,complement:n}}controlEscapeAtom(){let e;switch(this.popChar()){case"f":e=W("\f");break;case"n":e=W(` `);break;case"r":e=W("\r");break;case"t":e=W(" ");break;case"v":e=W("\v");break}if(rr(e))return{type:"Character",value:e}}controlLetterEscapeAtom(){this.consumeChar("c");const e=this.popChar();if(/[a-zA-Z]/.test(e)===!1)throw Error("Invalid ");return{type:"Character",value:e.toUpperCase().charCodeAt(0)-64}}nulCharacterAtom(){return this.consumeChar("0"),{type:"Character",value:W("\0")}}hexEscapeSequenceAtom(){return this.consumeChar("x"),this.parseHexDigits(2)}regExpUnicodeEscapeSequenceAtom(){return this.consumeChar("u"),this.parseHexDigits(4)}identityEscapeAtom(){const e=this.popChar();return{type:"Character",value:W(e)}}classPatternCharacterAtom(){switch(this.peekChar()){case` diff --git a/demo/aiui/assets/useContentImages-CagIZs4M.js b/demo/aiui/assets/useContentImages-7wLVntsF.js similarity index 90% rename from demo/aiui/assets/useContentImages-CagIZs4M.js rename to demo/aiui/assets/useContentImages-7wLVntsF.js index b56cc626..e313e62c 100644 --- a/demo/aiui/assets/useContentImages-CagIZs4M.js +++ b/demo/aiui/assets/useContentImages-7wLVntsF.js @@ -1 +1 @@ -import{A as v,r as g,B as w,C as I}from"./index-Lh5NfTCq.js";function k(t){const a=g(new Set),r=w(new Map);function i(e){a.value=new Set([...a.value,e])}function f(e){const n=t.id(e);return a.value.has(n)?null:t.existingUrl(e)||r.get(n)||null}function l(e){return t.fallback(e)}function d(e){i(t.id(e))}function o(e){const n=t.id(e);return!f(e)&&!a.value.has(n)}function h(e){for(const n of e){const c=t.id(n);t.existingUrl(n)||r.has(c)||a.value.has(c)||t.fetch(n).then(s=>{s?r.set(c,s):i(c)}).catch(()=>{i(c)})}}const u=t.items,m=I(u)?()=>u.value:u;return v(m,e=>h(e),{immediate:!0}),{coverSrc:f,fallbackSrc:l,onError:d,isLoading:o}}export{k as u}; +import{A as v,r as g,B as w,C as I}from"./index-8cIrvc8q.js";function k(t){const a=g(new Set),r=w(new Map);function i(e){a.value=new Set([...a.value,e])}function f(e){const n=t.id(e);return a.value.has(n)?null:t.existingUrl(e)||r.get(n)||null}function l(e){return t.fallback(e)}function d(e){i(t.id(e))}function o(e){const n=t.id(e);return!f(e)&&!a.value.has(n)}function h(e){for(const n of e){const c=t.id(n);t.existingUrl(n)||r.has(c)||a.value.has(c)||t.fetch(n).then(s=>{s?r.set(c,s):i(c)}).catch(()=>{i(c)})}}const u=t.items,m=I(u)?()=>u.value:u;return v(m,e=>h(e),{immediate:!0}),{coverSrc:f,fallbackSrc:l,onError:d,isLoading:o}}export{k as u}; diff --git a/demo/aiui/assets/useNostr-DYbkCQxC.js b/demo/aiui/assets/useNostr-XONW-p_l.js similarity index 98% rename from demo/aiui/assets/useNostr-DYbkCQxC.js rename to demo/aiui/assets/useNostr-XONW-p_l.js index fe379754..5736e513 100644 --- a/demo/aiui/assets/useNostr-DYbkCQxC.js +++ b/demo/aiui/assets/useNostr-XONW-p_l.js @@ -1 +1 @@ -import{o as D,r as R,s as P}from"./index-Lh5NfTCq.js";const W=[{url:"wss://relay.damus.io",read:!0,write:!0},{url:"wss://nos.lol",read:!0,write:!0},{url:"wss://relay.snort.social",read:!0,write:!0}],_="aiui-nostr-relays";function x(){try{const e=localStorage.getItem(_);if(e)return JSON.parse(e)}catch{}return W}function N(e){localStorage.setItem(_,JSON.stringify(e))}const w=P([]),h=R(!1),J=R([]),v=new Map,T=new Set;let i=[],p=null,k=!1;function S(){return"aiui-"+Math.random().toString(36).slice(2,10)}function O(e){return e.length<=12?e:e.slice(0,8)+"..."+e.slice(-4)}function Q(e){if(!Array.isArray(e)||e[0]!=="EVENT"||!e[2])return null;const t=e[2];return!t.id||!t.pubkey||typeof t.kind!="number"||typeof t.content!="string"?null:t}function V(e){if(e.kind===0)try{const t=JSON.parse(e.content),r={name:(t.display_name??t.name??"")||void 0,picture:(t.picture??"")||void 0,nip05:(t.nip05??"")||void 0};v.set(e.pubkey,r),T.delete(e.pubkey);const n=w.value.map(s=>s.pubkey!==e.pubkey?s:{...s,authorName:r.name||s.authorName,authorPicture:r.picture,nip05:r.nip05||s.nip05});w.value=n}catch{}}function U(e){const t=v.get(e.pubkey);return t?{...e,authorName:t.name||e.authorName,authorPicture:t.picture,nip05:t.nip05||e.nip05}:e}function Y(e){const t=e.filter(n=>!v.has(n)&&!T.has(n));if(t.length===0)return;for(const n of t)T.add(n);const r="prof-"+Math.random().toString(36).slice(2,8);for(const n of i)n.connected&&n.ws&&n.read&&n.ws.send(JSON.stringify(["REQ",r,{kinds:[0],authors:t,limit:t.length}]))}function L(e){if(e.kind===0){V(e);return}if(w.value.find(s=>s.id===e.id))return;const r=U({id:e.id,pubkey:e.pubkey,authorName:O(e.pubkey),kind:e.kind,content:e.content,created_at:e.created_at,tags:e.tags??[]}),n=[...w.value,r].sort((s,o)=>o.created_at-s.created_at).slice(0,200);w.value=n,Y([e.pubkey])}function M(e){if(!e.ws)try{e.connectTime=Date.now();const t=new WebSocket(e.url);e.ws=t,t.onopen=()=>{e.connected=!0,e.latencyMs=e.connectTime?Date.now()-e.connectTime:null,f(),h.value=i.some(r=>r.connected),e.read&&(p||(p=S()),t.send(JSON.stringify(["REQ",p,{kinds:[1],limit:50}])))},t.onmessage=r=>{try{const n=JSON.parse(r.data),s=Q(n);s&&L(s)}catch{}},t.onclose=()=>{e.connected=!1,e.ws=null,e.latencyMs=null,f(),h.value=i.some(r=>r.connected)},t.onerror=()=>{}}catch{e.connected=!1,f()}}function f(){J.value=i.map(e=>({url:e.url,connected:e.connected,read:e.read,write:e.write,latencyMs:e.latencyMs}))}function K(){if(k)return;k=!0,i=x().map(t=>({url:t.url,ws:null,connected:!1,read:t.read,write:t.write,latencyMs:null,connectTime:null})),f(),i.forEach(t=>M(t))}function A(){p&&(i.forEach(e=>{if(e.ws&&e.connected)try{e.ws.send(JSON.stringify(["CLOSE",p]))}catch{}}),p=null),i.forEach(e=>{e.ws&&(e.ws.close(),e.ws=null,e.connected=!1)}),f(),h.value=!1,k=!1}function I(e,t=!0,r=!0){if(i.some(s=>s.url===e))return;const n={url:e,ws:null,connected:!1,read:t,write:r,latencyMs:null,connectTime:null};i.push(n),N(i.map(s=>({url:s.url,read:s.read,write:s.write}))),f(),k&&M(n)}function j(e){const t=i.findIndex(n=>n.url===e);if(t===-1)return;const r=i[t];r.ws&&(r.ws.close(),r.ws=null),i.splice(t,1),N(i.map(n=>({url:n.url,read:n.read,write:n.write}))),f(),h.value=i.some(n=>n.connected)}function z(e){const t=i.find(r=>r.url===e);t&&(t.read=!t.read,N(i.map(r=>({url:r.url,read:r.read,write:r.write}))),f())}function F(e){const t=i.find(r=>r.url===e);t&&(t.write=!t.write,N(i.map(r=>({url:r.url,read:r.read,write:r.write}))),f())}function G(e){return new Promise(t=>{const r=Date.now(),n=setTimeout(()=>t(null),5e3);try{const s=new WebSocket(e);s.onopen=()=>{const o=Date.now()-r;clearTimeout(n),s.close(),t(o)},s.onerror=()=>{clearTimeout(n),t(null)}}catch{clearTimeout(n),t(null)}})}function q(e){if(e.kind===10002){for(const t of e.tags)if(t[0]==="r"&&t[1]){const r=t[1],n=t[2];I(r,!n||n==="read",!n||n==="write")}}}const b=["wss://relay.nostr.band","wss://nostr.wine"],g=P([]),E=R(!1);function B(e,t){if(!e.trim()){g.value=[];return}E.value=!0,g.value=[];const r=S(),n={search:e,limit:50};t&&t.length>0&&(n.kinds=t);const s=[];let o=0;for(const d of b)try{const u=new WebSocket(d),l=setTimeout(()=>{u.close()},8e3);u.onopen=()=>{u.send(JSON.stringify(["REQ",r,n]))},u.onmessage=m=>{try{const c=JSON.parse(m.data);if(Array.isArray(c)&&c[0]==="EVENT"&&c[1]===r&&c[2]){const a=c[2];s.find(y=>y.id===a.id)||(s.push({id:a.id,pubkey:a.pubkey,authorName:O(a.pubkey),kind:a.kind,content:a.content,created_at:a.created_at,tags:a.tags??[]}),g.value=[...s].sort((y,C)=>C.created_at-y.created_at))}Array.isArray(c)&&c[0]==="EOSE"&&c[1]===r&&(clearTimeout(l),u.close())}catch{}},u.onclose=()=>{o++,o>=b.length&&(E.value=!1)},u.onerror=()=>{clearTimeout(l)}}catch{o++,o>=b.length&&(E.value=!1)}}function H(e){const t=i.filter(n=>n.connected&&n.ws&&n.write);if(t.length===0)return Promise.resolve([]);const r=t.map(n=>new Promise(s=>{const o=setTimeout(()=>{s({url:n.url,success:!1,message:"Timeout"})},5e3),d=u=>{try{const l=JSON.parse(u.data);Array.isArray(l)&&l[0]==="OK"&&l[1]===e.id&&(clearTimeout(o),n.ws?.removeEventListener("message",d),s({url:n.url,success:!!l[2],message:l[3]??(l[2]?"Published":"Rejected")}))}catch{}};n.ws.addEventListener("message",d);try{n.ws.send(JSON.stringify(["EVENT",e]))}catch{clearTimeout(o),n.ws?.removeEventListener("message",d),s({url:n.url,success:!1,message:"Send failed"})}}));return L(e),Promise.all(r)}function X(e,t=5e3){const r=w.value.find(n=>n.id===e);return r?Promise.resolve(r):new Promise(n=>{const s=S();let o=!1;const d=setTimeout(()=>{o||(o=!0,n(null))},t),u=i.find(m=>m.connected&&m.ws&&m.read);if(!u?.ws){clearTimeout(d),n(null);return}const l=m=>{try{const c=JSON.parse(m.data);if(Array.isArray(c)&&c[0]==="EVENT"&&c[1]===s&&c[2]){const a=c[2],y={id:a.id,pubkey:a.pubkey,authorName:O(a.pubkey),kind:a.kind,content:a.content,created_at:a.created_at,tags:a.tags??[]};o||(o=!0,clearTimeout(d),n(y)),u.ws?.removeEventListener("message",l)}Array.isArray(c)&&c[0]==="EOSE"&&c[1]===s&&(o||(o=!0,clearTimeout(d),n(null)),u.ws?.removeEventListener("message",l))}catch{}};u.ws.addEventListener("message",l),u.ws.send(JSON.stringify(["REQ",s,{ids:[e],limit:1}]))})}function $(){return D(()=>{A()}),{events:w,isConnected:h,relayStates:J,connect:K,disconnect:A,fetchNote:X,publishEvent:H,searchResults:g,isSearching:E,searchNostr:B,addRelay:I,removeRelay:j,toggleRelayRead:z,toggleRelayWrite:F,testRelay:G,importNIP65Relays:q}}export{$ as useNostr}; +import{o as D,r as R,s as P}from"./index-8cIrvc8q.js";const W=[{url:"wss://relay.damus.io",read:!0,write:!0},{url:"wss://nos.lol",read:!0,write:!0},{url:"wss://relay.snort.social",read:!0,write:!0}],_="aiui-nostr-relays";function x(){try{const e=localStorage.getItem(_);if(e)return JSON.parse(e)}catch{}return W}function N(e){localStorage.setItem(_,JSON.stringify(e))}const w=P([]),h=R(!1),J=R([]),v=new Map,T=new Set;let i=[],p=null,k=!1;function S(){return"aiui-"+Math.random().toString(36).slice(2,10)}function O(e){return e.length<=12?e:e.slice(0,8)+"..."+e.slice(-4)}function Q(e){if(!Array.isArray(e)||e[0]!=="EVENT"||!e[2])return null;const t=e[2];return!t.id||!t.pubkey||typeof t.kind!="number"||typeof t.content!="string"?null:t}function V(e){if(e.kind===0)try{const t=JSON.parse(e.content),r={name:(t.display_name??t.name??"")||void 0,picture:(t.picture??"")||void 0,nip05:(t.nip05??"")||void 0};v.set(e.pubkey,r),T.delete(e.pubkey);const n=w.value.map(s=>s.pubkey!==e.pubkey?s:{...s,authorName:r.name||s.authorName,authorPicture:r.picture,nip05:r.nip05||s.nip05});w.value=n}catch{}}function U(e){const t=v.get(e.pubkey);return t?{...e,authorName:t.name||e.authorName,authorPicture:t.picture,nip05:t.nip05||e.nip05}:e}function Y(e){const t=e.filter(n=>!v.has(n)&&!T.has(n));if(t.length===0)return;for(const n of t)T.add(n);const r="prof-"+Math.random().toString(36).slice(2,8);for(const n of i)n.connected&&n.ws&&n.read&&n.ws.send(JSON.stringify(["REQ",r,{kinds:[0],authors:t,limit:t.length}]))}function L(e){if(e.kind===0){V(e);return}if(w.value.find(s=>s.id===e.id))return;const r=U({id:e.id,pubkey:e.pubkey,authorName:O(e.pubkey),kind:e.kind,content:e.content,created_at:e.created_at,tags:e.tags??[]}),n=[...w.value,r].sort((s,o)=>o.created_at-s.created_at).slice(0,200);w.value=n,Y([e.pubkey])}function M(e){if(!e.ws)try{e.connectTime=Date.now();const t=new WebSocket(e.url);e.ws=t,t.onopen=()=>{e.connected=!0,e.latencyMs=e.connectTime?Date.now()-e.connectTime:null,f(),h.value=i.some(r=>r.connected),e.read&&(p||(p=S()),t.send(JSON.stringify(["REQ",p,{kinds:[1],limit:50}])))},t.onmessage=r=>{try{const n=JSON.parse(r.data),s=Q(n);s&&L(s)}catch{}},t.onclose=()=>{e.connected=!1,e.ws=null,e.latencyMs=null,f(),h.value=i.some(r=>r.connected)},t.onerror=()=>{}}catch{e.connected=!1,f()}}function f(){J.value=i.map(e=>({url:e.url,connected:e.connected,read:e.read,write:e.write,latencyMs:e.latencyMs}))}function K(){if(k)return;k=!0,i=x().map(t=>({url:t.url,ws:null,connected:!1,read:t.read,write:t.write,latencyMs:null,connectTime:null})),f(),i.forEach(t=>M(t))}function A(){p&&(i.forEach(e=>{if(e.ws&&e.connected)try{e.ws.send(JSON.stringify(["CLOSE",p]))}catch{}}),p=null),i.forEach(e=>{e.ws&&(e.ws.close(),e.ws=null,e.connected=!1)}),f(),h.value=!1,k=!1}function I(e,t=!0,r=!0){if(i.some(s=>s.url===e))return;const n={url:e,ws:null,connected:!1,read:t,write:r,latencyMs:null,connectTime:null};i.push(n),N(i.map(s=>({url:s.url,read:s.read,write:s.write}))),f(),k&&M(n)}function j(e){const t=i.findIndex(n=>n.url===e);if(t===-1)return;const r=i[t];r.ws&&(r.ws.close(),r.ws=null),i.splice(t,1),N(i.map(n=>({url:n.url,read:n.read,write:n.write}))),f(),h.value=i.some(n=>n.connected)}function z(e){const t=i.find(r=>r.url===e);t&&(t.read=!t.read,N(i.map(r=>({url:r.url,read:r.read,write:r.write}))),f())}function F(e){const t=i.find(r=>r.url===e);t&&(t.write=!t.write,N(i.map(r=>({url:r.url,read:r.read,write:r.write}))),f())}function G(e){return new Promise(t=>{const r=Date.now(),n=setTimeout(()=>t(null),5e3);try{const s=new WebSocket(e);s.onopen=()=>{const o=Date.now()-r;clearTimeout(n),s.close(),t(o)},s.onerror=()=>{clearTimeout(n),t(null)}}catch{clearTimeout(n),t(null)}})}function q(e){if(e.kind===10002){for(const t of e.tags)if(t[0]==="r"&&t[1]){const r=t[1],n=t[2];I(r,!n||n==="read",!n||n==="write")}}}const b=["wss://relay.nostr.band","wss://nostr.wine"],g=P([]),E=R(!1);function B(e,t){if(!e.trim()){g.value=[];return}E.value=!0,g.value=[];const r=S(),n={search:e,limit:50};t&&t.length>0&&(n.kinds=t);const s=[];let o=0;for(const d of b)try{const u=new WebSocket(d),l=setTimeout(()=>{u.close()},8e3);u.onopen=()=>{u.send(JSON.stringify(["REQ",r,n]))},u.onmessage=m=>{try{const c=JSON.parse(m.data);if(Array.isArray(c)&&c[0]==="EVENT"&&c[1]===r&&c[2]){const a=c[2];s.find(y=>y.id===a.id)||(s.push({id:a.id,pubkey:a.pubkey,authorName:O(a.pubkey),kind:a.kind,content:a.content,created_at:a.created_at,tags:a.tags??[]}),g.value=[...s].sort((y,C)=>C.created_at-y.created_at))}Array.isArray(c)&&c[0]==="EOSE"&&c[1]===r&&(clearTimeout(l),u.close())}catch{}},u.onclose=()=>{o++,o>=b.length&&(E.value=!1)},u.onerror=()=>{clearTimeout(l)}}catch{o++,o>=b.length&&(E.value=!1)}}function H(e){const t=i.filter(n=>n.connected&&n.ws&&n.write);if(t.length===0)return Promise.resolve([]);const r=t.map(n=>new Promise(s=>{const o=setTimeout(()=>{s({url:n.url,success:!1,message:"Timeout"})},5e3),d=u=>{try{const l=JSON.parse(u.data);Array.isArray(l)&&l[0]==="OK"&&l[1]===e.id&&(clearTimeout(o),n.ws?.removeEventListener("message",d),s({url:n.url,success:!!l[2],message:l[3]??(l[2]?"Published":"Rejected")}))}catch{}};n.ws.addEventListener("message",d);try{n.ws.send(JSON.stringify(["EVENT",e]))}catch{clearTimeout(o),n.ws?.removeEventListener("message",d),s({url:n.url,success:!1,message:"Send failed"})}}));return L(e),Promise.all(r)}function X(e,t=5e3){const r=w.value.find(n=>n.id===e);return r?Promise.resolve(r):new Promise(n=>{const s=S();let o=!1;const d=setTimeout(()=>{o||(o=!0,n(null))},t),u=i.find(m=>m.connected&&m.ws&&m.read);if(!u?.ws){clearTimeout(d),n(null);return}const l=m=>{try{const c=JSON.parse(m.data);if(Array.isArray(c)&&c[0]==="EVENT"&&c[1]===s&&c[2]){const a=c[2],y={id:a.id,pubkey:a.pubkey,authorName:O(a.pubkey),kind:a.kind,content:a.content,created_at:a.created_at,tags:a.tags??[]};o||(o=!0,clearTimeout(d),n(y)),u.ws?.removeEventListener("message",l)}Array.isArray(c)&&c[0]==="EOSE"&&c[1]===s&&(o||(o=!0,clearTimeout(d),n(null)),u.ws?.removeEventListener("message",l))}catch{}};u.ws.addEventListener("message",l),u.ws.send(JSON.stringify(["REQ",s,{ids:[e],limit:1}]))})}function $(){return D(()=>{A()}),{events:w,isConnected:h,relayStates:J,connect:K,disconnect:A,fetchNote:X,publishEvent:H,searchResults:g,isSearching:E,searchNostr:B,addRelay:I,removeRelay:j,toggleRelayRead:z,toggleRelayWrite:F,testRelay:G,importNIP65Relays:q}}export{$ as useNostr}; diff --git a/demo/aiui/assets/xychartDiagram-PRI3JC2R-CAhXzwZ1.js b/demo/aiui/assets/xychartDiagram-PRI3JC2R-7BKZJd6v.js similarity index 99% rename from demo/aiui/assets/xychartDiagram-PRI3JC2R-CAhXzwZ1.js rename to demo/aiui/assets/xychartDiagram-PRI3JC2R-7BKZJd6v.js index 6c209d34..858fd0bf 100644 --- a/demo/aiui/assets/xychartDiagram-PRI3JC2R-CAhXzwZ1.js +++ b/demo/aiui/assets/xychartDiagram-PRI3JC2R-7BKZJd6v.js @@ -1,4 +1,4 @@ -import{s as gi,g as xi,q as Xt,p as di,a as fi,b as pi,_ as a,l as Nt,H as mi,e as yi,y as bi,F as St,i as Ai,D as Yt,E as wi,K as Ci,aF as Si,a9 as Wt}from"./mermaid.core-DaNhpuX9.js";import{i as _i}from"./init-Gi6I4Gst.js";import{o as ki}from"./ordinal-Cboi1Yqb.js";import{l as zt}from"./linear-BI0BnS_D.js";import"./index-Lh5NfTCq.js";import"./defaultLocale-DX6XiGOO.js";function Ri(e,t,i){e=+e,t=+t,i=(n=arguments.length)<2?(t=e,e=0,1):n<3?1:+i;for(var s=-1,n=Math.max(0,Math.ceil((t-e)/i))|0,o=new Array(n);++s"u"&&(T.yylloc={});var ft=T.yylloc;r.push(ft);var ci=T.options&&T.options.ranges;typeof Y.yy.parseError=="function"?this.parseError=Y.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ui(V){x.length=x.length-2*V,w.length=w.length-V,r.length=r.length-V}a(ui,"popStack");function Vt(){var V;return V=d.pop()||T.lex()||Mt,typeof V!="number"&&(V instanceof Array&&(d=V,V=d.pop()),V=u.symbols_[V]||V),V}a(Vt,"lex");for(var M,H,B,pt,q={},ct,O,Bt,ut;;){if(H=x[x.length-1],this.defaultActions[H]?B=this.defaultActions[H]:((M===null||typeof M>"u")&&(M=Vt()),B=at[H]&&at[H][M]),typeof B>"u"||!B.length||!B[0]){var mt="";ut=[];for(ct in at[H])this.terminals_[ct]&&ct>hi&&ut.push("'"+this.terminals_[ct]+"'");T.showPosition?mt="Parse error on line "+(lt+1)+`: +import{s as gi,g as xi,q as Xt,p as di,a as fi,b as pi,_ as a,l as Nt,H as mi,e as yi,y as bi,F as St,i as Ai,D as Yt,E as wi,K as Ci,aF as Si,a9 as Wt}from"./mermaid.core-v0oo9NRr.js";import{i as _i}from"./init-Gi6I4Gst.js";import{o as ki}from"./ordinal-Cboi1Yqb.js";import{l as zt}from"./linear-Bzk-L7jX.js";import"./index-8cIrvc8q.js";import"./defaultLocale-DX6XiGOO.js";function Ri(e,t,i){e=+e,t=+t,i=(n=arguments.length)<2?(t=e,e=0,1):n<3?1:+i;for(var s=-1,n=Math.max(0,Math.ceil((t-e)/i))|0,o=new Array(n);++s"u"&&(T.yylloc={});var ft=T.yylloc;r.push(ft);var ci=T.options&&T.options.ranges;typeof Y.yy.parseError=="function"?this.parseError=Y.yy.parseError:this.parseError=Object.getPrototypeOf(this).parseError;function ui(V){x.length=x.length-2*V,w.length=w.length-V,r.length=r.length-V}a(ui,"popStack");function Vt(){var V;return V=d.pop()||T.lex()||Mt,typeof V!="number"&&(V instanceof Array&&(d=V,V=d.pop()),V=u.symbols_[V]||V),V}a(Vt,"lex");for(var M,H,B,pt,q={},ct,O,Bt,ut;;){if(H=x[x.length-1],this.defaultActions[H]?B=this.defaultActions[H]:((M===null||typeof M>"u")&&(M=Vt()),B=at[H]&&at[H][M]),typeof B>"u"||!B.length||!B[0]){var mt="";ut=[];for(ct in at[H])this.terminals_[ct]&&ct>hi&&ut.push("'"+this.terminals_[ct]+"'");T.showPosition?mt="Parse error on line "+(lt+1)+`: `+T.showPosition()+` Expecting `+ut.join(", ")+", got '"+(this.terminals_[M]||M)+"'":mt="Parse error on line "+(lt+1)+": Unexpected "+(M==Mt?"end of input":"'"+(this.terminals_[M]||M)+"'"),this.parseError(mt,{text:T.match,token:this.terminals_[M]||M,line:T.yylineno,loc:ft,expected:ut})}if(B[0]instanceof Array&&B.length>1)throw new Error("Parse Error: multiple actions possible at state: "+H+", token: "+M);switch(B[0]){case 1:x.push(M),w.push(T.yytext),r.push(T.yylloc),x.push(B[1]),M=null,It=T.yyleng,f=T.yytext,lt=T.yylineno,ft=T.yylloc;break;case 2:if(O=this.productions_[B[1]][1],q.$=w[w.length-O],q._$={first_line:r[r.length-(O||1)].first_line,last_line:r[r.length-1].last_line,first_column:r[r.length-(O||1)].first_column,last_column:r[r.length-1].last_column},ci&&(q._$.range=[r[r.length-(O||1)].range[0],r[r.length-1].range[1]]),pt=this.performAction.apply(q,[f,It,lt,Y.yy,B[1],w,r].concat(li)),typeof pt<"u")return pt;O&&(x=x.slice(0,-1*O*2),w=w.slice(0,-1*O),r=r.slice(0,-1*O)),x.push(this.productions_[B[1]][0]),w.push(q.$),r.push(q._$),Bt=at[x[x.length-2]][x[x.length-1]],x.push(Bt);break;case 3:return!0}}return!0},"parse")},Et=(function(){var F={EOF:1,parseError:a(function(u,x){if(this.yy.parser)this.yy.parser.parseError(u,x);else throw new Error(u)},"parseError"),setInput:a(function(h,u){return this.yy=u||this.yy||{},this._input=h,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},"setInput"),input:a(function(){var h=this._input[0];this.yytext+=h,this.yyleng++,this.offset++,this.match+=h,this.matched+=h;var u=h.match(/(?:\r\n?|\n).*/g);return u?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),h},"input"),unput:a(function(h){var u=h.length,x=h.split(/(?:\r\n?|\n)/g);this._input=h+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-u),this.offset-=u;var d=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),x.length-1&&(this.yylineno-=x.length-1);var w=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:x?(x.length===d.length?this.yylloc.first_column:0)+d[d.length-x.length].length-x[0].length:this.yylloc.first_column-u},this.options.ranges&&(this.yylloc.range=[w[0],w[0]+this.yyleng-u]),this.yyleng=this.yytext.length,this},"unput"),more:a(function(){return this._more=!0,this},"more"),reject:a(function(){if(this.options.backtrack_lexer)this._backtrack=!0;else return this.parseError("Lexical error on line "+(this.yylineno+1)+`. You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true). `+this.showPosition(),{text:"",token:null,line:this.yylineno});return this},"reject"),less:a(function(h){this.unput(this.match.slice(h))},"less"),pastInput:a(function(){var h=this.matched.substr(0,this.matched.length-this.match.length);return(h.length>20?"...":"")+h.substr(-20).replace(/\n/g,"")},"pastInput"),upcomingInput:a(function(){var h=this.match;return h.length<20&&(h+=this._input.substr(0,20-h.length)),(h.substr(0,20)+(h.length>20?"...":"")).replace(/\n/g,"")},"upcomingInput"),showPosition:a(function(){var h=this.pastInput(),u=new Array(h.length+1).join("-");return h+this.upcomingInput()+` diff --git a/demo/aiui/index.html b/demo/aiui/index.html index edcadf00..d76f96d8 100644 --- a/demo/aiui/index.html +++ b/demo/aiui/index.html @@ -2,13 +2,15 @@ - - - + + @@ -20,33 +22,8 @@ AIUI - - - - + +
diff --git a/demo/aiui/sw.js b/demo/aiui/sw.js index 9f7a5a14..7ee265be 100644 --- a/demo/aiui/sw.js +++ b/demo/aiui/sw.js @@ -1 +1 @@ -if(!self.define){let s,e={};const i=(i,l)=>(i=new URL(i+".js",l).href,e[i]||new Promise(e=>{if("document"in self){const s=document.createElement("script");s.src=i,s.onload=e,document.head.appendChild(s)}else s=i,importScripts(i),e()}).then(()=>{let s=e[i];if(!s)throw new Error(`Module ${i} didn’t register its module`);return s}));self.define=(l,n)=>{const r=s||("document"in self?document.currentScript.src:"")||location.href;if(e[r])return;let a={};const u=s=>i(s,r),t={module:{uri:r},exports:a,require:u};e[r]=Promise.all(l.map(s=>t[s]||u(s))).then(s=>(n(...s),a))}}define(["./workbox-3c177d20"],function(s){"use strict";self.skipWaiting(),s.clientsClaim(),s.precacheAndRoute([{url:"registerSW.js",revision:"c492f944af160ee2e9a237c509dd270a"},{url:"index.html",revision:"43b4546ee8edc33febfebaca63124268"},{url:"icon.svg",revision:"dba94027bbb3b869c0ebf9b6beee1953"},{url:"favicon.svg",revision:"72e74ad8f660d9400c34fa69912b94a3"},{url:"images/loading-poster.svg",revision:"97c56238c72450e4953e1d7db2f6e8e6"},{url:"assets/xychartDiagram-PRI3JC2R-CAhXzwZ1.js",revision:null},{url:"assets/wikipedia-BNDKhpH7.js",revision:null},{url:"assets/useNostr-DYbkCQxC.js",revision:null},{url:"assets/useContentImages-CagIZs4M.js",revision:null},{url:"assets/treemap-GDKQZRPO-yRLasM0b.js",revision:null},{url:"assets/timeline-definition-IT6M3QCI-D5HR-Z95.js",revision:null},{url:"assets/stateDiagram-v2-4FDKWEC3-DCr2kVu5.js",revision:null},{url:"assets/stateDiagram-FKZM4ZOC-VzKqVF4W.js",revision:null},{url:"assets/song-renderer-DRNqTHD0.js",revision:null},{url:"assets/sequenceDiagram-WL72ISMW-BukSiqtq.js",revision:null},{url:"assets/seedPrompts-CLWaUv28.js",revision:null},{url:"assets/sankeyDiagram-TZEHDZUN-DqQOGyyA.js",revision:null},{url:"assets/requirementDiagram-UZGBJVZJ-BhL2HWfZ.js",revision:null},{url:"assets/quadrantDiagram-AYHSOK5B-Bp8ks7mP.js",revision:null},{url:"assets/pieDiagram-ADFJNKIX-DskAbgnA.js",revision:null},{url:"assets/ordinal-Cboi1Yqb.js",revision:null},{url:"assets/openlibrary-B8IPeH2e.js",revision:null},{url:"assets/ollama-provider-Ck1Tq0Ld.js",revision:null},{url:"assets/nodeDemoPrompts-DjnuaxJP.js",revision:null},{url:"assets/mindmap-definition-VGOIOE7T-BbRYcaHR.js",revision:null},{url:"assets/mermaid.core-DaNhpuX9.js",revision:null},{url:"assets/linear-BI0BnS_D.js",revision:null},{url:"assets/layout-T-4jL0RA.js",revision:null},{url:"assets/katex.min-CASE1JAf.css",revision:null},{url:"assets/katex-DGN8GczM.js",revision:null},{url:"assets/kanban-definition-3W4ZIXB7-4--rMebd.js",revision:null},{url:"assets/journeyDiagram-XKPGCS4Q-2pQB6VmZ.js",revision:null},{url:"assets/init-Gi6I4Gst.js",revision:null},{url:"assets/infoDiagram-HS3SLOUP-oVb3Z8wT.js",revision:null},{url:"assets/index-Lh5NfTCq.js",revision:null},{url:"assets/index-CHQ7uqBj.css",revision:null},{url:"assets/guideConversation-BYC5cBFP.js",revision:null},{url:"assets/graph-mkJTNBrq.js",revision:null},{url:"assets/gitGraphDiagram-V2S2FVAM-BTxiEL-p.js",revision:null},{url:"assets/ganttDiagram-JELNMOA3-DicMT2oN.js",revision:null},{url:"assets/freeFilms-B9DmMKj5.js",revision:null},{url:"assets/flowDiagram-NV44I4VS-D5zBfsz-.js",revision:null},{url:"assets/film-renderer-Ds7Zr4Tu.js",revision:null},{url:"assets/erDiagram-Q2GNP2WA-Ar7Pe8f0.js",revision:null},{url:"assets/diagram-S2PKOQOG-pDeg6Fn3.js",revision:null},{url:"assets/diagram-QEK2KX5R--DLuyaBU.js",revision:null},{url:"assets/diagram-PSM6KHXK-u9Bq7oQj.js",revision:null},{url:"assets/defaultLocale-DX6XiGOO.js",revision:null},{url:"assets/dagre-6UL2VRFP-pL4oLzgR.js",revision:null},{url:"assets/cytoscape.esm-5J0xJHOV.js",revision:null},{url:"assets/cose-bilkent-S5V4N54A-CceqRbLG.js",revision:null},{url:"assets/clone-CJT8Sng7.js",revision:null},{url:"assets/claude-provider-BpBBcvvu.js",revision:null},{url:"assets/classDiagram-v2-WZHVMYZB-pG5FcKCa.js",revision:null},{url:"assets/classDiagram-2ON5EDUG-pG5FcKCa.js",revision:null},{url:"assets/chunk-TZMSLE5B-Did4v35P.js",revision:null},{url:"assets/chunk-QZHKN3VN-BpY3MN1h.js",revision:null},{url:"assets/chunk-QN33PNHL-C8Gh8Kbh.js",revision:null},{url:"assets/chunk-FMBD7UC4-DGED6SBi.js",revision:null},{url:"assets/chunk-DI55MBZ5-CqWBFVaC.js",revision:null},{url:"assets/chunk-B4BG7PRW-1SR22WeC.js",revision:null},{url:"assets/chunk-55IACEB6-CWcaiZ1g.js",revision:null},{url:"assets/chunk-4BX2VUAB-WOh8BXBb.js",revision:null},{url:"assets/chat-BEnAHpY-.js",revision:null},{url:"assets/channel-DZA6uvxN.js",revision:null},{url:"assets/c4Diagram-YG6GDRKO-DqniwIVA.js",revision:null},{url:"assets/blockDiagram-VD42YOAC-DFxYaCGe.js",revision:null},{url:"assets/architectureDiagram-VXUJARFQ-CtiOagZF.js",revision:null},{url:"assets/arc-M-sFvFvX.js",revision:null},{url:"assets/_baseUniq-C5dU7AKy.js",revision:null},{url:"assets/_basePickBy-BlfxZvco.js",revision:null},{url:"assets/WidgetDemoPage-O5Vfu1LQ.js",revision:null},{url:"assets/WidgetDemoPage-BSWX2CxO.css",revision:null},{url:"assets/ThreadNode-Bt5yTyUn.js",revision:null},{url:"assets/SongGrid.vue_vue_type_script_setup_true_lang-CW1T9zpX.js",revision:null},{url:"assets/SongGrid-BgCYmw2O.js",revision:null},{url:"assets/SongDetail.vue_vue_type_script_setup_true_lang-CvC0ROCb.js",revision:null},{url:"assets/SongDetail-DCzBBkzH.js",revision:null},{url:"assets/GuidePage-CpiR8yAR.js",revision:null},{url:"assets/GuidePage-BvYaLEzG.css",revision:null},{url:"assets/FilmGrid.vue_vue_type_script_setup_true_lang-CWkUdZ32.js",revision:null},{url:"assets/FilmGrid-EKTg8OUS.js",revision:null},{url:"assets/FilmDetail.vue_vue_type_script_setup_true_lang-Cg4zvjy1.js",revision:null},{url:"assets/FilmDetail-XFjPooKR.js",revision:null},{url:"assets/ConversationViewerPage-1f3wXZHu.js",revision:null},{url:"assets/ChatWindow.vue_vue_type_script_setup_true_lang-DoshhDBV.js",revision:null},{url:"assets/ChatWindow-D6NcMh5O.css",revision:null},{url:"assets/ChatPage-UEkXBR6z.css",revision:null},{url:"assets/ChatPage-BOjiIMc2.js",revision:null},{url:"assets/BrowsePage-C71ADslt.js",revision:null},{url:"assets/icons/microphone.svg",revision:null},{url:"apple-touch-icon-180x180.png",revision:"7c24333289dd2af70268ed3018b06188"},{url:"favicon.svg",revision:"72e74ad8f660d9400c34fa69912b94a3"},{url:"icon.svg",revision:"dba94027bbb3b869c0ebf9b6beee1953"},{url:"pwa-192x192.png",revision:"b808488f273b70ad731254043774b56f"},{url:"pwa-512x512.png",revision:"93c28a922e11a852a2ff9c277dc60037"},{url:"manifest.webmanifest",revision:"28fc12e11969e378feb1aaa569dafb80"}],{}),s.cleanupOutdatedCaches(),s.registerRoute(new s.NavigationRoute(s.createHandlerBoundToURL("index.html"))),s.registerRoute(/^https:\/\/api\.anthropic\.com\/.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/^https:\/\/openrouter\.ai\/.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/web-search\?.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/ollama\/.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/rss-articles\?.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/tmdb\/.*/i,new s.StaleWhileRevalidate({cacheName:"tmdb-cache",plugins:[new s.ExpirationPlugin({maxEntries:200,maxAgeSeconds:86400})]}),"GET"),s.registerRoute(/^https:\/\/image\.tmdb\.org\/.*/i,new s.CacheFirst({cacheName:"tmdb-images",plugins:[new s.ExpirationPlugin({maxEntries:500,maxAgeSeconds:604800})]}),"GET"),s.registerRoute(/^https:\/\/upload\.wikimedia\.org\/.*/i,new s.CacheFirst({cacheName:"wiki-images",plugins:[new s.ExpirationPlugin({maxEntries:200,maxAgeSeconds:604800})]}),"GET"),s.registerRoute(/^https:\/\/d12wklypp119aj\.cloudfront\.net\/image\/.*/i,new s.CacheFirst({cacheName:"wavlake-images",plugins:[new s.ExpirationPlugin({maxEntries:300,maxAgeSeconds:604800})]}),"GET")}); +if(!self.define){let s,e={};const i=(i,l)=>(i=new URL(i+".js",l).href,e[i]||new Promise(e=>{if("document"in self){const s=document.createElement("script");s.src=i,s.onload=e,document.head.appendChild(s)}else s=i,importScripts(i),e()}).then(()=>{let s=e[i];if(!s)throw new Error(`Module ${i} didn’t register its module`);return s}));self.define=(l,n)=>{const r=s||("document"in self?document.currentScript.src:"")||location.href;if(e[r])return;let a={};const u=s=>i(s,r),t={module:{uri:r},exports:a,require:u};e[r]=Promise.all(l.map(s=>t[s]||u(s))).then(s=>(n(...s),a))}}define(["./workbox-3c177d20"],function(s){"use strict";self.skipWaiting(),s.clientsClaim(),s.precacheAndRoute([{url:"registerSW.js",revision:"c492f944af160ee2e9a237c509dd270a"},{url:"index.html",revision:"4289dfcc6202f1f590f889e27db369ca"},{url:"icon.svg",revision:"dba94027bbb3b869c0ebf9b6beee1953"},{url:"favicon.svg",revision:"72e74ad8f660d9400c34fa69912b94a3"},{url:"images/loading-poster.svg",revision:"97c56238c72450e4953e1d7db2f6e8e6"},{url:"assets/xychartDiagram-PRI3JC2R-7BKZJd6v.js",revision:null},{url:"assets/wikipedia-BNDKhpH7.js",revision:null},{url:"assets/useNostr-XONW-p_l.js",revision:null},{url:"assets/useContentImages-7wLVntsF.js",revision:null},{url:"assets/treemap-GDKQZRPO-DJjQsbt8.js",revision:null},{url:"assets/timeline-definition-IT6M3QCI-DY7_afEv.js",revision:null},{url:"assets/stateDiagram-v2-4FDKWEC3-LzueEDky.js",revision:null},{url:"assets/stateDiagram-FKZM4ZOC-pVxTgt6V.js",revision:null},{url:"assets/song-renderer-C7kO7K9V.js",revision:null},{url:"assets/sequenceDiagram-WL72ISMW-VBErL0-f.js",revision:null},{url:"assets/sankeyDiagram-TZEHDZUN-Bdq3WEjh.js",revision:null},{url:"assets/requirementDiagram-UZGBJVZJ-oASOEzEc.js",revision:null},{url:"assets/quadrantDiagram-AYHSOK5B-D5eqUBKn.js",revision:null},{url:"assets/pieDiagram-ADFJNKIX-1zGTmPKE.js",revision:null},{url:"assets/ordinal-Cboi1Yqb.js",revision:null},{url:"assets/openlibrary-B8IPeH2e.js",revision:null},{url:"assets/nodeDemoPrompts-ByvlmttR.js",revision:null},{url:"assets/mindmap-definition-VGOIOE7T-DQT8gnk8.js",revision:null},{url:"assets/mermaid.core-v0oo9NRr.js",revision:null},{url:"assets/linear-Bzk-L7jX.js",revision:null},{url:"assets/layout-D8RlHuWE.js",revision:null},{url:"assets/katex.min-CASE1JAf.css",revision:null},{url:"assets/katex-DGN8GczM.js",revision:null},{url:"assets/kanban-definition-3W4ZIXB7-DD0wGwEr.js",revision:null},{url:"assets/journeyDiagram-XKPGCS4Q-BH_LnklX.js",revision:null},{url:"assets/init-Gi6I4Gst.js",revision:null},{url:"assets/infoDiagram-HS3SLOUP-CDB_zSju.js",revision:null},{url:"assets/index-BJkaQ2c4.css",revision:null},{url:"assets/index-8cIrvc8q.js",revision:null},{url:"assets/guideConversation-BYC5cBFP.js",revision:null},{url:"assets/graph-C1er8lVu.js",revision:null},{url:"assets/gitGraphDiagram-V2S2FVAM-txN368h-.js",revision:null},{url:"assets/ganttDiagram-JELNMOA3-CgBtZE6e.js",revision:null},{url:"assets/freeFilms-B9DmMKj5.js",revision:null},{url:"assets/flowDiagram-NV44I4VS-CWTX8pT7.js",revision:null},{url:"assets/film-renderer-CWa3YMln.js",revision:null},{url:"assets/erDiagram-Q2GNP2WA-BmCE71lJ.js",revision:null},{url:"assets/diagram-S2PKOQOG-a8VNSi9i.js",revision:null},{url:"assets/diagram-QEK2KX5R-BNtLuIsF.js",revision:null},{url:"assets/diagram-PSM6KHXK-DIdn47Al.js",revision:null},{url:"assets/defaultLocale-DX6XiGOO.js",revision:null},{url:"assets/dagre-6UL2VRFP-B0u-JQFh.js",revision:null},{url:"assets/cytoscape.esm-5J0xJHOV.js",revision:null},{url:"assets/cose-bilkent-S5V4N54A-BayuTRyx.js",revision:null},{url:"assets/clone-C1u3K6Fy.js",revision:null},{url:"assets/claude-provider-DbzPoW6j.js",revision:null},{url:"assets/classDiagram-v2-WZHVMYZB-ClBnCz4x.js",revision:null},{url:"assets/classDiagram-2ON5EDUG-ClBnCz4x.js",revision:null},{url:"assets/chunk-TZMSLE5B-93PKdbpb.js",revision:null},{url:"assets/chunk-QZHKN3VN-Diifi0zg.js",revision:null},{url:"assets/chunk-QN33PNHL-DSThOC6-.js",revision:null},{url:"assets/chunk-FMBD7UC4-HblipWIM.js",revision:null},{url:"assets/chunk-DI55MBZ5-BzH7fNN2.js",revision:null},{url:"assets/chunk-B4BG7PRW-eem5VR5l.js",revision:null},{url:"assets/chunk-55IACEB6-CtULfmDo.js",revision:null},{url:"assets/chunk-4BX2VUAB-DWDvTYfd.js",revision:null},{url:"assets/chat-BVoaeFpp.js",revision:null},{url:"assets/channel-Dg2Em7BA.js",revision:null},{url:"assets/c4Diagram-YG6GDRKO-DXp1hgr3.js",revision:null},{url:"assets/blockDiagram-VD42YOAC-B0IT7n2Q.js",revision:null},{url:"assets/architectureDiagram-VXUJARFQ-CKJFX1bN.js",revision:null},{url:"assets/arc-UjuE1bPP.js",revision:null},{url:"assets/_baseUniq-DAOs4kUj.js",revision:null},{url:"assets/_basePickBy-CL4iQUG-.js",revision:null},{url:"assets/WidgetDemoPage-Mol6efr5.js",revision:null},{url:"assets/WidgetDemoPage-BSWX2CxO.css",revision:null},{url:"assets/ThreadNode-DBTMRZ0t.js",revision:null},{url:"assets/SongGrid.vue_vue_type_script_setup_true_lang-IvAOIQYW.js",revision:null},{url:"assets/SongGrid-C568K-c_.js",revision:null},{url:"assets/SongDetail.vue_vue_type_script_setup_true_lang-B3om3Z8v.js",revision:null},{url:"assets/SongDetail-BiVK4Yzb.js",revision:null},{url:"assets/GuidePage-DncbNAAY.js",revision:null},{url:"assets/GuidePage-CXT-xcyj.css",revision:null},{url:"assets/FilmGrid.vue_vue_type_script_setup_true_lang-Dj0SEfcW.js",revision:null},{url:"assets/FilmGrid-BM-3a1vS.js",revision:null},{url:"assets/FilmDetail.vue_vue_type_script_setup_true_lang-BhKlPG3Y.js",revision:null},{url:"assets/FilmDetail-0aNPT6Ze.js",revision:null},{url:"assets/ConversationViewerPage-PTutY4Lq.js",revision:null},{url:"assets/ChatWindow.vue_vue_type_script_setup_true_lang-zPFvWywX.js",revision:null},{url:"assets/ChatWindow-CHiy55Bk.css",revision:null},{url:"assets/ChatPage-B49DA8RU.css",revision:null},{url:"assets/ChatPage-AitDGZKH.js",revision:null},{url:"assets/BrowsePage-CfZ78QhW.js",revision:null},{url:"assets/icons/microphone.svg",revision:null},{url:"apple-touch-icon-180x180.png",revision:"7c24333289dd2af70268ed3018b06188"},{url:"favicon.svg",revision:"72e74ad8f660d9400c34fa69912b94a3"},{url:"icon.svg",revision:"dba94027bbb3b869c0ebf9b6beee1953"},{url:"pwa-192x192.png",revision:"b808488f273b70ad731254043774b56f"},{url:"pwa-512x512.png",revision:"93c28a922e11a852a2ff9c277dc60037"},{url:"manifest.webmanifest",revision:"28fc12e11969e378feb1aaa569dafb80"}],{}),s.cleanupOutdatedCaches(),s.registerRoute(new s.NavigationRoute(s.createHandlerBoundToURL("index.html"))),s.registerRoute(/^https:\/\/api\.anthropic\.com\/.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/^https:\/\/openrouter\.ai\/.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/web-search\?.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/rss-articles\?.*/i,new s.NetworkOnly,"GET"),s.registerRoute(/\/api\/tmdb\/.*/i,new s.StaleWhileRevalidate({cacheName:"tmdb-cache",plugins:[new s.ExpirationPlugin({maxEntries:200,maxAgeSeconds:86400})]}),"GET"),s.registerRoute(/^https:\/\/image\.tmdb\.org\/.*/i,new s.CacheFirst({cacheName:"tmdb-images",plugins:[new s.ExpirationPlugin({maxEntries:500,maxAgeSeconds:604800})]}),"GET"),s.registerRoute(/^https:\/\/upload\.wikimedia\.org\/.*/i,new s.CacheFirst({cacheName:"wiki-images",plugins:[new s.ExpirationPlugin({maxEntries:200,maxAgeSeconds:604800})]}),"GET"),s.registerRoute(/^https:\/\/d12wklypp119aj\.cloudfront\.net\/image\/.*/i,new s.CacheFirst({cacheName:"wavlake-images",plugins:[new s.ExpirationPlugin({maxEntries:300,maxAgeSeconds:604800})]}),"GET")}); diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html index ce7e2809..a479acaa 100644 --- a/docker/bitcoin-ui/index.html +++ b/docker/bitcoin-ui/index.html @@ -999,7 +999,7 @@
- +
diff --git a/docker/fips-ui/index.html b/docker/fips-ui/index.html index 3c7752ba..83651210 100644 --- a/docker/fips-ui/index.html +++ b/docker/fips-ui/index.html @@ -153,7 +153,7 @@
-
+
@@ -396,7 +396,7 @@ const transport = document.getElementById('aTransport').value; const label = document.getElementById('aLabel').value.trim(); if (!npub.startsWith('npub1')) { notice('anchorNotice', 'error', 'npub must start with npub1…'); return; } - if (!address.includes(':')) { notice('anchorNotice', 'error', 'Address must be host:port (e.g. 192.168.1.116:8668).'); return; } + if (!address.includes(':')) { notice('anchorNotice', 'error', 'Address must be host:port (e.g. 192.0.2.12:8668).'); return; } busy(btn, true, 'Adding…'); notice('anchorNotice', '', ''); try { diff --git a/docker/lnd-ui/index.html b/docker/lnd-ui/index.html index dc2f84cf..ee5d0511 100644 --- a/docker/lnd-ui/index.html +++ b/docker/lnd-ui/index.html @@ -974,7 +974,7 @@ // the /dashboard record in neode-ui/src/router/index.ts, so bare // /apps/lnd/channels is not a route at all. nginx's SPA fallback still // returns 200 for it, so it fails as vue-router's NotFound view rather - // than an HTTP 404 — which is exactly how it presented on archi-dev-box. + // than an HTTP 404 — which is exactly how it presented on a test node. const CHANNELS_URL = window.location.protocol + '//' + window.location.hostname + '/dashboard/apps/lnd/channels'; // ── State ─────────────────────────────────────────────────────── diff --git a/docker/mempool-frontend/Dockerfile b/docker/mempool-frontend/Dockerfile index d312506d..b773591e 100644 --- a/docker/mempool-frontend/Dockerfile +++ b/docker/mempool-frontend/Dockerfile @@ -5,7 +5,7 @@ # the frontend re-resolves the backend (mempool-api) via DNS on every request. # Without this, nginx pins the backend IP at startup and serves 502 / "offline" # after any backend restart (podman reassigns the IP). See the script header. -ARG BASE=146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0 +ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0 FROM ${BASE} # --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644 diff --git a/docs/1.8.0-RELEASE-HARDENING-PLAN.md b/docs/1.8.0-RELEASE-HARDENING-PLAN.md index 4c2a2c24..ca50ecb6 100644 --- a/docs/1.8.0-RELEASE-HARDENING-PLAN.md +++ b/docs/1.8.0-RELEASE-HARDENING-PLAN.md @@ -3,7 +3,6 @@ > **The one living checklist for shipping 1.8.0.** Derived from a full-system deep > audit (2026-07-02): backend security, backend code-quality, frontend, mesh, > tests/release pipeline, and the ISO build. Supersedes nothing — it *sits above* -> `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) as the release exit-criteria list. > **Keep it updated: tick a box the moment an item lands, with the commit sha.** **Definition of done for 1.8.0:** the supply chain is authenticated end-to-end @@ -72,7 +71,7 @@ arbitrary app catalog to the entire fleet — fully unattended under install cosign + publish real `image_signature` values (in that order); tracked with the Workstream B signing ceremony item. - [ ] 🟠 **Move the image mirror to HTTPS; drop `--tls-verify=false`.** - `podman_client.rs:641` `INSECURE_REGISTRY_HOSTS = ["146.59.87.168:3000"]` + + `podman_client.rs:641` `INSECURE_REGISTRY_HOSTS = ["source.archipelago-foundation.org"]` + `config.rs:104,124` allowlist pull images over unauthenticated HTTP. Remove the raw-IP entries; give the mirror a valid/pinned cert. (Same host also baked insecurely into the ISO — see §F.) @@ -302,16 +301,21 @@ media (latest artifact only one minor behind). a failed regeneration keeps the baked keys instead of leaving the device keyless. **Unverified on hardware**: needs one RC-ISO install to confirm the service fires and sshd/nginx pick up the new keys. -- [ ] 🟠 **Kill default credentials.** `archipelago`/`archipelago` (SSH+root), web `password123`, - and SSH `PasswordAuthentication yes` (`:411`) all ship. Lock root, force credential - creation in onboarding, disable SSH password auth (or force-change on first login). +- [~] 🟠 **Kill default credentials.** The **web** default is GONE: no default account is + ever created (`main.rs:356-362` deliberately does not call `AuthManager::ensure_default_user`), + the login screen shows a password-creation form while `auth.isSetup` is false, and the + `password123` pre-setup bypass is `#[cfg(debug_assertions)]` + `dev_mode` (`api/rpc/auth.rs:36-46`), + so no release binary carries it. STILL SHIPPING: the SSH login + `archipelago`/`archipelago` (`image-recipe/archipelago-scripts/install-to-disk.sh:205`) + and SSH `PasswordAuthentication yes`. Lock root, disable SSH password auth (or + force-change on first login). - [~] 🟠 **Sign + checksum the ISO.** Checksums DONE 2026-07-13 (`caf9e6d3`): the builder emits `.sha256` after xorriso, and `scripts/sign-iso-checksums.sh` signs `{artifact, sha256, size}` as a JSON doc with the release-root ceremony (verify with `archipelago ceremony verify` against the pinned anchor; build host never holds the key). **Still open:** Secure Boot — `BOOTX64.EFI` is unsigned though `grub-efi-amd64-signed` is installed. -- [ ] 🟠 **Registries over HTTPS in the image too** — `146.59.87.168:3000` +- [ ] 🟠 **Registries over HTTPS in the image too** — `source.archipelago-foundation.org` are baked `insecure=true`/`tls_verify:false` (`:216`, `:2308`). (Ties to §A.) - [ ] 🟡 **Add `unattended-upgrades` + a default-deny nftables firewall** (allow 22/80/443 + mesh/WG). Neither exists today; OS packages drift until reflash and there is no host @@ -375,7 +379,7 @@ media (latest artifact only one minor behind). --- -## §I — Carried-over open items (from `UNIFIED-TASK-TRACKER.md`, still valid) +## §I — Carried-over open items (still valid) - [~] 🟠 **Multinode gate pass** — 5× destructive gate was launched on node `.5`; bring the rest of the fleet to precondition, then run the existing (undocumented-but-present) @@ -392,7 +396,7 @@ media (latest artifact only one minor behind). **`1.8.0-alpha`**. Remaining work is the mechanical bump + `create-release.sh` run when the gate criteria are met. - [ ] 🟢 **Bitcoin multi-version fleet OTA** — DECIDED (user, 2026-07-08): timing doesn't - matter; fold the branch into the next fleet OTA (`docs/bitcoin-version-bulletproof-rollout.md`). + matter; fold the branch into the next fleet OTA. - [x] ~~⛔🟢 **3ccc stock-Meshtastic RF validation**~~ — DROPPED per user 2026-07-08; the code fix stays in, no live-radio validation will be scheduled. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 87a9a224..7f7e2179 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -130,7 +130,11 @@ curl -s http:///rpc/v1 -b jar.txt -H 'Content-Type: application/json' \ -d '{"method":"system.stats","params":{}}' ``` -Login returns a `session` cookie. Read-only methods (`system.stats`, `system.get-metrics`, `bitcoin.getinfo`, `monitoring.current`, `bitcoin.relay-status`, `tor.status`) are CSRF-exempt, so the cookie alone is enough; state-changing calls also need the `X-CSRF-Token` header. If TOTP is enabled, follow the login with `auth.login.totp`. +Login returns a `session` cookie. State-changing calls also need the `X-CSRF-Token` header. Exactly twelve read-only methods are CSRF-exempt, so for those the cookie alone is enough: + +`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-version` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes` + +Anything not on that list — including `bitcoin.getinfo` and `monitoring.current` — needs the CSRF header. If TOTP is enabled, follow the login with `auth.login.totp`. --- diff --git a/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md b/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md deleted file mode 100644 index 45a53f25..00000000 --- a/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md +++ /dev/null @@ -1,300 +0,0 @@ -# FIPS near-100% uptime + optimistic UI state — implementation plan - -**Date:** 2026-07-27. **Status:** researched + root-caused live on the fleet; ready to -implement for the next release. Two workstreams: (A) make node↔node FIPS transport -succeed whenever a FIPS path physically exists, (B) stop the UI reloading everything -on every navigation (optimistic/cached cards, stale-while-revalidate) while keeping -data fresh. - -**Honesty note on "100%":** if a node's network blackholes every anchor (the .116 -WiFi case, `docs/HANDOFF-2026-07-20-fips-peer-files.md:117-133`), Tor fallback is -*correct*. The achievable target is: **FIPS wins whenever a FIPS path exists, and -fallback frequency is measured in-product so regressions are visible.** Today several -paths are 0% FIPS *by construction* regardless of network health — that's the bug. - ---- - -## Part A — why Cloud/FIPS "commonly falls back to Tor": ranked root causes - -All verified live on 2026-07-27 (.116 local, .198, .228, Framework PT, x250s) plus a -full code audit of `core/archipelago/src/{fips,transport,federation,server.rs}`. - -### RC0 — 🔥 The hardening firewall drops the peer-API port on every hardened node (PROVEN) - -The fips0 default-deny baseline (`/etc/fips/fips.nft`) is opened by archipelago's -drop-in `80-web-ui.nft` (`fips/config.rs:236-255`) for **80 + 8443 + app ports only**. -The peer-API listener — which carries *all* federation sync, cloud browse/download, -mesh envelopes, DWN, invoices — is **`PEER_PORT = 5679`** (`fips/dial.rs:35`). -**5679 is not in the allowlist.** The drop-in's own comment claims "web UI + peer -API" but the peer API port was never added. - -Live proof (2026-07-27): -- .116 nft chain: 5,965 dropped packets; .198: **28,670 dropped packets** — that's - peers' FIPS dials dying at the firewall. -- .198 → .116 `GET :5679/health`: **timeout (6s)** before; **HTTP 200 in 0.35s** - after `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`. - Same result in reverse direction (200 in 0.64s). -- Explains the exact fleet split in `federation/nodes.json`: hardened-baseline nodes - (Framework PT, .198, .228, x250-dev, x250-mad2) = `last_transport: tor`; - non-hardened nodes (Austin Sapien, X250-Beta, X250-PA) answer :5679 (404 from the - path allowlist = listener reachable) = `last_transport: fips`. -- Every dial to a hardened peer pays the 8s FIPS connect timeout - (`dial.rs:114`) ×2 (retry, `dial.rs:128-140`) → then Tor. That's the "Cloud takes - forever / shows Tor" experience. - -**Fix (one line + reload):** add `tcp dport 5679 accept` to the drop-in in -`fips/config.rs` (use a constant shared with `dial.rs::PEER_PORT`, not a literal). -The drop-in reinstalls on every daemon config install, so it heals fleet-wide on OTA. -⚠️ Transient manual rules were inserted on .116 and .198 during diagnosis (2026-07-27) -— they vanish on the next `nft -f /etc/fips/fips.nft` reload or reboot; the code fix -makes them permanent. - -### RC1 — .228 (Shorty's) runs fips 0.3.0-dev; the 0.4.1 fleet can't reach it - -.228's daemon: `0.3.0-dev (rev 34e00b9f6e)`, both anchor links "connected", but its -ULA is 100% unreachable from 0.4.1 nodes (ping loss 100%). FIPS wire format is not -stable across revs (`docs/HANDOFF-2026-07-23-companion-apk-deploy.md:78`). Everything -to/from .228 rides Tor no matter what else we fix. - -**Fix:** fleet fips-version audit + upgrade to v0.4.1 everywhere (in-product updater -exists: `fips/update.rs`; .deb path per `reference_vps2_fips_anchor`). Add a version -check to `fips.status` and surface a "peer daemon outdated" warning. - -### RC2 — Direct LAN/endpoint peering is dead code + wrong port + stale seed anchors - -Without direct links, all peer traffic hairpins through the vps2 anchor spanning -tree (observed: .116→.198 cold RTT 1.5–3.5s on the same LAN; also the wedged-anchor -latency-rot incident, `HANDOFF-2026-07-23:141-160`). - -- **G1 — `lan_fips_anchors()` has never run.** It needs `PeerRecord.fips_npub`, but - `PeerRegistry::set_fips_npub` (`transport/mod.rs:302`) has **zero callers** — mDNS - TXT records only carry `did`/`pubkey`/`version` (`transport/lan.rs:50-54`). So the - "co-located peers form a direct link" feature (`anchors.rs:294-305`, - `server.rs:761-766`) is a fleet-wide no-op. -- **G2 — wrong UDP port.** `anchors.rs:293` dials `8668`, but the generated - fips.yaml binds UDP **2121** (`fips/config.rs:187`, `fips/mod.rs:130`). Even if G1 - ran, it would dial a dead port. `.116`'s live `seed-anchors.json` still carries - `.198@192.168.1.198:8668` — **stale IP (LAN renumbered to 192.168.63.x) AND dead - port**; both manual entries are useless today. -- No Tailscale/alternate endpoint fallback when LAN is unreachable (the .116↔.198 - fix of 2026-07-20 was hand-applied per-node config, never productized). - -**Fix:** (a) `FIPS_UDP_PORT` → `crate::fips::PUBLISHED_UDP_PORT` + drift-guard test; -(b) hydrate `fips_npub` into the registry from federation storage (did-keyed join) so -`lan_fips_anchors` goes live with no wire change; (c) advertise the npub in the mDNS -TXT + `set_fips_npub` on resolve as the proper fix; (d) teach the LAN-anchor tick to -also try a peer's Tailscale/last-known-good endpoint when LAN fails (reviewed change -— this area got handoffs wrong twice, per memory). - -### RC3 — No fast-fail on the hottest call sites; retry silently doubles every budget - -- `content.browse-peer` — **the Cloud page** — has NO `fips_timeout` - (`api/rpc/content.rs:363-366`): a cold FIPS path burns up to ~16.6s (8s connect + - 600ms + 8s retry) before Tor even starts, against a UI deadline of 30s - (`Cloud.vue:720`) — and the frontend then retries ×3. Users see errors, not - fallback. 12 call sites total lack `fips_timeout` (browse/download/preview-peer, - `/blob`, DWN, node_message, rotation notifies). -- `dial.rs:128-140` runs 2 full-budget attempts, so `fips_timeout(6s)` really means - ~12.6s everywhere. - -**Fix:** wrap `send_with_retry` in a single `tokio::time::timeout(fips_attempt_timeout())` -(call sites `dial.rs:455`, `dial.rs:488`; halve per-attempt client timeout), then add -`.fips_timeout(...)`: `content.rs:366` (6s), `content.rs:281` (8s), `content.rs:1139` -(6s), `typed_messages.rs:822` (8s), `dwn_sync.rs:188/213/272` (6s), -`node_message.rs:376` (8s), `node_message.rs:412` (4s), `tor/mod.rs:501` (6s), -`federation/handlers.rs:869` (6s). **Skip the three 900s streaming downloads** -(`content.rs:552/870/1061`, `proxy.rs:236`) — `dial.rs:311-319` documents why; the -retry-budget wrap covers their connect phase. - -### RC4 — Two features are 100% Tor by construction (allowlist 404) - -The peer listener path allowlist (`server.rs:1219-1239`) omits `/blob/` (mesh -file sharing, `typed_messages.rs:813-822`) and `/dwn/health` (step 1 of DWN sync, -`dwn_sync.rs:186`) → deterministic 404 over FIPS (`dial.rs:44-46` treats 404 as -fall-back) → deterministic Tor, after paying the full FIPS cost. Both endpoints are -already cryptographically gated, so they meet the allowlist's stated criterion. - -**Fix:** add `|| path.starts_with("/blob/") || path.starts_with("/dwn/")`; extend the -existing test block at `server.rs:1935-1945` (assert `/blob/abc` + `/dwn/health` -allowed, `/blobber` + `/dwnx` denied). - -### RC5 — Inbound listener can't heal; anchor flap = 5-minute Tor window; probe overhead - -- `peer_late_bind_loop` returns after first successful bind (`server.rs:1203`) and - `accept_loop` `continue`s on errors forever (`server.rs:1249-1258`): a fips0 - teardown/re-key leaves the node inbound-dead until process restart → **every peer** - falls back to Tor against it. -- Nothing reacts to anchor-link drops: anchors re-apply only on the 300s tick - (`server.rs:731`); worst-case 5min Tor-only after a flap (the historic "link dead - timeout 30s" flapping made this chronic). -- `is_service_active()` spawns up to 2 `systemctl` per FIPS attempt *and* per peer - per 25s warm tick (`dial.rs:284-294`); `warm_path` skips peers without - `fips_npub` in federation storage (`fips/mod.rs:88-95`); `anchors::apply` is - serial with unbounded subprocess waits (`anchors.rs:234-283`). - -**Fix:** rebindable listener; a ~25s connectivity watcher (reuse -`service::peer_connectivity_summary`, `fips/service.rs:178-207`) that re-applies -anchors immediately on a connected→disconnected edge with bounded backoff; 10s TTL -cache for `is_service_active` (mirror `transport/fips.rs:24-107`); warm the union of -federation+registry peers; make `apply()` concurrent with per-connect timeouts. - -### RC6 — Zero observability: fallbacks are invisible, so "uptime" is unfalsifiable - -Fallbacks log at `debug!` only (`dial.rs:458,491`); no counters; `last_transport` is -written by only 7 of ~20 call sites and **never read** to influence anything -(`storage.rs:120-147`). The parallel `TransportRouter` system can't even see FIPS -(`FipsTransport` is never constructed — `server.rs:422-442` registers Tor/Mesh/LAN -only). - -**Fix:** per-reason fallback counters (F1 no-npub / F2 service-inactive / F3 -DNS-fail / F4 connect-fail / F5 404 / F6 5xx) surfaced in `fips.status` + `info!` -logs with a `reason` field; call `record_peer_transport` from all peer-dial sites; -UI: per-peer transport badge on Cloud (the response already carries `transport` — -`content.rs:392-400` — Cloud.vue currently throws it away at `:716-721`). - ---- - -## Part A — execution phases - -### Phase A0 — fleet triage (no release needed; do first, validates everything) -1. Fleet audit: `fipsctl --version` + `nft list table inet fips` + `ss -tlnp | grep 5679` - on every node (roster: `reference_test_deploy_roster`). -2. Transient `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept` - on hardened nodes (already done on .116 + .198, 2026-07-27) — instant fleet-wide - FIPS recovery while the code fix rides the OTA. -3. Upgrade .228 (and any other 0.3.x) fips daemon to v0.4.1. -4. Regenerate/clean stale `seed-anchors.json` on .116 (dead 192.168.1.x + :8668 entries). -5. Baseline measurement: for each node pair, `content.browse-peer` time + transport. - -### Phase A1 — P0 code (one commit, mechanical, offline-testable) -1. **nft drop-in: open 5679** — `fips/config.rs` (share the constant with - `dial.rs::PEER_PORT`). ← RC0 -2. **Allowlist `/blob/`, `/dwn/`** — `server.rs:1219-1239` + tests. ← RC4 -3. **`FIPS_UDP_PORT` = `PUBLISHED_UDP_PORT` (2121)** — `anchors.rs:293` + drift-guard - test against `render_config_yaml()`. ← RC2-G2 -4. **Un-deaden `lan_fips_anchors`** — hydrate `fips_npub` from federation storage in - `server.rs:761-766`; then mDNS TXT `fips` key + `set_fips_npub` - (`transport/lan.rs:50-54`, `lan.rs:96-108`, `LanTransport::new` 4th arg via - `crate::identity::fips_npub(&data_dir.join("identity"))`). ← RC2-G1 -5. **Retry-budget wrap + `fips_timeout` on 12 call sites** (list in RC3). ← RC3 - Verify: `cd core && cargo test -p archipelago` — watch `test_rendered_yaml_exact_snapshot` - (`config.rs:419`) + `test_render_is_deterministic` (`config.rs:476`); item 3 must - not change rendered output. - -### Phase A2 — telemetry BEFORE tuning (second commit) -6. Fallback counters by reason + `fips.status` exposure + `info!` reason logs; - `record_peer_transport` from all sites. ← RC6 (gives the baseline that makes A3 - measurable and "100%" falsifiable) - -### Phase A3 — resilience (third commit, measured against A2 baseline) -7. `is_service_active` 10s TTL cache; warm-path union + `warm_path_unchecked`. -8. Link-state watcher → immediate anchor re-apply on drop (replaces waiting for the - 300s tick); concurrent `apply()` with subprocess timeouts. -9. Rebindable peer listener (`server.rs:1203`, `1249-1258`). -10. (Reviewed, separate PR) endpoint-fallback for direct peering: LAN → Tailscale → - last-known-good, npub-keyed. Mesh-routing area — needs careful review per memory. - -### Phase A4 — verification gate (on nodes, before tag) -- On .116/.198/framework-pt/.228: `content.browse-peer` to every peer must return - `transport: "fips"` with sub-second latency (LAN pairs) / <3s (WAN), 20/20 calls. -- Kill the fips daemon on one node → calls fall back to Tor gracefully within the - fast-fail budget (<8s), UI shows partial results, no errors. -- Restart daemon → FIPS recovers within one watcher tick (~25s), verified in - `fips.status` counters. -- Flap the anchor link (drop vps2 route) → direct LAN pairs keep FIPS via their - direct link (G1 fix proof). -- Add these as `tests/multinode/` cases per `docs/multinode-testing-plan.md`; also - fix the known `node_rpc()` missing `--max-time` (tracker item). - ---- - -## Part B — optimistic loading + state management (frontend) - -Full audit: Pinia exists but pages fetch-on-mount with `loading=true` spinners; -`Dashboard.vue:89` keys the router-view by `route.path`, so **every navigation -unmounts and refetches everything**; no KeepAlive/onActivated anywhere; no dedup, -no abort, no SWR layer. Four hand-rolled cache implementations already exist and -prove the pattern (`useFleetData.ts:198-231` sessionStorage hydrate; -`homeStatus.ts` sticky-ready loadState; `Home.vue:591-621` wallet localStorage -snapshot; `curatedApps.ts:21-77` TTL cache). `SkeletonCard.vue` exists, imported by -zero files. - -### B1 — one shared primitive: `useCachedResource` composable + `resources` Pinia store -Semantics (generalize `homeStatus.ts` + `useFleetData.ts`): -- Keyed resource: `{ data, loadState: idle|loading|ready|error|refreshing, fetchedAt, error }`. -- **Hydrate synchronously** from memory (Pinia, survives navigation) → sessionStorage - snapshot (survives reload) → then revalidate in background. -- Sticky-ready: once `ready`, never regress to `loading` - (`loadState = loadState==='ready' ? 'ready' : 'loading'` — the `homeStatus.ts:80` idiom); - keep-last-known-value on error with a stale badge (age from `fetchedAt`). -- TTL per resource; `revalidateOnFocus` + on WS push (debounced, the - `Home.vue:539-542` pattern); explicit `invalidate(key)` for mutations. -- Optimistic mutation helper: apply → RPC → rollback on error (generalize - `TransportPrefsCard.vue:112-127`). - -### B2 — rpc-client upgrades (`src/api/rpc-client.ts`) -- `AbortSignal` in `RPCOptions` (today the AbortController at `:87` is timeout-only) - → abort-on-unmount for fan-outs. -- In-flight dedup keyed `method+JSON(params)` — collapses duplicate concurrent calls. -- Per-call `maxRetries` override; set `maxRetries: 1` for `content.browse-peer` / - `preview-peer` (retry×3 on a 30s timeout is why one slow peer = 90s spinner). - -### B3 — Cloud page conversion (worst offender, the marquee win) -- Move `sectionCounts`, `peerNodes`, `myFiles`, `peerFiles`, `paidItems` out of - `Cloud.vue` component state (`:403,:476,:582,:689,:427`) into the cached store — - instant render on revisit, background refresh. -- **Incremental per-peer fan-in**: render each peer's card as its - `content.browse-peer` resolves (today `Promise.allSettled` at `:708-747` blocks on - the slowest peer). Per-peer states: cached/fresh/loading/unreachable. -- **Surface `transport` per peer** (already in the response, discarded at `:716-721`): - FIPS/Tor badge + latency — this is also the fleet-wide FIPS-uptime dashboard the - user asked for, for free. -- Skeleton cards (revive `SkeletonCard.vue`, copy `FileGrid.vue:3-19` shimmer) instead - of spinners for counts/folders/peer grids. -- Stop `CloudFolder.vue:307-319` calling `cloudStore.reset()` on every folder entry — - cache per-path listings, navigate renders cache + revalidates. -- `PeerFiles.vue`: persist catalog + preview cache in the store; cap the - `preview-peer` fan-out (`:832-841`, currently unbounded) with a small concurrency - queue + abort-on-unmount. - -### B4 — roll out to remaining offenders (in audit order) -PeerFiles → Web5 wallet/ecash/LND slices → Monitoring → Lightning channels -(`LightningChannelsPanel.vue:650`) → Federation (already has `{showLoader:false}` — -just adopt the store) → Server → Credentials/OpenWrtGateway/ContainerApps. -`Apps.vue`/`Marketplace.vue`/`Fleet.vue` are already good; don't touch. - -### B5 — freshness via the existing push channel -`/ws/db` firehose + `sync.ts` JSON-patch already exist. Wire `useCachedResource` -revalidation to relevant WS pushes (debounced 800ms), keep the 30s staleness -reconciliation as backstop. No new backend needed for v1; a per-topic subscribe can -come later. - -### Part B verification (on nodes) -- Navigate Cloud → Apps → Cloud: peer files render instantly from cache (0 spinner), - refresh indicator while revalidating, updated data lands without layout jump. -- One unreachable peer: its card shows stale/unreachable state; other peers render - immediately (no 30s all-or-nothing). -- Kill backend mid-view: stale data stays visible with age badge; recovery - revalidates automatically. -- Hard reload: sessionStorage hydrate paints before first RPC completes. - ---- - -## Sequencing for the next release - -1. **A0 now** (fleet triage + transient nft rules + .228 daemon upgrade + baseline). -2. **A1 + A2** land together (P0 fixes + telemetry) → deploy to .116/.198 → - Phase A4 checks on the pair → framework-pt → full fleet. -3. **B1 + B2 + B3** (composable + rpc-client + Cloud) in parallel with A-testing — - frontend-only, verifiable against .116 dev (`reference_neode_ui_dev_testing`). -4. **A3** after telemetry baseline exists; **B4/B5** ride the same or next OTA. -5. Gate: Phase A4 checklist green + Part B verification on-device + existing - single-node gate stays green → tag/OTA per ship ritual. - -## Success criteria -- `content.browse-peer` transport = fips for ≥99% of calls between healthy 0.4.1 - nodes over 24h (measured by the new counters), Tor reserved for genuinely - FIPS-unreachable peers (.116-WiFi-class networks). -- Cloud revisit paints in <100ms from cache; fresh data within one revalidate. -- Fallback counters visible in `fips.status` so regressions are caught on the - dashboard, not by users. diff --git a/docs/HANDOFF-2026-07-20-fips-peer-files.md b/docs/HANDOFF-2026-07-20-fips-peer-files.md deleted file mode 100644 index 74f18c16..00000000 --- a/docs/HANDOFF-2026-07-20-fips-peer-files.md +++ /dev/null @@ -1,238 +0,0 @@ -# Handoff — 2026-07-20 — peer-files diagnosis, FIPS 0.4.1, mobile transport pill - -Written for a fresh session that will **cut the OTA release and build the ISO**. -Everything below is already committed and pushed to `gitea-ai/main`. Last release -was `v1.7.105-alpha` (`e2f83c01`); the next one should be **`v1.7.106-alpha`**. - ---- - -## 1. What this release carries (3 commits on top of v1.7.105-alpha) - -| Commit | What | User-visible? | -|---|---|---| -| `9e3ac9ba` | Show the FIPS/Tor transport pill on **mobile** peer files | Yes | -| `3ab7fb52` | Log the full anyhow error chain on RPC failures | No (diagnostics) | -| `5fd0d6c3` | Generate `fips.yaml` from typed structs + enable **mDNS LAN discovery** | Indirectly | - -### `9e3ac9ba` — mobile transport pill -`PeerFiles.vue:15` wraps the peer title in `hidden md:block` (the global header -carries the name on mobile), and the transport pill was nested inside it — so it -vanished below 768px. Added a separate `md:hidden` pill next to the peer icon. -Frontend was rebuilt and the class verified present in the emitted bundle. - -Caveats worth knowing (pre-existing, not introduced here): -- On this code path the backend only ever emits `fips` or `tor`, so the `mesh` - and `lan` branches in `transportPill` (`PeerFiles.vue:609-627`) are dead. -- For **received** mesh messages, `mesh/mod.rs:1519-1533` falls back to a - hardcoded `"tor"` when the transport is unknown — that pill can genuinely lie. - The peer-files pill does not. - -### `3ab7fb52` — full error chain in logs -`api/rpc/mod.rs:441` logged only the outermost anyhow context, so every -peer-files failure read exactly `RPC error on content.browse-peer: Failed to -connect to peer` with the real cause discarded. Now `{:#}`. The client-facing -message still goes through `sanitize_error_message(&e.to_string())` (`{}`), so -no internal detail leaks. **This fix applies to every RPC method, not just -browse-peer.** - -### `5fd0d6c3` — typed FIPS config + mDNS -`fips/config.rs` built `/etc/fips/fips.yaml` by `format!`-ing a string literal. -Upstream's config structs are `#[serde(deny_unknown_fields)]`, so a wrong key -does not degrade — **the daemon refuses to start and the node leaves the mesh**. -Now a typed serde struct tree, verified field-by-field against jmcorgan/fips -**v0.4.1**, with 4 tests: exact-output snapshot, determinism, mDNS key path, and -the pre-existing schema test. All pass. - -Also enables `node.discovery.lan.enabled` (mDNS/DNS-SD, new upstream in v0.4.0) -so co-located nodes peer directly instead of depending on the public anchor. - -> ⚠️ **Expected one-time behaviour on first boot after this lands:** the startup -> drift check at `server.rs:864` compares the freshly rendered config against -> what's on disk. The render differs now, so it reinstalls the config and -> restarts the FIPS daemon **once**. This is the intended self-healing path and -> settles immediately. Do not mistake it for a regression. - -Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig` -has no `lan` field **and** no `deny_unknown_fields`, so v0.3.0 daemons ignore it -harmlessly (verified against the v0.3.0 source). It self-activates on upgrade. - ---- - -## 2. FIPS 0.4.1 — validated, but the fleet is NOT rolled - -Fleet was on FIPS **0.3.0 / 0.3.0-dev** (2026-05-11). Upstream is **v0.4.1** -(2026-07-19). Verified before touching anything: - -- **Wire-compatible** 0.3.0 → 0.4.0 → 0.4.1. Rolling upgrade, any order, no flag day. -- **Config forward-compatible** — every key we emit exists in 0.4.1. -- **Asset names match** what `fips/update.rs` expects (`fips__.deb` + - `checksums-linux.txt`), so the in-product updater should work. - -### Upgraded so far (2 of N) -| Node | Before | After | Result | -|---|---|---|---| -| OptiPlex `.198` / `100.114.134.21` | `0.3.0-dev-1` | **0.4.1** | ✅ anchor connected, `is_parent: true`, tree `depth: 4` | -| thinkpad (this machine) | `0.3.0` | **0.4.1** | ✅ service active, but still islanded (see §4) | - -The OptiPlex was still running the **old string-rendered config** and 0.4.1 -accepted it — empirical confirmation of the compat analysis, not just desk work. - -### Upgrade recipe (nodes cannot reach GitHub — sideload) -```bash -# 1. On a host with GitHub access: -curl -sL -o fips_0.4.1_amd64.deb \ - https://github.com/jmcorgan/fips/releases/download/v0.4.1/fips_0.4.1_amd64.deb -curl -sL -o checksums-linux.txt \ - https://github.com/jmcorgan/fips/releases/download/v0.4.1/checksums-linux.txt -sha256sum fips_0.4.1_amd64.deb # must match checksums-linux.txt -# expected: 9befcc0990c7e08742b5a88f75d753a1088134b20525156688d559a317334ded - -# 2. Sideload: -scp fips_0.4.1_amd64.deb archipelago@:/tmp/ - -# 3. On the node — the same command update.rs uses: -sudo -n systemd-run --collect --wait --quiet --pipe -- \ - env DEBIAN_FRONTEND=noninteractive dpkg --force-confold --force-downgrade -i \ - /tmp/fips_0.4.1_amd64.deb - -# 4. Restart the ACTIVE unit — it is archipelago-fips.service, -# NOT fips.service (which is inactive on these nodes): -sudo -n systemctl restart archipelago-fips.service - -# 5. Verify: -fipsctl --version -sudo -n fipsctl show links # expect anchor 185.18.221.160:8443 connected -sudo -n fipsctl show tree # expect is_root: false, depth > 0 -``` - -### ISO implication (important) -`image-recipe/build/auto-installer/Dockerfile.rootfs:23` builds FIPS from -**unpinned upstream main** (`git clone --depth 1`, no rev/tag/checksum, amd64 -only). So a freshly built ISO will pick up whatever main is that day — probably -≥0.4.1, but it is not deterministic. Pinning is an open item in -`docs/1.8.0-RELEASE-HARDENING-PLAN.md:319-322`. **Consider pinning to v0.4.1 -before building the release ISO** so the shipped version is knowable. - ---- - -## 3. The original bug — peer cloud files not loading - -**Status: root-caused for the thinkpad; NOT fully explained.** Being explicit -because it would be easy to read this as closed. - -What is established: -- FIPS was fully down on the thinkpad: `fipsctl show peers` → `[]`, `show links` - → `[]`, `show tree` → `is_root: true, depth 0`. An island. -- Cause is **network egress**, not FIPS config: the thinkpad cannot reach the - public anchor `185.18.221.160` (`fips.v0l.io`) **at all** — 100% packet loss on - ICMP, 443/8443/8668 all time out. `show transports` showed - `packets_sent: 760, packets_recv: 0` on both UDP and TCP. -- Local firewall is **not** the cause (nft/iptables policy `accept`; only stock - Tailscale anti-spoof DROPs). -- The OptiPlex, on the same `/24`, reaches the anchor fine → it's the thinkpad's - WiFi segment (`wlp3s0`), which also blocks L2 to `.198` (`ip neigh` → `FAILED`). -- With no FIPS tree, everything falls back to Tor. Every peer in - `federation/nodes.json` reads `last_transport: "tor"`, never `"fips"`. -- **Tor itself is healthy**: fetched the OptiPlex's `/content` over Tor 3×, - HTTP 200 in 4.1–8.5s — well inside the 30s budget at `content.rs:349`. - -What is **not** established: why three specific `content.browse-peer` calls -failed today (05:25, 16:37, 16:43 UTC). Tor tested healthy and was never -reproduced. Two hypotheses were tested and **disproved**: the Tor fallback logic -is correct (FIPS-unreachable returns `None` and falls through in Auto mode), and -the legs get independent timeouts (Tor gets a fresh 30s). Best remaining guess is -cold-circuit timeouts on first fetch after idle — **a guess, not a finding.** -`3ab7fb52` means the next occurrence will log the actual cause. - -### Corrections to earlier claims in this session -- "Point FIPS at the Tailscale IP" was **wrong**. FIPS routes by npub; the - `ip:port` in `fipsctl connect` is only an underlay endpoint hint. -- "The public anchor may be dead fleet-wide" was **wrong**. Its peer is healthy - (`delivery_ratio` 1.0 both directions, bloom filter syncing). The - `bytes_recv: 0` link counters are simply uninstrumented in 0.3.0. - ---- - -## 4. Open items — decisions NOT taken - -1. **Second FIPS anchor (user asked for this; not built).** Needs a host running - FIPS that is reachable from the restricted WiFi. Candidate found: OVH - **`146.59.87.168`** — pings fine from the thinkpad and general egress works - (github 200), while the upstream anchor fails even ICMP there. But it does not - run FIPS yet, so this means **installing FIPS on the box that hosts Gitea** — - a production change, deliberately not made unprompted. Code side is easy after: - `fips/anchors.rs:47-50` is a single hardcoded anchor that should become a list - (`default_public_anchor()` → `default_public_anchors() -> Vec`). -2. **Fleet rollout of FIPS 0.4.1** — only 2 nodes done. `.228` - (`100.64.204.114`) has been **offline ~20h** and could not be included. -3. **Deploying the archipelago binary** carrying `5fd0d6c3` — no node has it yet, - so mDNS is not actually live anywhere. That is what this OTA is for. -4. **mDNS caveat:** on the thinkpad's WiFi, multicast may also be blocked, so - mDNS may not rescue that particular node even after the OTA. It will help - co-located nodes on sane networks. -5. **Pin FIPS in the ISO build** (see §2) — recommended before the release ISO. - ---- - -## 5. Release ritual (from prior sessions — follow exactly) - -Working tree at handoff had pre-existing unrelated dirt: `core/Cargo.lock`, -`release-manifest.json`, `releases/manifest.json` modified, and an untracked -`neode-ui/vite.preview.config.mts`. **Stage explicitly by path** — another -agent may share this tree; never `git add -A`. - -```bash -V=1.7.106-alpha - -# Frontend build — MUST verify dist actually changed (build can silently no-op) -cd neode-ui && npm run build # → web/dist/neode-ui/ -grep -r "md:hidden" ../web/dist/neode-ui/assets/PeerFiles-*.js # sanity - -# Backend -cd core && cargo build --release -p archipelago -# If you hit `rust-lld: undefined hidden symbol`, it's incremental-cache -# corruption — rebuild with CARGO_INCREMENTAL=0 - -# Tarball MUST be flat (files at root, no neode-ui/ wrapper) or every fleet UI 403s -tar -czf releases/v$V/archipelago-frontend-$V.tar.gz -C web/dist/neode-ui . -tar -tzf releases/v$V/archipelago-frontend-$V.tar.gz | head -3 # ./ then ./index.html -# Exclude the ~17MB companion APK from tarballs. - -# Ship -scripts/create-release.sh $V -scripts/publish-release-assets.sh $V gitea-vps2 -git push origin main && git push origin --tags # tag or the Releases page stays empty -git push gitea-ai main # main is protected; use the `ai` account - -# Verify the live manifest -curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json -``` - -Notes: vps2 (`146.59.87.168`) is the **primary** OTA manifest host. Signing is -done at the **user's TTY** — do not attempt it unattended. Clean `/tmp` first -(past releases hit ENOSPC). Changelogs must be **layman-readable**, leading with -user benefit. - -### ISO -```bash -UNBUNDLED=1 bash image-recipe/build-debian-iso.sh -``` -ISO builds are **always unbundled** — the default env silently builds the wrong -full-bundle variant. Only filebrowser + fmcd are baked in. Verify the output -filename contains `unbundled` and is ≈2.4G. The ISO's frontend source is -`/opt/archipelago/web-ui` — rsync dist there first and verify **inside** the ISO. - ---- - -## 6. Node access quick reference - -- **thinkpad (`.116`) is the local machine** — do not SSH to it; read - `journalctl -u archipelago` and `/var/lib/archipelago/**` directly. -- **OptiPlex `.198`** = Tailscale `archipelago-5` / `100.114.134.21`, user - `archipelago`. Its LAN IP is unreachable from the thinkpad — use Tailscale. -- `.228` = `archipelago-2` / `100.64.204.114` — **offline as of 2026-07-20**, and - it is in real use; don't touch uninvited. -- `archipelago-1` (`100.82.34.38`) is a Ryzen AI Max desktop, **not** the OptiPlex. -- Nodes have no `sqlite3` — use `sudo -n python3` to read the JSON stores. -- `fipsctl` needs `sudo -n` (socket is `root:fips` 0660). -- **Never run `archipelago --version` on fleet nodes** (deployed binaries predate #74). diff --git a/docs/HANDOFF-2026-07-23-companion-apk-deploy.md b/docs/HANDOFF-2026-07-23-companion-apk-deploy.md deleted file mode 100644 index 17258ac9..00000000 --- a/docs/HANDOFF-2026-07-23-companion-apk-deploy.md +++ /dev/null @@ -1,205 +0,0 @@ -# HANDOFF — deploy companion APK 0.5.1 (vc21) to nodes - -**For: the agent on archi-dev-box.** User-reported failure this evening: -pairing flow on Framework PT — downloaded the companion from the node's -QR, then the pairing scan didn't work. The APK the node serves predates -today's scanner fixes; the pipeline below gets the fixed build into the -user's hands. - -## What changed on main today (all merged) - -- **Pairing-scanner fix** (`QrScannerOverlay.kt`): ZXing decode attempts are - frame-gated (~7/s, was every frame — the CPU contention made the preview - stutter badly enough to never decode) and PreviewView uses TextureView (no - more black flash on open). This is the likely fix for "doesn't scan". -- Three-finger menu gesture (was two-finger, collided with scroll) + one-time - teaching overlay ~2 min after login. -- Native wallet QR scanner behind `window.ArchipelagoQr` + WebView file-chooser - support; web scan modal hands live scanning to it. -- npub-keyed saved servers (pairing contract item 1, PR #106). -- Served APK refreshed: `neode-ui/public/packages/archipelago-companion.apk` - is now **0.5.3 / versionCode 23**. On top of the 0.5.1 scanner fixes it - guarantees dual-path peering — the node's LAN endpoint (direct p2p, npub- - keyed dial hints) AND the Archipelago public anchor (vps2, baked into the - app so even an old node's QR can't leave the phone LAN-only) — and fixes - the two field failures from the user's 5G test (screenshots, 21:54): - - **Mesh VPN no longer kills the phone's internet** — the IPv6-only TUN - never called `allowFamily(AF_INET)`, so Android blocked all IPv4 while - the mesh was up. Now allowed (+ `allowBypass`). - - **Off-LAN connect works** — `connect()` no longer hard-fails when the - scanned LAN IP doesn't answer; it brings the mesh up and probes the - node's ULA (`meshIp`) with retries before reporting failure. - -## What to do - -1. Redeploy the web-ui bundle from current main to the active nodes — - web root `/opt/archipelago/web-ui/` (NOT a neode-ui/ subfolder), all - nodes the user pairs against, at minimum the one Framework PT scans. -2. Verify the served artifact really updated: - `curl -sI http:///packages/archipelago-companion.apk` — size should - change (~27 MB build of 2026-07-23), or pull it and check - `aapt dump badging` shows `versionCode='21' versionName='0.5.1'`. -3. The demo stack gets its images from CI (run 100 pushed today with the new - web bundle) — confirm the Portainer stack re-pulled, or trigger its - redeploy, so the demo QR also serves vc21. -4. **Node side is half the 5G story**: away-from-home reachability needs the - NODE connected to the public anchor too. On Framework PT (and any test - node): deploy current main (node-side npub-first `fips.pair-info`), then - verify `sudo -n fipsctl show status` reports the anchor connected — - `fips.reconnect` RPC if not. A phone can dial the anchor perfectly and - still fail if the node never enrolled with it. -5. Re-test the user's exact flows with **vc24** (updates any older install in - place): (a) pair ON the LAN, then switch the phone to 5G — the UI must - come up via the mesh ULA; (b) pair while ALREADY on 5G (never on the - node's LAN) — scan, VPN consent, and the connect must succeed through - the anchor. - -## Live diagnosis update (22:30–22:50, phone on adb — Mac agent) - -vc23 on-device testing found and fixed the phone-side blocker, and narrowed -what remains to the node side. State as of vc24: - -- **Fixed: TUN reader died at startup.** Android hands the VpnService fd over - non-blocking; the fips fork's blocking reader thread treats EAGAIN as fatal - ("TUN read error … Try again (os error 11)") — so mesh sessions came up but - NO packet ever entered the tunnel. archy-fips-core now forces the fd - blocking before `start_with_tun_fd`. Verified on-device: reader survives, - and the 30s anchor-link flap disappeared with it (stable 8+ min on 5G). -- **Fixed: VPN marked not-metered** (`setMetered(false)`) — Android 10+ - defaults VPNs to metered, putting the phone into data-restricted behaviour - while the mesh is up. Note the user's phone also has system **always-on - VPN** enabled for the app (`always_on_vpn_app`), a Settings-side toggle. -- **Verified good on-device**: peer store has node (LAN udp/tcp hints) + vps2 - anchor; saved server is npub-keyed with ULA; anchor session establishes - from 5G in ~6s; VPN is bypassable, VALIDATED, only fd00::/8 routed. -- **REMAINING BLOCKER (node side)**: from the phone (app uid), ping6 and - HTTP to the node's ULA `fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824` get no - reply — packets enter the mesh, nothing returns. Phone↔anchor works, so - suspect phone-fork ↔ node-daemon session/routing mismatch (FIPS wire - format is not stable across revs; phone pins fips-native fork 46494a74). - From Framework PT please capture: - - `fipsctl show status` (daemon version + anchor state) - - `fipsctl show sessions` and `show bloom` while the phone pings - - `ping6 ` from the - node (tests the reverse path) - - `ip addr show fips0` + confirm the web server listens on `[::]:80` - Report whether the node ever sees a session attempt from - `npub132c5whrsa6ccs0eylcpzaejq9uxul5ldvczz0axq78dh7fxkqj9st4uvzu`. - -## Node-side diagnosis complete (23:00–23:30, archi-dev-box agent) - -Chain of findings, each verified live: - -1. **FIXED: nginx had no IPv6 listener anywhere** — every shipped config - listened on 0.0.0.0 only, so `http://[]` could NEVER connect on any - node, ever. Live-fixed on framework-pt + .116, canonical conf + bootstrap - self-heal shipped (`1e89362e`), heal binary deployed. ULA HTTP verified - answering on both nodes (local + over-mesh). -2. Firewall clean, fd00::/8 routes correct both ends, wire compat proven - (node + vps2 anchor both run fips 0.4.1 rev 15db6471db — the latest - upstream stable; nothing newer exists). -3. **THE REMAINING PROBLEM IS MESH SESSION PATH QUALITY.** From the vps2 - anchor — a DIRECT connected peer — `GET /health` on the node's ULA takes - **15–17 s per request and intermittently fails outright** (nginx logs - show 499 client-gave-up then 200; TCP SYN-retransmit backoff signature). - Session MMP is wildly asymmetric: node→vps2 srtt 204 ms, **vps2→node - srtt 4270 ms** — on a direct link whose raw RTT is 4 ms. Session traffic - is not riding the direct link; it appears to route through the ~1271-node - public tree (node's tree root is the public 00001a8c, depth 8; the node's - log also shows chronic "Discovery lookup timed out" for other targets). -4. The phone's npub never appears in the node's sessions — consistent with - discovery/handshake dying on the same degraded tree path, and the app's - ~8 s probe window being far smaller than the observed 15 s+ first-request - latency even on the GOOD path. - -### Recommendations - -- **App side (Mac agent):** widen the ULA probe/connect window to ≥30 s - with retransmit-friendly pacing, and PRE-WARM the mesh session (start - pinging the node ULA as soon as the VPN is up, decoupled from the UI - probe) so the WebView hits a warm session. -- **Infra decision (user):** consider detaching the fleet from the public - v0l mesh — private tree rooted at the vps2 anchor (drop the legacy - 185.18.221.160 seed anchor fleet-wide AND vps2's public peering). A - 2-hop private tree would make session paths ride the direct links and - should collapse latency to ms. Trade-off: no reachability to/from the - broader public mesh. -- **Upstream:** report the direct-peer session-path asymmetry to - jmcorgan/fips (0.4.1). - -## App-side recommendations implemented (23:30–23:50, Mac agent — 0.5.5/vc25) - -- Connect probe: mesh ULA now probed inside a **60s budget** with 15s - per-phase timeouts (rides out TCP retransmit backoff), replacing the old - ~8s window. -- **Session pre-warm**: the VPN service starts probing every saved node ULA - the moment the tunnel is up (5s cadence for the first minute, then a 60s - keep-warm tick) — discovery/handshake cost is paid in the background, and - the session never idles out while the mesh is connected. -- (A phone-side ping test in this window still showed zero replies — that - measurement predated the vps2 daemon restart below and is superseded.) - -## RESOLVED — root cause was vps2's degraded daemon, NOT the public tree (23:45) - -The privatize-the-mesh recommendation above is WITHDRAWN. Final diagnosis: -vps2's fips daemon (3 days uptime, 0.2% CPU, idle box) had internally -degraded — EVERY link it carried showed ~4.5 s RTT (even to peers 30 ms -away), and since the anchor sits on the phone↔node path, everything through -it inherited that. `systemctl restart fips` on vps2 restored link RTTs to -40–340 ms, and anchor→node mesh HTTP went from 14–17 s (intermittent hard -fails) to a steady **165–275 ms**. Node↔node direct sessions were always -fine (.116→framework-pt ULA HTTP: 894 ms cold, sub-second warm) — the -user's read was correct. - -Actions taken: dead legacy anchor (185.18.221.160) removed from -framework-pt + .116 seed files (fleet keeps vps2 + public-mesh membership -via vps2 — we stay in the open mesh); fresh daemons on both nodes; -**vps2 fips now has RuntimeMaxSec=1d + Restart=always** so a wedged anchor -daemon can never rot for days again. Report the slow-degradation behaviour -upstream (jmcorgan/fips, 0.4.1): long-running daemon in a ~1400-node mesh -accumulates multi-second link latency at idle CPU, cleared by restart. - -Phone side: vc25's 60 s probe + pre-warm now has a millisecond-latency mesh -to work with. Ready for the user's 5G test. - -## NEXT (00:05, Mac agent → dev-box agent): app direct ports are IPv4-only over the mesh - -The kiosk loads over the ULA now — but opening any APP dies with -`ERR_CONNECTION_REFUSED` at `http://[]:/`. User-hit first on -**Bitcoin Knots (:8334)**, and it will be every catalog app: the web UI -builds app URLs from the current host + the app's DIRECT port (Direct Port -Rule), and container-published ports only bind 0.0.0.0. Verified: -`192.168.63.249:8334` → HTTP 200 (nginx), ULA:8334 → refused. Same disease -as your :80 nginx fix, one layer down. - -Fix must cover EVERY catalog app port and survive app install/remove. Two -shapes; pick what fits the container layer best: - -1. **IPv6 publish at the container layer** — publish on `[::]` too - (pasta/rootless podman support address-specific `-p`), wired into the - container manager so new apps inherit it; or -2. **Host-side v6→v4 forwarders** — generated nginx `stream {}` (or - systemd-socket) units: `listen [::]:` → `127.0.0.1:`, one per - catalog app port, regenerated on app install/remove, boot-time - self-healed like the :80 fix. Keeps the Direct Port Rule URL contract - without touching containers. - -Either way: extend the bootstrap self-heal, and verify from the MESH side -(curl the ULA on 2–3 app ports incl. :8334 from vps2 or .116) — not just -from the LAN. - -## DONE (00:30, dev-box agent): app direct ports live over the mesh - -Shape 2-variant implemented INSIDE the backend (`mesh_ports.rs`, `2ad57c63`): -a reconcile loop mirrors every public IPv4 listener (>=1024, bound 0.0.0.0, -no existing IPv6 any-listener) as a v6-ONLY `[::]:` forwarder to -`127.0.0.1:`, following `/proc/net/tcp*` every 15s — so app -install/remove and hardcoded companion ports (bitcoin-ui :8334) are covered -with zero container changes and no generated units; self-healing because it -lives in the binary. Strictly ADDITIVE: IPv4/LAN/Tor paths untouched, v6only -cannot intercept v4, foreign IPv6 listeners win. - -Verified FROM THE MESH (vps2 → node ULA): :8334 HTTP 200 (466ms), -:18083 200 (306ms), :50002 200 (239ms); LAN :8334 still 200. Deployed to -framework-pt + .116 (binary sha 52ac0d8a…). Direct-port apps should now -open in the companion over 5G. diff --git a/docs/LICENSE-COMPLIANCE-AUDIT.md b/docs/LICENSE-COMPLIANCE-AUDIT.md index 63c022d2..fabac229 100644 --- a/docs/LICENSE-COMPLIANCE-AUDIT.md +++ b/docs/LICENSE-COMPLIANCE-AUDIT.md @@ -2,7 +2,13 @@ Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/*, Android companion, image-recipe ISO, docker/, app-catalog, reticulum-daemon, demo/) plus the external FIPS source and registry-mirrored images. -**Verdict:** the dependency graph is almost entirely permissive (MIT/Apache/BSD) and compatible with a free open-source release. But the repo is not releasable as-is: it has **no license of its own**, one **LGPL Rust dependency**, several **non-redistributable committed assets** (proprietary fonts, unknown-rights media), and **missing attribution machinery**. Everything below is ordered by severity. +**Verdict (as of the 2026-07-22 audit):** the dependency graph is almost entirely permissive (MIT/Apache/BSD) and compatible with a free open-source release. But the repo was not releasable as-is: it had **no license of its own**, one **LGPL Rust dependency**, several **non-redistributable committed assets** (proprietary fonts, unknown-rights media), and **missing attribution machinery**. Everything below is ordered by severity. + +> **Updated 2026-08-08.** §1 (no license) and §3 (non-redistributable committed +> files) are now **closed** — root `LICENSE` (MIT) + `NOTICE` are in the tree, and +> the proprietary fonts and unused packages have actually been deleted. **§2 +> (`zbase32`, LGPL-3.0+) is now closed too** — replaced by an in-tree +> implementation. No copyleft dependency remains in the Rust graph. --- @@ -10,7 +16,28 @@ Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/ **DONE:** - MIT adopted. Root `LICENSE` + `NOTICE` added; `license = "MIT"` in all 5 workspace crates (archy-fips-core already had it); `"license": "MIT"` (+ `"private": true`) in all 4 package.json files. -- Deleted: `Courier_New/`, `Benton_Sans/`, `Redacted/` fonts; `wireguard.apk`; `atob.s9pk`; obsolete `test-install.sh` (all git-rm'd; also removed from `web/dist`). +- Deleted: `Courier_New/`, `Benton_Sans/`, `Redacted/` fonts; `wireguard.apk`; + `atob.s9pk`. + + **History note (2026-08-08):** this line originally claimed all of these plus + `test-install.sh` were "git-rm'd" on 2026-07-23. They were not — only the + `web/dist` copies had been removed, and all seven sources were still tracked at + HEAD nearly three weeks later. The six listed above were actually deleted on + 2026-08-08 (`neode-ui/test-install.sh` was left; it is not a licensing + concern). Kept as a reminder that a DONE entry here is a claim, not evidence — + re-verify with: + + ``` + git ls-tree -r HEAD --name-only | grep -iE 'Courier_New|Benton_Sans|Redacted/|wireguard.apk|atob.s9pk' + ``` + + Deletion was safe: no `@font-face` rule ever referenced them (all four in the + tree load Montserrat), the `Courier New` hits in `tailwind.config.js` and two + public HTML files are `font-family` fallbacks naming the *system* font, and + `wireguard.apk` / `atob.s9pk` had zero references anywhere. Montserrat (OFL.txt) + and Open Sans (LICENSE.txt) remain, as does the actively-used + `archipelago-companion.apk`. Removing the two packages also took ~40 MB off + the frontend OTA tarball. - Media provenance resolved: all demo music/photos/posters, UI sfx, backgrounds, and intro video are the author's original work — recorded in `demo/content/README.md` and `NOTICE`. - Meshtastic device artwork attributed (`mesh-devices/ATTRIBUTION.md` + NOTICE); icon attribution added (`assets/icon/ATTRIBUTION.md`: game-icons.net CC BY 3.0, pixelarticons MIT). - Reticulum decision: include + disclose (NOTICE states the Reticulum License restrictions and that it applies only to the optional daemon). @@ -18,16 +45,23 @@ Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/ - License inventories generated: `core/THIRD-PARTY-LICENSES.md` (649 crates) and `neode-ui/THIRD-PARTY-LICENSES.md` (runtime deps + fonts + vendored). **REMAINING (code changes, awaiting review — see sections below for detail):** -1. Replace `zbase32` (LGPL-3.0+) with `z32` or original impl — §2. +1. ~~Replace `zbase32` (LGPL-3.0+) with `z32` or original impl~~ — **DONE 2026-08-08**, original impl (§2). 2. Swap `redis:7.4.8` → Valkey in `scripts/image-versions.sh` and deploys — §3. 3. Delete dead StartOS-derived crates `core/{js-engine,container-init,models,helpers}` — §4. 4. Attribution build integration: cargo-about in CI → ship full license texts in ISO; vite/rollup license plugin (or UI licenses page) for the web bundle; Android OSS-licenses screen — §5. 5. Release-checklist items: per-release Debian source pointer (snapshot.debian.org), catalog `license`/`sourceUrl` fields, restrict ISO image bundling to the audited list — §6. -6. Before repo goes public: purge deleted fonts/APKs from git history (`git filter-repo`), and verify game-icons author credit. +6. ~~Before repo goes public: purge deleted fonts/APKs from git history (`git filter-repo`)~~ — **superseded**: the launch plan is a fresh-history publish, so there is no history to rewrite. What still applies is verifying the game-icons author credit, and actually deleting the files (see the correction above — they were never removed). + +**Re-verified 2026-08-08:** +- ~~`zbase32 0.1.2` (LGPL-3.0+) is still a direct dependency.~~ **Removed 2026-08-08** — see §2. +- `LICENSE` (MIT) and `NOTICE` are present ✅. `core/THIRD-PARTY-LICENSES.md` and `neode-ui/THIRD-PARTY-LICENSES.md` are present ✅. +- The four StartOS-derived crates in item 3 (`core/{js-engine,container-init,models,helpers}`) **still exist** — note KEY-05 legitimately cites `core/models`, so that one needs a look before deletion rather than a blind `rm`. --- -## 1. BLOCKER — the project has no license +## 1. BLOCKER — the project has no license ✅ CLOSED + +_Resolved: MIT adopted, root `LICENSE` + `NOTICE` present. Original finding below._ There is no `LICENSE`/`COPYING` file anywhere in the repo. No crate in `core/` declares a `license` field; none of the four `package.json` files do either (and the three `apps/*` packages aren't even `private: true`). Until fixed, the code is "all rights reserved" — publicly visible, but legally not open source and not usable by anyone. @@ -37,20 +71,33 @@ There is no `LICENSE`/`COPYING` file anywhere in the repo. No crate in `core/` d - [ ] Add `license = "MIT"` to all five workspace member `Cargo.toml`s (archipelago, container, openwrt, performance, security) and `Android/rust/archy-fips-core` (declares MIT but ships no license file — add one). - [ ] Add `"license": "MIT"` to `neode-ui/package.json` and `apps/{morphos-server,router,did-wallet}/package.json`. -## 2. BLOCKER — copyleft dependency that must be replaced +## 2. BLOCKER — copyleft dependency that must be replaced ✅ CLOSED 2026-08-08 -- [ ] **`zbase32 0.1.2` — LGPL-3.0+** — the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. **Replace with the MIT `z32` crate** or a ~30-line original alphabet-substitution implementation. +- [x] **`zbase32 0.1.2` — LGPL-3.0+** — was the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. + + **DONE 2026-08-08.** Replaced with an original in-tree implementation at + `core/archipelago/src/network/zbase32.rs` (~60 lines incl. docs) rather than + the `z32` crate — the encoding is an alphabet substitution over a bit stream, + so this removes the blocker without adding any dependency or new supply-chain + surface. Dropped from `Cargo.toml` and `Cargo.lock`. + + Byte-compatibility was the hard requirement: a `did:dht` identifier *is* this + encoding of an Ed25519 public key, so any drift would silently rotate every + node's DID and orphan its published DHT records. The replacement is pinned + against the removed crate's own three doc-test vectors, the canonical vectors + from Zimmermann's z-base-32 spec, and four known 32-byte keys — plus a + `did_for_a_known_key_is_stable` test at the `did_dht.rs` call site. No GPL, AGPL, SSPL, or unlicensed crates exist anywhere else in the Rust graph. (`r-efi` and `self_cell` list LGPL/GPL only as options in OR-expressions — elect MIT/Apache, no action.) -## 3. BLOCKER — committed files we may not redistribute +## 3. BLOCKER — committed files we may not redistribute ◐ fonts/packages CLOSED 2026-08-08; media + redis items still open -Remove from git (and **purge from history** before the repo goes public — they're in past commits): +Remove from git (history purge is **moot** — the launch plan is a fresh-history publish, so past commits are not carried over): -- [ ] `neode-ui/public/assets/fonts/Courier_New/` — Monotype proprietary font, no license, **unused in CSS**. Delete. -- [ ] `neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf` — commercial Font Bureau typeface, no license, unused. Delete. -- [ ] `neode-ui/public/packages/wireguard.apk` (17 MB) — official WireGuard Android APK containing GPL-2.0 `libwg` components; redistribution triggers GPL source-offer. **Unreferenced since the FIPS migration** — delete. -- [ ] `neode-ui/public/packages/atob.s9pk` (24 MB) — Start9 service package, unknown license, referenced only by a test script. Delete. +- [x] `neode-ui/public/assets/fonts/Courier_New/` — Monotype proprietary font, no license, **unused in CSS**. ~~Delete.~~ **DELETED 2026-08-08.** +- [x] `neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf` — commercial Font Bureau typeface, no license, unused. ~~Delete.~~ **DELETED 2026-08-08.** +- [x] `neode-ui/public/packages/wireguard.apk` (17 MB) — **DELETED 2026-08-08.** — official WireGuard Android APK containing GPL-2.0 `libwg` components; redistribution triggers GPL source-offer. **Unreferenced since the FIPS migration** — delete. +- [x] `neode-ui/public/packages/atob.s9pk` (24 MB) — **DELETED 2026-08-08.** — Start9 service package, unknown license, referenced only by a test script. Delete. - [ ] `demo/content/music/` (18 full tracks, ~150 MB) and `demo/peer-media/` (17 photos/book covers/film posters) — no recorded rights. If they're your own/AI-generated work, document that in a `demo/content/README`; otherwise remove. - [ ] `neode-ui/public/assets/video/video-intro.mp4`, `Kratter.MP3`, photographic `bg-*.jpg` backgrounds, UI/arcade sound effects in `assets/audio/` — same: document provenance (user-made per project convention) or replace. `welcome-noderunner.mp3` is ElevenLabs TTS — their commercial-use terms allow this on paid plans; note it. - [ ] **Registry: `redis:7.4.8`** (`scripts/image-versions.sh` `REDIS_IMAGE`) — Redis ≥ 7.4 is RSALv2/SSPLv1, **not open source**; re-hosting it on your registry is redistribution under a restricted license. **Switch to Valkey** (BSD-3, already mirrored) everywhere. @@ -59,7 +106,7 @@ Remove from git (and **purge from history** before the repo goes public — they - [ ] **`neode-ui/public/assets/img/mesh-devices/` (36 SVGs)** — almost certainly Meshtastic project device artwork (meshtastic/web is GPL-3.0). Confirm source; either replace with original art or comply with the upstream license + attribution. - [ ] **`neode-ui/public/assets/icon/`** — `barbarian.svg`, `batteries.svg` match game-icons.net (**CC BY 3.0 — visible attribution required**); pixel-style icons match pixelarticons (MIT). Confirm and add attribution, or replace. -- [ ] `Redacted/redacted.regular.ttf` — upstream is SIL OFL 1.1 but no license file is shipped. Add `OFL.txt` or delete (unused). +- [x] `Redacted/redacted.regular.ttf` — upstream is SIL OFL 1.1 but no license file is shipped. ~~Add `OFL.txt` or delete (unused).~~ **DELETED 2026-08-08** (unused; deleting was cheaper than sourcing the OFL text). - [ ] **indeedhub** — submodule (private gitea) not checked out; no known license, yet `indeedhub{,-api,-ffmpeg}:1.0.0` images are distributed via registry/ISO. `indeedhub-ffmpeg` implies a bundled FFmpeg (LGPL/GPL → source-offer obligations). Must license the project and audit the ffmpeg build before public release. - [ ] `minmoto/fmcd` v0.8.0 and `ark-bitcoin/bark` (barkd) — binaries redistributed in your images; verify upstream licenses (bark claims Apache-2.0/MIT dual) and include their notices. - [ ] **Start9/StartOS heritage** — `core/{js-engine,container-init,models,helpers}` are StartOS-derived (embassy paths, s9pk handling). start-os is MIT → attribution required if kept. **Better: delete these four crates** — they are not workspace members, cannot compile (broken `../../patch-db` path dep), and carry an unpinned `yajrc = "*"` git dep on a moving branch. Deleting removes both the attribution question and dead code. @@ -99,7 +146,7 @@ The ISO redistributes a full Debian (trixie) system plus ~29 container image tar ## Quick reference: what's already clean -- All 649 Rust crates except `zbase32`: permissive or dual-licensed. +- All Rust crates: permissive or dual-licensed (`zbase32` was the sole exception and is gone as of 2026-08-08). - All 833 npm packages in neode-ui: no GPL/AGPL anywhere; only dev-tool LGPL (sharp's libvips, never distributed). - Android Gradle deps: 100 % Apache-2.0, all pinned, no Play Services/telemetry. - FIPS mesh: MIT (© 2026 Johnathan Corgan) — keep notice. diff --git a/docs/OPEN-SOURCE-READINESS-PLAN.md b/docs/OPEN-SOURCE-READINESS-PLAN.md deleted file mode 100644 index 378c0e7a..00000000 --- a/docs/OPEN-SOURCE-READINESS-PLAN.md +++ /dev/null @@ -1,288 +0,0 @@ -# Open-Source Readiness Plan — Archipelago public launch - -> Working plan, 2026-07-27. Source of truth for the pre-open-source cleanup. -> A second agent is working the same goal concurrently — before executing any phase, -> diff against `git log` since `7e8d3314` and skip/merge what's already done. -> (Session plan file: `~/.claude/plans/resilient-moseying-reef.md`.) - -## Context - -The repo goes public in a few days, targeting bitcoin/bitcoin-level polish. Three deep -exploration passes (docs/structure, code health, secrets sweep) found the repo is -fundamentally strong — README, `apps/` manifest examples, ADRs, the bats lifecycle gate, -1,104 Rust tests — but has hard blockers: **two live Anthropic API keys committed in -tracked files**, node passwords in 7 tracked files, no LICENSE (README links a 404), -5.5 GB `.git` (re-committed 27 MB APKs), ~290 hardcoded references to the private Gitea -registry `146.59.87.168:3000` that make every app image unpullable for outsiders, and -~28 internal AI-session/tracker docs mixed into `docs/`. - -**Decisions made by the user:** -1. **Fresh-history publish** — new public repo with a clean initial commit; private repo keeps full history. -2. **Registry: domain + parameterize** — real domain in front of the existing registry; host configurable everywhere. -3. **Deep code cleanup** — orphan crates, dead_code lifts, clippy trims, legacy fallback deletion (sequenced, cut-line-friendly). -4. **Internal docs: sanitize and keep public** — scrub creds/IPs/hostnames but publish plans/trackers for transparency. - -**Invariant throughout:** the single-node production gate (`tests/lifecycle/run-gate.sh`) -is GREEN and must stay green. Re-run after any orchestrator/lifecycle change (Phase E -especially). All cargo verification uses `--all-features` to match CI. Stage by explicit -path, never `git add -A` (shared tree). - -## Current local pass status - -This branch is replayed on top of `origin/main` as `public-prelaunch`. - -Completed locally in this pass: - -- Redacted the two tracked Anthropic API key literals from - `scripts/setup-aiui-server.sh` and - `image-recipe/_archived/build-auto-installer-iso.sh`. -- Removed `Android/app/debug.keystore` and `core/.env.production` from the - source tree; copies were preserved in - `~/Desktop/archipelago-sensitive-backup-2026-07-27/`. -- Reworked `scripts/audit-secrets.sh` to scan tracked source more aggressively - and to catch non-example env files and credential file patterns. -- Reworked `scripts/validate-app-manifest.sh` so the current `app:` manifest - schema can be audited without a Python `PyYAML` dependency. -- Updated root/community docs, CI, PR template, app developer notes, and - container/deployment docs toward public contributor expectations. -- Fixed native FIPS activation fallback: nodes that have the packaged - `fips.service` but not `archipelago-fips.service` now start the available - unit instead of repeatedly failing activation against a missing unit. This - now covers startup, supervisor self-heal, manual dashboard start/reconnect, - and post-onboarding activation. The UI now labels the action as `Start` - instead of making native FIPS look like an installable app. -- Fixed the FIPS app-port relay design so it binds relays to the node's FIPS - ULA instead of wildcard `[::]`, avoiding collisions with Podman-published app - ports such as FileBrowser `8083` and Botfights `9100`. -- Added `docs/nostr-git-source-hosting.md`, a NIP-34/ngit/GRASP source hosting - plan using a Bitcoin Core-style maintainer model: public review and easy - forks, with canonical merge rights held by a small signed maintainer set. - -Verified locally: - -- `./scripts/audit-secrets.sh` passes. -- Full `apps/*/manifest.yml` repository audit passes with warnings only. -- `bash -n` passes for the edited shell scripts. -- Targeted FIPS dashboard vitest passes. -- Targeted Rust tests for FIPS service unit detection and FIPS app relay - address selection pass. - -Verified on a Linux Archipelago verification node: - -- Native FIPS was restored by starting the already-installed packaged - `fips.service`; the daemon became active and joined the FIPS tree. -- Correct local lifecycle API endpoint is HTTP, not HTTPS - (`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http`). -- Read-only lifecycle run progressed past login and confirmed required - containers, Bitcoin RPC, ElectrumX TCP, and manifest port-drift checks, but - did not complete cleanly: `botfights` and `filebrowser` remained in - `restarting` longer than the matrix window, and the LND `lncli getinfo` - probe hung. Do not run the destructive gate until those live-node issues are - understood. -- After the node updated to `1.7.116-alpha`, `botfights`, `filebrowser`, and - `lnd` were active/running and ports `8083`/`9100` were held by Podman's - `rootlessport` as expected. The packaged `fips.service` remained installed - and enabled but inactive, so the native FIPS service fallback should still - ship before the public launch. - -Still required before public publish: - -- Rotate/revoke compromised credentials listed in Phase 0. -- Finish Phase 1 password/node/token sanitization beyond the two API keys. -- Publish from fresh history after the sanitized tree is final. -- Run full Rust, frontend, Android, and lifecycle gate verification. -- Resolve the live-node lifecycle blockers above, then rerun the read-only - suite followed by the destructive gate only on an approved verification node. -- Decide the canonical Archipelago maintainer npub and merge-maintainer npub - list before publishing the Nostr Git source-hosting workflow. - ---- - -## Phase 0 — Credential rotation (immediate, independent of the repo) - -Treat all of these as already compromised; rotate even though we're doing fresh-history: - -- **Anthropic API key #1**: `image-recipe/_archived/build-auto-installer-iso.sh:2837` (the "intentional alpha" ISO key). Revoke + reissue; move the live key OUT of source into a build-time secret/env (`ISO_ANTHROPIC_API_KEY`), keep the alpha-baking behavior if desired but never the literal in git. -- **Anthropic API key #2**: `scripts/setup-aiui-server.sh:28` — a *different* live key, not covered by the documented alpha exception. Revoke; parameterize the script. -- **The shared node SSH/sudo/UI password** (two variants) — in 7 tracked files + 24+ commits. Rotate fleet-wide (user task). -- **Gitea `ai` account password + 2 Gitea tokens** — embedded in `.git/config` remote URLs (not tracked, but leaks in any directory copy/tarball). Rotate; switch remotes to credential-helper storage instead of URL-embedded creds. - -## Phase 1 — Secrets & sanitization of tracked files - -1. Strip the password/credential lines from the 7 files: - `docs/PRODUCTION-MASTER-PLAN.md` (lines ~428–429, 454–457, 483, 521–528, 886 — the fleet cred table), - `docs/archive/SESSION-1.8.0-OTA-PROGRESS.md`, `docs/archive/HANDOVER-2026-07-02-iso-feedback.md`, - `docs/bitcoin-version-bulletproof-rollout.md`, `tests/production-quality/TRACKER.md`, - `tests/multinode/meshtastic.sh:26`, `neode-ui/test-openwrt.mjs:4` (→ env var). -2. `.gitea/workflows/post-install-tests.yml` — remove `sshpass -p '…'` + default target IP; use secrets/vars. -3. Sanitize infra identifiers repo-wide (in the *sanitize-and-keep* docs and scripts): - replace Tailscale IPs (17 unique, 14 files), LAN IPs (`192.168.1.x`, 93 files), hostnames - (`tx1138`, `shorty-s`, `archy-x250`, `archy-dev-pa`) with placeholders like `` / - `NODE_IP`. Key script targets: `scripts/deploy-config-defaults.sh`, `scripts/deploy-tailscale.sh`, - `docs/operations-runbook.md` (opens with real node IPs), `docs/developer-guide.md`, `docs/api-reference.md`, `docs/hotfix-process.md`. -4. Fix the audit tool that let this happen: `scripts/audit-secrets.sh:28` — remove `\.md$` and - bare `test` from ALLOW_PATTERNS; add `sk-ant-` and password-table patterns; scan all - tracked files not just `*.env`. Run it clean as a Phase-1 exit check. -5. `.gitignore` additions: `.claude/`, `*.key`, `*.pem`, `id_rsa*`, `*.sqlite`, `*.db` - (`.claude/settings.local.json` with creds is currently only ignored by a machine-global rule). -6. Product-security note to raise (not fix now): `password123` is a shipped default (auth.rs, en.json, user-walkthrough) — file a public issue for forced first-run password change if not already enforced. - -## Phase 2 — Repo restructure: deletions, binaries, layout - -Delete (each its own commit): -- `loop/` (AI overnight harness w/ node SSH lines), `.agents/`, `.codex`, `.githooks/pre-push` - (the hook that re-commits the 27 MB APK — root cause of the 5.5 GB history). -- `indeedhub/` submodule + `.gitmodules` entry (points at private HTTP Gitea, breaks `--recursive` - clones); `indeedhub-demo/` (single Dockerfile — merge or drop). -- `RELEASE-NOTES-v1.0.0.md` (superseded by CHANGELOG), `neode-ui/docs/GAMEPAD-NAV-MAP.md` (duplicate of `docs/GAMEPAD-NAV.md`). -- Stray generated HTML: `docs/container-architecture.html` (311 KB), `docs/archive/architecture-review.html`, `docs/archive/lora-functionality.html`. -- `Android/local.properties` from tracking (local absolute path); remove `Android/app/debug.keystore` (standard practice). - -Move out of git (→ release assets on the Releases page, referenced by URL): -- `neode-ui/public/packages/archipelago-companion.apk` (27 MB), `wireguard.apk` (17 MB), `atob.s9pk` (23 MB). -- `Android/archipelago-0.3.0-debug.apk.zip` (16 MB, stale). -- `demo/content/music/*` + heavy `demo/aiui/assets` (~261 MB, third-party/unclear-licence media — MUST not ship publicly regardless of size). -- `neode-ui/dev-dist/` (generated Workbox output) → gitignore. - -Rename/fix the naming lie: `image-recipe/_archived/` contains the *production* ISO builder -(`build-auto-installer-iso.sh`, referenced by `.gitea/workflows/build-iso.yml`). Move live -files up into `image-recipe/`, delete the genuinely archived rest. - -## Phase 3 — Registry domain + parameterization (functional blocker) - -Infra (user assists: DNS + TLS): -- Put a domain (e.g. `registry.archipelago-os.org` / `git.archipelago-os.org`) with HTTPS in - front of the existing Gitea on vps2. OTA download URLs move from plain HTTP to HTTPS. - -Repo changes: -- Introduce a single source of truth for the registry host (e.g. `REGISTRY_HOST` in - `scripts/lib/` + a default in the orchestrator config). Replace `146.59.87.168:3000` in: - all 56 `apps/*/manifest.yml`, `app-catalog/catalog.json`, `releases/manifest.json`, - `release-manifest.json`, the 11 scripts (`self-update.sh`, `create-release.sh`, - `generate-app-catalog.sh`, `validate-app-manifest.sh`, `first-boot-containers.sh`, …), - both `demo-images.yml` workflows, `demo-deploy/.env.example`, and the Android sources - (`FipsPreferences.kt`, `PartyScreen.kt`). -- Because the catalog is signed: regenerate + re-sign + republish the app catalog after the - manifest host change (catalog-overlay supremacy — disk edits don't apply otherwise). - Signing needs the user's mnemonic → schedule one ceremony after manifests are final. -- Verify: fresh machine with no LAN/tailnet access can `podman pull` one app image via the - domain and the gate node still installs apps after the re-signed catalog lands. - -## Phase 4 — Documentation overhaul - -### 4a. Community/legal files (missing today) -- `LICENSE` — MIT (matches existing README badge). Add `[workspace.package] license` + - `license.workspace = true` in the 5 member Cargo.tomls (also see Phase A4). -- `SECURITY.md` — disclosure address, PGP key, supported-versions; cite the March 2026 audit (`docs/archive/security-code-audit-2026-03.md`). -- `CODE_OF_CONDUCT.md` — Contributor Covenant (CONTRIBUTING.md already links to it, 404 today). -- `CONTRIBUTING.md` edits: Gitea→GitHub fork flow, remove private deploy instructions, absorb - the public-worthy CLAUDE.md invariants (rootless podman, manifest-driven, secrets model, - non-destructive migrations), versioning policy note for the `-alpha` scheme. -- `CLAUDE.md` — rewrite: keep invariants/build-verify (public-worthy), remove status banner, - node numbers, `gitea-ai` push mechanics, MEMORY references (those move to private notes). - -### 4b. New developer docs (the three real gaps for app developers) -1. **`docs/quadlet-compilation.md`** — how a manifest becomes a Quadlet/systemd unit: naming, - `systemctl --user` lifecycle, where units land, how to inspect/debug one. (Source: - `core/archipelago/src/container/quadlet*.rs`, prod_orchestrator.) -2. **`docs/container-lifecycle.md`** — the 30 s level-triggered reconciler, install/adopt/ - restart/uninstall state machine, health checks, crash recovery. (Replaces the plan-shaped - `docs/bulletproof-containers.md` as the current description; salvage its content.) -3. **`docs/secrets.md`** — `generated_secrets` declaration → materialisation by - `container::secrets` (0600, rootless) → injection; what developers must never do. -- Also: make every example in `docs/app-developer-guide.md` + `apps/*/manifest.yml` copy-paste - work against the new public registry host; add an end-to-end "write your first app" walkthrough - that a stranger can follow with only the public repo + an Archipelago node. - -### 4c. Sanitize-and-keep internal docs (user's transparency choice) -- Keep, after Phase-1 scrubbing: `docs/PRODUCTION-MASTER-PLAN.md`, `docs/UNIFIED-TASK-TRACKER.md`, - `docs/1.8.0-RELEASE-HARDENING-PLAN.md`, `docs/RETICULUM-TRANSPORT-PROGRESS.md`, HANDOFF-*, test - plans, `docs/archive/*` — but **move all session/handoff/tracker material under - `docs/history/`** (extending the existing honest `docs/archive/README.md` pattern) so the - top-level `docs/` reads as current reference only. Add a banner to each: "historical working - document, sanitized; not maintained." -- Remove dangling agent-memory references in tracked docs (`docs/bulletproof-containers.md`, - `docs/RETICULUM-TRANSPORT-PROGRESS.md`, `docs/registry-manifest-design.md`, - `docs/bitcoin-multi-version-design.md` progress block). -- De-status the 14 design docs (strip "Status/RESUME POINT" headers into a one-line status - field; e.g. `docs/APP-PACKAGING-MIGRATION-PLAN.md` → public app-platform design doc). -- Extract North-Star narrative from PRODUCTION-MASTER-PLAN into `docs/ROADMAP.md`; extract - the "run the gate ON the node" philosophy from `docs/multinode-testing-plan.md` into - `tests/lifecycle/TESTING.md`. -- Add `docs/README.md` index (bitcoin/bitcoin `doc/` style): Getting started / Architecture / - App development / Operations / Design docs (ADRs) / History. -- README fixes: LICENSE link becomes real, Documentation table repointed at the reorganized - docs, remove "Deploy to a Test Node" private-LAN section, point Contributing at - CONTRIBUTING.md only. - -## Phase 5 — Deep code cleanup (ordered zero-risk → highest-risk; cut-line after any commit) - -### A. Zero-risk deletions & metadata (S each, own commits) -- **A1** Delete orphan non-compiling StartOS crates: `core/models`, `core/helpers`, - `core/js-engine` (incl. 2 committed `JS_SNAPSHOT.*.bin`), `core/container-init` (~4,100 LOC, - zero references). Verify: `cargo build --workspace && cargo test --all-features`. -- **A2** Delete unreferenced Vue components: `neode-ui/src/components/{AppSwitcher,EmptyState,SkeletonCard}.vue`. Verify: `npm run type-check && npm run build`. -- **A3** Fix `.gitignore` lockfile lines (7: `Cargo.lock`, 15: `package-lock.json`) — lockfiles are intentionally tracked; the rules are misleading and swallow future lockfiles. -- **A4** LICENSE + Cargo license fields (see 4a). Verify with `cargo metadata`. -- **A5** `core/rust-toolchain.toml` pinning `1.95.0`; align `.github/workflows/ci.yml` (remove explicit `toolchain: stable` input so the file wins). Upgrades become deliberate PRs. -- **A6** `core/rustfmt.toml` codifying **defaults only** (`edition = "2021"` + comment) — do NOT add style options days before launch (whole-tree reformat churn). Verify `cargo fmt --all -- --check` yields no diff. - -### B. CI guards (zero runtime risk) -- **B1** Enable vitest in CI: run `cd neode-ui && npm run test` locally; fix trivial failures, `.skip`+issue flaky ones; add step to the frontend job. Playwright → tracked issue only (needs browsers + mock backend orchestration). -- **B2** Raw podman/systemctl **ratchet, not migration**: the 132 raw `Command::new("podman"/"systemctl")` sites use subcommands the `core/container/src/podman_client.rs` wrapper doesn't expose (network/inspect/ps/port), 43 sites are in gate-critical `install.rs`, and the prod path intentionally uses Quadlet+systemctl. Add `scripts/ci/raw-podman-ratchet.sh` (count vs committed baseline, fail on increase) as a CI step + tracked issue for wrapper API design. - -### C. Clippy suppression trim (`core/archipelago/src/main.rs:8-18`, per-lint commits) -- Remove cheaply: `assertions_on_constants`, `drop_non_drop`, `wildcard_in_or_patterns`, `doc_lazy_continuation`, `enum_variant_names` (targeted allows on serde enums — never rename wire variants). -- Own careful commit: `unused_io_amount` — a **correctness** lint; fix sites with `read_exact`/`write_all` or documented targeted allows (`mesh/serial.rs:456,496` has raw partial reads; serial framing may be intentional). Full test suite + gate after. -- Keep crate-wide with justifying comment: `too_many_arguments`, `type_complexity`; attempt `ptr_arg` (`&Vec`→`&[T]`, mechanical) if time allows — first to cut. -- Verify each: `cargo clippy --all-targets --all-features -- -D warnings && cargo test --all-features`. - -### D. dead_code lift — Tiers 1–2 pre-launch, Tier 3 → commented allows + issues -Per-module procedure (one file per commit): remove `#![allow(dead_code)]` → `cargo check ---all-targets --all-features` → triage each warning: (a) genuinely dead → delete; -(b) future-feature/protocol-mandated → targeted `#[allow(dead_code)] // TODO(#NNN): …`; -(c) missing wiring → keep + targeted allow + issue (don't fix wiring in this workstream) → -clippy `-D warnings` + tests → commit. -- **Tier 1 (small/leaf, S each):** `swarm/seed_advert.rs`, `transport/{mesh_transport,lan,chunking,delta}.rs`, `mesh/{crypto,alerts,types,outbox}.rs`, `streaming/mod.rs`, `wallet/mod.rs`. -- **Tier 2 (M each):** `fips/{mod,iface,dial}.rs` (41 external refs → little residual deadness), `mesh/{x3dh,ratchet,steganography,message_types}.rs` — for crypto files bias to (b) with roadmap comments (unused crypto attracts auditor noise; every kept item needs its why). -- **Tier 3 (defer, riskiest):** `mesh/{mod,reticulum,protocol,serial,bitcoin_relay}.rs`, `transport/mod.rs` — change each blanket allow to `#![allow(dead_code)] // Hardware-mesh surface partially wired; triage tracked in #NNN`. -- Optional S/M win: move `prod_orchestrator.rs`'s 5,034-line `#[cfg(test)]` module to a sibling file via `#[path]` (pure move, halves the 6,291-line file). - -### E. stacks.rs legacy fallbacks (highest risk — LAST, evidence-gated) -Legacy installers for immich/btcpay/mempool/indeedhub (`core/archipelago/src/api/rpc/package/stacks.rs:838/1047/1267/1498`, ~1,000 LOC with hardcoded registry IPs) fire only on "unknown app_id, zero members installed", logging `INSTALL ORCH SKIP` (stacks.rs:673). Netbird already uses the hard-error replacement (stacks.rs:1898-1920). -1. Run the full gate on the node; grep install logs for `INSTALL ORCH SKIP`. -2. Zero SKIPs → replace each legacy body with the netbird-style hard error (keep orchestrator call + `adopt_stack_if_exists`; satisfies migrations-never-destroy-data). Re-run gate; any red → revert + issue. -3. Any SKIP → don't delete; issue: "deploy manifests fleet-wide, then delete legacy installers". - -### Explicitly deferred → public tracked issues at launch -PodmanClient API extension + call-site migration; god-module splits (`install.rs`, `update.rs`, `mesh/mod.rs`); Playwright in CI; Tier-3 dead_code triage; `password123` default hardening. - -## Phase 6 — Fresh-history publish - -1. Freeze: all phases merged on internal `main`, gate green, catalog re-signed. -2. Build the public tree: `git archive`-style export of HEAD (never copy `.git/` — it holds - credentialed remotes) → new repo, single initial commit ("Initial public release, vX.Y.Z"), - optionally preserving CHANGELOG.md as the human-readable history. -3. Pre-publish gate on the export: `scripts/audit-secrets.sh` (fixed version) clean; grep-zero for - `sk-ant-`, rotated-password strings, `146.59.87.168`, tailnet `100.` IPs, `192.168.1.`, - internal hostnames; `du -sh .git` sanity (< ~100 MB); fresh `git clone` + `cd core && cargo build` - + `cd neode-ui && npm ci && npm run build` on a clean machine/container; one app image pull - from the public domain. -4. Publish to GitHub; enable issue templates (already present in `.github/`); file the deferred-work - issues (from Phase 5's issue list) as the initial public issue set — honest and gives contributors entry points. -5. Internal repo remains the private full-history remote; decide sync direction post-launch - (recommend: public repo becomes canonical, private keeps only ops/infra notes). - -## Verification (end-to-end) - -- `tests/lifecycle/run-gate.sh` green on the node after Phases 3 + 5E (and after any lifecycle-touching commit). -- CI green on every phase commit: `cargo fmt --check`, `clippy -D warnings`, `cargo test --all-features`, frontend type-check + build + (new) vitest. -- Phase-6 clean-machine clone/build/pull test is the final acceptance test — it simulates the first outside developer. -- Docs acceptance: a reader following `docs/app-developer-guide.md` + the new quadlet/lifecycle/secrets docs can build and install an app manifest without any private infra. - -## Sequencing / cut-line - -Order: 0 → 1 → 2 → (3 ∥ 4) → 5 (A→E) → 6. Phases 0–2 are non-negotiable security; Phase 3 is the -functional blocker; Phase 4 is the developer-experience payload; Phase 5 can be cut after any -commit (minimum viable: A1–A6, B1–B2, unused_io_amount fix); Phase 6 last. If the timeline -compresses, Tier-2 dead_code and Phase E move to public issues — everything else holds. diff --git a/docs/PRODUCTION-MASTER-PLAN.md b/docs/PRODUCTION-MASTER-PLAN.md deleted file mode 100644 index 39bc6a2c..00000000 --- a/docs/PRODUCTION-MASTER-PLAN.md +++ /dev/null @@ -1,1265 +0,0 @@ -# PRODUCTION MASTER PLAN — Archipelago App Platform & Registry - -> **📋 Live day-to-day task tracker: `docs/UNIFIED-TASK-TRACKER.md`.** This doc remains -> the authoritative north-star narrative and detailed workstream history, but for -> "what's left, in priority order" work off the unified tracker instead of hunting -> through §6/§8b here. -> -> **✅ SINGLE-NODE PRODUCTION GATE IS GREEN (2026-06-23): `run-gate.sh` 5/5 on .228, 0 failures.** -> This remains the authoritative plan for the broader north star (manifest-driven -> platform, registry-distributed manifests, external marketplace), but it is no -> longer a hard priority banner blocking all other work. Remaining workstreams are -> in §6 / §8b. Next exit-criteria: multinode (`docs/multinode-testing-plan.md`) + -> workstreams B/C/D. -> -> Last updated: 2026-06-26 · zombie-container guard + gitea launch-port fix shipped, binary `040df5ce` rolled to the fleet (see §8b SESSION h). Prior: orchestrator Fix A+B (`a721532f`/`e0343137`) deployed + proven. - ---- - -## 1. The North Star - -Make Archipelago a **world-class, developer-ready app platform** where: - -1. **Every app is manifest-driven** — install/run/update/uninstall needs only the - app's manifest (+ catalog entry). **Zero OS-level code reliance**: no per-app - Rust installers, no `sudo mkdir/chown`, no host provisioning. -2. **Manifests are distributed via the (signed) registry**, not baked into the - binary OTA as disk files. Bumping/adding an app = a signed catalog change. -3. **Third-party developers can build and ship apps via an external registry** — - a decentralized marketplace (DID-signed manifests, Nostr discovery, reputation), - not a gatekept central store. `archy app validate/render/install/test` tooling. -4. The platform stays **rootless, secure-by-default, elegant, robust, and - 100%-uptime-capable** (reboot-survivable, self-healing, no data loss on migrate). - -**Definition of done:** the production test gate (§5) is green for the app set on -real nodes. Until then, this plan is the priority. - -## 2. Invariants (never violate) - -- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged - containers unless explicitly approved. (ADR-001, ADR-009.) -- **No app-specific business logic in the Rust backend.** The orchestrator owns - the lifecycle state machine; apps are declarative. Legacy `install_immich_stack` - (hardcoded `podman run` + `sudo chown`) is the anti-pattern being deleted. -- **Secrets are manifest-declared** (`generated_secrets`, materialised by - `container::secrets` 0600/rootless, idempotent + self-healing) — never hardcoded, - per-app, or logged. Replaces the deleted `ensure_fmcd_password`. -- **Migrations never destroy data.** Preserve `/var/lib/archipelago/`, - generated secrets, displayed credentials, public ports, and adoption container - names. Always provide a rollback path. Stop/recreate only when necessary. -- **Verify on the real node .228 before any tag.** (Fleet/multinode verification is - a separate pass → `docs/multinode-testing-plan.md`.) - -## 3. Current state (2026-06-21) - -- **~40 apps are manifest-based and Quadlet-migrated** (survive - `archipelago.service` restart + reboot). Exhaustive per-app table: - `docs/archive/app-registry-status-2026-06-21.md`. -- **Legacy holdout: immich** — the one app with **no manifest** and a hardcoded - Rust stack installer (in-cgroup, not Quadlet). 3 containers, healthy, live data. - The migration proof case. -- **Manifests still travel by OTA disk rsync** (`apps/ → /opt/archipelago/apps`). - The signed catalog (`app-catalog.json`) currently distributes **only image - overrides** — not full manifests. Gap closed by workstream B. -- **The 4 companions** (`archy-bitcoin-ui`, `-lnd-ui`, `-electrs-ui`, - `-fedimint-ui`) build from `docker/` contexts via `companion.rs`, not the - manifest registry — a later phase folds them in. -- **No app has passed the formal production gate.** That is the blocker. - -## 4. Workstreams (each links its authoritative detail doc) - -| # | Workstream | Detail doc | Status | -|---|-----------|-----------|--------| -| A | **Manifest-driven app platform** — packaging contract, single/multi-container runtime, routing, controlled hooks, dev tooling (6 phases, security model, migration rules) | `APP-PACKAGING-MIGRATION-PLAN.md` | mostly done; immich + multi-container polish remain | -| B | **Registry-distributed manifests** — catalog carries full signed manifest; orchestrator installs from registry; disk = migration fallback | `registry-manifest-design.md` | **phases 1+2 done** (node consume + opt-in publisher embed); not yet flipped on for the fleet | -| C | **Developer-ready external registry** — 3rd-party DID-signed manifests, decentralized Nostr discovery (NIP-78 kind 30078) + trust score, `archy app …` tooling | `marketplace-protocol.md`, `app-developer-guide.md` | design exists; tooling + trust UX pending | -| D | **Distribution backbone** — signed catalog, BLAKE3 content-addressing, iroh swarm (origin-always-wins) | `dht-distribution-design.md` | phases 0–2 code-complete (worktree) | -| E | **Production test gate** — 5× lifecycle on **.228**, per-app L1/L2 matrix; multinode is split out → `multinode-testing-plan.md` | `tests/lifecycle/TESTING.md`, `bulletproof-containers.md` | **✅ .228 5×-GREEN (110/110 ×5, 0 not-ok, 2026-06-23)** — but this is DESTRUCTIVE-tier / ~8 core apps only; see §6c for the coverage gaps | -| F | **Lifecycle perfection — cascade + progress + ALL apps** — extend the gate to uninstall/reinstall (cascade), real install/uninstall progress UI, and EVERY installed app (not just the 8 core). The "insanely-perfect OS/container environment" bar. | §6c (below), `tests/lifecycle/TESTING.md` | **IN PROGRESS (2026-06-26)** — root bug FIXED: uninstall could hang → ghost/stuck-bar/reinstall-block (`71cc9ac4`, unbounded systemctl/podman in `quadlet::disable_remove`); `cascade-uninstall.bats` **7/7 green on .228** w/ binary `ae349a75`. Remaining: wire CASCADE into the canonical gate run, progress-UI truthfulness, all-apps matrix, guardian/IBD state. | - -**Orchestrator architecture** (foundation for A/B): `archive/rust-orchestrator-migration.md` -(ProdContainerOrchestrator, BootReconciler 30s level-triggered reconcile, adoption -scan, Quadlet rendering) and `bulletproof-containers.md` (the six container failure -modes FM1–FM6 + the desired-state-first reconciler that fixes them). - -## 5. Production test gate (exit criterion) - -An app is **production-ready** only when `tests/lifecycle/run-gate.sh` is green -across the full matrix — install / UI-reachable / stop / start / restart / -reinstall / **reboot-survive** / **archipelago-restart-survive** / uninstall — -**5× on .228** (`ARCHY_ITERATIONS=5`). **The gate runs ON the node** (it uses local -podman/systemctl/bitcoin probes; running it via RPC from another host silently -tests the runner). **Multinode / fleet verification (.198 + others) is a SEPARATE -plan — `docs/multinode-testing-plan.md` — NOT part of this single-node criterion.** -Coverage today: L0 unit (631 ●), L1 RPC ● for 6 core apps, L2 UI ● dashboard + -proxies; L3 survival ◐; ~30 apps have zero automated coverage. - -> ⚠️ **The 2026-06-23 5×-green is NOT the full bar.** `run-gate.sh` runs only the -> **DESTRUCTIVE tier** (stop/start/restart/survive) over ~8 core apps; it **skips -> uninstall/reinstall** (CASCADE is gated behind `ARCHY_ALLOW_CASCADE_DESTRUCTIVE`, -> never set by the gate) and tests no install/uninstall **progress UI**. Real -> uninstall/reinstall/progress bugs (immich + grafana) were found in manual testing -> right after — see **§6c (workstream F)** for the gap and the expanded-gate plan. -> The true "every app, fully" criterion is F's definition-of-done, not this run. - -## 6. Immediate sequence (live workstream) - -1. ✅ **B-phase 1** — `manifest` field on `AppCatalogEntry`; `load_manifests` - catalog-wins merge; `manifest_dir` kept (build-source catalog manifests skipped - in phase 1); unit tests. *(commit 220666d3)* -2. ✅ **B-phase 2** — `EMBED_MANIFESTS` publisher generator + round-trip guard. - *(7bfbe8fe; signing via existing ceremony — not yet flipped on for the fleet.)* -3. ✅ **C immich proof** — immich is a manifest-driven stack (immich + immich-postgres - + immich-redis) installed via `install_stack_via_orchestrator`; legacy installer - is now fallback-only. Live-migrated + verified on .228. Found+fixed: container_name - duplicate-on-shared-PGDATA, version-digit validation, partial-fallback hardening, - data_uid 100998. Canonical app_id `immich` (title+icon). *(9e6c5370, d5ef4573)* -4. ✅ **Reboot-survival** — podman-restart.service enabled (startup, fleet-wide) - for the podman-`--restart` path. *(f160e0c4)* -5. ✅ **E** — 5× gate on **.228** (`ARCHY_ITERATIONS=5`) is **GREEN: 5/5, 0 not-ok** - (2026-06-23). Two real orchestrator bugs were found + fixed en route (package.stop - per-app grace; package.restart phantom stack-member injection → `order_present_containers`, - commit 92d7f52d) plus two single-shot-read probes hardened (bitcoin-knots state, immich - lan_address). The single-node criterion is met. -6. ✅ Banner demoted (this doc, 2026-06-23). Next: multinode pass + workstreams B/C/D. - -**Multinode / fleet verification (.198 and the rest) is split into its own plan:** -`docs/multinode-testing-plan.md`. Do it AFTER the .228 single-node gate is green. - -**Not yet done / deliberate follow-ups:** flip `EMBED_MANIFESTS` on for the -published catalog (then sign) to actually distribute manifests via the registry; -Phase-3 `use_quadlet_backends` rollout so orchestrator backends are Quadlet (not -just podman-`--restart`). - -## 6b. Post-deploy task order (agreed 2026-06-23) - -After the 2026-06-23 multinode test deploy (latest backend + UX frontend to .116/.198/.228 -+ Tailscale testers), do these IN ORDER: -1. **netbird #20 ph4** — the last real manifest migration (workstream A). -2. **Phase-3 `use_quadlet_backends`** — orchestrator backends become Quadlet units. -3. **§6c Lifecycle perfection** (workstream F) — the comprehensive uninstall/reinstall + - progress-UI + all-apps gate expansion below. - -## 6b-bis. Bitcoin multi-version bulletproofing (2026-06-29) — READY TO MERGE + DEPLOY - -Branch `bitcoin-version-bulletproof` (base `095a76cd`). Fixes the "switch version silently -fails / crash-loops" class + a data-access mismatch that can corrupt a node's index. All -code + images + catalog + frontend DONE; **.228** carries it (Knots chainstate mid-reindex -recovery). The **coordinated fleet rollout** (OTA binary+frontend, mirror catalog publish, -`:latest` repoint sequencing, full switch-matrix test) is the remaining work — fold it into -the next release. **Authoritative detail + exact remaining steps + test matrix → -`docs/bitcoin-version-bulletproof-rollout.md`.** Pairs with `docs/bitcoin-multi-version-design.md`. - -## 6c. Lifecycle perfection — what "green" MISSED (workstream F, the perfection bar) - -**Why this exists:** the 2026-06-23 single-node gate went 5×-green but is **NOT** the -"every app fully lifecycle-tested" guarantee a user reasonably assumes. The canonical gate -(`run-gate.sh`) only runs the **DESTRUCTIVE tier** (stop / start / restart / survive) over -**~8 core apps** (bitcoin-knots, btcpay, electrumx, lnd, mempool, immich, fedimint, -filebrowser). It explicitly **SKIPS uninstall/reinstall** (the CASCADE tier is gated behind -`ARCHY_ALLOW_CASCADE_DESTRUCTIVE`, which `run-gate.sh` never sets) and has **zero coverage** -for the other ~30 apps (grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, -uptime-kuma, homeassistant, … — see `archive/app-registry-status-2026-06-21.md`). So uninstall, -reinstall, install-progress UI, and most apps were never under test. - -**Real bugs found in manual multinode testing on .198 (2026-06-23) — the motivating evidence:** -- **Uninstall is broken for immich + grafana:** takes very long, the progress bar sits at a - **solid full-red with no real progression**, and the app **does not actually uninstall** — - it still appears in **My Apps** afterward (ghost entry / state not cleared). -- **grafana reinstall just stops** partway (no completion, no clear error). -- **fedimint guardian** suddenly showed **"starting up — Guardian opens a wait page until - Bitcoin finishes initial sync" / "starting"** on that node — verify this is correct - wait-for-IBD behavior vs a stuck/false state (it's a backend that depends on bitcoin sync). - -**✅ 2026-06-26 — root cause of the immich/grafana uninstall trio FOUND + FIXED (`71cc9ac4`).** -Single cause: `quadlet::disable_remove()` (first op in uninstall teardown, via companion + -orchestrator) ran `systemctl --user stop` / `daemon-reload` / `podman rm -f` with **no timeout**. -On rootless podman a generated unit can wedge "deactivating" while podman hangs → `systemctl stop` -blocks forever → the spawned uninstall task returns neither Ok nor Err, so (a) `set_uninstall_stage` -never fires → **frozen full-red bar**, (b) `remove_package_state_entry` never runs → **ghost stuck in -`Removing`**, (c) the install guard rejects reinstall (`already Removing`). The spawn wrapper already -reverts state on Err/removes on Ok — only a *hang* stranded it. Fix bounds all three calls -(stop→`QUADLET_STOP_TIMEOUT` + SIGKILL/reset-failed escalation; daemon-reload→30s; podman rm→timeout). -**Validated live: `cascade-uninstall.bats` 7/7 on .228** (binary `ae349a75`) — grafana install → -uninstall (no ghost, data dir gone) → reinstall → running → cleanup. NOTE: proves the happy path + -no-regression; the original hang was load/timing-induced and not separately reproduced. - -**Workstream F scope — the gate must grow to (in priority order):** -1. **CASCADE tier in the canonical gate:** uninstall → verify the app is GONE from My Apps / - `container-list` / package state (no ghost), data preserved per policy, then reinstall → - verify it returns healthy. Catch the immich/grafana ghost + reinstall-stops bugs. - *(✅ DONE `b7d92107`: `run-gate.sh` now runs ONE cascade pass after the 5× loop when - `ARCHY_GATE_CASCADE=1` (+`ARCHY_ALLOW_DESTRUCTIVE=1`), counted into the tally — opt-in so default - behavior is unchanged, and deliberately NOT folded into all 5 iterations. `cascade-uninstall.bats` - 7/7 on .228. Next: extend cascade coverage beyond the single throwaway app to the multi-container - stacks, e.g. an immich/btcpay cascade variant.)* -2. **Progress-UI assertions:** install AND uninstall must report monotonic, truthful progress - (not a stuck full-red bar); a long op must surface a real stage/percentage and a terminal - success/failure — no silent hang. (Likely both a backend progress-event fix AND a UI fix.) - *(✅ 2026-06-26 `9f17ba68`: the "stuck full-red bar" was `AppCard.vue` hardcoding the uninstall - bar to `w-full bg-red-400/60 animate-pulse` — solid, full, red, fake-pulse. Now derives a real - percentage from the backend's existing `uninstall-stage` label ("Stopping containers (X/N)"→10–50%, - "Cleaning up volumes"→70%, "Removing app data"→90%) and renders like install (neutral fill, real - width+%, shimmer). FE built `index-DtZyZomC.js`, rolled to .228/.116/.198/.89 (+.88/.5/.120). - STILL TODO: a bats/UI assertion that the bar is monotonic + lands on a terminal state; possibly a - backend numeric-progress field so the UI doesn't parse stage strings.)* -3. **ALL-apps coverage:** a generic per-app lifecycle matrix (install / UI-reach / stop / start / - restart / uninstall / reinstall / reboot-survive) driven by the manifest set, so grafana and - the ~30 uncovered apps are gated too — not just the 8 core. Manifest-driven, so new apps are - covered automatically. - *(✅ 2026-06-26 `43934eef`: `bats/all-apps-lifecycle.bats` — DESTRUCTIVE counterpart to the - read-only `all-apps-matrix.bats`. Discovers the app set from My Apps ∩ the node `catalog.json`; - drives stop/start/restart for every app and, under `ARCHY_ALLOW_CASCADE_DESTRUCTIVE`, a FULL - teardown (uninstall→no-ghost→reinstall) with the catalog `{dockerImage, containerConfig}` as the - reinstall spec. PROTECTED (never touched): bitcoin*/electrum* (resync cost) + lnd/btcpay*/fedimint* - (irreversible wallet loss — user asked to protect only bitcoin+electrum; wallet apps added for - safety, override via `ARCHY_MATRIX_PROTECT`). Validated on .228 (discovery + 1-app lifecycle - green). HEAVY/destructive → a supervised pass on LAN nodes (.116/.198/.228), NOT folded into - run-gate. Invoke: `ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ALLOW_CASCADE_DESTRUCTIVE=1 ARCHY_PASSWORD=… - ARCHY_SCHEME=https bats bats/all-apps-lifecycle.bats`.)* - **✅ FIRST FULL DESTRUCTIVE RUN on .228 (2026-06-26):** lifecycle **11/11 clean**; teardown - **8/11** (immich 3-container stack incl.) — and it surfaced **3 real reinstall bugs** (the payoff): - 1. **fresh-install bind-dir ownership = root:root** → EACCES on reinstall (jellyfin `/config` - denied exit 139; netbird-server can't open its SQLite store). Fix B's chown-to-parent only - runs on the reconcile path, **not** `package.install`. The important orchestrator fix. - 2. **netbird reinstall adopts leftover containers → skips the manifest cert/file render** - (tls.crt/key/nginx.conf never written → proxy can't start → app reads absent). Only a fully - clean reinstall renders them. - 3. **portainer image pin `lfg2025/portainer:2.19.4` is `manifest unknown`** (never pushed to the - registry) and the pin OVERRIDES the RPC dockerImage → portainer is un(re)installable - fleet-wide. Registry/catalog data bug (push the image or change the pin). - .228 restored (jellyfin+netbird via manual chown / clean reinstall; all installed apps running, - 28 ctrs; portainer left uninstalled — uninstallable until #3 fixed). TODO: fix #1 (extend chown - to install path) + #2 + #3; add reboot-survive + UI-reach per app to the matrix. -4. **Guardian/IBD-dependent states:** assert that "waiting for bitcoin sync"-style states are a - legitimate, surfaced wait (with a path to ready) and never a permanent stuck state. - -**Definition of done for F:** the expanded gate (CASCADE + progress + all-apps) is 5×-green on -.228, then re-verified across the multinode fleet — i.e. an *insanely-perfect* OS/container -environment where every app installs, runs, updates, uninstalls, and reinstalls cleanly with -honest progress, no ghosts, no data loss, reboot-survivable. - -## 7. Release blockers & operational gotchas (durable) - -Carried forward from prior handoffs (deduped against persistent memory): - -- **Rootless control-plane responsiveness** — slow `podman ps`/store cleanup at - startup must not surface a false "no apps installed" UI. **My Apps must preserve - last-known apps during scanner backoff**, never show empty during a transient. -- **Reboot survival** — gate on ≥3 (prefer 5) consecutive clean post-reboot - lifecycle passes. Quadlet units under `user.slice` survive `archipelago.service` - restart; legacy in-cgroup containers get SIGKILLed and reconciled back. -- **Startup patterns** — wait on a socket/health, never `sleep`. Tailscale waits - for its socket; Fedimint Guardian waits for Bitcoin RPC `initialblockdownload:false` - before launching fedimintd (proxy/wait companion on :8175 during IBD). -- **Bitcoin must run full** (`txindex=1`, non-pruned) for ElectrumX/mempool. -- **Adoption** — match existing containers by name and adopt without recreate; - record a migration version in app state; preserve Nostr signer bridges - (IndeeHub needs `/nostr-provider.js` served, not just port reachability). -- **Image presence** — use bounded targeted `podman image inspect`, not - `podman image exists` (avoids store-walk stalls). -- **Companion rebuilds** — `companion.rs` must rebuild `:latest` when the build - context changes (staleness check), else baked-in fixes (e.g. guardian CSS) never - reach nodes. `:local` is a manual override, never auto-rebuilt. - -## 8. Roadmap - -**Pipeline:** Feature Testing (internal) → User Testing (controlled hardware) → -Beta Live (public). Hardening priorities feeding the gate: - -- **P0** Container app reliability — bulletproof install/health/restart/uninstall - across all apps, dependency chains, multi-container stacks. -- **P0** Networking stack first-install → reboot-proof (WireGuard/NetBird, Tor - hidden services, LND Connect). -- **P1** LUKS2 full-partition encryption for `/var/lib/archipelago/` - (AES-256-XTS, Argon2id, key from setup password + hardware salt). -- **P1** Meshtastic plug-and-play parity with MeshCore. -- **P1 ✅ CODE-COMPLETE** (branch `companion-mobile-ux`, 2026-06-23; needs - on-device + mobile-web verification before merge to `main`) — Mobile app-launch - UX — drop the "this app opens in a tab" interstitial. - Two surfaces (both: no interstitial screen, launch the app directly): - - **Companion app (Android):** open **every** app in the **in-app WebView** - (not just non-iframeable ones) — *and* carry the current mobile-iframe footer - controls into the WebView (back/forward/reload/close — good, useful UX). - - **Mobile web browser (PWA):** open tab-apps directly in a **new browser tab**. - Touch points: `neode-ui/src/stores/appLauncher.ts`, `AppLauncherOverlay.vue`, - the Android in-app WebView bridge, and the mesh-mobile iframe footer controls. - (Reference prior work: `b5a9deb8` in-app webview for non-iframeable apps, - `d1fbcd9b` "open in browser" via native bridge.) - - **✅ Done (branch `companion-mobile-ux`):** mobile launches now use the - store-driven panel (no route push) so the background tab no longer changes and - closing returns you where you launched; tab-only apps open directly (in-app - WebView on companion via `openInApp`, new browser tab on PWA) with **no - interstitial**; the Android `InAppBrowser` (`WebViewScreen.kt`) gained a bottom - footer bar (back/forward/reload/open-in-browser/close) + a centered loading - screen (favicon + progress); a shared `AppLoadingScreen` (icon + progress) - replaced the black/spinner loaders on the app session **and** legacy iframe - overlay; the dashboard is pinned to `100dvh` on mobile so the mesh chat/tools - panes stop sliding under the tab bar in mobile browsers (no-op in companion); - ElectrumX shows its real icon in My Apps. Companion APK bumped to **v0.4.7** - (versionCode 11) with a committed shared debug keystore so updates install - without an uninstall. **Not yet:** merge to `main`; publish the 0.4.7 companion - download (deferred until the gate work lands so they ship together). - -**Post-beta (deferred — do not start until gate is green):** P2P encrypted -voice/video (WebRTC over federation via Tor); watch-only wallet + mesh BTC -hardening; paid swarm streaming + IndeeHub source (`phase4-streaming-ecash-plan.md`); -Meshroller Rust-native mesh AI (`meshroller-integration-design.md`); dual-ecash -phases 2–6 (`dual-ecash-design.md`). - -## 8b. SESSION STATE + RESUME (updated 2026-06-26) — READ §8b "CURRENT STATE + RESUME" FIRST - -### ▶ SESSION i (2026-06-30) — CURRENT HANDOFF / 1.8.0 OTA RESUME - -**Branch/worktree:** currently on `bitcoin-version-bulletproof`, not `main`. Worktree is dirty. -Do **not** discard mesh changes: they include E2E/transport indicator plumbing and the Meshtastic -receive-path fixes below. Separate recovery note: `docs/archive/SESSION-1.8.0-OTA-PROGRESS.md`. - -**What was done this session:** -1. ✅ **Local Rust release gate fixed and green.** `cargo test -p archipelago --bin archipelago` is - green: **849/849** after fixing stale tests and the invalid `fedimint-clientd` manifest - (`cpu_limit` was `0.25`, invalid for the current schema; now integer). `cargo check -p archipelago` - also green after mesh edits. -2. ✅ **Catalog/release static gates green.** `python3 scripts/check-app-catalog-drift.py --release - --strict` is green. `scripts/check-release-manifest.sh` is green for the currently staged - `1.7.99-alpha` manifest/artifacts. `npm run build` and `npm run type-check` are green. -3. ✅ **Frontend unit gate fixed.** `npx vitest run --silent` now green: **81 files / 668 tests**. Fixes - were test-only: add `router.onError` to the login test router mock and update the `AppIconGrid` - mobile unresolved-new-tab expectation to match current app-launcher behavior. -4. ✅ **Workstream F harness gap closed.** `tests/lifecycle/bats/cascade-uninstall.bats` now asserts - uninstall progress truthfulness via backend `uninstall-stage`: stage must be parseable, monotonic, - below 100 before terminal absence, and present before the app disappears. Non-destructive skip-mode - parse check is green: `ARCHY_PASSWORD=dummy bats tests/lifecycle/bats/cascade-uninstall.bats` → 7 skip-ok. -5. ✅ **3ccc → .116 Meshtastic receive bug taken over and partially live-validated.** Context: `3ccc` - is the stock/non-Archy Meshtastic peer. The bug was LoRa text from `3ccc` not surfacing in - `.116` `mesh.messages`. Root causes/fixes: - - The prior attempted fix dropped any packet older than 10 minutes by `rx_time`; live `.116` logs - showed `FromRadio.packet` from `!433e3ccc` being dropped as stale (`rx_time` about an hour old). - The window is now **24h**, so recent radio FIFO/store-forward backlog surfaces instead of vanishing. - - Radios with unset clocks can report tiny nonzero epoch values; those are now treated as unknown, - not stale. - - Serial prevalidation was rejecting valid `FromRadio.queueStatus` frames (`field 11`, live bytes like - `5a04100e1810`) as corrupt payloads; field 11 and other modern non-message `FromRadio` variants - are now accepted/ignored instead of poisoning the stream. - - Focused Meshtastic tests green: **8/8**, including `packet_to_inbound_frame_accepts_recent_meshtastic_backlog` - and `packet_to_inbound_frame_accepts_stock_peer_with_unset_clock`. - - Deployed patched binary to **.116**: sha256 - `028ec6ff9a60ca8970c081987457d78ed1c517cd81f7089f51b9a01745b5c3c4` at `/usr/local/bin/archipelago`. - Service active. Post-deploy checked window showed `FromRadio field=11` accepted and no new - `Dropping stale ... !433e3ccc` entries. - - There are stale other-agent `RXDIAG` shell watcher processes on `.116`; leave them unless they - actively interfere. -6. ✅ **Phase-3 Quadlet read-only check on .116 skip-clean.** Copied lifecycle tests to `.116` and ran - `bats bats/use-quadlet-backends-install.bats`: **6/6 skip-clean** because no backend `.container` - units exist. This confirms `use_quadlet_backends` is not active on `.116`; Phase-3 remains a rollout gate. - -**Commands/results worth trusting:** -- `cargo test -p archipelago --bin archipelago` → 849/849 green. -- `npx vitest run --silent` from `neode-ui/` → 81 files / 668 tests green. -- `npm run build` from `neode-ui/` → green, bundle `index-CYaDgfX3.js`. -- `python3 scripts/check-app-catalog-drift.py --release --strict` → green. -- `scripts/check-release-manifest.sh` → green for **v1.7.99-alpha** staged artifacts. -- `tests/release/run.sh --manifest` was rerun after `cargo fmt`; it previously reached frontend tests, - which are now fixed. Re-run it from scratch as the next static gate. - -**Remaining blockers / decisions before 1.8.0 OTA:** -1. **Release version metadata is not 1.8.0 yet.** `releases/manifest.json`, Cargo, and npm still say - `1.7.99-alpha`; `CHANGELOG.md` top says `v1.8.00-alpha` (note double zero). Do not silently publish - until the release version naming is decided (`1.8.0-alpha` vs `1.8.00-alpha` vs `1.8.0`). -2. **Workstream B signing is blocked on the offline release-root mnemonic.** `docs/workstream-b-signing-runbook.md` - says catalog distribution/embedded manifests are live, but authenticity requires the publisher to pin - `RELEASE_ROOT_PUBKEY_HEX` and sign `releases/app-catalog.json` with `RELEASE_MASTER_MNEMONIC`. - This cannot be automated by an agent without the offline mnemonic. -3. **Phase-3 `use_quadlet_backends` is implemented but default-off.** Completing this requires explicit - node/fleet flag rollout plus backend reinstall/migration verification. `.116` currently skip-clean only. -4. **Bitcoin multi-version coordinated rollout is still separately owned/blocked by its runbook.** See - `docs/bitcoin-version-bulletproof-rollout.md`; do not repoint `bitcoin-knots:latest` before fixed binary - is fleet-wide. -5. **True RF validation of 3ccc requires either a live 3ccc send or waiting for another FIFO/backlog packet.** - Parser/unit coverage and `.116` logs strongly validate the drop-path fix, but no human was available to - send a fresh 3ccc message during this session. - -**Immediate next steps for the next agent:** -1. Run `tests/release/run.sh --manifest` from repo root again; frontend unit failures are fixed, so expect - it to pass or continue from the next failing stage. -2. If `.116` is still the canary, monitor logs after any 3ccc activity: - `journalctl -u archipelago --since "
- - -
- - -
-

Archipelago

-

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

-
- Rust + Vue 3 + Podman - ~45,000 lines of Rust (213 files) - ~45,500 lines of TypeScript/Vue (232 files) - ~40 shell scripts - v0.1.0-beta -
-
- - -

What Is Archipelago?

- -

Archipelago (nicknamed "Archy") is a personal server operating system focused on Bitcoin. You download an ISO file, flash it to a USB drive, install it on any computer, and it gives you:

- -
    -
  • A full Bitcoin node — you verify your own transactions, no trust in anyone else
  • -
  • A Lightning Network node — fast, cheap Bitcoin payments
  • -
  • A web dashboard — manage everything from your phone or laptop browser
  • -
  • An app marketplace — install apps like Nextcloud, Jellyfin, Vaultwarden with one click
  • -
  • Privacy by default — Tor routing, encrypted secrets, no telemetry
  • -
- -
-

Think of it like an iPhone for servers. Apple gives you a phone with an App Store where you install apps. Archipelago gives you a server with a Marketplace where you install self-hosted apps. The difference? You own and control everything — your data never leaves your machine.

-
- -

Similar projects exist (Umbrel, Start9, RaspiBlitz), but Archipelago is built from scratch with production-grade security and a custom Rust backend instead of Node.js.

- - -

The Big Picture

- -

Before diving into code, understand the four layers of the system and how they stack:

- -
-┌──────────────────────────────────────────────────────┐ -│ YOUR BROWSER │ -│ (Vue.js Single Page Application) │ -└──────────────────────┬───────────────────────────────┘ - │ HTTP requests (fetch API) -┌──────────────────────┴───────────────────────────────┐ -│ NGINX │ -│ Reverse proxy — routes traffic to the right place │ -│ /rpc/v1 → backend /app/bitcoin/ → container │ -└──────────────────────┬───────────────────────────────┘ - │ Internal HTTP (port 5678) -┌──────────────────────┴───────────────────────────────┐ -│ RUST BACKEND │ -│ The brain — handles auth, app installs, Bitcoin │ -│ RPC, mesh networking, federation, health checks │ -└──────────────────────┬───────────────────────────────┘ - │ Podman REST API (Unix socket) -┌──────────────────────┴───────────────────────────────┐ -│ PODMAN CONTAINERS │ -│ Bitcoin Core, LND, Mempool, Nextcloud, etc. │ -│ Each app runs isolated in its own container │ -└──────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────┐ -│ DEBIAN 12 (Linux OS) │ -│ The foundation — systemd, firewall, filesystem │ -└──────────────────────────────────────────────────────┘ -
- -
- Key Concept: Separation of Concerns - Each layer has ONE job. The browser shows things. Nginx routes traffic. Rust makes decisions. Podman runs apps. This makes the system easier to understand, test, and fix — if the UI breaks, you know the problem is in the Vue code, not the Rust code. -
- - -

How It Runs on a Machine

- -

When you install Archipelago on a computer and power it on, here's what happens in order:

- -
1

Linux boots — Debian 12 starts up, loads drivers, mounts disks

-
2

systemd starts services — A program called systemd reads archipelago.service and launches the Rust backend

-
3

Rust backend initializes — Loads config, creates/loads encryption keys, starts the HTTP server on port 5678

-
4

Health monitor starts — Checks which containers are running, restarts crashed ones, reports readiness

-
5

Nginx starts — Listens on port 80 (HTTP) and routes all incoming traffic

-
6

Containers start — Bitcoin, LND, and other apps start in priority order (Bitcoin first, then things that depend on it)

-
7

Ready! — You open a browser, go to your server's IP address, and see the dashboard

- -
-

It's like starting a restaurant. First the building opens (Linux). Then the manager arrives (Rust backend). They check if all kitchen stations are ready (health monitor). The front door opens (Nginx). The cooks start preparing (containers). Customers can now order (you open the web UI).

-
- - -

The Four Layers — Detailed

- -

Layer 1: The Rust Backend (The Brain)

- -

This is the most important piece. It's written in Rust — a programming language known for speed and safety. The backend is the "brain" that controls everything.

- -
- Why Rust? - Rust prevents entire categories of bugs (memory leaks, crashes, race conditions) at compile time. For a server that manages Bitcoin wallets and runs 24/7, this matters. A crash could mean lost money. Rust makes crashes nearly impossible. -
- -

How the code is organized

-

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

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CrateWhat It DoesLinesAnalogy
archipelagoThe main binary. API endpoints, auth, identity, federation, mesh networking, monitoring, health checks~42,000The restaurant manager — coordinates everything
containerPodmanClient (REST API socket), manifest parser, dependency resolver, health monitor, Bitcoin simulator~2,060The kitchen manager — controls cook stations
securityEncrypted secrets (Argon2 + ChaCha20-Poly1305), AppArmor profiles, Cosign image verification~743The security guard — locks doors, checks IDs
parmanodeCompatibility layer for migrating from an older project~234A translation book — speaks the old language
performanceCPU, memory, and disk resource management~92The meter reader — watches resource gauges
- -

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:

- - - - - - - - - - - - - - - -
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

- -
-Browser sends: POST /rpc/v1 -Body: { "method": "package.install", "params": { "id": "bitcoin-knots" } } - -Step 1: Nginx receives it on port 80, forwards to port 5678 -Step 2: Rust HTTP server (Hyper) receives the raw bytes -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.

- -
- What is a Single Page Application (SPA)? - 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 (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/              ← 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 (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:

- -
<!-- 1. THE LOGIC (TypeScript) -->
-<script setup lang="ts">
-import { ref, onMounted } from 'vue'
-import { rpcClient } from '@/api/rpc-client'
-
-// "ref" is a reactive variable — when it changes, the UI updates automatically
-const apps = ref([])
-const loading = ref(true)
-
-// "onMounted" runs when the component first appears on screen
-onMounted(async () => {
-  apps.value = await rpcClient.getMarketplace()
-  loading.value = false
-})
-</script>
-
-<!-- 2. THE TEMPLATE (HTML with Vue directives) -->
-<template>
-  <div v-if="loading">Loading...</div>
-  <div v-else v-for="app in apps" class="glass-card">
-    {{ app.name }}
-  </div>
-</template>
-
-<!-- 3. THE STYLES (CSS, scoped to this component) -->
-<style scoped>
-  /* Styles here only affect THIS component */
-</style>
- -
-

A Vue component is like a LEGO brick. Each brick (component) has its own shape (template), color (styles), and moving parts (script). You snap them together to build the full UI. The <Dashboard> component contains <Sidebar>, which contains <NavItem> components — just like nesting LEGO bricks.

-
- -
- -

Layer 3: The Container System (The Apps)

- -

Containers are how Archipelago runs apps like Bitcoin Core, Lightning, Nextcloud, etc. Each app runs in its own isolated "box" called a container.

- -
- What is a Container? - 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 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:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RuleWhat It MeansWhy
--cap-drop=ALLRemove all Linux capabilities (super-powers)A hacked container can't do anything dangerous
--cap-add=CHOWNGive back only the specific powers neededMinimum privilege — only what's necessary
readonly_root: trueContainer can't modify its own program filesPrevents malware from modifying the app
--user 1001:1001Run as non-root userEven if exploited, can't access system files
no-new-privilegesCan't escalate to higher permissionsPrevents privilege escalation attacks
- -

Container startup order (tiers)

-
-Tier 1: Foundation (start first, other apps depend on these) - ├── Bitcoin Core/Knots ← The blockchain - ├── MySQL/PostgreSQL ← Databases - └── Redis ← Cache - -Tier 2: Core Services (need Tier 1 to be running) - ├── LND (Lightning) ← Needs Bitcoin - ├── ElectrumX ← Needs Bitcoin - ├── Mempool ← Needs Bitcoin + ElectrumX - └── BTCPay Server ← Needs Bitcoin + LND - -Tier 3: Applications (independent or need Tier 2) - ├── Nextcloud, Jellyfin ← File storage, media - ├── Vaultwarden ← Password manager - ├── Home Assistant ← Smart home - └── Grafana ← Monitoring dashboards -
- -
- -

Layer 4: Nginx (The Traffic Cop)

- -

Nginx (pronounced "engine-X") is a web server that sits between the internet and everything else. Every single request goes through it first. Archipelago's nginx config is ~1,100 lines — one of the most complex parts of the system.

- -
-

Nginx is like the receptionist at a hospital. You walk in and say what you need. "I need the API" — they send you to the Rust backend. "I need the Bitcoin app" — they send you to the Bitcoin container. "I need the website" — they hand you the static files. Without the receptionist, you'd be wandering the hallways lost.

-
- - -

Why Nginx? Comparing Reverse Proxies

- -

Every node OS needs a reverse proxy to route traffic. Here's how the major projects differ:

- -
-
-

Nginx Archipelago

-

Battle-tested (30+ years)
- Sub-millisecond routing
- Fine-grained rate limiting
- sub_filter HTML rewriting
- Full CSP / HSTS control
- ~ Manual config (1,100 lines)
- No auto-TLS (manual certs)

-
-
-

Caddy Umbrel

-

Automatic HTTPS / Let's Encrypt
- Simple Caddyfile syntax
- Built-in HTTP/3 support
- No sub_filter (needs plugins)
- Higher memory footprint
- Less granular rate limiting
- ~ Newer, smaller ecosystem

-
-
-

Tor-only StartOS

-

Maximum privacy (no clearnet)
- No port forwarding needed
- Built-in NAT traversal
- Slow (500ms–3s latency)
- No LAN access without config
- Requires .onion browser support
- No WebSocket over Tor (flaky)

-
-
-

NixOS Module Nix-Bitcoin

-

Declarative, reproducible
- Atomic rollbacks
- Any proxy (Nginx/Caddy/HAProxy)
- ~ Steep learning curve (Nix lang)
- No web UI (CLI only)
- Not beginner-friendly
- Long rebuild times

-
-
- -
- Archipelago's choice: Nginx gives the most control over security headers, rate limiting, and HTML rewriting (injecting Nostr provider scripts into app iframes). The tradeoff is a 1,100-line config instead of a 50-line Caddyfile — but for a Bitcoin node OS, that control is worth it. -
- - -

Head-to-Head: Architecture Decisions

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FeatureArchipelagoUmbrelStartOSNix-BitcoinRaspiBlitz
Reverse ProxyNginxCaddyTor hidden svcNginx (Nix module)Nginx
BackendRustNode.js + GoRust (startos)Shell/NixShell scripts
ContainersRootless PodmanDocker (root)Docker (root)None (native pkgs)Docker (root)
TLS/HTTPSSelf-signed + HSTSAuto (Let's Encrypt)Tor-only (no TLS)Let's EncryptSelf-signed
Rate LimitingDual-zone (RPC 20r/s + Auth 3r/s)NoneNoneOptional (manual)None
Security HeadersFull CSP + HSTS + PermissionsBasicN/A (Tor)ConfigurableMinimal
App IsolationCap-drop, readonly root, non-root UIDDocker defaultsDocker + sandboxingsystemd sandboxingDocker defaults
LAN + RemoteLAN + Tailscale + TorLAN + Tor + TailscaleTor-only (LAN optional)LAN + WireGuardLAN + Tor
WebSocketNative (24h timeout)Polling + WSSSE over TorN/APolling
App UI Injectionsub_filter (Nostr NIP-07)NoneNoneN/ANone
- - -

How Nginx Routes Traffic

- -

The config defines 30+ location blocks across HTTP (port 80) and HTTPS (port 443). Here are the major routing categories:

- -
-

Backend & API Routes

- - - - - - - - - - -
URL PatternBackendRate LimitTimeoutPurpose
/rpc/:567820r/s (burst 40)600sAll RPC API calls (1MB body limit)
/ws:567886,400s (24h)WebSocket — real-time state updates
/health:5678defaultHealth check (no auth)
/archipelago/:5678defaultSystem endpoints
/content:5678defaultPeer content sharing
/dwn:5678defaultDecentralized Web Node
/electrs-status:5678defaultElectrum sync status (CORS enabled)
/lnd-connect-info:5678defaultLND connection URI (CORS enabled)
-
- -
-

App Proxies — 24 Container Apps

-

Every /app/{id}/ route proxies into a container. All share a common pattern: strip the upstream X-Frame-Options, set SAMEORIGIN, inject the Nostr provider script, and forward real IP headers.

- - - - - - - - - - - - - - - - - - - - - - - - - - -
AppPortSpecial Config
bitcoin-ui8334
mempool4080300s timeouts
lnd8081300s timeouts
electrumx50002
btcpay23000
fedimint8175300s timeouts
fedimint-gateway8176300s timeouts
filebrowser808310GB uploads, path traversal blocking
nextcloud8085300s timeouts
vaultwarden8082
immich2283300s timeouts
jellyfin8096
grafana3000
portainer9000
uptime-kuma3001
searxng8888
ollama11434
indeedhub7777URL rewriting, WS, 30-day asset cache
homeassistant812386,400s timeout (persistent)
penpot9001300s timeouts
photoprism2342
onlyoffice8044
endurain8080
nginx-proxy-manager8181
-
- -
-

AIUI Routes (AI Chat Interface)

-

The AI chat UI has its own set of proxied API backends — all require a valid session cookie or return 401.

- - - - - - - -
URL PatternBackendTimeoutPurpose
/aiui/Static filesChat UI (no-cache for HTML)
/aiui/api/claude/:3142300s readClaude proxy (streaming, no buffering)
/aiui/api/ollama/:11434300s readLocal Ollama model (streaming)
/aiui/api/openrouter/openrouter.ai120sExternal AI API (SSL passthrough)
/aiui/api/web-search:888830sSearXNG search (503 JSON on failure)
-
- - -

Security Headers — How Archipelago Compares

- -

Security headers tell the browser what's allowed and what isn't. Here's what each node OS sends:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
HeaderArchipelagoUmbrelStartOSRaspiBlitz
Content-Security-PolicyFull self + WS + frame-srcBasicNoneNone
HSTS1 year + includeSubDomainsYesN/A (Tor)No
X-Frame-OptionsSAMEORIGINVariesNoneNone
X-Content-Type-OptionsnosniffnosniffNoneNone
Permissions-PolicyAll blockedNoneNoneNone
Referrer-Policystrict-originNoneNoneNone
Rate LimitingDual-zoneNoneNoneNone
- -
- Archipelago leads on security headers. - Most node OS projects ship with minimal or no HTTP security headers. Archipelago sets a full Content-Security-Policy, HSTS with 1-year max-age, Permissions-Policy blocking camera/microphone/geolocation/payment, and dual-zone rate limiting — defense-in-depth at the proxy layer. -
- - -

Unique Nginx Features in Archipelago

- -
-
-

Nostr NIP-07 Injection

-
    -
  • Every app proxy uses sub_filter to inject nostr-provider.js into </head>
  • -
  • Gives all container apps window.nostr for signing
  • -
  • No other node OS does this — unique to Archipelago
  • -
  • Accept-Encoding disabled to enable text rewriting
  • -
-
-
-

Dual Rate Limit Zones

-
    -
  • rpc zone: 20 req/s base, burst of 40 — for API calls
  • -
  • auth zone: 3 req/s — for login/auth endpoints (brute-force protection)
  • -
  • Returns HTTP 429 on violation
  • -
  • Per-IP tracking with 10MB shared memory zone
  • -
-
-
-

External Site Proxying

-
    -
  • /ext/botfights/, /ext/484-kitchen/, etc. proxy external HTTPS sites
  • -
  • Strips CORS/COEP/COOP headers for iframe embedding
  • -
  • Rewrites href/src attributes to rebase paths
  • -
  • Standalone proxy servers on ports 8901–8903
  • -
-
-
-

FileBrowser Security

-
    -
  • Path traversal blocked: /\.\. patterns return 403
  • -
  • 10GB upload limit (client_max_body_size 10G)
  • -
  • proxy_request_buffering off for streaming large uploads
  • -
  • Separate protection for /api/resources/ and /api/raw/ paths
  • -
-
-
-

SSL/TLS Configuration

-
    -
  • TLSv1.2 + TLSv1.3 only (no older protocols)
  • -
  • Modern cipher suite: ECDHE-ECDSA + ECDHE-RSA with AES-GCM
  • -
  • Self-signed certificate at /etc/archipelago/ssl/
  • -
  • Dual-server setup: port 80 (HTTP) + port 443 (HTTPS)
  • -
-
-
-

IndeedhHub Complexity

-
    -
  • Most complex app proxy: URL rewriting, WebSocket, caching
  • -
  • _next/ assets cached 30 days with immutable
  • -
  • WebSocket at /app/indeedhub/ws/ with 24h timeout
  • -
  • Rewrites both single and double quoted href/src
  • -
-
-
- - -

Nginx Config File Map

- -
- - - - - - - - - - - - -
FileLinesPurpose
image-recipe/configs/nginx-archipelago.conf~1,100Production config — HTTP + HTTPS servers, all routing
image-recipe/configs/snippets/archipelago-https-app-proxies.conf~400HTTPS app proxy blocks (included in main config)
image-recipe/configs/snippets/archipelago-pwa.conf~30PWA service worker and manifest caching
image-recipe/configs/external-app-proxies.conf~200External site reverse proxies (BotFights, 484 Kitchen)
neode-ui/docker/nginx.conf~60Dev Docker config (mock backend on :5959)
neode-ui/docker/nginx-demo.conf~80Demo mode config (no security, mock backend)
docker/bitcoin-ui/nginx.conf~50Bitcoin UI container — RPC proxy with CORS
docker/electrs-ui/nginx.conf~30Electrs UI container — status endpoint
docker/lnd-ui/nginx.conf~30LND UI container — connect info
indeedhub/nginx.conf~200IndeedhHub container — MinIO, API, relay, SPA
-
- -
- Why so many nginx configs? - There are three layers of nginx: (1) the main server nginx that routes all traffic, (2) per-app container nginx configs inside some containers (bitcoin-ui, electrs-ui, lnd-ui, indeedhub) that serve their own SPAs and proxy to internal services, and (3) dev/demo nginx configs for local development. Changes to app routing require updating BOTH the main config AND the relevant container config. -
- - -

How Data Flows Through the System

- -

Let's trace what happens when you click "Install Bitcoin" in the UI:

- -
-
1

You click the Install button in Marketplace.vue. Vue calls the Pinia store action installPackage('bitcoin-knots')

-
2

The store calls the RPC client: rpcClient.installPackage('bitcoin-knots', 'docker.io/bitcoin/knots:28')

-
3

RPC client sends HTTP POST to /rpc/v1 with a session cookie and CSRF token for security

-
4

Nginx receives the request on port 80, checks rate limits, forwards to the Rust backend on port 5678

-
5

Rust backend validates — checks your session is valid, CSRF token matches, app ID is safe (no shell injection characters)

-
6

Rust checks dependencies — if you're installing LND, it checks Bitcoin is already running

-
7

Rust tells Podman to pull the imagepodman pull docker.io/bitcoin/knots:28 (downloads the app)

-
8

Rust creates and starts the container with all security flags (cap-drop, readonly root, etc.)

-
9

Backend sends a WebSocket update — the frontend receives a "state changed" event in real time

-
10

Vue reactively updates the UI — the Marketplace card changes from "Install" to "Running" with no page reload

-
- - -

RPC: How Frontend Talks to Backend

- -

RPC stands for Remote Procedure Call. It's a way for the frontend to tell the backend "do something" — like calling a function on a remote computer.

- -
- RPC vs REST - Most web APIs use REST (different URLs for different things: GET /users, POST /users, DELETE /users/5). Archipelago uses RPC instead — every request goes to the same URL (/rpc/v1) and the method name says what to do. It's like having one phone number for a building, and you say who you want to talk to. -
- -

The frontend has a class called RPCClient (in rpc-client.ts) with ~70 methods. Each method maps to a backend function:

- - - - - - - - -
Frontend MethodBackend HandlerWhat It Does
rpcClient.login(password)auth.loginLog in with password
rpcClient.getServerInfo()system.infoGet server name, version, uptime
rpcClient.installPackage(id, image)package.installInstall a container app
rpcClient.getBitcoinInfo()bitcoin.infoGet blockchain sync %, block height
rpcClient.sendMeshMessage(text)mesh.sendSend a message over LoRa radio
- -

Built-in resilience

-

The RPC client has built-in protections:

-
    -
  • Auto-retry — if a request fails (502/503), it waits and tries again (up to 3 times)
  • -
  • Timeout — if the backend doesn't respond in 30 seconds, the request fails instead of hanging forever
  • -
  • Session expiry — if you get a 401 (unauthorized), it redirects to the login page
  • -
  • CSRF protection — every request includes a security token to prevent cross-site attacks
  • -
- - -

State Management

- -

State is the data your app is currently working with: is the user logged in? What apps are installed? Is Bitcoin synced? This data needs to be shared between components.

- -
- What is Pinia? - 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 18 Pinia stores (up from 15 — the "god store" was decomposed):

- -
-
-

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 (9.2 KB)

-
-
-

mesh.ts good

-

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

-
-
-

appLauncher.ts good

-

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

-
-
-

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.

- -
-Traditional polling (slow, wasteful): - Frontend: "Anything new?" → Backend: "No" (every 1 second) - Frontend: "Anything new?" → Backend: "No" - Frontend: "Anything new?" → Backend: "Yes! Bitcoin synced!" - -WebSocket (fast, efficient): - Frontend ←→ Backend: persistent connection - Backend: "Bitcoin synced!" → Frontend instantly updates - Backend: "New container started!" → Frontend instantly updates -
- - -

Authentication & Sessions

- -

When you log in, the backend creates a session — a temporary "you're allowed in" token. Here's how it works:

- -
1

You enter your password on the login page

-
2

Backend hashes it with bcrypt — a one-way function that makes it impossible to reverse

-
3

Backend compares the hash to the stored hash (never compares raw passwords)

-
4

Backend creates a session — generates a random 256-bit token using a cryptographically secure random number generator

-
5

Session ID sent as a cookie — the browser stores it and sends it with every request

-
6

CSRF token also sent — a second token that prevents cross-site request forgery attacks

- -
- Why two tokens? - The session cookie proves you're logged in. The CSRF token proves the request came from YOUR browser tab, not a malicious website that tricked your browser into sending a request. Both must match for any request to succeed. -
- - -

Security Model

- -

Archipelago is a defense-in-depth system — multiple layers of security so that if one fails, others still protect you.

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LayerProtectionAgainst What
OSUFW firewall, AppArmor profilesNetwork attacks, process escape
NginxRate limiting, security headers, HSTSDDoS, XSS, clickjacking
BackendCSRF validation, session auth, input sanitizationCSRF, injection, unauthorized access
ContainersCapability dropping, readonly root, non-root userContainer escape, privilege escalation
CryptoChaCha20-Poly1305 encryption, Argon2 key derivation, ed25519 signaturesData theft, key compromise, impersonation
NetworkTor routing, onion servicesTraffic analysis, IP exposure
-
- - -

Bitcoin Integration

- -

Bitcoin is the heart of Archipelago. The backend communicates with Bitcoin Core/Knots using JSON-RPC — the same protocol Bitcoin has used since 2009.

- -
- Critical Rule: Never Use Floating Point for Bitcoin - Bitcoin amounts are always in satoshis (1 BTC = 100,000,000 sats) as integers. Using floating point (decimals) causes rounding errors. 0.1 + 0.2 ≠ 0.3 in floating point. When you're dealing with money, that's unacceptable. Archipelago uses u64 in Rust and BigInt in TypeScript for all Bitcoin amounts. -
- -

Bitcoin RPC examples

-
// The backend calls Bitcoin Core like this:
-bitcoin_rpc("getblockchaininfo")   → sync progress, block height
-bitcoin_rpc("getnetworkinfo")      → peer count, version
-bitcoin_rpc("getmempoolinfo")      → unconfirmed transaction count
-bitcoin_rpc("estimatesmartfee", 6) → fee estimate for 6-block confirmation
- - -

Federation & Multi-Node

- -

Multiple Archipelago nodes can form a federation — a trusted network of servers that sync data, share state, and communicate privately.

- -
-Your Node (.228) ←── Tor ──→ Friend's Node - │ │ - └──── Tor ──→ Office Node ←── Tor ──┘ - -Each node has: - • Ed25519 identity key (cryptographic identity) - • DID (Decentralized Identifier — like a username that can't be taken away) - • Onion address (Tor hidden service — no IP address exposed) - • DWN (Decentralized Web Node — stores and syncs data) -
- -

Nodes discover each other through Nostr relays (publish presence, but never onion addresses — those are exchanged privately via encrypted DMs).

- - -

Mesh Networking

- -

Archipelago can communicate over LoRa radio — no internet needed. A small radio device plugs into the server's USB port and sends messages up to 10+ km using the Meshtastic/Meshcore protocol.

- -
-

Imagine walkie-talkies that can send text messages. Each radio can relay messages for others, so even if two radios can't reach each other directly, they can communicate through intermediate radios. That's mesh networking — no cell towers, no ISPs, no internet required.

-
- - -

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,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

-
3

Upload frontend — rsyncs built files to /opt/archipelago/web-ui/ on the server

-
4

Upload Rust source — rsyncs core/ to the server (builds ON the server, not macOS)

-
5

Build on server — runs cargo build --release on the Linux server

-
6

Sync configs — copies nginx config, systemd service from image-recipe/configs/

-
7

Restart services — reloads nginx, restarts the Rust backend via systemd

-
8

Health check — pings /health endpoint to verify everything came back up

-
9

Deploy manifest — writes a JSON file recording the commit, timestamp, and deploy status

- -
- Why build on the server? - Rust compiles to machine code specific to the CPU architecture. If you compile on macOS (ARM/x86) and copy the binary to a Linux server, it won't run — you get an "Exec format error". The deploy script sends the source code and compiles on the target machine. -
- - -

ISO Build Process

- -

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. -
  3. Creates a Docker container to build a custom root filesystem
  4. -
  5. Installs Podman, Nginx, and all system dependencies
  6. -
  7. Captures running container images from the live dev server
  8. -
  9. Bundles the frontend files, backend binary, and configs
  10. -
  11. Writes a first-boot script that sets everything up on install
  12. -
  13. Packages everything into a bootable ISO file
  14. -
- - -

First Boot Sequence

- -

When someone installs the ISO and boots for the first time, first-boot-containers.sh runs automatically and:

- -
    -
  1. Generates unique credentials for this installation (Bitcoin RPC password, database passwords)
  2. -
  3. Sets up swap space based on available RAM
  4. -
  5. Creates the archy-net container network for inter-container communication
  6. -
  7. Starts 30+ containers in tiered order (databases first, then Bitcoin, then everything else)
  8. -
  9. Runs health checks on critical containers
  10. -
  11. Configures Tor hidden services
  12. -
- - - - - -

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:

- -
-
-
Rust Error Handling
-
A
-

Zero unwrap/panic in prod code

-
-
-
TypeScript Safety
-
A
-

Strict mode, zero any types

-
-
-
Security
-
A
-

33-finding pentest, all remediated

-
-
-
Frontend Architecture
-
A
-

God store split, god views split

-
-
-
Backend Modularity
-
A-
-

Monoliths split into subdirectories

-
-
-
Container Security
-
A
-

Cap-drop, readonly, non-root

-
-
-
Script Modularity
-
B-
-

Shared lib created, still large scripts

-
-
-
Test Coverage
-
B-
-

38 frontend + 36 backend test files

-
-
-
CI/CD
-
C
-

macOS release CI, no test gating

-
-
-
Documentation
-
A
-

This review + MASTER_PLAN + consolidated docs

-
-
-
Dependency Hygiene
-
B-
-

Floating crypto versions

-
-
-
Deploy Safety
-
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. 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. 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 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. 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, 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. 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

- -

Production Reliability P0 — blocks beta

- -
-

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.

-
- -
-

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.

-
- -
-

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.

-
- -
-

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) FIXED

-

What: 6 calls to client.connect().await across identity_manager.rs, nostr_discovery.rs, and marketplace.rs had no timeout wrapper. If a relay is down, peer discovery hangs forever.

-

Fix: All 6 calls wrapped in tokio::time::timeout(Duration::from_secs(10), ...). (v1.3.1, 2026-03-25)

-
- -
-

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) FIXED

-

What: Several containers in first-boot-containers.sh and the ISO build pulled floating tags — no exact version pinning.

-

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

-

Fix: All 15 floating tags in image-versions.sh pinned to exact patch versions (e.g., postgres:15→15.17, redis:7→7.4.8, nginx:alpine→1.29.6-alpine). DWN pinned by SHA256 digest. (v1.3.1, 2026-03-25)

-
- -
-

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 FIXED

-

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

-

Why it's bad: A minor version bump in a crypto library could introduce a vulnerability or behavioral change. The project's own rules require exact pinning for crypto deps.

-

Fix: All 12 crypto deps pinned to exact versions from Cargo.lock: ed25519-dalek=2.2.0, zeroize=1.8.2, chacha20poly1305=0.10.1, sha2=0.10.9, hmac=0.12.1, argon2=0.5.3, aes-gcm=0.10.3, etc. (v1.3.1, 2026-03-25)

-
- -
-

6. No frontend-backend type synchronization

-

What: TypeScript types in types/api.ts are manually maintained copies of Rust structs. If the backend changes a field name, the frontend doesn't know until runtime.

-

Why it's bad: Types can drift apart silently. A backend developer renames sync_progress to syncProgress and the frontend breaks in production.

-

Fix: Generate TypeScript types from Rust structs (using ts-rs or a JSON Schema).

-
- -
-

7. Container metadata duplicated in 3 places

-

What: App configuration (ports, volumes, env vars) exists in: package.rs (RPC handler), docker_packages.rs (metadata reader), health_monitor.rs (startup tiers).

-

Why it's bad: Adding a new app means updating 3 files. If you forget one, the app partially works but something is wrong.

-

Fix: Single app config source (manifest YAML or a shared Rust module) that all three consumers read from.

-
- -
-

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

- -
-

9. App integration requires updates in 6+ locations

-

What: Adding a new app to Archipelago requires manual changes in: manifest YAML, package.rs (backend config), docker_packages.rs (metadata), nginx config (routing), Marketplace.vue (frontend listing), appLauncher.ts (port mapping), first-boot-containers.sh (first boot), build-auto-installer-iso.sh (ISO capture).

-

Fix: Move toward a single manifest file per app that drives all of these automatically.

-
- -
-

10. CI/CD pipeline is minimal FIXED

-

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: Added .github/workflows/ci.yml with two parallel jobs: Rust (fmt check + clippy -D warnings + tests) and Frontend (npm ci + type-check + build). Runs on push to main and all PRs. (v1.3.1, 2026-03-25)

-
- -
-

11. Session persistence uses blocking I/O

-

What: On startup, session.rs reads sessions.json using synchronous (blocking) file I/O in an async context.

-

Fix: Use tokio::fs::read_to_string for non-blocking I/O at startup.

-
- -
-

12. Inconsistent loading state patterns in frontend

-

What: Some components use loading, others isLoading, others loadingApps. No shared composable.

-

Fix: Create a useAsyncState composable that standardizes loading/error/data patterns.

-
- - -

Refactoring Priorities

- -

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

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#TaskImpactEffortStatus
1Split package.rs (1,795 lines) into focused fileshigh2-3 daysDONE
2Split useAppStore into auth/server/synchigh2 daysDONE
3Add CI pipeline (clippy + type-check + basic tests)high1 dayDONE
4Split Web5.vue (3,940 lines) into sub-viewsmedium3 daysDONE
5Pin all crypto dependency versions exactlymedium1 hourDONE
6Extract shared shell library (lib/common.sh)medium1 dayDONE
7Consolidate container metadata to single sourcemedium2 daysTODO
8Generate TypeScript types from Rust structsmedium1 dayTODO
9Split deploy/ISO scripts into moduleslow2 daysPOST-BETA
10Add integration tests for critical pathshigh3 daysTODO
11Split large backend files (federation, identity, handler, system, tor)medium2 daysDONE
12Split large Vue views (Settings 1,792, Mesh, Dashboard, Apps, etc.)low2 daysDONE
- - -

Technical Debt Map

- -

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

- -
-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) — 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) — ~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 - ██████ Tests: 74+ files but gaps in integration coverage - ██████ CI: cargo fmt + clippy + tests, frontend type-check + build - ████ Manual type sync (Rust ↔ TypeScript) - ████ App integration requires 6+ file changes - ████ Crypto deps pinned to exact 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: ████████████████████████████████████████████████ ~85% green (was ~40%) -
- - -

Recommended Learning Path

- -

If you want to understand this codebase deeply and become proficient in all the technologies, study in this order:

- -
-

Phase 1: Foundations (Weeks 1-4)

-
    -
  1. Linux basics — commands, file permissions, processes, systemd
  2. -
  3. Git — branches, commits, diffs, rebasing
  4. -
  5. HTML/CSS/JavaScript — the building blocks of web UIs
  6. -
  7. TypeScript — JavaScript with type safety (read the official handbook)
  8. -
-
- -
-

Phase 2: Frontend (Weeks 5-8)

-
    -
  1. Vue 3 Composition APIref, computed, watch, onMounted
  2. -
  3. Pinia — state management (read stores/container.ts as a good example)
  4. -
  5. Vue Router — URL-to-component mapping
  6. -
  7. Tailwind CSS — utility-first CSS framework
  8. -
  9. Vite — the build tool that bundles everything
  10. -
-
- -
-

Phase 3: Backend (Weeks 9-14)

-
    -
  1. Rust basics — ownership, borrowing, lifetimes, pattern matching (read "The Rust Book")
  2. -
  3. Async Rust with Tokioasync/await, futures, tokio::spawn
  4. -
  5. Hyper — the HTTP server library (read server.rs)
  6. -
  7. Serde — JSON serialization/deserialization
  8. -
  9. Error handlinganyhow, thiserror, the ? operator
  10. -
-
- -
-

Phase 4: Infrastructure (Weeks 15-18)

-
    -
  1. Containers — Docker/Podman concepts (images, containers, volumes, networks)
  2. -
  3. Nginx — reverse proxy, location blocks, upstream servers
  4. -
  5. Shell scripting — bash/zsh, set -e, functions, trap
  6. -
  7. systemd — service management, unit files, journalctl
  8. -
  9. Networking — TCP/IP, DNS, ports, firewalls (UFW)
  10. -
-
- -
-

Phase 5: Bitcoin & Crypto (Weeks 19-24)

-
    -
  1. Bitcoin protocol — blocks, transactions, UTXOs, mining (read "Mastering Bitcoin")
  2. -
  3. Lightning Network — payment channels, routing, invoices
  4. -
  5. Cryptography — hashing, symmetric/asymmetric encryption, digital signatures
  6. -
  7. Tor — onion routing, hidden services, SOCKS5 proxy
  8. -
  9. Nostr — decentralized messaging protocol, NIPs
  10. -
  11. DIDs — Decentralized Identifiers, Verifiable Credentials
  12. -
-
- -
- Recommended first files to read -
    -
  1. neode-ui/src/stores/auth.ts — Clean authentication state machine (new, focused store)
  2. -
  3. neode-ui/src/stores/sync.ts — WebSocket + JSON patch data sync (new)
  4. -
  5. neode-ui/src/api/rpc-client.ts — Well-designed API client with retry logic
  6. -
  7. core/archipelago/src/api/rpc/dispatcher.rs — How RPC routing works (new)
  8. -
  9. core/archipelago/src/api/rpc/package/install.rs — App install flow (focused)
  10. -
  11. core/archipelago/src/session.rs — Auth flow in Rust with crypto
  12. -
  13. core/container/src/podman_client.rs — How Rust talks to Podman
  14. -
  15. image-recipe/configs/nginx-archipelago.conf — The full routing map
  16. -
-
- - -

Glossary

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TermWhat It Means
APIApplication Programming Interface — a defined way for two programs to talk to each other
Async/AwaitA way to write code that waits for slow things (network, disk) without blocking other work
BackendThe server-side code that runs on the machine (not visible to users)
ContainerAn isolated environment for running an app, like a lightweight virtual machine
ComposableA reusable piece of logic in Vue (similar to React hooks)
CSRFCross-Site Request Forgery — an attack where a malicious site tricks your browser into sending requests
CrateA Rust package (like npm package for JavaScript)
DIDDecentralized Identifier — a self-owned digital identity (no central authority controls it)
DWNDecentralized Web Node — personal data storage that syncs across your devices
FrontendThe browser-side code that users see and interact with
ISOA disk image file — like a digital copy of an installation CD
JWTJSON Web Token — a compact way to pass verified identity between systems
LoRaLong Range radio — low-power wireless communication over several kilometers
NginxA web server that also works as a reverse proxy (routes traffic to the right service)
NostrA decentralized messaging protocol using public/private key pairs
Onion ServiceA Tor hidden service — a server accessible only through the Tor network (no IP address)
PiniaVue's official state management library (successor to Vuex)
PodmanA container runtime like Docker, but rootless (more secure)
RPCRemote Procedure Call — calling a function on another computer over the network
ReactiveData that automatically updates the UI when it changes (core Vue concept)
Reverse ProxyA server that sits between clients and backend servers, forwarding requests
RustA systems programming language focused on safety and performance
SPASingle Page Application — a web app that loads once and dynamically updates content
Satoshi (sat)The smallest unit of Bitcoin. 1 BTC = 100,000,000 sats
systemdLinux's service manager — starts, stops, and monitors background services
TokioRust's async runtime — handles thousands of concurrent operations efficiently
TorThe Onion Router — anonymizes internet traffic by routing through multiple relays
TypeScriptJavaScript with static types — catches bugs at compile time instead of runtime
Vue 3A JavaScript framework for building reactive user interfaces
WebSocketA persistent, two-way connection between browser and server for real-time data
- -
-

- 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/archive/demo-deployment-design.md b/docs/archive/demo-deployment-design.md index d0e6a4c1..3edf5d9a 100644 --- a/docs/archive/demo-deployment-design.md +++ b/docs/archive/demo-deployment-design.md @@ -7,8 +7,7 @@ secrets/backend. Deployed via **Portainer**, mock-data driven, with working file storage and a testnet-flavored Bitcoin sandbox so visitors can play freely. See also: `neode-ui/mock-backend.js` (existing mock), `docker-compose.demo.yml` -(existing demo stack), `MEMORY → reference_neode_ui_dev_testing`, -`MEMORY → reference_ovh_168_mirror` (Portainer/registry host). +(existing demo stack). --- @@ -46,7 +45,7 @@ layer**. CI: build archy-demo-web + archy-demo-backend │ push :demo / :latest ▼ - registry (146.59.87.168:3000 / vps2) + registry (source.archipelago-foundation.org / vps2) │ Portainer webhook / re-pull ▼ archy-demo (public repo — tiny) @@ -162,7 +161,7 @@ Today filebrowser upload/delete/rename are 200-OK no-ops. 1. **Demo host** — which Portainer instance (OVH `.168`? a dedicated VPS)? Public DNS + TLS for `demo.`? -2. **Registry for `:demo` images** — `146.59.87.168:3000` vs vps2; public-pull or +2. **Registry for `:demo` images** — `source.archipelago-foundation.org` vs vps2; public-pull or creds baked into Portainer? 3. **Session TTL + concurrency cap** — concrete numbers (30 min / N sessions / 50 MB)? 4. **Chat in the demo** — enable Claude chat (needs key + budget cap) or stub it? diff --git a/docs/archive/lora-functionality.html b/docs/archive/lora-functionality.html deleted file mode 100644 index 3fceac08..00000000 --- a/docs/archive/lora-functionality.html +++ /dev/null @@ -1,899 +0,0 @@ - - - - - -Archipelago — LoRa & Mesh Functionality Guide - - - - - - -
- -
-

LoRa & Mesh Functionality

-

How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.

-
- Meshcore Companion USB - Double Ratchet E2E - 23 Message Types - 160-byte LoRa Frame -
-
- -

Introduction

-

This document explains Archipelago's mesh subsystem — the code under core/archipelago/src/mesh/ that lets nodes talk to each other over LoRa radio instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.

-

The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.

- -

What is LoRa? Layman

-
- Think of LoRa as a whisper that travels 10 kilometers. - Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about 160 bytes at a time, and each whisper takes a second or two to complete. -
-

Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses chirp spread spectrum (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 5–15 km with a simple antenna; data rates are 0.3–50 kbps.

-

Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running Meshcore firmware, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.

- -

Why Archipelago uses it

-
-
-

Off-grid safety

-

Dead-man switch and emergency alerts reach family without cell coverage.

-
-
-

Censorship resistance

-

No ISP, no DNS, no TLS termination — just radio waves between nodes.

-
-
-

Bitcoin when internet is down

-

Relay signed transactions and Lightning payments through on-grid peers.

-
-
-

Truly peer-to-peer chat

-

Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.

-
-
- -
- -

Hardware & Firmware

-

Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.

- - - - - - - - - - - -
ComponentRoleExamples
MCURuns Meshcore firmware, talks USB serialESP32, nRF52840
RadioSemtech LoRa transceiverSX1262, SX1276
BoardMCU + radio + USB + antennaHeltec V3, T-Beam, RAK WisBlock, Station G2
FirmwareMesh routing + Companion USB protocolMeshcore
ConnectionUSB CDC-ACM serial/dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM*
Link params115200 baud, 8N1Set in mesh/serial.rs
- -
- It's a modem. Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through. -
- -

USB Serial Transport

-

Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with '<' and listens for '>'.

- -
Host → Device: 0x3C '<' │ len_lo len_hiframe_bytes... -Device → Host: 0x3E '>' │ len_lo len_hiframe_bytes... - -Baud: 115200 Framing: 8N1 Source: mesh/serial.rs
- -

The frame body is a Meshcore Companion command or response. Archipelago builds these in mesh/protocol.rs and parses replies in mesh/listener/decode.rs.

- -

Companion commands Archipelago uses

- - - - - - - - - - - - - - - -
CodeNamePurpose
0x01APP_STARTHandshake; device returns its node_id and name
0x02SEND_TXT_MSGSend payload to a contact (targeted by 6-byte pubkey prefix)
0x03SEND_CHANNEL_TXT_MSGBroadcast on a channel (no specific recipient)
0x04GET_CONTACTSPull the device's contact table
0x06SET_DEVICE_TIMESync Unix timestamp for message dating
0x07SEND_SELF_ADVERTBroadcast our identity onto the mesh
0x08SET_ADVERT_NAMESet our display name
0x0ASYNC_NEXT_MESSAGEPop the next queued inbound message
0x0BSET_RADIO_PARAMSFrequency, spreading factor, bandwidth
0x0CSET_RADIO_TX_POWERTransmit power (dBm)
0x38GET_STATSDevice statistics
- -

Responses and push notifications

-

Responses begin with a status byte. Codes < 0x80 are replies to a command we sent; codes >= 0x80 are asynchronous push events from the device.

- - - - - - - - - - - - -
CodeNameMeaning
0x00RESP_OKCommand accepted
0x01RESP_ERRCommand failed + error code
0x03RESP_CONTACTOne contact entry (32-byte pubkey + metadata)
0x05RESP_SELF_INFOOur node_id and name after APP_START
0x10RESP_CONTACT_MSG_V3Direct inbound message (SNR + sender prefix + payload)
0x11RESP_CHANNEL_MSG_V3Channel broadcast inbound
0x83PUSH_MESSAGES_WAITINGAsync: new messages in queue, call SYNC_NEXT_MESSAGE
- -

Wire Format — the payload byte 0

-

Once a frame reaches the message payload, Archipelago looks at the first byte to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.

- -
0x00 Plain text (legacy, unencrypted) -0x01 Identity broadcast (ARCHY:2 / ARCHY:3) -0x02 Typed CBOR envelope (plaintext, used for debug or intra-LAN) -0xEE Encrypted typed — ChaCha20-Poly1305 w/ static shared secret -0xDD Ratcheted typed — Double Ratchet, forward-secure
- -

Markers 0xEE and 0xDD are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.

- -

0xEE — static-key encrypted envelope

-
[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]
-
    -
  • Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.
  • -
  • Cipher: ChaCha20-Poly1305 AEAD.
  • -
  • Max plaintext: 160 − 1 − 12 − 16 = 131 bytes (see crypto::MAX_ENCRYPTED_PLAINTEXT).
  • -
  • Properties: confidential + authenticated, but compromise of a key decrypts all history.
  • -
- -

0xDD — Double Ratchet envelope

-
[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
-
    -
  • Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).
  • -
  • Handles out-of-order delivery via a skipped-keys cache.
  • -
  • Properties: forward secrecy + post-compromise recovery. Used for mesh.* chat once a session is established.
  • -
  • Implementation: mesh/ratchet.rs, session load/save in mesh/listener/session.rs.
  • -
- -
- Static key vs. ratchet = a safe vs. a self-shredding envelope. - The 0xEE lane is like a locked safe: one key opens everything. The 0xDD lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages. -
- -

Encryption Layers

-

Three cryptographic primitives combine to produce the 0xDD ratchet flow:

- -
-
-

X25519 ECDH

-

Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.

-
-
-

HKDF-SHA256

-

Derives root key, chain key, and message key at each ratchet step.

-
-
-

ChaCha20-Poly1305

-

Symmetric AEAD used for the actual payload encryption + authentication tag.

-
-
- -

Session bootstrap — X3DH-like handshake

-

Before the ratchet can start, peers exchange a PrekeyBundle (type 5) and a SessionInit (type 6). Those two messages are carried by the 0xEE static-key envelope, because the ratchet session doesn't exist yet. Once SessionInit is processed, subsequent traffic switches to 0xDD. See mesh/x3dh.rs.

- -

Fragmentation — how a 500-byte message rides a 160-byte pipe

-

The LoRa frame budget is 160 bytes (protocol::MAX_MESSAGE_LEN). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.

- -
Chunk header ┌──────────┬──────────┬────────────┐ - │ type (1) │ id (1) │ total (1) │ - └──────────┴──────────┴────────────┘ -Chunk body Up to 140 bytes of Base64-encoded payload - -Sender: compress → encrypt → split into 140-char chunks - → send with tiny inter-chunk delay -Receiver: accumulate by (sender, chunk_id) → reassemble - → decrypt → decompress → dispatch
- -

For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.

- -
- Escape hatch: federation fallback. If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago skips LoRa entirely and routes the message over Tor federation instead. See the ContentRef path in rpc/mesh/typed_messages.rs. -
- -

Dual Transport — LoRa + Tor federation

-

Archipelago treats LoRa and Tor federation as two lanes of the same highway. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.

- -
┌──────────────────┐ - │ mesh.send(...) │ - └────────┬─────────┘ - │ - ┌──────────┴──────────┐ - │ Is peer synthetic? │ - └──────────┬──────────┘ - No │ Yes - ┌──────────┘ └──────────┐ - ▼ ▼ - LoRa radio Tor federation - (160-byte frame) (unlimited, slower setup) - │ │ - │ if > 160 B && synth ──────┘ (fallback) - ▼ - Chunked over LoRa - or refused if no fallback
- -

Addressing

-
    -
  • Contact ID — 32-bit handle from Meshcore's contact table. Used by SEND_TXT_MSG.
  • -
  • Pubkey prefix — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.
  • -
  • DID / onion — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.
  • -
- -

Synthetic federation contacts

-

To let the chat list show federation peers before any message arrives, Archipelago inserts synthetic contacts into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (≥ 0x8000_0000), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.

- -
- -

All 23 Message Types

-

Every typed message is a CBOR envelope identified by a single MeshMessageType byte. The Transport column shows which marker carries it on the wire and which Companion command is used.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDTypePurposeMarkerCmdChunked?
0TextPlain chat message0xDD0x02If >160 B
1AlertEmergency / dead-man heartbeat0xDD0x02/0x03No (short)
2InvoiceLightning / BOLT11 invoice0xDD0x02Usually
3PsbtHashUnsigned tx hash for co-signing0xDD0x02No
4CoordinateGPS location share0xDD0x02No
5PrekeyBundleX3DH bootstrap (pre-session)0xEE0x02No
6SessionInitInitial ratchet message0xEE0x02No
7BlockHeaderBitcoin block height/hash0xDD0x03No
8TxRelaySigned Bitcoin tx for on-grid peer to broadcast0xDD0x02Yes
9TxRelayResponsetxid or error from the relay peer0xDD0x02No
10LightningRelayBOLT11 to pay via on-grid peer0xDD0x02Yes
11LightningRelayResponsepayment_hash or error0xDD0x02No
12TxConfirmationDepth update (1/2/3 confs)0xDD0x02No
13ReplyQuoted reply to a previous message0xDD0x02If long
14ReactionEmoji reaction on MessageKey0xDD0x02No
15ReadReceipt"Seen up to MessageKey X"0xDD0x02No
16ForwardRe-forwarded original w/ provenance0xDD0x02Yes
17EditIn-place text replacement0xDD0x02If long
18DeleteTombstone for earlier message0xDD0x02No
19ContentRefCID of blob held by sender (file/image)0xDD0x02 or TorFederation fallback
20PresenceHeartbeat + last-activity epoch0xDD0x03No
21ChannelInviteGroup membership announcement0xDD0x03No
22ContactCardShareable federation node card0xDD0x02Maybe
- -

The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.

- -

Text, Reply, Edit, Delete, Forward

- -

Text (type 0)

-

Sender path. rpc.mesh.sendtyped_messages::send_text → CBOR-encode the Text{body} variant → ratchet-encrypt → prefix 0xDD → if under 160 B, send in one SEND_TXT_MSG frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.

- -

Reply (type 13)

-

Same as Text, but the CBOR envelope carries a MessageKey pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.

- -

Edit (type 17)

-

Envelope contains the original MessageKey plus the new body. Receiver updates its local store in-place and tags the entry "edited".

- -

Delete (type 18)

-

Tombstone only: MessageKey with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.

- -

Forward (type 16)

-

Wraps original {sender_name, original_timestamp, body} so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are almost always chunked.

- -

Reaction, ReadReceipt, Presence

- -

Reaction (type 14)

-

Envelope: {target: MessageKey, emoji: String}. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see MessageActions in neode-ui).

- -

ReadReceipt (type 15)

-

Envelope: {up_to: MessageKey}. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).

- -

Presence (type 20)

-

Periodic heartbeat carrying {last_activity_epoch}. Broadcast on a channel (SEND_CHANNEL_TXT_MSG, cmd 0x03) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.

- -
- Like a lighthouse beacon. Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed. -
- -

ContentRef — files and images without bloating the radio

-

LoRa cannot move a 500 KB image. The ContentRef type (19) solves this by sending only a pointer — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.

- -
Sender Receiver -────── ──────── -store blob locally (CID) -┌──────────────────────┐ -│ ContentRef {cid, │ ──ratchet──▶ -│ mime, size, │ 0xDD -│ thumb_hash} │ over LoRa -└──────────────────────┘ - see CID in chat - click to fetch - ┌─────────────────┐ - │ rpc.mesh.fetch- │ - │ content(cid) │ - └────────┬────────┘ - ▼ - federation (Tor) - resolve DID → pull blob
- -
- Resolution bug fix note. An earlier revision of ContentRef routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit 5f7ebf14) resolves the owning peer by DID and falls back to name-match only if DID lookup fails. -
- -

Bitcoin & Lightning over LoRa

-

Archipelago uses the mesh as a Bitcoin transport of last resort. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.

- -

TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)

-
Offline signer On-grid relay peer Bitcoin p2p -────────────── ────────────────── ─────────── -sign tx -┌─────────────┐ -│ TxRelay │ ─ratchet/LoRa▶ decrypt → validate -│ {raw_tx} │ broadcast via bitcoind ───▶ mempool -└─────────────┘ │ - ▼ - ┌────────────────────────┐ - ◀─ratchet│ TxRelayResponse{txid} │ - └────────────────────────┘ - (or {error}) - -later, as blocks arrive: - ┌────────────────────────┐ - ◀─ratchet│ TxConfirmation │ - │ {txid, depth: 1..3} │ - └────────────────────────┘
- -

The binary framing in mesh/bitcoin_relay.rs is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.

- -

LightningRelay (10) → LightningRelayResponse (11)

-

Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns payment_hash or an error. Invoices are often long enough to chunk.

- -

Invoice (2) and PsbtHash (3)

-

These are not relays — they're peer-to-peer handoffs. Invoice delivers a BOLT11 to be paid by the recipient. PsbtHash carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.

- -

BlockHeader (7)

-

Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via SEND_CHANNEL_TXT_MSG) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.

- -

Alerts, Coordinates, Dead-Man

- -

Alert (type 1)

-

Envelope: {kind, message, sender_contact_id}. Kinds include Emergency and Deadman. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).

- -

Dead-man switch

-

A background task in mesh/alerts.rs sends a Deadman alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.

- -

Coordinate (type 4)

-

Envelope: {lat, lon, accuracy_m} with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.

- -

ChannelInvite (type 21)

-

Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via SEND_CHANNEL_TXT_MSG.

- -

Identity, PrekeyBundle, ContactCard

- -

Identity broadcast (marker 0x01, ARCHY:2/3)

-

The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed 0x01. This is how peers discover each other. The payload encodes protocol version (ARCHY:2 or ARCHY:3) and the raw pubkey. Carried by CMD_SEND_SELF_ADVERT (0x07).

- -

PrekeyBundle (type 5) and SessionInit (type 6)

-

X3DH handshake. PrekeyBundle advertises a signed prekey; SessionInit consumes it to derive the initial ratchet root key. Both ride on 0xEE (static-key encryption), because the ratchet session they're creating doesn't yet exist.

- -

ContactCard (type 22)

-

A shareable card containing {did, onion_address, pubkey, display_name}. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.

- -
- -

RPC API — what callers actually invoke

-

Every user-facing action goes through the RPC dispatcher (api/rpc/dispatcher.rs, lines 287+) and ends in api/rpc/mesh/typed_messages.rs. The tables below show the public surface.

- -

Core commands

- - - - - - - - - - - - -
RPCEffect
mesh.statusDevice info, peer count, enabled state
mesh.peersList all discovered peers with RSSI / SNR / hop count
mesh.messagesRetrieve stored mesh messages
mesh.sendSend plain text to a specific peer
mesh.send-channelBroadcast on a channel
mesh.broadcastMesh-wide announcement
mesh.configureSet device params (name, power, channel)
mesh.debug-dumpRaw state for debugging
- -

Rich message commands

- - - - - - - - - - - - - - - - -
RPCMsg TypeNotes
mesh.send-invoiceInvoice (2)Deliver BOLT11 to peer
mesh.send-coordinateCoordinate (4)Single frame, fixed-point
mesh.send-alertAlert (1)Emergency or deadman
mesh.send-contentContentRef (19)Stores blob, sends CID
mesh.fetch-contentPulls blob via federation
mesh.send-psbtPsbtHash (3)Hash only, full PSBT via fetch
mesh.send-replyReply (13)Quoted response
mesh.send-reactionReaction (14)Emoji
mesh.send-read-receiptReadReceipt (15)Cumulative "seen up to"
mesh.forward-messageForward (16)Wraps original + provenance
mesh.edit-messageEdit (17)In-place text replacement
mesh.delete-messageDelete (18)Tombstone
- -

User Interface

-

The Vue side lives under neode-ui/src/views/mesh/ with state in stores/mesh.ts. Notable panels:

-
-
-

Mesh chat

-

Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.

-
-
-

MeshBitcoinPanel

-

UI for TxRelay / LightningRelay submission and confirmation tracking.

-
-
-

MeshDeadmanPanel

-

Configure dead-man interval, pick recipients, show last heartbeat time.

-
-
-

Unified inbox

-

Federation and mesh chats appear side-by-side; the transport is invisible to the user.

-
-
- -

Listener loop — how inbound traffic is decoded

-

A long-running async task in mesh/listener/mod.rs owns the serial device and feeds events into the rest of the system.

- -
loop { - event = await serial_read() - match event { - PUSH_MESSAGES_WAITING → send SYNC_NEXT_MESSAGE until empty - RESP_CONTACT_MSG_V3 → decode.rs extracts payload - → match first byte: - 0x00 plain text - 0x01 identity → frames::parse_identity - 0x02 typed CBOR plaintext - 0xEE → crypto::decrypt_static - 0xDD → session::load + ratchet::decrypt - → dispatch.rs routes typed msg - to chat store / bitcoin relay / - alerts / presence / ... - RESP_CONTACT → contact list update - RESP_SELF_INFO → record our node_id - } -}
- -

Chunk reassembly happens in listener/session.rs, keyed by (sender_pubkey_prefix, chunk_id). Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.

- -

File Map

- - - - - - - - - - - - - - - - - - -
FileSizeRole
mesh/mod.rs52 KBPublic API, send paths, federation integration
mesh/protocol.rs26 KBFrame encoding/decoding, command builders
mesh/serial.rs15 KBUSB driver, device detection, handshake
mesh/crypto.rs10 KBX25519 ECDH, ChaCha20-Poly1305, HKDF
mesh/ratchet.rs16 KBDouble Ratchet implementation
mesh/message_types.rs23 KB23 typed message discriminators + CBOR schemas
mesh/bitcoin_relay.rs17 KBTxRelay / LightningRelay binary framing
mesh/listener/dispatch.rs29 KBTyped-message routing into chat/relay/alerts
mesh/listener/session.rs14 KBRatchet session persistence + chunk reassembly
mesh/x3dh.rsPrekey / SessionInit bootstrap
mesh/outbox.rsRetry queue for unacked sends
mesh/steganography.rsWeather/sensor framing for deniable traffic
api/rpc/mesh/typed_messages.rsAll mesh.* RPC handlers
neode-ui/src/stores/mesh.ts14 KBPinia store consumed by all mesh Vue views
- -
- -

Summary scoreboard

-
-
23
Message types
-
160
Bytes / frame
-
2
Transports
-
5
Wire markers
-
~6k
LoC in mesh/
-
FS
Forward-secure
-
- -
- Bottom line. Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link. -
- -
- - diff --git a/docs/bitcoin-multi-version-design.md b/docs/bitcoin-multi-version-design.md index 3f74ff01..a91db710 100644 --- a/docs/bitcoin-multi-version-design.md +++ b/docs/bitcoin-multi-version-design.md @@ -1,91 +1,7 @@ # Bitcoin Multi-Version Support — Design - - -**Status:** design (2026-06-22) +**Status:** implemented — all four phases shipped (catalog schema, install-time selection, in-app switch + auto-update toggle, and the verified image build pipeline). Downgrades are guarded: the update path never offers a lower version than what is running. **Goal:** let a user choose *which* version of Bitcoin Core / Bitcoin Knots to install (latest pre-selected, older versions in a dropdown), and later switch versions or opt into auto-update — all manifest/catalog-driven, all served from @@ -94,13 +10,8 @@ changes. See also: [`docs/registry-manifest-design.md`](registry-manifest-design.md) (catalog distribution + signing this builds on), -[`docs/PRODUCTION-MASTER-PLAN.md`](PRODUCTION-MASTER-PLAN.md) (gate that must be -green first), `MEMORY → project_decoupled_app_updates`, -`MEMORY → project_manifest_driven_north_star`. +and the production test gate (which must be green first). -> **Scheduling:** this is net-new scope. It lands **after** the production test -> gate (`tests/lifecycle/run-20x.sh`) is green on `.228` + `.198`. The data- -> preservation invariant (downgrade vs. chainstate) is the highest risk here. --- @@ -111,7 +22,7 @@ green first), `MEMORY → project_decoupled_app_updates`, |-------|-------| | `apps/bitcoin-core/Dockerfile` | `FROM bitcoin/bitcoin:24.0` — a **community** image, **stale** (manifest says 28.4), no project-official Docker image exists | | `apps/bitcoin-knots/` | **no Dockerfile** — `:latest` is built/pushed by hand | -| Registry | `scripts/image-versions.sh` → `ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"`; only `BITCOIN_KNOTS_IMAGE=…/bitcoin-knots:latest` pinned, no Core pin | +| Registry | `scripts/image-versions.sh` → `ARCHY_REGISTRY="source.archipelago-foundation.org/lfg2025"`; only `BITCOIN_KNOTS_IMAGE=…/bitcoin-knots:latest` pinned, no Core pin | | Tags in registry | **one tag per image**. No historical versions. | ### Version pinning @@ -260,7 +171,7 @@ free. `version`/`image` stay as the default for back-compat. - **Floating tags** (`latest`) are never advertised as a selectable "version" and never counted as an available update (already handled by `available_update_for_app`). -- **Verify on a real node** (`.228` then `.198`) and pass `run-20x` before any +- **Verify on a real node** and pass the lifecycle gate before any tag. --- diff --git a/docs/bitcoin-rpc-relay.md b/docs/bitcoin-rpc-relay.md index 8c6d7857..9c85a691 100644 --- a/docs/bitcoin-rpc-relay.md +++ b/docs/bitcoin-rpc-relay.md @@ -171,7 +171,7 @@ http://:80 For the tested node the LAN upstream was: ```text -http://192.168.1.116:80 +http://archipelago.local:80 ``` The public proxy should serve a valid TLS certificate for the chosen subdomain. @@ -276,7 +276,7 @@ Expected result: The working endpoint used in this setup was: ```text -https://shard.tx1138.com/ +https:/// ``` It was verified with: diff --git a/docs/bitcoin-version-bulletproof-rollout.md b/docs/bitcoin-version-bulletproof-rollout.md deleted file mode 100644 index f8a6773f..00000000 --- a/docs/bitcoin-version-bulletproof-rollout.md +++ /dev/null @@ -1,131 +0,0 @@ -# Bitcoin Multi-Version — Bulletproofing & Rollout (handoff) - -> **Status 2026-06-29:** code + images + catalog + frontend DONE on branch -> `bitcoin-version-bulletproof` (base commit `095a76cd`, plus the catalog-generator -> + handoff follow-ups). **.228 is the test node**: binary + frontend + catalog are -> live there; its Knots chainstate is mid-**reindex recovery** (see §5). The fleet -> rollout (OTA binary+frontend, mirror catalog publish, `:latest` repoint) is the -> **coordinated step the other agent owns** — see §4. Pairs with -> `docs/bitcoin-multi-version-design.md` (the original design). - -## 1. What was broken (root causes) - -User report: "switched Knots to `v29.3.knots20260508`, version didn't update in the UI." -Three **stacked** bugs, plus a data-corruption hazard: - -1. **Reconciler reverted the pin.** `prod_orchestrator::sync_quadlet_unit` re-rendered the - quadlet every reconcile tick using the manifest's `:latest`, ignoring the per-app - pinned version → any switch silently reverted within one tick. -2. **Entrypoint render bug.** The renderer folded the manifest `entrypoint: ["sh","-lc"]` - into `Exec=`. That only works when the image ENTRYPOINT is a passthrough shell wrapper. - The versioned images use `ENTRYPOINT ["bitcoind"]`, so `Exec=sh -lc …` became - `bitcoind sh -lc …` → `unexpected token 'sh'` → crash loop. -3. **Image USER divergence.** The versioned images were built `USER bitcoin` (uid 1000); - the legacy `:latest` ran as **root**. Chain data is owned by the `data_uid` - (host 100101 / container uid 102). Root reads it via `CAP_DAC_OVERRIDE` (granted in the - manifest); uid-1000 cannot → `Error initializing block database`. -4. **Data hazard (already hit on .228).** Repeated failed starts under mixed UIDs left - bitcoind's two LevelDBs (`blocks/index/` + `chainstate/`) truncated to KB stubs while - the raw `blocks/blk*.dat` (797 GB) stayed intact. Recovery = `bitcoind -reindex` from - local blocks (no re-download). The uniform-root image fix (below) removes the mixed-UID - cause going forward; the proper switch flow was already data-safe (600s stop grace, - clean stop→rm→recreate, conflict-stops the other impl — they share port 8332 + datadir - `/var/lib/archipelago/bitcoin`). - -## 2. What was fixed (all on the branch) - -- **Renderer** (`core/archipelago/src/container/`): - - `prod_orchestrator.rs`: factored `resolve_catalog_image()` (catalog/pinned-version → - image) and call it in BOTH `install_fresh` and `sync_quadlet_unit` — the pin now - survives reconcile. - - `quadlet.rs`: emit a real `Entrypoint=` + `Exec=` instead of folding; - `exec_changed` now also diffs `Entrypoint=` so the recreate fires. Validated against - the live podman 5.4.2 quadlet generator. -- **Images** (`scripts/build-bitcoin-image.sh`, `apps/bitcoin-{knots,core}/Dockerfile`): - removed `USER bitcoin` → run as **container-root** like legacy (still 100% rootless: - container-root maps to the unprivileged host service user; `CAP_DAC_OVERRIDE` from the - manifest lets bitcoind read the `data_uid`-owned datadir). **All** images rebuilt root + - pushed to the mirror (`146.59.87.168:3000/lfg2025`): - - Knots: `29.3.knots20260508`, `29.3.knots20260507`, `29.3.knots20260210`, `29.2.knots20251110` - - Core: `25.2 26.2 27.2 28.4 29.2 29.3 30.2 31.0` + `latest` (→31.0) -- **Catalog** (`scripts/generate-app-catalog.sh` VERSIONS map + regenerated - `releases/app-catalog.json`): Knots & Core `versions[]` populated; the generator now - forces top-level `version` == the `default` entry's version (the `169ff2e2` invariant) - regardless of the manifest version. Knots `latest` entry points at the newest **dated** - image (`29.3.knots20260508`) so "Always use latest" = newest on fixed-binary nodes. -- **Frontend** (`neode-ui/`): - - `AppSidebar.vue`: rename the latest option to **"Always use the latest version"** - (no `v` prefix), fix right padding, and `pickSelection()` guarantees the bound value is - a real option (fixes the blank dropdown). - - New `components/InstallVersionModal.vue`: full-screen version chooser shown from the - App Store / Discover **card** install button for multi-version apps — app icon + - "Install ", latest pre-selected. Wired in `Discover.vue handleInstall`. - - i18n keys: `appDetails.alwaysUseLatestVersion`, `marketplace.installModalTitle/Hint`. - -## 3. Current live state on .228 (test node) - -- Binary with both renderer fixes: **deployed** (`/usr/local/bin/archipelago`). -- New frontend bundle: **deployed** to `/opt/archipelago/web-ui` (hard-refresh to see it). -- Updated catalog: placed at `/var/lib/archipelago/app-catalog.json` (local override — - will refresh from the mirror's OLDER copy at the next hourly fetch until §4 publishes it). -- Knots: `bitcoin-knots` service held **stopped** (`package.stop`, user_stopped); - a detached `bitcoin-knots-reindex` container is rebuilding the index+UTXO (§5). - -## 4. Remaining — coordinated fleet rollout (OTHER AGENT) - -Do this together with the other workstream's release, AFTER both are ready: - -1. **Merge** branch `bitcoin-version-bulletproof` into the release line. -2. **Build + OTA** the binary + frontend (these carry the renderer fix + UI). The renderer - fix is a **hard prerequisite** for the new images everywhere — see fleet-safety below. -3. **Publish the catalog** to the mirror (push `releases/app-catalog.json` to gitea-vps2 - `main`, the raw URL nodes fetch hourly). The current catalog is **fleet-safe even before - the binary lands**: unpinned/auto-update nodes resolve via the manifest's floating - `:latest` (still the legacy image); only explicit version selection (needs the new UI) - uses the new root images. -4. **Only AFTER the binary is fleet-wide:** optionally repoint the `bitcoin-knots:latest` - tag → `29.3.knots20260508` (root) and simplify the catalog `latest` entry back to the - `:latest` tag. **Do NOT repoint `:latest` before then** — old-binary nodes fold - `Exec=sh -lc …` and would crash on an `ENTRYPOINT ["bitcoind"]` image. (Core never - worked on old binaries — it always shipped `ENTRYPOINT ["bitcoind"]` — so Core has no - such constraint.) -5. **Verify the full switch matrix** on a healthy node (§6). - -## 5. Finishing .228's reindex (OTHER AGENT owns this — not babysat by the original author) - -The detached `bitcoin-knots-reindex` container runs the new **root** `29.3.knots20260508` -image with `-reindex -server=0` against `/var/lib/archipelago/bitcoin`. It holds the datadir -lock, so the managed service (held stopped) can't collide. When it has connected blocks up -to ~the prior tip (height ≥ ~955800) it's done; then: - -```sh -# on .228 (SSH/sudo/UI pw all: ThisIsWeb54321@) -podman stop -t 600 bitcoin-knots-reindex && podman rm bitcoin-knots-reindex -# start the managed service via RPC (sets desired=running, clears user_stopped): -# package.start {id: bitcoin-knots} (POST https://127.0.0.1/rpc/v1, CSRF: echo csrf_token cookie as X-CSRF-Token) -# verify: -podman exec bitcoin-knots sh -lc '$(command -v bitcoind) --version | head -1' # → v29.3.knots20260508 -# RPC up → the Bitcoin UI populates; it syncs the gap to tip. -``` -The "Bitcoin RPC connection refused (127.0.0.1:8332)" the UI shows is EXPECTED until this -swap (reindex runs with RPC off). - -## 6. Switch-matrix test plan (what "bulletproof" must prove) - -On a healthy node, each step must end with bitcoind running + RPC answering + syncing, with -NO `Error initializing block database` and NO data loss: -- Knots: switch `latest` → `29.3.knots20260507` → `29.3.knots20260210` → back to `latest`. -- Core: install `latest`; switch `31.0` → `28.4.0`. -- **Knots ↔ Core** (shared datadir/port): Knots→Core upgrade path (Core ≥ data version) and - the reverse. **Cross-major DOWNGRADES** (e.g. 29.x data → Core 28.4) legitimately need a - reindex — the UI already surfaces a downgrade warning; confirm it does and that confirming - reindexes cleanly rather than crash-looping. -- Reboot survival after each switch. - -## 7. Notes / assumptions - -- **"29.2"** in the request doesn't exist as a Knots build (404 upstream); added as **Bitcoin - Core 29.2** (exists). Revisit if a Knots 29.2 was meant. -- Reindex is unavoidable ONLY because .228's index was already corrupted by the pre-fix - crash loop; a normal switch on the fixed binary does NOT reindex. -- Creds for .228: SSH/sudo + UI/RPC all `ThisIsWeb54321@`. diff --git a/docs/bulletproof-containers.md b/docs/bulletproof-containers.md index 6b0940ef..f75e845e 100644 --- a/docs/bulletproof-containers.md +++ b/docs/bulletproof-containers.md @@ -1,15 +1,35 @@ -# Bulletproof Containers for Beta +# Bulletproof Containers -**Status**: plan agreed 2026-04-22, implementation started. -**Target**: zero-manual-intervention container lifecycle for the beta launch. A user installs, uninstalls, reboots, updates, or loses power — every combination must leave the node in a known-good state without SSH. -**Project memory**: `~/.claude/projects/-home-archipelago-Projects-archy/memory/project_reconcile_architecture.md` -**Failure log**: `~/.claude/projects/-home-archipelago-Projects-archy/memory/feedback_container_lifecycle_failure_modes.md` +**Status**: historical design record (agreed 2026-04-22). The *architecture* — +level-triggered, desired-state reconciliation — was adopted and is live. Several +specifics below were not built as written, so read this for the incident history +and the reasoning, not as a description of the code. For how the lifecycle +actually works today, read [Container lifecycle](container-lifecycle.md). + +What became of the plan, verified against the tree: + +| Item | Outcome | +|---|---| +| Level-triggered reconciler | ✅ Shipped, but as `container/boot_reconciler.rs` + `container/prod_orchestrator.rs`. The `core/archipelago/src/reconcile/` module laid out below (`desired.rs`/`current.rs`/`diff.rs`/`apply.rs`/…) **was never created** — no file in it exists | +| FM5 post-OTA probe + auto-rollback | ✅ Shipped — `update-pending-verify.json` (`update.rs:100`) | +| FM4 `host.archipelago` alias | ✅ Shipped — `AddHost=host.archipelago:10.89.0.1` in generated units | +| FM1/FM3 Quadlet ownership | ◐ Partial. Companion UIs run as Quadlet units; **main app containers do not** — `use_quadlet_backends` still defaults false, so the "v1.7.48+ full migration" below has not happened | +| FM2 bitcoin.conf drift | ◐ Solved differently. There is no `reconcile::derived::render_bitcoin_conf`; instead bitcoind is run with an explicit `-conf` derived from secrets at each start and stale datadir configs are removed (`remove_stale_bitcoin_conf`) | +| FM6 podman corrupt-state self-heal | ❌ **Not implemented.** No `podman system renumber` recovery, no startup probe for "invalid internal status". The failure that made a node unreachable in 2026-04 would still need manual SSH | + +Note also that the unit paths below say `/etc/containers/systemd/`; units are +actually written per-user to `~/.config/containers/systemd/` +(`quadlet.rs:DEFAULT_REL_UNIT_DIR`), since the whole path is rootless. + +**Target**: zero-manual-intervention container lifecycle. A user installs, +uninstalls, reboots, updates, or loses power — every combination must leave the +node in a known-good state without SSH. --- ## Why we're doing this -The v1.7.38 and v1.7.39 rollouts on 2026-04-22 exposed a cluster of container-lifecycle failures that required manual SSH recovery on every affected node (.116, .198, .228, .253). If a user had been on those nodes, they'd have been stuck with "can't reach" or 500 errors and no path forward. We can't ship beta with this class of failure on the table. +The v1.7.38 and v1.7.39 rollouts on 2026-04-22 exposed a cluster of container-lifecycle failures that required manual SSH recovery on every affected node. If a user had been on those nodes, they'd have been stuck with "can't reach" or 500 errors and no path forward. We can't ship beta with this class of failure on the table. The pattern under every failure: **the canonical source of truth had the right answer, but derived state drifted away from it and nothing noticed or fixed it.** @@ -297,18 +317,3 @@ Ordered by likelihood × severity: ### Tor - [rend-spec-v3](https://github.com/torproject/torspec/blob/main/rend-spec-v3.txt) — descriptor lifetime + republish cadence - [stem](https://stem.torproject.org/) — Python Tor controller for `HS_DESC UPLOADED` waits - ---- - -## To resume - -1. Read project memory: `~/.claude/projects/-home-archipelago-Projects-archy/memory/project_reconcile_architecture.md` -2. Read failure-mode memory: `~/.claude/projects/-home-archipelago-Projects-archy/memory/feedback_container_lifecycle_failure_modes.md` -3. Check task list for current release (should start with v1.7.41) -4. Current state on fleet as of 2026-04-22: - - All 4 mirrors (tx1138, gitea-local, .160, .168) synced to v1.7.40-alpha - - .116, .198, .228, .253 healed manually via `systemd-run chmod 755 /opt/archipelago/web-ui` - - .228 still has stale `bitcoin.conf` rpcauth (regenerated during triage; will drift again until v1.7.43) - - .228 UI companions (archy-bitcoin-ui, archy-lnd-ui) keep vanishing (Quadlet migration in v1.7.45+ fixes) - - .160 Gitea required `podman system renumber` recovery (v1.7.44 automates this) -5. Implementation is in progress on `main` branch — next edit is `core/archipelago/src/update.rs` for v1.7.41. diff --git a/docs/combined-test-plan-2026-07-22.md b/docs/combined-test-plan-2026-07-22.md deleted file mode 100644 index cd0308ec..00000000 --- a/docs/combined-test-plan-2026-07-22.md +++ /dev/null @@ -1,173 +0,0 @@ -# Combined test session — 2026-07-22 batch (one sitting) - -Staged on **framework-pt** (`100.65.115.109`) AND **archi thinkpad** (this -machine's node) so everything can be tested in one pass. Items marked ✅ were -already verified by the agent on a node; ❑ items need a human. - -**What's in this batch:** mesh message/DM persistence across restarts · -first-message + DM announce fix · 15s announce poll · radio hot-swap modal -(probe / keep-as-is / apply-settings) · whisper beam-1 (release-gated, see §D) · -real-time wallet push (0-conf tx shows in seconds) · calm Lightning -"still starting" notice · external tx-explorer fallback with consent modal + -wallet-settings On-chain tab · apps open ABOVE modals with the launch -animation · mempool installs no longer blocked by a resyncing ElectrumX · -[pending: other agents' two push sets — section F fills in when their code -lands]. - -## H. Wallet & explorer (new — test on the thinkpad node, it's pruned) - -1. ❑ **Real-time tx display:** send a small on-chain amount to this node's - wallet → the balance and the yellow "unconfirmed" transaction appear - within a few seconds of broadcast, no refresh, no wallet action. -2. ❑ **External explorer consent:** with no local Mempool app running, tap a - transaction → amber consent modal explains it opens on another node's - mempool (default tx1138.com, placeholder mempool.guide, editable) → - Open Explorer opens `/tx/` in a new tab. Tick "don't ask - again" and confirm the next tap opens directly. -3. ❑ **Wallet Settings → On-chain tab:** explorer URL editable, warning shown, - "don't warn" toggle; tabs now read Channels / Cashu / Fedi / Ark / On-chain - and fit on one row (check mobile too). -4. ❑ **Modal → app animation:** on a node WITH Mempool running, open - Transactions and tap a tx → the Mempool app animates in ABOVE the modal - (previously loaded invisibly underneath); closing it returns to the modal. -5. ❑ **Lightning "still starting":** right after a node restart, try opening a - channel → either it just works (silent retry) or a calm amber ⏳ notice - appears — never the red "Failed to connect to peer" error. - ---- - -## A. Staged state (agent-verified before you start) - -- ✅ Dev binary (persistence + announce seeder + hot-swap) on - `/usr/local/bin/archipelago`, service healthy, no crash-loop. -- ✅ Frontend bundle with the new device modal at `/opt/archipelago/web-ui`. -- ✅ Seeder re-ran: `automations.yaml` upgraded v1→v2 (first-message announce), - `configuration.yaml` rest block at `scan_interval: 15`, HA restarted clean. -- ✅ `mesh-messages.json` persisting + restored across a service restart. -- ✅ `mesh.probe-device` returns real firmware details for the plugged stick. - -## B. Mesh history survives restarts (the "messages go missing" fix) - -1. ❑ Open Mesh chat — your existing DM/channel history from today is visible. -2. ❑ Send one channel message and one DM (either direction). -3. ❑ Reboot the whole node (not just the service). After it's back: history - still there, including the two new messages, correct timestamps/senders. -4. ❑ Send a NEW message to another node right after the reboot and confirm the - other side receives it (this exercises the send-seq fix — before it, the - first post-reboot sends were silently dropped by peers as replays). - -## C. Speaker announcements - -1. ❑ Have another node send a **public channel** message → speaker announces - sender + text within ~15s (was ~30s). -2. ❑ Have another node send you a **DM** → speaker announces it the same way. -3. ❑ Restart Home Assistant (or the node) → the last old message is NOT - re-announced (no announce storm). -4. ❑ (First-message case — the original bug — only reproducible on a node with - an empty history: optional, covered by agent verification of the guard.) - -## D. Voice (regression + speed) - -1. ❑ "Hey Jarvis, what's the block height" and one fuzzy phrasing — same - correct answers as before (no behavior change is the pass condition). -2. ⓘ The ~45% faster speech-to-text (whisper beam-1) ships via the **signed - catalog in the release** — it is NOT on the node during this test session. - Benchmarked on this exact hardware: identical transcripts, 0.94s → 0.51s. - -## E. Radio hot-swap modal (your Reticulum stick is already plugged in) - -1. ❑ Open the web UI anywhere — within ~30s a "Mesh Radio Detected" modal - appears showing the stick on `/dev/ttyACM0`, with a card of what's on it - (firmware badge: Reticulum RNode / MeshCore / Meshtastic + current - name/region/channels where the firmware exposes them). -2. ❑ Press **Keep As Is** → mesh connects using the radio exactly as flashed - (check Mesh → Device tab: connected, firmware type correct; nothing on the - radio changed). -3. ❑ Unplug the stick, plug the old MeshCore one → the modal appears AGAIN - (every plug re-triggers, same or different /dev path). -4. ❑ This time press **Set Up with Archipelago Settings** → second screen - shows channel `archipelago`, your region, and the node's RF params (the - validated Portugal preset on this fleet) BEFORE anything is written; - confirm → radio provisions and joins the mesh. -5. ❑ Swap sticks once more with no UI interaction except "Keep As Is" — chat - still works end-to-end afterwards (hot-swap without ceremony). - -## F. Companion pairing + mobile onboarding (other agent — push set #1, MERGED) - -1. ❑ Companion app: pair with the node via the new named QR (device tokens) — - pairing completes instantly, device appears in the paired-devices list. -2. ❑ Remote access now rides the embedded FIPS mesh (WireGuard replaced): - with the phone OFF the node's WiFi, the companion still reaches the node. -3. ❑ The reworked mobile onboarding/intro overlay screens flow correctly on - first launch of the new APK (in-tarball APK is the 27MB build). -4. ❑ (Push set #2 from the other agents is still pending — the release waits - for it; this staged build does NOT include it yet.) - -## G. Quick regressions - -1. ❑ Pine launcher page (:10380) still shows the live node card; "Connect - Pine to WiFi" button loads without JS errors. -2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic). -3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves. - -## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E) - -Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell -tested on real hardware vs. code-reviewed only as this is run. - -1. ❑ From the hot-swap modal's step 1 (device already probed), press - **Flash Firmware…** → new step shows firmware-family + board pickers and - the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until - family, board, AND the checkbox are all set. -2. ❑ Confirm what's currently on the test stick via the existing probe - BEFORE flashing it — don't flash the only known-good device without a - fallback board on hand. -3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run; - only exercise a primary/in-use stick once the flow is proven safe. -4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log - tail update live, ends at "Flash complete". -5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from - the esp32s3 release zip. -6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path - completes (no raw esptool erase/write step for this family — see - `mesh/flash.rs` doc comment). -7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware: - V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid - 303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific) - — so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable - by vid:pid. Board auto-detect should fail closed for it every time - (manual board selection required, "couldn't confirm automatically" - warning shown) — this is expected steady-state behavior, not a gap to - close later. -8. ❑ After a successful flash, the modal automatically re-probes and shows - the NEW firmware's badge/details — same as unplugging and replugging - (Section E item 3), but without physically touching the cable. -9. ❑ Deliberately test a failure path once (disconnect the board mid-write, - or point at a bad cached asset) — confirm the error surfaces in the - progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware - flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/ - rnodeconf command) actually get the board back to a flashable state. -10. ❑ Cancel button only appears (and only works) while still in the - "Downloading firmware…" stage — once erasing/writing starts, no cancel - affordance is offered. -11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash - (e.g. kill network access mid-download to force a failure), confirm the - mesh listener does NOT auto-resume — `journalctl -u archipelago` should - show a single `Leaving mesh listener stopped after failed flash` line - and then go quiet for that device, not a repeating `mesh::serial: - Opened serial port... Starting Meshcore handshake` cycle every few - seconds. Reconnect manually via the hot-swap modal afterward and confirm - it connects normally (the board itself should be untouched — the - download fails before esptool/rnodeconf ever runs). -12. ❑ Separately, force a device to flap connected/disconnected a few times - in under 20s each (e.g. a marginal USB connection) and confirm - `reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...) - rather than resetting to 5s on every attempt — see - `STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`. - ---- - -After this passes: fold the batch + other agent's work into the next release -(OTA binary + frontend tarball + catalog regen/sign/publish for pine-whisper -3.4.2), then re-run `tests/lifecycle/run-gate.sh` on .228 (back online as -Tailscale `shorty-s`). diff --git a/docs/companion-keyboard-viewport.md b/docs/companion-keyboard-viewport.md new file mode 100644 index 00000000..1163e076 --- /dev/null +++ b/docs/companion-keyboard-viewport.md @@ -0,0 +1,99 @@ +# Companion app — soft-keyboard viewport handover + +**Audience:** the companion (Android WebView wrapper) developer. +**Reported:** 2026-08-04 by the operator — in chat, when the soft keyboard opens, +padding is added to the bottom tab bar and the page scrolls; the chat window +should instead scale to the height that remains above the keyboard. + +## Why this is (most likely) companion-side + +The symptom described — content keeps its full height, the browser *pans/scrolls* +the focused input into view, and the fixed bottom bar picks up a visual gap — is +the classic Android `adjustPan` (or edge-to-edge-without-IME-insets) signature. + +The web side already implements the correct contract, verified in-repo: + +1. **`neode-ui/index.html`** carries + `interactive-widget=resizes-content` in its viewport meta. In Chrome 108+ + this makes the keyboard resize the **layout** viewport, so + `window.innerHeight` shrinks. +2. **`neode-ui/src/main.ts` → `syncViewportHeightVar()`** mirrors + `window.innerHeight` into the CSS var `--visual-viewport-height` on + `resize`, `orientationchange`, and `visualViewport.resize`. It deliberately + uses `innerHeight` (not `visualViewport.height`) so the value shares a + reference frame with `position: fixed` elements like the mobile tab bar. +3. **`neode-ui/src/style.css`** sizes the mobile layout (including the chat + iframe container) from + `var(--visual-viewport-height, 100dvh)` minus the tab-bar/safe-area vars. + +So on mobile web Chrome, the keyboard shrinks `innerHeight`, the var updates, +and the chat scales. **An Android WebView ignores the `interactive-widget` +meta entirely** — keyboard resize there is governed by the host app. If the +host pans instead of resizing, no amount of web CSS can fix it: the WebView's +`innerHeight` never changes and the system scrolls the page instead. + +## What the companion app should do + +Pick the branch that matches how the Activity is configured: + +### A. Not edge-to-edge (no `WindowCompat.setDecorFitsSystemWindows(window, false)`) + +Set the soft-input mode so the WebView is *resized*, not panned: + +```xml + + +``` + +`adjustPan` (and on some OEM builds the historical default `adjustUnspecified`) +produces exactly the reported behaviour. + +### B. Edge-to-edge (decorFitsSystemWindows = false) + +`adjustResize` alone stops working in edge-to-edge; you must consume the IME +inset yourself and resize the WebView: + +```kotlin +ViewCompat.setOnApplyWindowInsetsListener(webViewContainer) { view, insets -> + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + // Resize the container to end above the keyboard (or the nav bar when closed) + view.updatePadding(bottom = maxOf(ime.bottom, bars.bottom)) + WindowInsetsCompat.CONSUMED +} +``` + +(If targeting SDK 35+, edge-to-edge is enforced, so branch B is the one that +applies.) + +### Do NOT compensate on the web side + +Please don't inject extra bottom padding, margins, or scroll offsets into the +page from the wrapper — the web layout already subtracts the tab bar and safe +areas from `--visual-viewport-height`, and wrapper-side compensation double +counts (that is the "padding added to the tabs" half of the symptom). + +## How to verify the fix + +In the WebView's remote-debug console (`chrome://inspect`), focus the chat +input and check: + +- `window.innerHeight` **shrinks** by roughly the keyboard height → correct + (branch A/B working). The chat window will scale; no page scroll. +- `window.innerHeight` **unchanged** while `window.visualViewport.height` + shrinks → the WebView is still panning; the manifest/insets change hasn't + taken effect. + +## Web-side status (for completeness) + +- neode-ui (the page the companion actually loads): already correct, no change + needed. +- AIUI standalone (`aiui/packages/app/index.html`): was missing the + `interactive-widget` token; added 2026-08-04 for parity. Only affects AIUI + used outside a node, not the embedded/companion path. +- iOS Safari / iOS WKWebView: `interactive-widget` is not supported there. If + an iOS wrapper appears later, the equivalent is + `KeyboardLayoutGuide`/`keyboardWillChangeFrame` driving the WKWebView frame — + same principle: resize the web content, never pan it. diff --git a/docs/companion-pairing-qr.md b/docs/companion-pairing-qr.md index a5290f1d..e8242aed 100644 --- a/docs/companion-pairing-qr.md +++ b/docs/companion-pairing-qr.md @@ -30,7 +30,7 @@ Query parameters: | param | required | meaning | |-------|----------|---------| | `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". | -| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. | +| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.0.2.10`, etc. No trailing slash guaranteed either way — normalize. | | `name`| no | Display name for the server entry. Real nodes send the configured server name, or `My Archipelago` when it's still the factory default. | | `tok` | no | **Device token** minted via `auth.createDeviceToken` when the QR is rendered. The app logs in with `{"method":"auth.login","params":{"token":"…"}}` — same endpoint, same rate limiter, skips TOTP (the token was minted from an authenticated session). Long-lived until re-minted (re-showing the pair screen replaces the `companion` token) or revoked (`auth.revokeDeviceToken`). Scan → instantly connected, no typing. | | `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. | @@ -43,7 +43,7 @@ Query parameters: Examples the web UI actually emits: - Demo: `archipelago://pair?v=1&url=https%3A%2F%2Fdemo.archipelago-foundation.org&pw=entertoexit` -- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.168.1.228` +- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.0.2.10` - Real node kiosk (UI runs on localhost, so it advertises the mDNS name from `system.get-hostname`): `archipelago://pair?v=1&url=http%3A%2F%2Farchipelago.local` diff --git a/docs/container-architecture.html b/docs/container-architecture.html deleted file mode 100644 index ce1e6e96..00000000 --- a/docs/container-architecture.html +++ /dev/null @@ -1,4279 +0,0 @@ - - - - - -Archipelago System Architecture - - - -
-
-

Archipelago System Architecture

-

Complete interactive map of every layer, protocol, container, and data path · Click anything to expand

-
- - -
-
- -
- - - - -
- - -
- - - - -
- -
-
34
Containers
-
260+
RPC Methods
-
9
Protocols
-
LUKS2
Encryption
-
Rootless
Podman
-
8 GB+
Recommended RAM
-
- -
-
-
- Kiosk Display - Layer 8 · Physical -
-
X11 + Chromium fullscreen on VT7, showing the web UI directly on connected monitor
-
The TV/monitor screen you see when the box is plugged in. No keyboard needed — it just shows the dashboard.
-
-
-
- Web UI (Vue.js SPA) - Layer 7 · Application -
-
Vue 3 + TypeScript + Pinia frontend served by nginx, communicates via JSON-RPC and WebSocket
-
The dashboard you use in your browser to manage everything — apps, Bitcoin, settings.
-
-
-
- Rust Backend - Layer 6 · Service -
-
Archipelago binary on 127.0.0.1:5678 — RPC server, auth, session management, container orchestration, Tor control
-
The brain of the system. Handles login, manages containers, talks to Bitcoin, and coordinates everything.
-
-
-
- Container Layer (Podman Rootless) - Layer 5 · Isolation -
-
34 containers on archy-net (internal DNS) and bridge networks, managed by rootless Podman
-
Each app runs in its own sandbox. If one app crashes or gets hacked, the others are unaffected.
-
-
-
- Network Layer - Layer 4 · Network -
-
Nginx reverse proxy (80/443), Tailscale mesh VPN, Tor hidden services, UFW firewall
-
Controls what traffic goes where. One front door (nginx) routes requests to the right app. Tor makes you reachable without exposing your IP.
-
-
-
- Encryption Layer - Layer 3 · Security -
-
LUKS2 full-disk encryption on /var/lib/archipelago with auto-detected cipher (AES-XTS or ChaCha20-Adiantum)
-
All your Bitcoin data, passwords, and app data are encrypted. If someone steals the hard drive, they get nothing.
-
-
-
- Operating System - Layer 2 · OS -
-
Debian 12 (Bookworm) minimal — systemd services, x86_64/ARM64, debootstrap custom base
-
The operating system. Debian is rock-solid Linux used by servers worldwide. We strip it down to just what's needed.
-
-
-
- Hardware / Boot - Layer 1 · Physical -
-
UEFI + BIOS dual-boot, GPT partitions, USB flash installer, auto-detect disk + network + CPU features
-
The physical computer. Flash a USB stick, boot from it, and the installer sets everything up automatically.
-
-
- -
-

Container Dependency Chain

-
// Startup order: Databases → Core → Services → Apps -// Health monitor restarts in this order too - -mempool-db ───┐ -btcpay-db ───┤ - - ├──→ bitcoin-knots ──→ electrumx - - ┌────┴────┬──────────┬──────────┐ - - lnd fedimint mempool-api nbxplorer - - fedi-gw mempool-web btcpay - - └──→ lnd-ui - -// IndeedHub stack (independent) -ih-postgres ──→ ih-api ──→ indeedhub -ih-redis ──→ ih-api -ih-minio ──→ ih-api - -// Penpot stack (independent) -penpot-pg ──→ penpot-be ──→ penpot-fe -penpot-vk ──→ penpot-be ──→ penpot-exp - -// Tier 3: All independent — start in any order -filebrowser grafana homeassist jellyfin photoprism -vaultwarden nextcloud searxng uptime-kuma ollama -onlyoffice nginx-pm portainer
-
- -

System Resources

- -
- -
-
Hardware Requirements
- - - - - - - - -
Minimum RAM4 GB
Recommended RAM8 GB+ (core stack uses ~8–10 GB)
Minimum Disk32 GB SSD
Recommended Disk1 TB+ NVMe SSD
CPUx86_64 or ARM64, 4+ cores recommended
NetworkEthernet recommended (WiFi supported)
TargetsHP ProDesk, Intel NUC, any standard PC
-
- -
-
Memory Budget (all containers)
- - - - - - - - - - -
Bitcoin Knots2 GB (1 GB low-memory mode)
ElectrumX1 GB
LND512 MB
BTCPay + DB1.5 GB (1 GB + 512 MB)
Mempool stack1.3 GB (512+256+512 MB)
Fedimint + GW1 GB (512+512 MB)
Ollama (AI)4 GB (1 GB low-memory)
All other apps128–1024 MB each
Total allocated~20 GB (not all run simultaneously)
-
- -
- -
- -
-
Disk Usage by Component
- - - - - - - - - -
Bitcoin blockchain (full)~600 GB
Bitcoin (pruned)~550 MB
ElectrumX index~50 GB
LND channels + wallet~1 GB
Databases (all)~2–10 GB
Container images~15 GB
Ollama models1–50 GB (varies)
Media (Jellyfin/Photos)User-determined
-
- -
-
Network Ports (External)
- - - - - - - -
80 / 443Nginx → Web UI, app proxies
8333Bitcoin P2P (node discovery)
9735Lightning P2P (payment routing)
50001Electrum protocol (wallet queries)
22SSH (admin access)
Internal only8332 (RPC), 10009 (gRPC), 8080 (REST), 8999, 4080, 3000, 3001, 8082–8096, 9000…
-
- -
- -
-
-
Container Security Defaults
- - - - - - - -
Capabilities--cap-drop=ALL then add only needed: CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE. Some get NET_RAW (LND), NET_BIND_SERVICE (Vaultwarden, nginx-pm, LND-UI).
Privileges--security-opt=no-new-privileges on all containers
Health checksAll containers: --health-interval=120s --health-timeout=5s --health-retries=3
Low-memory modeAuto-detected: Bitcoin 2G→1G, PhotoPrism 1G→512M, OnlyOffice 2G→1G, Ollama 4G→1G
Disk modeAuto: if disk <1TB → Bitcoin prune=550, dbcache=512M. If ≥1TB → full txindex, dbcache=4G
RPC methods260+ registered across 20+ namespaces (auth, seed, package, bitcoin, lnd, identity, tor, nostr, mesh, federation, dwn, system, monitoring…)
-
-
- -
- - - - -
- -
- -
-
- 1. Hardware / Boot - Physical -
-
UEFI + BIOS dual-boot installer, GPT partition table, auto-detect hardware
-
You flash a USB drive, plug it in, and the computer installs itself. Works on both old and new machines.
-
-

Partition Layout

-
    -
  • 1 MB — BIOS boot (for older machines without UEFI)
  • -
  • 512 MB — EFI System Partition (UEFI boot files)
  • -
  • 30 GB — Root filesystem (Debian OS, binaries, Podman storage)
  • -
  • Remaining — LUKS2 encrypted → /var/lib/archipelago (all user data)
  • -
-

Installer Features

-
    -
  • Auto-detects target disk (largest available, prefers NVMe)
  • -
  • Auto-detects AES-NI CPU support for encryption cipher selection
  • -
  • Debootstrap minimal Debian 12 (no bloat)
  • -
  • Configures GRUB for both UEFI and legacy BIOS
  • -
  • Creates archipelago user (UID 1000) with Podman subuid/subgid mapping
  • -
-

Hardware Targets

-
    -
  • x86_64: HP ProDesk, Intel NUC, any standard PC
  • -
  • ARM64: Planned but not primary target yet
  • -
  • Minimum: 4 cores, 8GB RAM, 256GB disk
  • -
  • Recommended: 4+ cores, 16GB RAM, 1TB+ disk (for full Bitcoin node)
  • -
-
-
- -
-
- 2. Operating System — Debian 12 - OS -
-
Minimal Debian Bookworm with systemd, custom kernel parameters, hardened services
-
The foundation. Debian is one of the most stable and trusted Linux versions. We remove everything unnecessary.
-
-

Key Packages

-
    -
  • podman — Rootless container runtime (replaces Docker)
  • -
  • nginx — Reverse proxy (front door for all web traffic)
  • -
  • tor — Privacy network daemon
  • -
  • tailscale — Mesh VPN for remote access
  • -
  • chromium — Kiosk browser for local display
  • -
  • cryptsetup — LUKS disk encryption
  • -
  • xorg — Display server for kiosk mode
  • -
-

Kernel Tuning

-
    -
  • net.ipv4.ip_unprivileged_port_start=80 — lets rootless Podman bind ports 80+
  • -
  • vm.overcommit_memory=1 — for Redis/Valkey container requirements
  • -
  • User namespaces enabled for rootless containers
  • -
-

Users

-
    -
  • archipelago (UID 1000) — main user, owns all containers and data
  • -
  • root — only for Tor management, LUKS, and boot services
  • -
-
-
- -
-
- 3. Encryption — LUKS2 - Security -
-
Full-disk encryption on all user data with auto-detected hardware-accelerated ciphers
-
Your Bitcoin wallet, passwords, photos — everything is scrambled. Without the key, the data is just noise.
-
-

Cipher Selection (auto-detected at install)

-
    -
  • With AES-NI: aes-xts-plain64 (AES-256-XTS) — hardware-accelerated, fastest option
  • -
  • Without AES-NI: xchacha20,aes-adiantum-plain64 (ChaCha20-Adiantum) — fast on any CPU
  • -
-

Key Derivation

-
    -
  • PBKDF: Argon2id (memory-hard, GPU-resistant)
  • -
  • Key size: 512 bits
  • -
  • Key file: /root/.luks-archipelago.key (4KB random, auto-generated)
  • -
-

What's Encrypted

-
    -
  • Bitcoin blockchain data
  • -
  • LND wallet & Lightning channels
  • -
  • All database volumes (PostgreSQL, MariaDB)
  • -
  • All app data directories
  • -
  • Secrets (RPC passwords, macaroons, API keys)
  • -
  • Tor hidden service keys
  • -
-

What's NOT Encrypted

-
    -
  • Root filesystem (OS binaries, system config) — no sensitive data here
  • -
  • EFI/boot partitions (must be readable to start)
  • -
-
-
- -
-
- 4. Network Layer - Network -
-
Nginx reverse proxy, Tailscale mesh VPN, Tor hidden services, UFW firewall
-
One front door (nginx) for all traffic. Tor lets people reach you without knowing your real address. Tailscale lets YOU reach the box from anywhere.
-
-

Nginx Reverse Proxy

-
    -
  • Listens on :80 (HTTP) and :443 (HTTPS with self-signed cert)
  • -
  • Serves Vue.js SPA at /
  • -
  • Proxies backend at /rpc/v1, /ws, /health
  • -
  • Proxies each app at /app/{name}/
  • -
  • Rate limits: auth (3/s), RPC (20/s), P2P (10/s)
  • -
  • Security headers: CSP, HSTS, X-Frame-Options, Permissions-Policy
  • -
  • Injects nostr-provider.js into all app iframes
  • -
-

Tor

-
    -
  • System-level Tor daemon (not containerized)
  • -
  • SOCKS5 proxy at 127.0.0.1:9050
  • -
  • Hidden services for: web UI, LND, BTCPay, Mempool, Fedimint
  • -
  • Backend manages services via privileged helper script
  • -
  • Containers connect via host.containers.internal:9050
  • -
-

Tailscale

-
    -
  • Mesh VPN — access your node from anywhere via encrypted tunnel
  • -
  • Runs as system service or container
  • -
  • Provides stable IP (e.g., 100.x.x.x) regardless of network
  • -
-

Firewall (UFW)

-
    -
  • DEFAULT_FORWARD_POLICY=ACCEPT (required for rootless Podman)
  • -
  • Allow: 22 (SSH), 80 (HTTP), 443 (HTTPS), 8333 (Bitcoin P2P), 9735 (Lightning P2P)
  • -
-
-
- -
-
- 5. Rust Backend - Service -
-
Archipelago binary — JSON-RPC server, auth, RBAC, container management, Tor control, DID identity
-
The control center. Every button you click in the dashboard sends a message here, and it makes things happen.
-
-

Bind

-
    -
  • 127.0.0.1:5678 — localhost only, nginx handles external access
  • -
-

Endpoints

-
    -
  • POST /rpc/v1 — JSON-RPC 2.0 (all commands)
  • -
  • WS /ws — WebSocket (live updates, container status, logs)
  • -
  • GET /health — Health check (no auth)
  • -
  • /archipelago/ — P2P node messaging
  • -
  • /content — Content sharing (via Tor)
  • -
  • /dwn — Decentralized Web Node protocol
  • -
-

Key RPC Methods

-
    -
  • auth.* — login, TOTP, password change, onboarding
  • -
  • seed.* — generate, verify, restore wallet seeds
  • -
  • package.* — container CRUD (create, start, stop, remove)
  • -
  • node.* — DID identity, signing, backups
  • -
  • app.* — marketplace, app config
  • -
-

Systemd Service

-
    -
  • Type: notify (signals readiness to systemd)
  • -
  • Watchdog: 300s (must ping every 120s or gets killed)
  • -
  • MemoryMax: 4GB
  • -
  • Crash recovery on startup (detects unclean shutdown, restarts containers)
  • -
  • Periodic container state snapshots for recovery
  • -
-
-
- -
-
- 6. Container Layer — Rootless Podman - Isolation -
-
34 containers, custom bridge network (archy-net), UID mapping, security caps, memory limits
-
Apps run in sealed boxes. They can only see what we let them see, use only the memory we allow, and can't mess with each other.
-
-

Rootless Podman

-
    -
  • All containers run as user archipelago (UID 1000)
  • -
  • No root access required — even if a container is compromised, it can't escalate to root
  • -
  • Subuid/subgid: archipelago:100000:65536
  • -
  • Socket: /run/user/1000/podman/podman.sock
  • -
-

Networks

-
    -
  • archy-net (custom bridge) — Bitcoin stack + services, containers can reach each other by name (DNS)
  • -
  • bridge (default) — standalone apps, port-mapped only
  • -
  • host — Tailscale only (needs full network access)
  • -
-

Security Defaults (per container)

-
    -
  • --cap-drop=ALL then add only what's needed (least privilege)
  • -
  • --security-opt=no-new-privileges
  • -
  • Memory limits (128MB to 4GB depending on app)
  • -
  • Health checks with auto-restart on failure
  • -
  • Read-only root filesystem where possible (--read-only)
  • -
-

UID Mapping (inside container → host)

-
    -
  • root (0) → host UID 100000
  • -
  • postgres (70) → host UID 100070
  • -
  • bitcoin (101) → host UID 100101
  • -
  • grafana (472) → host UID 100472
  • -
  • mariadb (999) → host UID 100999
  • -
-

Registry

-
    -
  • Private registry at 146.59.87.168:3000/lfg2025/
  • -
  • HTTPS (self-hosted Gitea)
  • -
  • All images pre-pulled into registry; nodes pull on first boot
  • -
-
-
- -
-
- 7. Web UI — Vue.js SPA - Application -
-
Vue 3 + TypeScript + Pinia + Vite, served as static files by nginx
-
The website that runs on the box. Open it in any browser on your network to manage everything.
-
-

Tech Stack

-
    -
  • Framework: Vue 3 with <script setup lang="ts">
  • -
  • State: Pinia stores
  • -
  • Bundler: Vite 7
  • -
  • Styling: Global CSS with Tailwind utility classes in style.css
  • -
-

Communication

-
    -
  • JSON-RPC: All commands go through rpc-client.tsPOST /rpc/v1
  • -
  • WebSocket: Real-time container status, logs, events via /ws
  • -
  • CSRF: Token in cookie + X-CSRF-Token header
  • -
  • Session: HttpOnly cookie, SameSite=Lax
  • -
  • Retry: Auto-retry 3x with exponential backoff on 502/503
  • -
  • Timeout: 15s default (configurable per call)
  • -
-

Key Views

-
    -
  • / — Dashboard (system status, apps)
  • -
  • /kiosk — Kiosk mode (public, no auth)
  • -
  • /kiosk-recovery — Fallback with IP + QR code
  • -
  • /marketplace — App installer
  • -
  • /settings — System configuration
  • -
-

App Embedding

-
    -
  • Apps open as iframes via /app/{name}/ proxy paths
  • -
  • Each iframe gets nostr-provider.js injected for identity
  • -
-
-
- -
-
- 8. Kiosk Display - Physical -
-
X11 + Chromium in kiosk mode on VT7, auto-start, crash recovery
-
Plug in a monitor and the dashboard appears fullscreen. No login, no desktop, just your node. Press Ctrl+Alt+F1 for a terminal.
-
-

How It Works

-
    -
  • X11 server (Xorg) starts on Virtual Terminal 7
  • -
  • Chromium launches in --kiosk --app=http://localhost/kiosk mode
  • -
  • No address bar, no tabs, no right-click — just the dashboard
  • -
  • Cursor hidden after 3 seconds of inactivity
  • -
  • Screen blanking disabled
  • -
-

Resource Limits

-
    -
  • --disable-gpu — software rendering only
  • -
  • --renderer-process-limit=1 — single renderer process
  • -
  • --js-flags="--max-old-space-size=128" — 128MB JS heap max
  • -
  • --disable-metrics-reporting — no telemetry to Google
  • -
  • --enable-low-end-device-mode — reduce animations and compositing
  • -
-

Controls

-
    -
  • Ctrl+Alt+F7 — switch to kiosk
  • -
  • Ctrl+Alt+F1 — switch to terminal
  • -
  • sudo archipelago-kiosk enable|disable|toggle|status
  • -
-
-
- -
-
- - - - -
- -
- - - - - - - - - -
- - -
-
Tier 0 — Databases
-
- -
-
archy-mempool-dbarchy-net
-
MariaDB database storing Bitcoin mempool transaction data for the Mempool block explorer.
-
A database that remembers pending Bitcoin transactions so the block explorer can show them.
-
mariadb:11.4.10
-
No exposed ports (internal only)
-
-
UID100999:100999 (mariadb user)
-
Memory512 MB
-
Healthmariadb -uroot -e 'SELECT 1'
-
Data/var/lib/archipelago/mysql-mempool
-
Databasemempool (user: mempool)
-
DepsNone
-
Needed bymempool-api
-
-
- -
-
archy-btcpay-dbarchy-net
-
PostgreSQL database for BTCPay Server and NBXplorer, storing invoices, transactions, and merchant data.
-
Stores your payment invoices and transaction history for the Bitcoin payment processor.
-
postgres:15.17
-
No exposed ports (internal only)
-
-
UID100070:100070 (postgres user)
-
Memory512 MB
-
Healthpg_isready -U postgres
-
Data/var/lib/archipelago/postgres-btcpay
-
Databasesbtcpay, nbxplorer
-
Needed bynbxplorer btcpay
-
-
- -
-
indeedhub-postgresarchy-net
-
PostgreSQL database for IndeedHub social platform, storing posts, user profiles, and relay data.
-
The database that stores all the social media posts and user data for IndeedHub.
-
postgres:16.13-alpine
-
No exposed ports (internal only)
-
-
Needed byindeedhub-api
-
-
- -
-
indeedhub-redisarchy-net
-
Redis in-memory cache for IndeedHub, handling sessions, job queues, and real-time data.
-
A fast temporary memory store so IndeedHub pages load quickly and background tasks run smoothly.
-
redis:7.4.8-alpine
-
No exposed ports
-
-
Needed byindeedhub-api
-
-
- -
-
penpot-postgresbridge
-
PostgreSQL database for Penpot design tool, storing projects, layers, and design assets.
-
Stores all the design projects and files for the Penpot design tool.
-
postgres:15
-
No exposed ports
-
-
Memory256 MB
-
Needed bypenpot-backend
-
-
- -
-
penpot-valkeybridge
-
Valkey (Redis fork) cache for Penpot, handling sessions and real-time collaboration sync.
-
Fast memory cache that makes Penpot's real-time collaboration work smoothly.
-
valkey:8.1
-
No exposed ports
-
-
Memory128 MB
-
Needed bypenpot-backend
-
-
- -
-
immich_postgresbridge
-
PostgreSQL with vector extensions for Immich photo AI/search. Optional — only if Immich is installed.
-
Database for the photo manager. Has special AI search features for finding photos by what's in them.
-
immich-postgres:14-vectorchord (optional)
-
-
Memory256 MB
-
Needed byimmich
-
-
- -
-
immich_redisbridge
-
Valkey cache for Immich job queue (photo processing, thumbnail generation).
-
Manages the queue of photos waiting to be processed and thumbnailed.
-
valkey:8.1.6 (optional)
-
-
Memory128 MB
-
Needed byimmich
-
-
- -
-
- - -
-
Tier 1 — Core Bitcoin Infrastructure
-
- -
-
bitcoin-knotsarchy-net
-
Full Bitcoin node (Knots variant). Validates every transaction and block independently. The root dependency for the entire Bitcoin stack.
-
Your own copy of the entire Bitcoin network. Nobody can lie to you about your balance because you verify everything yourself.
-
bitcoin-knots:latest
-
Ports: 8332 8333 28332 28333
-
-
Port 8332JSON-RPC API (how other apps talk to Bitcoin)
-
Port 8333P2P network (connects to other Bitcoin nodes worldwide)
-
Port 28332ZMQ block notifications (instant alert when new block arrives)
-
Port 28333ZMQ transaction notifications (instant alert for new transactions)
-
Memory2 GB (1 GB on low-memory systems)
-
Healthbitcoin-cli getblockchaininfo
-
Data/var/lib/archipelago/bitcoin (~500GB full, ~550MB pruned)
-
Disk modeAuto: prune if <1TB, full txindex if ≥1TB
-
RPC AuthHMAC-SHA256 salted hash (no plaintext password in config)
-
TorRoutes P2P through Tor SOCKS5 for privacy
-
CapsCHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE
-
DepsNone — ROOT DEPENDENCY
-
Needed byelectrumx lnd mempool nbxplorer fedimint
-
-
- -
-
electrumxarchy-net
-
Electrum protocol server. Indexes the blockchain by address so wallets can look up balances instantly without scanning every block.
-
An index for Bitcoin. Like a book's table of contents — instead of reading every page to find your info, you jump straight to it.
-
electrumx:v1.18.0
-
Ports: 50001 8000
-
-
Port 50001Electrum protocol (wallet connections)
-
Port 8000Health check / status API
-
Memory1 GB
-
Data/var/lib/archipelago/electrumx
-
ProtocolElectrum JSON-RPC over TCP
-
Depsbitcoin-knots (reads blockchain via RPC)
-
Needed bymempool-api
-
-
- -
-
- - -
-
Tier 2 — Services (depend on Bitcoin core)
-
- -
-
lndarchy-net
-
Lightning Network Daemon. Enables instant, low-fee Bitcoin payments through payment channels.
-
Lets you send and receive Bitcoin instantly (instead of waiting 10+ minutes for a block). Like a tab at a bar — settle up later on-chain.
-
lnd:v0.18.4-beta
-
Ports: 9735 10009 8080
-
-
Port 9735Lightning P2P (connects to other Lightning nodes)
-
Port 10009gRPC API (admin operations, authenticated with macaroons)
-
Port 8080REST API (simpler HTTP interface to LND)
-
Memory512 MB
-
Data/var/lib/archipelago/lnd (wallet, channels, macaroons)
-
AuthMacaroon tokens (read-only for queries, admin for mutations)
-
TorActive with stream isolation (each connection uses different circuit)
-
CapsCHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW
-
Depsbitcoin-knots
-
Needed byfedi-gateway (LND mode) lnd-ui
-
-
- -
-
mempool-apiarchy-net
-
Mempool.space backend API. Provides blockchain analytics, fee estimates, and transaction tracking.
-
The engine behind the block explorer. Shows you what's happening on the Bitcoin network in real time.
-
mempool-backend:v3.0.0
-
Ports: 8999
-
-
Memory512 MB
-
Data/var/lib/archipelago/mempool
-
Depsbitcoin-knots electrumx mempool-db
-
Needed bymempool-web
-
-
- -
-
archy-mempool-webarchy-net
-
Mempool.space frontend. The visual block explorer with real-time mempool visualization and fee graphs.
-
Your personal mempool.space — watch Bitcoin blocks being mined, see fee rates, track your transactions.
-
mempool-frontend:v3.0.0
-
Ports: 4080
-
-
Memory256 MB
-
Depsmempool-api
-
Nginx path/app/mempool/
-
-
- -
-
archy-nbxplorerarchy-net
-
NBXplorer blockchain scanner. Watches Bitcoin addresses for BTCPay and notifies when payments arrive.
-
Watches Bitcoin for incoming payments and tells BTCPay Server when money arrives for your invoices.
-
nbxplorer:2.6.0
-
Ports: 32838
-
-
Memory512 MB
-
Data/var/lib/archipelago/nbxplorer
-
Depsbitcoin-knots btcpay-db
-
Needed bybtcpay
-
-
- -
-
btcpay-serverarchy-net
-
Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.
-
Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.
-
btcpayserver:2.3.9
-
Ports: 23000
-
-
Memory1 GB
-
Data/var/lib/archipelago/btcpay
-
Depsnbxplorer btcpay-db
-
Nginx path/app/btcpay/
-
TorHas its own .onion address for receiving payments privately
-
-
- -
-
fedimintarchy-net
-
Federated mint daemon. Enables community-run Bitcoin custody with threshold signing and e-cash tokens.
-
A way for a group of trusted people to collectively hold Bitcoin. No single person can steal the funds — you need a majority to approve.
-
fedimintd:v0.10.0
-
Ports: 8173 8174 8175
-
-
Port 8173P2P (federation member communication)
-
Port 8174API / WebSocket (client connections)
-
Port 8175Web UI (guardian dashboard)
-
Memory512 MB
-
Data/var/lib/archipelago/fedimint
-
Depsbitcoin-knots
-
Needed byfedi-gateway
-
-
- -
-
fedimint-gatewayarchy-net
-
Lightning bridge for Fedimint. Connects the federation to the Lightning Network for instant payments.
-
Connects your community mint to Lightning so federation members can send/receive instant payments.
-
gatewayd:v0.10.0
-
Ports: 8176
-
-
Memory512 MB
-
Data/var/lib/archipelago/fedimint-gateway
-
ModeAuto-detect: uses LND if available, otherwise built-in LDK Lightning
-
Depsbitcoin-knots fedimint
-
-
- -
-
immich_serverbridge
-
Self-hosted Google Photos replacement with AI-powered search, face detection, and automatic organization.
-
Like Google Photos but on your own hardware. Your photos never leave your box. AI finds faces and objects locally.
-
immich-server:release (optional)
-
Ports: 2283
-
-
Depsimmich_postgres immich_redis
-
Nginx path/app/immich/
-
-
- -
-
- - -
-
Tier 3 — Applications (independent, no cross-dependencies)
-
- -
-
archy-bitcoin-uiarchy-net
-
Custom Bitcoin node dashboard showing sync status, peer connections, and blockchain info.
-
A pretty dashboard for your Bitcoin node. See how synced you are, how many peers you have, block height.
-
bitcoin-ui:latest
-
Ports: 8334
-
-
Memory128 MB
-
Nginx path/app/bitcoin-ui/
-
-
- -
-
archy-lnd-uiarchy-net
-
Custom Lightning dashboard showing channels, balances, routing stats, and payment history.
-
Dashboard for your Lightning node. See your channels, balance, and recent payments at a glance.
-
lnd-ui:latest
-
Ports: 8081
-
-
Memory128 MB
-
Nginx path/app/lnd/
-
-
- -
-
archy-electrs-uihost
-
ElectrumX status dashboard showing sync progress, connected clients, and index health.
-
Shows whether the Electrum index is synced and healthy. How far behind it is, how many wallets are connected.
-
electrs-ui:latest
-
Ports: 50002
-
-
Memory128 MB
-
Networkhost (needs direct access to localhost:50001)
-
Nginx path/app/electrumx/
-
-
- -
-
homeassistantbridge
-
Open-source home automation platform. Control lights, sensors, cameras, and IoT devices from one dashboard.
-
Smart home control center. Turn lights on, check sensors, automate your house — all locally, no cloud needed.
-
home-assistant:2024.1
-
Ports: 8123
-
-
Memory512 MB
-
Data/var/lib/archipelago/home-assistant
-
Nginx path/app/homeassistant/
-
-
- -
-
grafanabridge
-
Monitoring and visualization platform. Dashboards for system metrics, Bitcoin stats, and container health.
-
Beautiful graphs and charts showing how your system is doing. CPU, memory, Bitcoin sync, everything visualized.
-
grafana:10.2.0
-
Ports: 3000
-
-
UID100472:100472 (grafana user)
-
Memory256 MB
-
Data/var/lib/archipelago/grafana
-
Read-onlyYes (tmpfs for /tmp, /run)
-
Nginx path/app/grafana/
-
-
- -
-
uptime-kumabridge
-
Self-hosted uptime monitor. Pings your services and alerts you when something goes down.
-
Watches all your apps and sends alerts if anything stops working. Like a security guard for your services.
-
uptime-kuma:1
-
Ports: 3001
-
-
Memory256 MB
-
Data/var/lib/archipelago/uptime-kuma
-
-
- -
-
jellyfinbridge
-
Self-hosted media server. Stream your movies, TV shows, and music from your own hardware.
-
Your own Netflix. Put movies on the box, watch them on any device. No subscription, no limits.
-
jellyfin:10.8.13
-
Ports: 8096
-
-
Memory1 GB
-
Data/var/lib/archipelago/jellyfin/{config,cache}
-
Transcodetmpfs /tmp (256MB, rw,exec)
-
Nginx path/app/jellyfin/
-
-
- -
-
photoprismbridge
-
AI-powered photo management. Automatic face recognition, location mapping, and smart search.
-
Photo organizer that uses AI to tag and sort your pictures. Find photos by searching "sunset" or "cat."
-
photoprism:240915
-
Ports: 2342
-
-
Memory1 GB (512 MB on low-memory)
-
Data/var/lib/archipelago/photoprism
-
-
- -
-
vaultwardenbridge
-
Bitwarden-compatible password manager. Store all your passwords encrypted, sync across devices.
-
Your personal password safe. Store every password securely and auto-fill them on your phone and computer.
-
vaultwarden:1.30.0-alpine
-
Ports: 8082
-
-
Memory256 MB
-
Data/var/lib/archipelago/vaultwarden
-
CapsCHOWN, SETUID, SETGID, NET_BIND_SERVICE
-
-
- -
-
nextcloudbridge
-
Self-hosted file sync and collaboration platform. Dropbox/Google Drive replacement with calendar, contacts, and office docs.
-
Your own Dropbox. Sync files, share documents, manage calendar and contacts — all on your own hardware.
-
nextcloud:29
-
Ports: 8085
-
-
Memory1 GB
-
Data/var/lib/archipelago/nextcloud
-
-
- -
-
searxngbridge
-
Privacy-respecting metasearch engine. Searches Google, Bing, DuckDuckGo and others without tracking you.
-
Private search engine. Searches the web without anyone tracking what you look for.
-
searxng:latest
-
Ports: 8888
-
-
Memory512 MB
-
Data/var/lib/archipelago/searxng
-
Read-onlyYes (tmpfs for /tmp, /run)
-
-
- -
-
onlyofficebridge
-
Self-hosted document editor. Edit Word, Excel, and PowerPoint files collaboratively in the browser.
-
Like Google Docs but on your own box. Edit spreadsheets and documents with others in real time.
-
onlyoffice:latest
-
Ports: 9980
-
-
Memory2 GB (1 GB on low-memory)
-
-
- -
-
ollamabridge
-
Local AI model runner. Run LLMs (like Llama, Mistral) entirely on your hardware, no cloud needed.
-
ChatGPT on your own box. Talk to AI privately — nothing you say leaves your machine.
-
ollama:latest (optional)
-
Ports: 11434
-
-
Memory4 GB (1 GB on low-memory)
-
Data/var/lib/archipelago/ollama
-
Read-onlyYes (tmpfs for /tmp, /run)
-
ProtocolREST API at :11434 (OpenAI-compatible)
-
-
- -
-
filebrowserbridge
-
Web-based file manager. Browse, upload, and download files through the browser.
-
A file explorer in your browser. Upload, download, and manage files on the box without SSH.
-
filebrowser:v2.27.0
-
Ports: 8083
-
-
Memory256 MB
-
Data/var/lib/archipelago/filebrowser (served), filebrowser-data (DB)
-
Read-onlyYes
-
Max upload10 GB (nginx limit)
-
-
- -
-
nginx-proxy-managerbridge
-
GUI for managing nginx proxy rules and SSL certificates. Point-and-click reverse proxy configuration.
-
A visual tool for routing web traffic. Point domains to services and manage HTTPS certificates with clicks, not config files.
-
nginx-proxy-manager:latest
-
Ports: 81 8084 8443
-
-
Port 81Admin dashboard
-
Port 8084HTTP proxy
-
Port 8443HTTPS proxy
-
Memory256 MB
-
-
- -
-
portainerbridge
-
Container management UI. Visual dashboard for Podman containers — start, stop, inspect, view logs.
-
Visual control panel for all your containers. See what's running, restart things, read logs — no terminal needed.
-
portainer:latest
-
Ports: 9000
-
-
Memory256 MB
-
SocketPodman socket mounted as Docker socket
-
-
- -
-
- - -
-
IndeedHub Stack — Nostr-based Social Platform
-
- -
-
indeedhub-minioarchy-net
-
S3-compatible object storage for IndeedHub media files (images, videos, attachments).
-
File storage for IndeedHub. When someone posts an image, it lives here.
-
minio:RELEASE.2024-11-07
-
-
Needed byindeedhub-api
-
-
- -
-
indeedhub-apiarchy-net
-
IndeedHub backend API. Handles Nostr events, user profiles, media uploads, and relay communication.
-
The engine behind IndeedHub. Processes posts, handles user accounts, talks to Nostr relays.
-
indeedhub-api (custom build)
-
-
Depspostgres redis minio
-
Needed byindeedhub
-
-
- -
-
indeedhub-ffmpegarchy-net
-
Video transcoding worker for IndeedHub. Converts uploaded videos to web-friendly formats.
-
Converts videos so they play smoothly in the browser. Like a video format translator.
-
indeedhub-ffmpeg (custom build)
-
- -
-
indeedhub-relayarchy-net
-
Nostr relay for IndeedHub. Stores and distributes Nostr events (posts, follows, reactions).
-
A message board that stores Nostr posts. Other Nostr apps can connect here to read and post.
-
indeedhub-relay (custom build)
-
- -
-
indeedhubarchy-net
-
IndeedHub web frontend. Nostr-based social media client with feeds, profiles, messaging, and media.
-
The social media app itself. Post, follow people, send messages, share media — all on the Nostr protocol.
-
indeedhub-frontend (custom build)
-
Ports: 7777
-
-
Nginx path/app/indeedhub/
-
WebSocketYes (for real-time updates)
-
Depsindeedhub-api
-
-
- -
-
- - -
-
Penpot Stack — Design Tool
-
- -
-
penpot-backendbridge
-
Penpot application server. Handles design data, real-time collaboration, and file storage.
-
The engine behind the design tool. Saves your designs and lets multiple people edit at the same time.
-
penpot-backend:2.4
-
-
Memory512 MB
-
Depspenpot-postgres penpot-valkey
-
-
- -
-
penpot-exporterbridge
-
Renders Penpot designs to PDF, SVG, and image formats for export.
-
Turns your designs into downloadable files — PDFs, images, SVGs.
-
penpot-exporter:2.4
-
-
Memory256 MB
-
Depspenpot-backend
-
-
- -
-
penpot-frontendbridge
-
Penpot web UI. Open-source Figma alternative with vector editing, prototyping, and collaboration.
-
Your own Figma. Design interfaces, create prototypes, collaborate — completely self-hosted.
-
penpot-frontend:2.4
-
Ports: 9001
-
-
Memory256 MB
-
Nginx path/app/penpot/
-
Depspenpot-backend
-
-
- -
-
- -
-
archy-net (internal DNS, Bitcoin stack)
-
bridge (standalone, port-mapped)
-
host (direct network access)
-
Dimmed = optional / not always installed
-
-
- - - - -
- -
- -
-
JSON-RPC 2.0
-
Primary protocol between the web UI and the Rust backend. All commands are RPC calls.
-
Like texting the backend: you send a message ("please start this app"), it texts back ("done" or "error").
-
- Endpoint: POST /rpc/v1
- Format: {"jsonrpc":"2.0","method":"package.start","params":{"id":"bitcoin-knots"},"id":1}
- Auth: Session cookie + CSRF token header
- Timeout: 15s default
- Retry: 3 attempts with exponential backoff on 502/503
- Rate limit: 20 req/s (burst 40)
- Used by: Vue.js frontend → Rust backend -
-
- -
-
WebSocket
-
Real-time bidirectional channel for live updates — container status changes, logs, events.
-
An open phone line between your browser and the server. Instead of asking "any updates?" every second, the server just tells you when something changes.
-
- Endpoint: WS /ws (HTTP upgrade)
- Auth: Session cookie
- Read timeout: 86,400s (24 hours)
- Events: Container state changes, log streams, system alerts
- Used by: Vue.js frontend ↔ Rust backend -
-
- -
-
Bitcoin RPC (JSON-RPC 1.0)
-
How apps talk to the Bitcoin node. Authenticated with username + HMAC-hashed password.
-
The language apps use to ask Bitcoin questions: "what's the current block?" or "send this transaction."
-
- Endpoint: bitcoin-knots:8332 (inside archy-net)
- Auth: HTTP Basic with rpcauth hash (HMAC-SHA256, no plaintext)
- Methods: getblockchaininfo, getmempoolinfo, sendrawtransaction, etc.
- Timeout: 10s default, 30s for heavy ops
- Used by: ElectrumX, LND, Mempool, NBXplorer, Fedimint → Bitcoin Knots -
-
- -
-
gRPC
-
High-performance RPC protocol used by LND for admin operations. Binary format, strongly typed.
-
A fast, structured way for apps to control the Lightning node. More efficient than regular HTTP for complex operations.
-
- Endpoint: lnd:10009
- Auth: Macaroon tokens (read-only for queries, admin for mutations)
- TLS: Self-signed certificate (auto-generated)
- Methods: OpenChannel, SendPayment, GetInfo, ListChannels, etc.
- Used by: Fedimint Gateway, LND UI → LND -
-
- -
-
Electrum Protocol
-
Lightweight protocol for wallet address lookups. JSON-RPC over raw TCP sockets.
-
How Bitcoin wallets check their balance without downloading the entire blockchain. Ask "what transactions touched this address?" and get an instant answer.
-
- Endpoint: electrumx:50001 (TCP)
- Format: Newline-delimited JSON-RPC
- Methods: blockchain.scripthash.get_balance, blockchain.transaction.get, etc.
- Used by: Wallets (Sparrow, Electrum), Mempool API → ElectrumX -
-
- -
-
ZMQ (ZeroMQ)
-
Publish-subscribe messaging from Bitcoin node. Instant notifications for new blocks and transactions.
-
A broadcasting system. When a new Bitcoin block is found, Bitcoin instantly shouts it out and everyone listening hears immediately.
-
- Endpoints:
- • tcp://bitcoin-knots:28332 — New block hashes (hashblock)
- • tcp://bitcoin-knots:28333 — New raw transactions (rawtx)
- Pattern: PUB/SUB (publisher/subscriber)
- Subscribers: LND, Mempool, ElectrumX -
-
- -
-
Tor (SOCKS5 + Hidden Services)
-
Privacy layer. Routes Bitcoin P2P through onion routing, exposes services as .onion addresses.
-
Like sending a letter through 3 random post offices so nobody knows where it came from. Also lets people reach your node without knowing your real IP.
-
- SOCKS5 proxy: 127.0.0.1:9050
- Container access: host.containers.internal:9050
- Hidden services: Web UI, LND, BTCPay, Mempool, Fedimint
- Managed by: System Tor daemon + tor-helper.sh (privileged helper)
- Used by: Bitcoin P2P, LND P2P, BTCPay invoices -
-
- -
-
Nostr (NIP-01)
-
Decentralized social protocol. WebSocket-based relay communication for events (posts, follows, messages).
-
A social media protocol where no company controls the network. Your posts live on relays, and you own your identity with a cryptographic key.
-
- Transport: WebSocket (WSS)
- Format: JSON events signed with secp256k1 keys
- Relay: IndeedHub relay (local), configurable external relays
- Integration: nostr-provider.js injected into all app iframes
- Identity: DID-based, linked to node Ed25519 keypair -
-
- -
-
DWN (Decentralized Web Node)
-
W3C protocol for storing encrypted data and messages in a decentralized way. Identity-linked storage.
-
A personal data vault. Apps can store data here that only you control. Like a safety deposit box that follows you across the internet.
-
- Endpoint: /dwn (proxied through nginx)
- Auth: Per-record DID-based permissions
- Reachable via: Tor hidden service
- Used for: Encrypted backups, cross-node messaging, app data sync -
-
- -
-
- - - - -
- -
-
2
DID Methods
-
26+
Identity RPCs
-
W3C 2.0
VC Spec
-
Ed25519
Primary Key
-
DWN
Data Store
-
Dual Key
Ed25519 + secp256k1
-
- -
- -
-
- Applications - Layer 5 · UI -
-
Vue.js views for identity management, credential issuance, DWN dashboard, and quick actions
-
The screens where you manage your digital identity, issue credentials, and control your personal data store.
-
-

Key Views

-
    -
  • Web5Identities.vue — Create/manage identities (Personal, Business, Anonymous purposes)
  • -
  • Web5CredentialsSummary.vue — View issued/held credentials with status badges
  • -
  • Web5DWN.vue — DWN status, protocol registration, message browser
  • -
  • Web5QuickActions.vue — Copy DID, publish to DHT, trigger sync
  • -
-

Data Types (TypeScript)

-
    -
  • ManagedIdentity — id, name, purpose, did, pubkey, nostr_pubkey, profile
  • -
  • VCData — id, issuer, subject, type, claims, status (active/revoked/expired)
  • -
  • DwnStatusData — running, sync_status, message_count, registered_protocols
  • -
-
-
- -
-
- Verifiable Credentials (W3C VC 2.0) - Layer 4 · Trust -
-
Issue, verify, revoke, and present credentials with Ed25519Signature2020 proofs — W3C VC Data Model 2.0
-
Digital certificates that prove things about you — signed by one identity, held by another, verified by anyone. Like a digitally signed diploma.
-
-

Three-Party Model

-
    -
  • Issuer: Creates and signs the credential (any managed identity)
  • -
  • Holder: Stores credentials, creates Verifiable Presentations
  • -
  • Verifier: Checks signature + expiration + revocation status
  • -
-

Credential Structure

-
    -
  • @context: W3C Credentials v2 + Ed25519 signature suite
  • -
  • type: ["VerifiableCredential", "CustomType"]
  • -
  • issuer: did:key or did:dht
  • -
  • credentialSubject: { id: did, claims: {...} }
  • -
  • proof: Ed25519Signature2020 with verification method reference
  • -
  • credentialStatus: CredentialStatusList2021 for revocation
  • -
-

Verifiable Presentations

-
    -
  • Bundle one or more VCs with holder's own signature
  • -
  • Proof purpose: authentication (vs. assertionMethod for VCs)
  • -
  • Selective disclosure — present only relevant credentials
  • -
-

RPC Methods

-
    -
  • identity.issue-credential — Issue from any managed identity
  • -
  • identity.verify-credential — Verify by credential ID
  • -
  • identity.list-credentials — List with optional filtering
  • -
-

Storage

-
    -
  • /var/lib/archipelago/credentials/store.json
  • -
-
-
- -
-
- Decentralized Web Node (DWN) - Layer 3 · Storage -
-
Personal data store with protocol-governed records, peer sync over Tor, and DID-based authorization
-
Your personal database that YOU own. Apps ask permission to read/write data. Syncs with trusted peers automatically over Tor.
-
-

Records Interface

-
    -
  • Records.Write — Store a message (UUID-based record_id)
  • -
  • Records.Read — Retrieve by record_id
  • -
  • Records.Query — Filter by protocol, schema, author, date range
  • -
  • Records.Delete — Remove record
  • -
-

Protocol Definitions

-
    -
  • Declarative rule sets governing data structure and access permissions
  • -
  • types: Define allowed dataFormats and optional schema URIs
  • -
  • structure: Hierarchical — records can have child records (post → comment)
  • -
  • $actions: Who can create/read/update/delete (anyone, author, recipient)
  • -
  • Registered via dwn.register-protocol RPC, enforced automatically
  • -
-

Peer Sync

-
    -
  • Bidirectional sync with trusted peers over Tor SOCKS5 proxy (127.0.0.1:9050)
  • -
  • Deduplication by record_id, batched (200 messages per sync)
  • -
  • 30s per-peer timeout, 90s total timeout
  • -
  • State persisted to /var/lib/archipelago/dwn/sync_state.json
  • -
  • Triggered manually or via background task
  • -
-

HTTP API

-
    -
  • Endpoint: POST /dwn (proxied through nginx)
  • -
  • Reachable remotely via Tor hidden service
  • -
-

RPC Methods (8)

-
    -
  • dwn.status — Running state, sync status, message count
  • -
  • dwn.sync — Trigger background sync with trusted peers
  • -
  • dwn.register-protocol / dwn.list-protocols / dwn.remove-protocol
  • -
  • dwn.write-message / dwn.query-messages / dwn.read-message / dwn.delete-message
  • -
-

Storage

-
    -
  • Messages: /var/lib/archipelago/dwn/messages/{record_id}.json
  • -
  • Protocols: /var/lib/archipelago/dwn/protocols/{protocol_uri}.json
  • -
-
-
- -
-
- Decentralized Identifiers (DIDs) - Layer 2 · Identity -
-
W3C DID Core 1.0 — did:key (primary, offline-capable) and did:dht (discoverability via BitTorrent Mainline DHT)
-
Your self-sovereign digital identity. No company issues it, no platform controls it. You prove who you are with cryptographic keys.
-
-

did:key (Primary Method)

-
    -
  • Self-contained — no external resolution, works fully offline
  • -
  • Format: did:key:z6Mk... (multicodec Ed25519 in base58btc)
  • -
  • Instant, zero-cost, no network dependency
  • -
  • Used for: VCs, federation trust, backup encryption, DWN signing
  • -
  • Cannot be rotated — key is the identifier
  • -
-

did:dht (Discovery Method)

-
    -
  • Publishes DID Document to BitTorrent Mainline DHT via BEP-44 signed mutable items
  • -
  • Format: did:dht:z... (z-base-32 encoded Ed25519 pubkey)
  • -
  • Globally discoverable without any centralized registry
  • -
  • DID Document encoded as DNS Resource Records in DNS packet
  • -
  • Supports key rotation (increment sequence number, republish)
  • -
  • 1-hour TTL cache for performance
  • -
  • Replaced did:ion (Bitcoin-anchored) — simpler, no full node required
  • -
-

DID Document (W3C Core 1.0)

-
    -
  • verificationMethod: Ed25519VerificationKey2020 + derived X25519KeyAgreementKey2020
  • -
  • authentication, assertionMethod, capabilityInvocation, capabilityDelegation
  • -
  • keyAgreement: X25519 (derived from Ed25519 via Curve25519)
  • -
  • Optional EcdsaSecp256k1VerificationKey2019 for Nostr interop
  • -
  • Service endpoints: DWN URL, Nostr relay list
  • -
-

Multi-Identity Manager

-
    -
  • Users create multiple identities with purpose tags: Personal, Business, Anonymous
  • -
  • One default identity (marked with star in UI)
  • -
  • Each identity: Ed25519 key + optional Nostr secp256k1 key + optional NIP-01 profile
  • -
  • Stored as JSON in /var/lib/archipelago/identities/{id}.json
  • -
-

Identity RPC Methods (26+)

-
    -
  • identity.create / .list / .get / .delete / .set-default
  • -
  • identity.sign / .verify — Ed25519 message signing
  • -
  • identity.resolve-did / .verify-did-document
  • -
  • identity.create-dht-did / .resolve-dht-did / .refresh-dht-did
  • -
  • identity.create-nostr-key / .nostr-sign
  • -
  • identity.nostr-encrypt-nip04 / .nostr-decrypt-nip04
  • -
  • identity.nostr-encrypt-nip44 / .nostr-decrypt-nip44
  • -
  • identity.update-profile / .resolve-remote-did
  • -
-
-
- -
-
- Cryptographic Keys - Layer 1 · Foundation -
-
Dual key architecture — Ed25519 for Web5/DIDs + secp256k1 for Bitcoin/Nostr, both derived from BIP-39 master seed
-
Two types of cryptographic keys derived from one master seed. One for identity (Web5), one for money and social (Bitcoin/Nostr).
-
-

Ed25519 (Web5 & Identity)

-
    -
  • W3C DIDs, Verifiable Credentials (Ed25519Signature2020)
  • -
  • DWN message signing and authorization
  • -
  • Federation peer authentication and trust
  • -
  • Backup encryption (via derived X25519 key agreement)
  • -
  • Storage: /var/lib/archipelago/identity/node_key (32 bytes raw)
  • -
-

secp256k1 (Bitcoin & Nostr)

-
    -
  • Nostr event signing (NIP-01), encrypted DMs (NIP-04, NIP-44)
  • -
  • Lightning Network node identity
  • -
  • Social presence and discovery (NIP-05, kind 30078)
  • -
  • Format: hex pubkey + Nostr npub (NIP-19 bech32)
  • -
-

Key Derivation

-
    -
  • Single BIP-39 master seed (12 or 24 word mnemonic)
  • -
  • Deterministic derivation of both Ed25519 and secp256k1 keys
  • -
  • All keys recoverable from seed phrase alone
  • -
  • Seed generated at first boot, stored on LUKS-encrypted partition
  • -
-

Rust Dependencies

-
    -
  • ed25519-dalek 2.2.0 — Ed25519 signatures
  • -
  • curve25519-dalek 4.1.3 — X25519 key agreement (Ed25519 → X25519 conversion)
  • -
  • nostr-sdk 0.44 — secp256k1 signing, NIP-04/44 encryption
  • -
  • mainline 2 — BitTorrent Mainline DHT client (did:dht)
  • -
  • zbase32 0.1 — z-base-32 encoding for DID identifiers
  • -
-
-
- -
- -

Specification Status

-

- Web5 was initiated by TBD (Block/Jack Dorsey) and shut down November 2024. Open-source components were donated to the Decentralized Identity Foundation (DIF). - The W3C specs (DIDs, VCs) are independent standards with broad industry adoption. Archipelago implements these W3C standards directly with a custom DWN — not dependent on TBD's SDK. -

- - - - - - - - - -
ComponentSpecStatusArchipelago
DID Core 1.0W3C RecommendationStabledid:key + did:dht, full DID Document generation
VC Data Model 2.0W3C Recommendation (May 2025)StableIssue/verify/revoke with Ed25519Signature2020
DWNDIF DraftDraftCustom Records interface, protocol management, Tor sync
did:dhtNear v1.0ActiveMainline DHT publishing via mainline crate
did:ion (Sidetree)DIF 1.0AbandonedNot implemented — requires Bitcoin + IPFS full nodes
Presentation Exchange 2.0DIF RatifiedStableVerifiable Presentations with holder proof
- -

Architecture Decision Records

- - - - - -
ADRDecisionRationale
ADR-002did:key as primary DID methodSelf-contained, offline-capable, zero-cost, aligns with sovereignty
ADR-008Dual key architecture (Ed25519 + secp256k1)Ed25519 for W3C/Web5, secp256k1 for Bitcoin/Lightning/Nostr ecosystems
ADR-011Custom DWN, not full W3C spec complianceTBD shut down, DWN spec stalled. Federation + Nostr relays prioritized for peer sync
- -
- - - - -
- -
-
-
1
-
-
BIOS / UEFI → GRUB Bootloader
-
Firmware loads GRUB from EFI partition or BIOS boot sector. GRUB loads the Linux kernel.
-
The computer turns on and finds the operating system to start. Works on both old and new machines.
-
GRUB installed for both UEFI (EFI partition) and legacy BIOS (1MB boot sector) — dual-boot compatible
-
-
- -
-
2
-
-
LUKS Unlock → Mount Encrypted Partition
-
Cryptsetup opens the LUKS2 volume using /root/.luks-archipelago.key and mounts it at /var/lib/archipelago.
-
The encrypted safe is unlocked automatically (no password prompt). All your data becomes accessible.
-
Configured in /etc/crypttab for automatic boot-time unlock
-
-
- -
-
3
-
-
systemd Starts → Network Online
-
systemd boots Debian, starts networking, reaches network-online.target. Tor and Tailscale start.
-
The operating system finishes starting up and connects to the internet.
-
-
- -
-
4
-
-
Archipelago Backend Starts
-
archipelago.service launches the Rust binary. Runs crash recovery, starts container state snapshots, initializes JSON-RPC server on :5678.
-
The brain of the system wakes up. If there was a crash last time, it automatically recovers.
-
Type=notify, WatchdogSec=300s, MemoryMax=4G
-
-
- -
-
5
-
-
First Boot: Container Creation
-
first-boot-containers.sh runs once (guarded by marker file). Creates all containers in tier order: databases → Bitcoin → services → apps.
-
On first startup only: installs all the apps. Databases first, then Bitcoin, then everything else. Takes 5-15 minutes.
-
ConditionPathExists=!/var/lib/archipelago/.first-boot-containers-done · Timeout: 900s
-
-
- -
-
6
-
-
Nginx Ready → Web UI Accessible
-
Nginx serves the Vue.js SPA on :80/:443. Backend health check at /health passes.
-
The website is now live. Open a browser and go to the machine's IP address to see the dashboard.
-
-
- -
-
7
-
-
Kiosk Starts (if enabled)
-
archipelago-kiosk.service waits for /health endpoint (up to 30s), then starts X11 + Chromium on VT7.
-
If a monitor is plugged in, the dashboard appears fullscreen automatically. No login needed for the local screen.
-
Polls /health 15 times at 2s intervals before launching Chromium
-
-
- -
-
8
-
-
Background Services Start
-
Timers activate: container doctor (health repair), reconciler (spec enforcement), self-update. Tor helper watches for service config changes.
-
Maintenance robots start working in the background. They check on apps, fix broken ones, and keep everything updated.
-
archipelago-doctor.timer, archipelago-reconcile.timer, archipelago-update.timer, archipelago-tor-helper.path
-
-
-
-
- - - - -
- -
- -
-
LUKS2 Full-Disk Encryption
-
All user data on an encrypted partition. Auto-detects AES-NI for hardware acceleration, falls back to ChaCha20-Adiantum. Key derived with Argon2id (GPU-resistant).
-
If someone physically steals your hard drive, all they get is encrypted noise. The data is useless without the key.
-
- -
-
Rootless Containers
-
All containers run as unprivileged user archipelago (UID 1000). Even a compromised container cannot escalate to root. UID remapping isolates container users from host users.
-
Apps run in sealed boxes without admin access. A hacked app can't take over the whole machine.
-
- -
-
Capability Dropping
-
--cap-drop=ALL then add only specific capabilities needed. --security-opt=no-new-privileges prevents privilege escalation inside containers.
-
Each app only gets the exact permissions it needs, nothing more. Like giving a valet driver only the car key, not your house keys.
-
- -
-
RBAC (Role-Based Access Control)
-
Backend uses explicit method allowlists per role. No prefix matching — each RPC method must be explicitly permitted. Session cookies are HttpOnly, SameSite=Lax.
-
Different users have different permissions. A viewer can't install apps, and nobody can run commands that aren't on the approved list.
-
- -
-
Rate Limiting
-
Nginx rate limits: auth endpoints (3/s), RPC (20/s), P2P (10/s). Prevents brute-force attacks and API abuse.
-
If someone tries to guess your password by trying thousands of combinations, they get locked out after a few attempts per second.
-
- -
-
Tor Privacy
-
Bitcoin P2P routes through Tor with stream isolation. Hidden services expose node without revealing IP. LND uses Tor for Lightning P2P.
-
Your Bitcoin node connects through the Tor privacy network. Nobody can see your real IP address or location.
-
- -
-
Credential Management
-
Secrets auto-generated at first boot (CSPRNG). Stored in /var/lib/archipelago/secrets/ (mode 700). Bitcoin RPC uses HMAC-SHA256 auth hashes, never plaintext.
-
Passwords are randomly generated and stored securely. They never appear in config files as readable text.
-
- -
-
Memory Limits & Health Checks
-
Every container has a memory limit (128MB–4GB). Health checks auto-restart failed containers. Backend has systemd watchdog (300s).
-
Runaway apps can't eat all the memory. Crashed apps restart automatically. The system self-heals.
-
- -
-
Security Headers
-
CSP (Content Security Policy), HSTS, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, strict Referrer-Policy, disabled camera/mic/geolocation.
-
The web UI tells browsers to follow strict security rules — no loading scripts from unknown sites, no accessing your camera.
-
- -
-
Systemd Hardening
-
ProtectSystem=strict, MemoryDenyWriteExecute, RestrictRealtime, RestrictAddressFamilies. Backend can only write to approved paths.
-
The operating system restricts what the backend can do. It can only touch the files it needs to, nothing else on the system.
-
- -
-
- - - - -
- -
/var/lib/archipelago/ ← LUKS2 encrypted partition - ├── bitcoin/ Bitcoin blockchain data (~500GB full, ~550MB pruned) - ├── lnd/ Lightning wallet, channels, macaroons, TLS cert - ├── electrumx/ Address index database - ├── postgres-btcpay/ BTCPay PostgreSQL data - ├── mysql-mempool/ Mempool MariaDB data - ├── mempool/ Mempool backend cache - ├── btcpay/ BTCPay server data, plugins - ├── nbxplorer/ NBXplorer blockchain scan state - ├── fedimint/ Federation data, consensus state - ├── fedimint-gateway/ Gateway keys and routing table - ├── home-assistant/ Smart home config, automations, database - ├── grafana/ Dashboards, datasources, alerting rules - ├── uptime-kuma/ Monitor definitions, status history - ├── jellyfin/ Media library metadata, transcoding cache - ├── photoprism/ Photo index, thumbnails, AI models - ├── ollama/ Downloaded LLM models (can be multi-GB) - ├── vaultwarden/ Encrypted password vault database - ├── nextcloud/ Files, calendar, contacts, config - ├── searxng/ Search engine settings - ├── filebrowser/ Served files (user uploads) - ├── filebrowser-data/ FileBrowser internal database - ├── nginx-proxy-manager/ Proxy rules, Let's Encrypt certificates - ├── portainer/ Portainer config and database - ├── tailscale/ VPN state, node identity - ├── secrets/ RPC passwords, DB passwords (mode 700) - ├── identity/ Node Ed25519 keypair (DID identity) - ├── identities/ User DIDs - ├── tor-config/ Tor service definitions (backend-managed) - ├── tor-hostnames/ .onion addresses (synced from /var/lib/tor) - └── .first-boot-containers-done Marker: first boot completed - -/opt/archipelago/ ← Unencrypted (on root partition) - ├── web-ui/ Vue.js SPA (static files served by nginx) - ├── scripts/ Deploy, container, and maintenance scripts - └── image-versions.sh Pinned container image versions - -/usr/local/bin/archipelago Rust backend binary -/etc/nginx/ Nginx config (reverse proxy rules) -/etc/tor/torrc Tor daemon configuration
- -
- - - - -
- -
- -
-
Podman
-
Daemonless container engine (OCI-compatible, Docker alternative)
-
A tool that runs apps in isolated sandboxes. Like Docker but doesn't need a background service running as root.
-
- -
-
Rootless
-
Containers run entirely in user namespace, no root privileges required
-
The sandboxes run without admin access. Even if someone breaks into one, they can't take over the system.
-
- -
-
LUKS2
-
Linux Unified Key Setup v2 — dm-crypt disk encryption with Argon2 KDF
-
Industry-standard disk encryption for Linux. Scrambles the entire partition so data is unreadable without the key.
-
- -
-
Argon2id
-
Memory-hard password hashing function resistant to GPU/ASIC brute-force
-
A way to protect passwords that requires lots of memory to crack, making it extremely expensive to brute-force even with specialized hardware.
-
- -
-
Nginx
-
High-performance HTTP reverse proxy and web server
-
The front door of the system. All web traffic goes through nginx, which directs each request to the right app.
-
- -
-
Reverse Proxy
-
Server that forwards client requests to backend services based on URL path or hostname
-
A traffic cop for web requests. You visit one address, and the proxy routes you to the right app behind the scenes.
-
- -
-
JSON-RPC
-
Remote procedure call protocol using JSON over HTTP/TCP
-
A simple way for one program to ask another to do something. Send a JSON message, get a JSON reply.
-
- -
-
WebSocket
-
Full-duplex TCP communication channel over HTTP upgrade
-
A persistent connection between browser and server. Instead of repeatedly asking "anything new?", the server pushes updates instantly.
-
- -
-
gRPC
-
Google's high-performance RPC framework using Protocol Buffers over HTTP/2
-
A fast, structured way for programs to communicate. Used by LND because it handles many Lightning operations efficiently.
-
- -
-
ZMQ (ZeroMQ)
-
Asynchronous messaging library for pub/sub and push/pull patterns
-
A broadcasting system. Bitcoin publishes "new block!" and every subscribed app hears it instantly.
-
- -
-
Tor
-
Onion routing network for anonymous communication via encrypted relay circuits
-
A privacy network that bounces your traffic through multiple servers so nobody can trace it back to you.
-
- -
-
Hidden Service (.onion)
-
Tor service accessible via .onion address without revealing server IP
-
A way to make your node reachable on the internet without revealing your IP address or location.
-
- -
-
Tailscale
-
WireGuard-based mesh VPN with NAT traversal and SSO integration
-
A private tunnel to your node from anywhere. Like a VPN but easier — install the app on your phone, and you can access the node from a coffee shop.
-
- -
-
Macaroon
-
Bearer token with embedded caveats (permissions) used by LND for API auth
-
A special key for LND that says exactly what you're allowed to do. A "read-only" macaroon can check balance but can't send money.
-
- -
-
CSRF Token
-
Cross-Site Request Forgery prevention token sent in cookie + header
-
A secret code that proves your browser request is genuine and not a trick from a malicious website.
-
- -
-
DID (Decentralized Identifier)
-
W3C standard for self-sovereign identity using cryptographic keypairs
-
Your digital identity that you own completely. Like a passport that no government issued — you prove who you are with math, not authority.
-
- -
-
Nostr
-
Notes and Other Stuff Transmitted by Relays — decentralized social protocol
-
A social media protocol where you own your identity. No company can ban you because your account is just a cryptographic key.
-
- -
-
Lightning Network
-
Bitcoin Layer 2 payment channel network for instant, low-fee transactions
-
A way to send Bitcoin instantly (milliseconds) for tiny fees. Works by opening "tabs" between nodes and settling on-chain later.
-
- -
-
Fedimint
-
Federated Bitcoin custody protocol with threshold signing and Chaumian e-cash
-
A community Bitcoin bank where a group of trusted guardians hold funds together. No single person can steal — you need a majority to sign.
-
- -
-
archy-net
-
Custom Podman bridge network with DNS resolution for Bitcoin-stack containers
-
A private network inside the box where Bitcoin apps can find each other by name. Like a local phone book for containers.
-
- -
-
Capability (CAP)
-
Fine-grained Linux privilege (e.g. CAP_NET_RAW, CAP_CHOWN) instead of full root
-
Instead of giving an app all admin powers, we give it only the specific abilities it needs. A file manager gets "change file ownership" but not "change network settings."
-
- -
-
Systemd
-
Linux init system and service manager (PID 1)
-
The thing that starts everything when Linux boots. Manages all services, restarts crashed ones, and enforces resource limits.
-
- -
-
RBAC
-
Role-Based Access Control — permissions assigned by user role, not individually
-
Different users get different permissions based on their role (admin, viewer, etc). Prevents regular users from doing dangerous things.
-
- -
-
- - - - -
- -

Architecture analysis sourced from Start9Labs/start-os on GitHub (master branch). Click any layer to expand.

- -
-
LXC
Container Runtime
-
Rust
Backend (startd)
-
Angular 21
Frontend
-
S9PK v2
Package Format
-
Optional
LUKS Encryption
-
btrfs
Filesystem
-
- -
-
Angular 21 + Taiga UI 5UI
Three Angular apps: admin UI, setup wizard, VPN management. Patch-DB reactive sync via CBOR diffs over WebSocket.
-
Rust Backend (startd / startbox)Service
Single binary with 5 personalities (symlinks). Built-in reverse proxy (Axum), DNS (hickory-server), ACME, WireGuard, SOCKS5.
-
LXC ContainersIsolation
Two-layer model: outer LXC per service (SquashFS + OverlayFS), inner subcontainers from S9PK images. JSON-RPC over Unix sockets.
-
Network (built-in)Network
VHostController reverse proxy, hickory-server DNS, ACME TLS, WireGuard tunnels, SOCKS5 at 10.0.3.1:1080. No nginx/caddy.
-
Optional LUKS on btrfsSecurity
User chooses encrypted or unencrypted during setup. LVM with btrfs for COW snapshots enabling safe app installs.
-
Debian BookwormOS
Same Debian 12 base. Targets x86_64, ARM64 (aarch64), and RISC-V (riscv64).
-
- -
- - - - -
-
- -
-
- Web UI — Angular 21 - Application -
-
Angular 21 + TypeScript + Taiga UI 5 components, served directly by the Rust backend (Axum)
-
The dashboard you use in your browser. Built with Angular (Google's web framework), not served by a separate web server.
-
-

Three Separate Angular Apps

-
    -
  • projects/ui/ — Main admin interface
  • -
  • projects/setup-wizard/ — Initial setup flow
  • -
  • projects/start-tunnel/ — VPN management UI
  • -
-

State Management

-
    -
  • Patch-DB: Backend pushes CBOR diffs over WebSocket
  • -
  • Frontend applies diffs and notifies observers via PatchDB.watch$()
  • -
  • Converted to Angular signals via toSignal()
  • -
  • Reactive — UI updates automatically when backend state changes
  • -
-

Communication

-
    -
  • JSON-RPC exclusively (not REST)
  • -
  • ApiService abstract class with 100+ methods
  • -
  • i18n: 5 languages (en, es, de, fr, pl)
  • -
-
-
- -
-
- Rust Backend — startd (startbox) - Service -
-
Single Rust binary (multi-personality via symlinks: startd, start-cli, start-container, registrybox, tunnelbox)
-
The brain of the system. One binary that does everything — serves the UI, manages containers, handles networking, runs the built-in reverse proxy.
-
-

Key Components

-
    -
  • Axum web server: Serves UI + JSON-RPC API (no separate web server)
  • -
  • VHostController: Built-in reverse proxy with TLS termination (no nginx/caddy)
  • -
  • Patch-DB: Custom CBOR-encoded reactive database with diff-based WebSocket sync
  • -
  • LxcManager: Container lifecycle (create, destroy, garbage collection)
  • -
  • NetController: DNS (hickory-server), SOCKS5, ACME, WiFi, WireGuard, port forwarding
  • -
  • Service Actors: Per-service state machines managing lifecycle
  • -
-

Binary Personalities (symlinks)

-
    -
  • startd — Main daemon
  • -
  • start-cli — CLI interface
  • -
  • start-container — Runs inside LXC containers, communicates with host
  • -
  • registrybox — Package registry daemon
  • -
  • tunnelbox — WireGuard VPN tunnel daemon
  • -
-

Key Dependencies

-
    -
  • Async: Tokio · Web: Axum 0.8 + Hyper 1.5 · TLS: tokio-rustls 0.26 + OpenSSL (vendored)
  • -
  • DNS: hickory-server · Crypto: blake3, ed25519, x25519-dalek, aes
  • -
  • TypeScript bindings: ts-rs (auto-generates TS types from Rust structs)
  • -
-

Systemd

-
    -
  • startd.service: Type=simple, Restart=always, RestartSec=3
  • -
  • LimitNOFILE=65536
  • -
-
-
- -
-
- Container Layer — LXC - Isolation -
-
Linux Containers (LXC) with two-layer model: outer LXC per service + inner subcontainers from S9PK images
-
Each app gets its own sealed Linux environment. Unlike Docker, these are full system containers with their own init process.
-
-

Two-Layer Container Model

-
    -
  • Outer LXC container: One per service. Created by Rust backend via lxc-create/destroy
  • -
  • Base rootfs: SquashFS image (/usr/lib/startos/container-runtime/rootfs.squashfs) mounted as OverlayFS
  • -
  • Inner subcontainers: Node.js container runtime inside each LXC can launch additional containers from S9PK-bundled images
  • -
  • Timeout: 30-second container creation timeout
  • -
-

LXC Configuration

-
    -
  • User namespaces: lxc.idmap = u 0 100000 65536
  • -
  • AppArmor profile: generated with nesting allowed
  • -
  • Network: veth bridge on lxcbr0 (10.0.3.x subnet, host at 10.0.3.1)
  • -
  • OverlayFS rootfs (base read-only squashfs, writes to overlay)
  • -
  • GPU passthrough support: /dev/dri, /dev/nvidia*, /dev/kfd
  • -
-

Communication

-
    -
  • JSON-RPC over Unix domain sockets
  • -
  • /media/startos/rpc/service.sock — Inbound (runtime listens)
  • -
  • /media/startos/rpc/host.sock — Host callbacks (effects)
  • -
-
-
- -
-
- Network Layer - Network -
-
Built-in reverse proxy (Axum/Hyper), DNS (hickory-server), SOCKS5, ACME (Let's Encrypt), WireGuard tunnels
-
No nginx or caddy — the Rust backend IS the web server, proxy, and DNS. Also manages VPN tunnels and encryption certificates.
-
-

Built-in Reverse Proxy

-
    -
  • VHostController in core/src/net/vhost.rs handles all HTTP routing
  • -
  • TLS termination via tokio-rustls with SNI-based routing
  • -
  • No external proxy software (no nginx, no caddy, no traefik)
  • -
  • Virtual hosting with per-service domain assignment
  • -
-

DNS

-
    -
  • Built-in DNS server using hickory-server (formerly trust-dns)
  • -
  • Service discovery and resolution for containers
  • -
  • mDNS via avahi-resolve-host-name for .local domains
  • -
-

TLS / Certificates

-
    -
  • Self-signed root CA per server (NIST P-256 via OpenSSL)
  • -
  • Built-in ACME client (async-acme) for Let's Encrypt with TLS-ALPN-01 challenge
  • -
  • Certificate store managed in Patch-DB
  • -
-

Connectivity

-
    -
  • SOCKS5 proxy: Built-in at 10.0.3.1:1080 for container outbound traffic
  • -
  • WireGuard: First-class support via wg-quick + x25519-dalek
  • -
  • Multi-gateway: Supports multiple interfaces (Ethernet, WiFi, WireGuard) with separate domain configs
  • -
  • Port forwarding: iptables-based via InterfacePortForwardController
  • -
-

Tor (Status: Removed in v0.4)

-
    -
  • Architecture doc mentions "Tor via Arti" but Arti is absent from current Cargo.toml
  • -
  • Previous versions (0.3.x) used the C Tor daemon for hidden services
  • -
  • Likely planned for re-integration but not yet implemented in the 0.4 rewrite
  • -
-
-
- -
-
- Encryption — Optional LUKS on btrfs - Security -
-
Optional LUKS encryption on LVM volumes, btrfs filesystem with COW snapshots for safe installs
-
Disk encryption is optional (you choose during setup). Uses btrfs which can make instant copies of data for safe app updates.
-
-

Encryption (Optional)

-
    -
  • User chooses encrypted or unencrypted during setup
  • -
  • LVM volume groups: STARTOS_<random> (encrypted) or STARTOS_<random>_UNENC
  • -
  • LUKS via cryptsetup luksFormat/luksOpen with password-based key
  • -
  • Default password: "password" (changed during setup)
  • -
-

Filesystem: btrfs

-
    -
  • Copy-on-Write (COW) snapshots for safe service installs
  • -
  • cp --reflink=always for instant volume snapshots before upgrades
  • -
  • If install fails, volumes restored from snapshot automatically
  • -
-

Volume Layout (LVM)

-
    -
  • main (8 GB) — System data, Patch-DB
  • -
  • package-data (100% remaining) — All service/app data
  • -
-
-
- -
-
- Operating System — Debian Bookworm - OS -
-
Debian 12 (same base as Archipelago), systemd services, x86_64 + ARM64 + RISC-V targets
-
Same stable Debian foundation as Archipelago. Supports more CPU architectures including RISC-V.
-
-

Platform Targets

-
    -
  • x86_64: Standard PCs and servers
  • -
  • aarch64: ARM64 (Raspberry Pi 4/5, etc.)
  • -
  • riscv64: RISC-V (emerging architecture)
  • -
-
-
- -
- -
- - - - -
- -

LXC Container Model

-
-
-
Two-Layer Architecture
-
Outer: One LXC container per service, created by the Rust backend. Base rootfs is a read-only SquashFS image mounted as OverlayFS. Inner: Node.js container runtime inside each LXC can launch subcontainers from S9PK-bundled images.
-
Each app gets its own sealed Linux environment with a read-only base. Any changes go to a separate overlay layer.
-
-
-
Communication
-
JSON-RPC over Unix domain sockets. /media/startos/rpc/service.sock (inbound) and host.sock (host callbacks). Services export init(), uninit(), main() via JavaScript ABI.
-
Apps talk to the system through socket files, not network ports. Each app implements 3 required JavaScript functions.
-
-
-
Isolation
-
User namespaces (container UID 0 → host UID 100000, range 65536). AppArmor profiles with nesting. veth bridge on lxcbr0 (10.0.3.x subnet). GPU passthrough support via manifest flag.
-
Container root maps to an unprivileged host user. Each container gets its own virtual network interface.
-
-
-
Lifecycle
-
LxcManager handles creation (30s timeout), garbage collection, and cleanup. Service actors manage per-service state machines. btrfs reflink snapshots before install/upgrade for atomic rollback.
-
-
- -

S9PK Package Format (v2)

-
-
-
Signed Merkle Archive
-
Ed25519 signatures with prehashed content (SHA-512 over blake3 merkle root). Magic bytes: 0x3b 0x3b 0x02. Enables partial downloads, integrity verification of subsets, and efficient delta updates.
-
App packages are cryptographically signed and structured so you can verify integrity without downloading the entire thing.
-
-
-
Archive Contents
-
manifest.json (metadata) + javascript.squashfs (service logic, Node.js) + images/<arch>/*.squashfs (container filesystems per CPU architecture) + assets.squashfs (optional static assets) + icon + LICENSE.md
-
Each package bundles its own containers, logic code, icon, and license in one downloadable file.
-
-
-
Service ABI (JavaScript/Node.js)
-
Services implement init(), uninit(), and main() in JavaScript. The container runtime provides an Effects interface for host callbacks (dependency queries, config, health reporting).
-
App developers write their service logic in JavaScript. The system provides a standard API for the app to interact with the host.
-
-
- -
- - - - -
- -
-
-
JSON-RPC (Host ↔ Service)
-
All communication between the Rust backend and services uses JSON-RPC over Unix domain sockets.
-
Apps and the system talk through a structured messaging format over local socket files — fast and secure.
-
- Transport: Unix domain sockets
- Inbound: /media/startos/rpc/service.sock
- Host callbacks: /media/startos/rpc/host.sock (Effects interface)
- Library: rpc-toolkit (custom Rust crate)
- Used by: All service containers ↔ startd -
-
- -
-
JSON-RPC (UI ↔ Backend)
-
The Angular frontend communicates with startd via JSON-RPC over HTTP. 100+ API methods. State sync via Patch-DB WebSocket.
-
The dashboard sends commands and gets responses in JSON format. Live updates stream automatically through a WebSocket.
-
- Transport: HTTP POST (commands) + WebSocket (state sync)
- State sync: Patch-DB pushes CBOR-encoded diffs over WebSocket
- Frontend applies: PatchDB.watch$() → Angular signals via toSignal()
- Methods: 100+ via ApiService abstract class -
-
- -
-
Patch-DB (CBOR Reactive Sync)
-
Custom reactive database using CBOR encoding. Backend pushes diffs over WebSocket — UI updates automatically without polling.
-
Instead of the UI constantly asking "what changed?", the backend pushes only what changed, in a compact binary format.
-
- Encoding: CBOR (Concise Binary Object Representation, RFC 8949)
- Sync model: Server-push diffs, not request-response
- Storage: /media/startos/data/main/
- Advantage: Much smaller than JSON, real-time without polling -
-
- -
-
HTTPS / TLS (Built-in)
-
TLS termination handled directly by the Rust backend (tokio-rustls). Self-signed root CA per server + ACME for public domains.
-
The backend IS the web server — no nginx or caddy needed. It handles encryption directly.
-
- TLS library: tokio-rustls 0.26 + OpenSSL (vendored, for cert generation)
- Local certs: Self-signed root CA (NIST P-256 keys)
- Public certs: ACME client (async-acme) with TLS-ALPN-01 challenge
- Routing: SNI-based virtual hosting via VHostController -
-
- -
-
WireGuard (VPN Tunnels)
-
First-class WireGuard support for remote access. Users add WireGuard configs as "gateways." Managed by tunnelbox daemon.
-
Built-in VPN for accessing your node from anywhere. Add a WireGuard config and get a secure tunnel.
-
- Implementation: wg-quick + x25519-dalek (Rust)
- Daemon: tunnelbox (symlink of startbox binary)
- Multi-gateway: Supports multiple interfaces with separate domain configs -
-
- -
-
DNS (hickory-server)
-
Built-in DNS server for service discovery and resolution. Also uses mDNS (avahi) for .local domain access on LAN.
-
The system runs its own DNS so containers can find each other by name. Your phone finds the node via .local address.
-
- Library: hickory-server (formerly trust-dns)
- mDNS: avahi-resolve-host-name for .local domains
- Container network: lxcbr0 bridge, host at 10.0.3.1 -
-
-
-
- - - - -
-
-

Not Implemented in StartOS

-

StartOS does not implement Web5 (DIDs, DWNs, or Verifiable Credentials).
Authentication uses password-based sessions and public/private key signatures.

-
-
- - - - -
-
-
-
LXC + User Namespaces
-
Each service in its own LXC container with UID/GID mapping (container 0 → host 100000, range 65536). AppArmor profiles with nesting. OverlayFS rootfs (base read-only).
-
Apps run in isolated Linux environments with their own user systems. Container root is mapped to an unprivileged host user.
-
-
-
Package Signing (Ed25519)
-
All S9PK packages signed with Ed25519 over blake3 merkle roots. Signature verified before installation. Prevents supply chain attacks.
-
Every app package is cryptographically signed. If someone tampers with it, the signature check fails and installation is blocked.
-
-
-
btrfs Snapshots
-
COW filesystem snapshots before every install/upgrade. If an install fails, data is atomically restored to the pre-install state.
-
The system takes a snapshot before every app update. If the update fails, your data is automatically rolled back.
-
-
-
Authentication
-
Password-based + session cookies. Local authcookie for CLI. Public/private key signatures for remote admin. Encrypted wire protocol during setup (public key exchange + encrypted password).
-
-
- -
- - - - -
-
-
-
1
-
-
Preinit Script
-
Optional /media/startos/config/preinit.sh runs before anything else. Enables local auth cookie.
-
-
-
-
2
-
-
Load Database + SSH Keys
-
Patch-DB loaded from disk (CBOR format). SSH developer keys written. MOK enrollment for Secure Boot if applicable.
-
-
-
-
3
-
-
Network Controller
-
DNS server (hickory-server), SOCKS5 proxy, VHost reverse proxy, port forwarding, ACME client, WiFi configuration — all start together.
-
-
-
-
4
-
-
System Initialization
-
Mount logs to data drive, load CA certificate, set CPU governor to performance, NTP clock sync, enable zram, hardware inventory via lshw.
-
-
-
-
5
-
-
Launch Service Intranet + Services
-
LXC bridge network (lxcbr0) created. Database validated. Service actors start all installed services. Postinit script runs.
-
-
-
- -
- - - - -
-
/media/startos/data/ ← Root data directory (optionally LUKS encrypted) - ├── main/ System data, Patch-DB (8 GB LVM volume) - └── package-data/ All service data (remaining disk space) - ├── volumes/{pkg-id}/data/{vol}/ Per-service volume data - ├── volumes/{pkg-id}/assets/{ver}/ Per-service read-only assets - └── logs/{pkg-id}/ Per-service log output - -/usr/lib/startos/ ← System binaries and base images - ├── container-runtime/rootfs.squashfs Base LXC container image - └── package/ Mounted JS from S9PK inside containers - -/var/lib/lxc/ LXC container storage -/media/startos/config/ System config (preinit.sh, postinit.sh, standby) -/media/startos/backups/ Backup mount points per service
-
- - - - - - - - - - - - - - -
- -

Architecture analysis sourced from getumbrel/umbrel on GitHub (master branch). Click any layer to expand.

- -
-
Docker
Container Runtime
-
Node.js
Backend (umbreld)
-
React 19
Frontend
-
Compose
App Format
-
None
Disk Encryption
-
A/B Boot
Rugix Partitions
-
- -
-
React 19 + Tailwind 4 + Radix UIUI
Static SPA served by umbreld's Express server. Zustand + TanStack React Query for state. tRPC for typed API. 8+ languages.
-
umbreld (Node.js 22 / TypeScript)Service
Single daemon on port 80: Express + tRPC API, app lifecycle via Docker Compose, file manager, backups (Kopia), terminal (node-pty).
-
Docker 28.5 (rootful)Containers
Each app is a Docker Compose project. Flat bridge network (10.21.0.0/16). Per-app auth proxy containers. All containers destroyed on boot.
-
NetworkingNetwork
No reverse proxy. Express serves on :80 directly. Optional Tor (containerized). mDNS via avahi. No TLS by default.
-
Debian Trixie (testing) + RugixOS
Date-pinned Debian testing. Rugix A/B root partitions for atomic OS updates with automatic rollback. /data partition persists.
-
- -
- -
-
- -
-
- Web UI — React 19 - Application -
-
React 19 + TypeScript + Vite 6 + Tailwind 4 + Radix UI, served as static SPA by umbreld's Express server
-
The dashboard you use in your browser. Built with React (Meta's web framework), styled with Tailwind.
-
-

Tech Stack

-
    -
  • Framework: React 19 + TypeScript (strict)
  • -
  • Build: Vite 6
  • -
  • Styling: Tailwind CSS 4 + Radix UI primitives + shadcn/ui patterns
  • -
  • State: Zustand (client) + TanStack React Query v5 (server)
  • -
  • API: tRPC React Query v11 for typed RPC
  • -
  • i18n: i18next (8+ languages)
  • -
  • Animations: Framer Motion (as motion package)
  • -
  • Terminal: xterm.js for in-browser terminal
  • -
  • Charts: Recharts for data visualization
  • -
-
-
- -
-
- Backend — umbreld (Node.js/TypeScript) - Service -
-
Single Node.js 22 daemon handling web server, tRPC API, app lifecycle, Tor, backups, file management, and OS updates
-
The brain of the system. A TypeScript process that does everything — web server, app manager, backup handler.
-
-

Key Modules

-
    -
  • Server: Express 4 + tRPC v11 over HTTP and WebSocket on port 80
  • -
  • Apps: Docker Compose lifecycle (install, start, stop, update, uninstall)
  • -
  • AppStore: Git-based — clones getumbrel/umbrel-apps, pulls every 5 minutes
  • -
  • User: Single-user JWT auth (bcrypt $2b$, 12 rounds) + optional TOTP 2FA
  • -
  • Files: File browser with Samba sharing, thumbnails, external storage
  • -
  • Hardware: RAID (ZFS) for Umbrel Home Pro, internal/external storage detection
  • -
  • Backups: Kopia v0.19.0 encrypted backups to external drives
  • -
  • Notifications: In-app notification system + widgets
  • -
  • Terminal: WebSocket-based terminal (node-pty + xterm.js)
  • -
  • Dbus: D-Bus interface to systemd for reboot/shutdown/hostname
  • -
-

State Storage

-
    -
  • YAML file: umbrel.yaml — no database, just a YAML file
  • -
  • Validation: Zod schemas
  • -
  • Docker: dockerode library + execa shell calls
  • -
  • Git: isomorphic-git for app store management
  • -
-

Legacy Compat Layer

-
    -
  • App lifecycle handled by a large bash script (app-script)
  • -
  • Shells out to: docker compose, yq, envsubst, openssl
  • -
  • Explicitly labeled "legacy" in the codebase
  • -
-

Systemd

-
    -
  • umbrel.service: After=network-online.target docker.service
  • -
  • Restart=always, 15-minute stop timeout, StartLimitInterval=0
  • -
-
-
- -
-
- Container Layer — Docker (rootful) - Isolation -
-
Docker 28.5.0 (rootful, not rootless) + Docker Compose v2. Each app is a separate Compose project.
-
Apps run in Docker containers managed by Docker Compose. Unlike Podman, Docker runs as root — simpler but less isolated.
-
-

Docker Setup

-
    -
  • Installed via official Docker install script, pinned to v28.5.0
  • -
  • Rootful (runs as root) — not rootless
  • -
  • Each app: separate Docker Compose project (--project-name <app-id>)
  • -
  • Legacy container naming: <app-id>_<service>_1 for DNS compat
  • -
-

Network: Flat Bridge

-
    -
  • Single shared network: umbrel_main_network (10.21.0.0/16)
  • -
  • All apps share one flat network — any container can talk to any other
  • -
  • Static IPs assigned per service (defined in exports.sh)
  • -
  • No per-app network isolation
  • -
-

Per-App Proxy

-
    -
  • Each app gets an app_proxy container (Node.js Express, getumbrel/app-proxy)
  • -
  • Handles JWT authentication for iframe embedding
  • -
  • Proxies to the actual app container on its internal port
  • -
  • UI renders apps in iframes pointing to proxy port
  • -
-

Boot Cleanup

-
    -
  • On every startup: stops ALL containers, prunes ALL networks
  • -
  • Prevents stale state from previous versions
  • -
  • Pre-loads images from /images/ (tor, auth-server baked into ISO)
  • -
-
-
- -
-
- Network Layer - Network -
-
No traditional reverse proxy. umbreld serves on port 80. Per-app proxy containers. Optional Tor via container.
-
No nginx, no caddy — the backend itself serves on port 80. Each app has its own mini proxy container for authentication.
-
-

HTTP

-
    -
  • umbreld's Express server listens directly on port 80
  • -
  • Serves UI static files + tRPC API
  • -
  • No port 443/TLS by default — HTTP only on LAN
  • -
  • mDNS via avahi for HOSTNAME.local access
  • -
-

Tor (Optional)

-
    -
  • Toggle per-system (not per-app)
  • -
  • tor_proxy container on 10.21.21.11 (SOCKS5)
  • -
  • Each app gets a tor_server container creating hidden services
  • -
  • Dashboard also gets its own hidden service
  • -
  • Provides end-to-end encryption for remote access
  • -
-

Inter-App Communication

-
    -
  • Via static IPs on the flat 10.21.0.0/16 bridge network
  • -
  • No DNS-based service discovery — IPs hardcoded in exports.sh
  • -
-
-
- -
-
- Operating System — Debian Trixie (testing) - OS -
-
Debian Trixie (testing branch, not stable), built from date-pinned snapshot for reproducibility, Rugix A/B partitions
-
Uses Debian's "testing" branch (less stable than Bookworm). Has a clever A/B partition system for safe OS updates.
-
-

OS Build

-
    -
  • Built inside Docker via multi-stage Dockerfile (umbrelos.Dockerfile)
  • -
  • Date-pinned Debian snapshot (e.g., 20251229) for reproducibility
  • -
  • Includes: NetworkManager, avahi, systemd-timesyncd, Bluetooth, SSH
  • -
  • Node.js 22.13.0 baked in
  • -
-

Rugix A/B Partitions

-
    -
  • Two root partitions — active and standby
  • -
  • OS updates write to inactive partition, then swap on reboot
  • -
  • If boot fails, automatic rollback to previous partition
  • -
  • Root filesystem committed after successful boot
  • -
-

Persistent Bind Mounts

-
    -
  • /var/log/data/umbrel-os/var/log
  • -
  • /var/lib/docker/data/umbrel-os/var/lib/docker
  • -
  • /home/data/umbrel-os/home
  • -
  • Separate /data partition persists across OS updates
  • -
-

User

-
    -
  • umbrel (UID 1000), default password umbrel
  • -
  • Synced to web UI password after onboarding
  • -
  • Has sudo access
  • -
-
-
- -
- -
- -
- -

Docker Container Model

-
-
-
Docker 28.5 (Rootful)
-
Docker daemon runs as root (not rootless). Each app is a separate Docker Compose v2 project (--project-name <app-id>). Legacy container naming: <app-id>_<service>_1 for DNS compatibility.
-
Standard Docker running with root permissions. Each app is managed as a Compose project with its own containers.
-
-
-
Flat Network (No Isolation)
-
Single shared bridge: umbrel_main_network (10.21.0.0/16). All apps share one network — any container can communicate with any other. Static IPs assigned per service via exports.sh.
-
All apps are on the same network. A compromised app could potentially reach other apps' services directly.
-
-
-
Per-App Auth Proxy
-
Each app gets an app_proxy container (getumbrel/app-proxy, Node.js Express) that handles JWT authentication for iframe embedding. Proxies to the actual app on its internal port.
-
Each app has a mini web server in front of it that checks your login before letting you in.
-
-
-
Boot Cleanup
-
On every startup: stops ALL containers, prunes ALL networks to prevent stale state. Pre-loads images from /images/ (tor, auth-server baked into ISO).
-
Every reboot starts fresh by destroying all containers and recreating them. Clean but adds startup time.
-
-
- -

App Packaging

-
-
-
umbrel-app.yml
-
App manifest with: id, name, version, port, category, dependencies, permissions (GPU), gallery images, release notes, widgets, torOnly flag, installSize. Validated by Zod schema.
-
A YAML file describing the app — what it does, what it needs, what ports it uses, and how to display it in the store.
-
-
-
docker-compose.yml
-
Standard Docker Compose v3.7. Services reference umbrel_main_network. Images pinned by SHA256 digest. Apps define their own security constraints (no enforced capability dropping).
-
Standard Docker Compose file that defines the app's containers, networks, and volumes.
-
-
-
exports.sh + hooks/
-
exports.sh exports environment variables (IPs, ports, credentials) for dependency resolution. hooks/ directory with lifecycle scripts: pre/post-install, pre/post-start, pre/post-stop, pre/post-update, pre-uninstall.
-
Shell scripts that set up environment variables so apps can find each other, plus hooks that run at key lifecycle moments.
-
-
-
App Store (Git repo)
-
Apps distributed via Git repository (getumbrel/umbrel-apps). Cloned locally, pulled every 5 minutes. Community app stores supported. implements field enables alternative implementations (e.g., Bitcoin Knots for Bitcoin Core).
-
The app store is just a Git repository. Umbrel checks for updates every 5 minutes by pulling the latest commits.
-
-
- -
- -
-
-
-
tRPC (UI ↔ Backend)
-
TypeScript-first RPC framework with end-to-end type safety. Runs over both HTTP and WebSocket on port 80.
-
A typed communication channel between the dashboard and the backend. If the API changes, TypeScript catches errors automatically.
-
- Version: tRPC v11
- Transport: HTTP + WebSocket (via Express 4)
- Port: 80 (Express serves both UI and API)
- Type safety: Server types flow directly to client (TanStack React Query v5)
- Used by: React 19 frontend ↔ umbreld -
-
- -
-
Docker Compose (App Lifecycle)
-
Each app managed via Docker Compose v2. Install/start/stop/update handled by a bash script (app-script) calling docker compose.
-
Apps are defined as Docker Compose projects. A bash script handles the lifecycle by calling docker compose commands.
-
- Compose version: v2 (docker compose plugin, not docker-compose binary)
- Lifecycle script: app-script (bash, labeled "legacy")
- Tools used: docker compose, yq, envsubst, openssl
- Hooks: pre/post-install, pre/post-start, pre/post-stop, pre/post-update, pre-uninstall -
-
- -
-
exports.sh (Dependency Resolution)
-
Shell scripts that export environment variables (IPs, ports, RPC credentials). When app B depends on app A, A's exports.sh is sourced first.
-
Apps share their connection details through environment variables set by shell scripts.
-
- Variables exported: IP addresses (static), ports, RPC passwords, hidden service hostnames
- Resolution: Transitive deps resolved in post-order (depth-first)
- Alternative implementations: settings.yml can map dependency (e.g., bitcoin → bitcoin-knots)
- Deps NOT auto-installed: UI warns users to install dependencies first -
-
- -
-
Tor (Optional, Containerized)
-
Toggle per-system. tor_proxy container provides SOCKS5 at 10.21.21.11. Per-app tor_server containers create hidden services.
-
Tor is optional and runs in its own container. When enabled, each app gets its own .onion address for remote access.
-
- SOCKS5: 10.21.21.11 (tor_proxy container)
- Per-app: tor_server container creates hidden service pointing to app_proxy
- Dashboard: Also gets its own hidden service
- Provides: End-to-end encryption for remote access (since no TLS by default) -
-
- -
-
JWT + Proxy Tokens (Auth)
-
JWT for API authentication. Separate "proxy tokens" validate iframe requests to app_proxy containers. bcrypt password hashing.
-
Login tokens that prove who you are. Separate tokens for the dashboard API and for accessing individual apps.
-
- API auth: JWT (jsonwebtoken library)
- Password: bcrypt ($2b$, 12 rounds)
- App auth: UMBREL_PROXY_TOKEN cookie validated by app_proxy containers
- 2FA: Optional TOTP -
-
- -
-
Git (App Store)
-
App store is a Git repository cloned locally via isomorphic-git. Pulled every 5 minutes for updates.
-
The app catalog is just a Git repo. Umbrel checks for new apps and updates by pulling the latest commits every 5 minutes.
-
- Default repo: getumbrel/umbrel-apps (GitHub)
- Library: isomorphic-git (pure JS Git implementation)
- Pull interval: Every 5 minutes
- Community stores: Supported (add custom Git URLs) -
-
-
-
- -
-
-

Not Implemented in umbrelOS

-

umbrelOS does not implement Web5 (DIDs, DWNs, or Verifiable Credentials).
Authentication uses a single-user JWT model. Per-app passwords are derived from a deterministic seed via HMAC-SHA256.

-
-
- -
-
-
-
No Disk Encryption
-
No LUKS, no dm-crypt. All data stored unencrypted on disk. Backups use Kopia with per-repository passwords. A deterministic seed (256-byte random token) derives per-app passwords via HMAC-SHA256.
-
If someone physically steals the drive, all data is readable. No encryption at rest.
-
-
-
Flat Network (No App Isolation)
-
All apps share one Docker bridge (10.21.0.0/16). Any container can communicate with any other container. The app_proxy adds authentication but not network isolation.
-
All apps are on the same network. A compromised app could potentially access other apps' services.
-
-
-
Authentication
-
Single user model. Password hashed with bcrypt ($2b$, 12 rounds). JWT tokens for API auth. Separate "proxy tokens" for app iframe auth. Optional TOTP 2FA. Session cookie: UMBREL_PROXY_TOKEN.
-
-
-
Rootful Docker
-
Docker daemon runs as root. Containers run as UID 1000 where possible, but no enforced capability dropping or security profiles. No --cap-drop=ALL, no no-new-privileges by default.
-
Docker has root access to the machine. Individual containers may or may not restrict their own privileges.
-
-
- -
- -
-
-
-
1
-
-
GRUB / Rugix → Select Active Partition
-
GRUB (amd64) or tryboot (RPi) loads the kernel from the active A/B partition. Rugix commits to current partition on successful boot.
-
-
-
-
2
-
-
systemd → Docker → umbreld
-
systemd starts, brings up networking (NetworkManager) and Docker daemon. umbrel.service starts umbreld --data-directory=/home/umbrel/umbrel.
-
-
-
-
3
-
-
umbreld Initialization
-
Runs startup migrations, syncs system password, restores WiFi, waits for NTP sync (10s, important for RPi with no RTC).
-
-
-
-
4
-
-
Docker Clean Slate
-
Stops and removes ALL containers, prunes ALL networks. Pre-loads images from /images/. Prevents stale state from previous versions.
-
Destructive reset on every boot — ensures clean state but adds startup time
-
-
-
-
5
-
-
Start App Environment + All Apps
-
Starts tor_proxy + auth containers first, then all installed apps in parallel. Express HTTP server starts on port 80. App store update loop begins (every 5 min).
-
-
-
- -
- -
-
/home/umbrel/umbrel/ ← Main data directory (NO encryption) - ├── umbrel.yaml Main config/state (YAML file, not a database) - ├── app-data/{app-id}/ Per-app data, compose files, manifests - ├── app-stores/ Git clones of app store repositories - ├── tor/data/app-{id}/hostname Per-app .onion addresses - ├── db/umbrel-seed/seed Deterministic seed (256-byte) for per-app passwords - ├── secrets/jwt JWT signing secret - └── home/ User files, backups - -/opt/umbreld/ umbreld daemon (npm-linked) -/opt/umbreld/ui/ React SPA static files -/images/ Pre-loaded Docker images (tor, auth-server)
-
- - - - - - - -
- -
-
3
Systems Compared
-
Rust
2/3 Backends
-
Debian
3/3 Base OS
-
3
Container Runtimes
-
3
Frontend Frameworks
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AspectArchipelagoStartOSumbrelOSNotes / Trade-offs
Core Architecture
Backend LanguageRustRustTypeScript / Node.js 22Rust: memory safety, performance, no GC pauses. Node.js: faster prototyping, larger ecosystem, but runtime overhead.
FrontendVue 3 + Vite 7 + TailwindAngular 21 + Taiga UI 5React 19 + Vite 6 + Tailwind 4All modern choices. Angular is heaviest (TypeScript-only). Vue/React are lighter. Tailwind enables rapid UI iteration.
API ProtocolJSON-RPC 2.0JSON-RPC (rpc-toolkit)tRPC v11JSON-RPC is standard and language-agnostic. tRPC gives end-to-end TypeScript type safety but couples frontend/backend.
State SyncPinia + JSON Patch over WebSocketPatch-DB (CBOR diffs over WebSocket)Zustand + TanStack React QueryPatch-DB is most efficient (binary diffs). Archipelago and StartOS push updates; Umbrel polls via React Query.
Reverse ProxyNginx (external, battle-tested)Built into Rust backend (Axum/Hyper)None (Express on :80 + per-app proxy containers)Nginx: proven, configurable, rate limiting. Built-in: fewer moving parts. Umbrel: no central proxy means no rate limiting or security headers.
Container Isolation
Container RuntimePodman (rootless, OCI)LXC (system containers, AppArmor)Docker 28.5 (rootful)Podman: no daemon, rootless by design. LXC: heavier isolation (full system containers). Docker: rootful daemon is a larger attack surface.
Rootless ContainersYes (all containers as UID 1000)User namespaces (UID 0 → host 100000)No (Docker daemon runs as root)Rootless Podman: container escape = unprivileged user. LXC: namespace mapping mitigates. Docker rootful: escape = root on host.
Capability Dropping--cap-drop=ALL + whitelistAppArmor profiles (generated)Not enforced by defaultArchipelago: explicit least-privilege. StartOS: AppArmor provides MAC. Umbrel: apps define their own security (inconsistent).
Network IsolationPer-tier networks (archy-net + bridge)Per-service veth on lxcbr0Flat bridge (10.21.0.0/16, all apps share)Archipelago/StartOS: apps can't see each other unless connected. Umbrel: any container can reach any other.
Memory LimitsPer-container (128MB–4GB)Configurable via manifestNot enforced by defaultMemory limits prevent a single app from consuming all RAM and crashing the system.
Security
Disk EncryptionMandatory LUKS2 (AES-XTS or ChaCha20-Adiantum)Optional LUKS on LVM/btrfsNonePhysical theft risk: Archipelago data is unreadable. StartOS depends on user choice. Umbrel data is fully exposed.
Security HeadersCSP, HSTS, X-Frame-Options, Permissions-PolicyPartial (built-in proxy)None (no central proxy)Headers prevent XSS, clickjacking, and protocol downgrade attacks. Critical for browser-based management.
Rate LimitingAuth 3/s, RPC 20/s, P2P 10/sRBAC via method metadataNoneRate limiting prevents brute-force password attacks and API abuse.
TLSSelf-signed cert on :443 + HSTSSelf-signed CA + ACME (Let's Encrypt)None (HTTP only on LAN)Without TLS, any device on the LAN can intercept credentials. Tor provides encryption for remote but not LAN access.
Auth ModelRBAC (Admin/Viewer/AppUser) + CSRF + session cookiesPassword + session cookies + key signaturesSingle-user JWT + optional TOTPArchipelago supports multiple roles. Others are single-user only.
App Ecosystem
App FormatContainer images from private registryS9PK v2 (signed merkle archive)docker-compose.yml + umbrel-app.ymlS9PK: most sophisticated (signed, partial downloads, delta updates). Compose: simplest for developers. Registry: fast deployment.
Package SigningRegistry-based trustEd25519 over blake3 merkle rootsDocker image digests onlyStartOS has the strongest supply chain security. Archipelago trusts its private registry. Umbrel relies on Docker content trust.
App StoreBuilt-in marketplace (curated)Registry-based (marketplace)Git repository (pulled every 5 min)Git-based: easy for devs to contribute. Registry: more control. Curated: quality gate but slower additions.
Update MechanismISO reflash / manual upgradeRegistry-based OTARugix A/B partitions (atomic, rollback)Umbrel has the smoothest update path with automatic rollback. Archipelago's ISO approach is most disruptive.
Networking & Privacy
TorSystem daemon + hidden services (always available)Removed in v0.4 (planned re-integration)Optional (containerized)Archipelago: Tor is first-class. StartOS temporarily lost Tor in 0.4 rewrite. Umbrel: toggle on/off.
VPNTailscale (WireGuard mesh)WireGuard (first-class, tunnelbox)None built-inBoth Archipelago and StartOS offer remote access without port forwarding. Umbrel relies on Tor or manual setup.
DNSSystem DNS + container NetAvark DNSBuilt-in (hickory-server)Docker DNS + static IPs in exports.shStartOS has the most integrated DNS. Archipelago uses standard tools. Umbrel hardcodes IPs.
Identity & Web5
DID Supportdid:key + did:dht + W3C DID DocumentsNoneNoneArchipelago is the only node OS with decentralized identity support. Enables credential issuance and cross-node trust.
Verifiable CredentialsW3C VC 2.0 (Ed25519Signature2020)NoneNoneArchipelago can issue and verify digital certificates without any central authority.
DWN (Data Store)Custom implementation + peer sync via TorNoneNonePersonal data store that syncs across nodes. Unique to Archipelago.
Nostr IntegrationNIP-01/04/44, nostr-provider.js in iframesNoneNoneArchipelago injects Nostr identity into every app iframe for seamless decentralized social integration.
Infrastructure
Base OSDebian 12 Bookworm (stable)Debian BookwormDebian Trixie (testing)Stable: proven, security patches. Testing: newer packages but less battle-tested, potential for regressions.
Filesystemext4btrfs (COW snapshots)ext4 (A/B partitions)btrfs snapshots enable instant rollback on failed installs. ext4 is simpler and more mature. A/B adds OS-level rollback.
Kiosk DisplayX11 + Chromium on VT7NoneNonePlug in a monitor and the dashboard appears fullscreen. Unique physical UX for dedicated hardware.
Boot RecoveryCrash recovery + container state snapshotsbtrfs snapshots + preinit/postinit hooksDestroys all containers on every bootArchipelago/StartOS: resume from last known state. Umbrel: clean slate every boot (slower but deterministic).
-
- -

Summary

-
-
-
Archipelago
-
Strengths: Security (rootless, LUKS, caps, rate limiting, CSP), identity (DIDs, VCs, DWN, Nostr), kiosk display, Tor first-class.
Trade-offs: No OTA updates (ISO reflash), ext4 lacks snapshot rollback, smaller app ecosystem.
-
-
-
StartOS
-
Strengths: Package signing (S9PK), btrfs snapshots, built-in reverse proxy (fewer moving parts), WireGuard VPN, multi-arch (x86/ARM/RISC-V).
Trade-offs: Tor removed in v0.4, no identity system, Angular is heavier, LXC is less container-ecosystem-compatible.
-
-
-
umbrelOS
-
Strengths: Easiest setup, A/B OTA updates with rollback, largest app ecosystem, Git-based app store (easy contributions), React UI polish.
Trade-offs: No disk encryption, flat network (no isolation), rootful Docker, no TLS, no rate limiting, no security headers, Node.js backend.
-
-
- -
- - - - - diff --git a/docs/container-lifecycle.md b/docs/container-lifecycle.md new file mode 100644 index 00000000..f3b6c3b9 --- /dev/null +++ b/docs/container-lifecycle.md @@ -0,0 +1,108 @@ +# Container lifecycle + +How Archipelago keeps apps in the state you asked for — install, start, stop, +restart, uninstall — and how it self-heals without ever resurrecting something +you deliberately stopped. Source of truth: +`core/archipelago/src/container/prod_orchestrator.rs` and +`core/archipelago/src/container/boot_reconciler.rs`. + +## The model: level-triggered, not fire-and-forget + +Archipelago does not start a container and hope. A long-running **reconciler** +compares *desired state* (what the manifests and your explicit choices say +should be running) against *actual state* (what podman reports) and repairs the +difference. It is **level-triggered**: it acts on the current gap every tick, not +on a one-time event, so a container that dies, a unit that vanishes, or a reboot +that clears everything are all just "the gap is non-zero, close it". + +The reconciler is spawned once at boot (`BootReconciler`) after an initial +`adopt_existing()` pass, and runs every **30 seconds**. It finishes an in-flight +pull or build before honouring a shutdown signal — it is never interrupted +mid-operation. + +Concurrency: each app has its own async mutex guarding all mutating operations +against the reconciler, so a manual `stop` and a reconcile tick can't race, but +reconciles across different apps still run without serialising against each +other. + +## Desired state has three inputs + +For each app the reconciler asks: *should this be running right now?* The answer +comes from three durable signals, checked in this order: + +1. **Explicitly user-stopped** (`user-stopped.json`). If you stopped an app, its + id is recorded and the reconciler leaves it down — it is **not** a gap to + repair. Cleared when you start it again. This is what makes a stop *stick* + across restarts and reboots. +2. **Explicitly uninstalled** (`user-uninstalled.json`). Same idea for uninstall: + a baseline app you removed stays removed, so self-heal can't reinstall it. +3. **Otherwise, the manifest set** — every catalog/disk app that isn't stopped or + uninstalled should be running. + +Dependencies are pulled in: an app that is up requires its declared +dependencies, so they are kept up too — but a dependency you explicitly stopped +still stays stopped. + +## The operations + +All go through the orchestrator, all take the per-app lock, all are idempotent: + +| Operation | What it does | +|-------------|--------------| +| **adopt** | At boot, take ownership of a pre-existing container **by name** rather than recreating it — preserves data, ports and identity across a daemon restart. | +| **install** | Materialise secrets → ensure image (build from a local Dockerfile or use a pre-pulled image) → create and start the container (via Quadlet where enabled). | +| **start / stop** | Bring the container up/down and record the desired-state change. A stop writes the app to `user-stopped.json`. | +| **restart** | Stop then start, preserving the container's data and identity. | +| **remove** | Stop and remove the container, **preserving `/var/lib/archipelago/`, secrets, credentials and ports** — a reinstall or upgrade lands on the same data. | +| **upgrade** | Recreate at a new image while preserving data (see the version rules below). | +| **health** | Report the container's health from its declared `health_check`. | + +## Self-heal vs. respecting your choice + +The one rule that ties it together: **self-heal must never override a deliberate +stop or uninstall.** + +- A container that disappeared while its siblings run — a wedged teardown, a + reboot that cleared it — is a hole to repair, and the reconciler rebuilds it + from the durable "was running" snapshot. +- A container that is down because you stopped or uninstalled it is a *choice*, + and the reconciler leaves it alone. + +A small set of **baseline apps** are expected to exist from first boot and +self-heal when their container is missing — but the `user_stopped` / +`user_uninstalled` gates are checked first, so even a baseline app you turned off +stays off. Getting this wrong in either direction is a real bug: resurrecting a +stopped app ignores the operator, and failing to rebuild a crashed one is the +fire-and-forget failure the whole design exists to remove. + +## Migrations never destroy data + +Any recreate path — upgrade, reinstall, repair — preserves the app's data +directory, its generated secrets, its credentials, its ports, and the container +name used for adoption. An update that would roll a version *backwards* is +refused (see the version guard in `container::image_versions`): the update button +never offers a lower version than what is running, so a stale record cannot turn +into a downgrade. Version pins are honoured — a pinned app is not "updated" out +from under the operator by the catalog. + +## Inspecting lifecycle state + +```bash +# what podman actually has — run as the archipelago service user (rootless) +podman ps -a --format '{{.Names}}\t{{.Status}}' + +# the durable desired-state signals +cat /var/lib/archipelago/user-stopped.json +cat /var/lib/archipelago/user-uninstalled.json + +# the reconciler's decisions. archipelago.service is a SYSTEM unit that runs +# as User=archipelago (WantedBy=multi-user.target), so this is not --user — +# unlike the companion Quadlet units, which are per-user. +sudo journalctl -u archipelago | grep -iE 'reconcile|adopt|install|user.stopped' +``` + +## Related + +- [Manifest → Quadlet unit](quadlet-compilation.md) — how the unit the reconciler manages is generated +- [App secrets](secrets.md) — the `ensure_generated_secrets` tick that runs before start +- [App Manifest Specification](app-manifest-spec.md) — `health_check`, `dependencies`, `restart` fields diff --git a/docs/demo-build-info.md b/docs/demo-build-info.md index 4992b7c9..027c95d8 100644 --- a/docs/demo-build-info.md +++ b/docs/demo-build-info.md @@ -3,7 +3,7 @@ **Status:** implemented & deployable (2026-07-14) **Branch:** `main` — the demo machinery was merged from the old `demo-build` branch and now lives on main, pushed to -`gitea-vps2` = `http://146.59.87.168:3000/lfg2025/archy.git`. +`gitea-vps2` = `https://source.archipelago-foundation.org/lfg2025/archy.git`. A public, click-to-play demo of the Archipelago UI, 100% mock-data driven, multi-visitor, deployed via Portainer. See also `docs/archive/demo-deployment-design.md` @@ -17,7 +17,7 @@ Build-from-repo (works today, no registry needed): | Field | Value | |-------|-------| -| Repository URL | `http://146.59.87.168:3000/lfg2025/archy.git` | +| Repository URL | `https://source.archipelago-foundation.org/lfg2025/archy.git` | | Reference | `refs/heads/main` | | Compose path | `docker-compose.demo.yml` | | Auth | user `lfg2025`, password = Gitea token | diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 37d81e91..184d5233 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -54,11 +54,10 @@ archy/ │ ├── vite.config.ts │ └── package.json ├── scripts/ # Deployment and utility scripts -│ ├── deploy-to-target.sh # Main deploy script │ ├── first-boot-containers.sh # ISO first-boot setup │ └── run-tests.sh # CI test runner ├── image-recipe/ # ISO build configuration -│ ├── build-auto-installer-iso.sh +│ ├── build-debian-iso.sh │ └── configs/ # Nginx, systemd configs ├── docs/ # Documentation │ ├── architecture.md @@ -66,7 +65,7 @@ archy/ │ ├── marketplace-protocol.md │ └── multi-node-architecture.md ├── apps/ # App manifests (YAML) -├── CLAUDE.md # AI development instructions +├── CLAUDE.md # Contributor guide (invariants, build/verify) └── docs/ROADMAP.md # Project roadmap ``` @@ -91,19 +90,16 @@ The dev server at `http://localhost:8100` uses a mock backend. ### Deploying Changes -Release and host-integration builds should run on Linux. The deploy script rsyncs -source to a configured Linux target and builds there. +Release and host-integration builds should run on Linux. Build the backend and +frontend on the target, or cross-build and copy the artifacts across: ```bash -# Deploy to the configured primary target (builds backend + frontend, restarts services) -./scripts/deploy-to-target.sh --live - -# Deploy to both configured targets -./scripts/deploy-to-target.sh --both +cd core && cargo build --release +cd neode-ui && npm ci && npm run build ``` -The deploy script: -1. Rsyncs source to the server +A deploy then: +1. Copies the build output to the node 2. Builds Rust backend on the server (`cargo build --release`) 3. Builds Vue frontend (`npm run build`) 4. Copies artifacts to production paths @@ -203,7 +199,6 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string ### 5. Deploy and Test ```bash -./scripts/deploy-to-target.sh --live curl -X POST http:///rpc/v1 \ -H "Content-Type: application/json" \ -b "archipelago_session=YOUR_SESSION" \ @@ -312,6 +307,5 @@ mod tests { 1. Create a feature branch: `git checkout -b feature/my-feature` 2. Make changes following the standards above 3. Test locally: `cd neode-ui && npm test` -4. Deploy to dev server: `./scripts/deploy-to-target.sh --live` -5. Verify on your configured development target -6. Commit with conventional format: `feat: add my feature` +4. Verify on an Archipelago node +5. Commit with conventional format: `feat: add my feature` diff --git a/docs/dht-distribution-design.md b/docs/dht-distribution-design.md index a209c4c7..a8fd7c48 100644 --- a/docs/dht-distribution-design.md +++ b/docs/dht-distribution-design.md @@ -1,6 +1,13 @@ # DHT / Peer-Distributed Content Design -**Status:** Design (no code yet) · **Date:** 2026-06-16 · **Author:** archipelago + Claude +**Status:** partially implemented — **not** "no code yet" as this line previously +read. `core/archipelago/src/swarm/` exists (`mod.rs`, `iroh_provider.rs`, +`paid.rs`, `paid_alpn.rs`, `payment.rs`) along with `content_hash.rs`, behind the +**default-off** `iroh-swarm` cargo feature (`Cargo.toml:21` — the iroh/iroh-blobs +deps are optional and only pulled in by that feature). `config.swarm_enabled` +gates it at runtime and also defaults off, so a stock build ships this inert. +Treat the phases below as design; check the feature flag before assuming a phase +is live. · **Date:** 2026-06-16 · **Author:** archipelago + Claude ## 1. Purpose @@ -28,7 +35,7 @@ origin": ### OTA (`core/archipelago/src/update.rs`) - Manifest at `DEFAULT_UPDATE_MANIFEST_URL` (`update.rs:67`) = vps2 OVH - (`146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json`). + (`source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json`). - `check_for_updates()` (`:565`) walks an operator mirror list (`default_mirrors()` `:105`, `load_mirrors()` `:123`), origin-rewrites component URLs to the chosen mirror (`rewrite_manifest_origins()` `:227`). @@ -65,9 +72,9 @@ origin": ### IndeeHub (the streaming target) - Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/` - (Vue 3 + NestJS). Submodule `146.59.87.168:3000/lfg2025/indeehub.git` (repointed off the retired host — + (Vue 3 + NestJS). Submodule `source.archipelago-foundation.org/lfg2025/indeehub.git` (repointed off the retired host — needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls - `146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis, + `source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis, minio, nostr-rs-relay). - Streaming today: FFmpeg → **HLS (.m3u8 + AES-128 .ts segments)** in **MinIO** (`indeedhub-private`/`-public`), metadata in Postgres, transcode queue in Redis, diff --git a/docs/dual-ecash-design.md b/docs/dual-ecash-design.md index 664bcbfe..7911a95f 100644 --- a/docs/dual-ecash-design.md +++ b/docs/dual-ecash-design.md @@ -94,7 +94,7 @@ in sync with the manifest env. clientd is a **client, not the guardian** — it under `image-recipe/_archived/` (likely stale); `first-boot-containers.sh`/`image-versions.sh` are current. - Image: build from source (no official image; `flake.nix` only) → push to vps2 - `146.59.87.168:3000/lfg2025/fedimint-clientd:v0.4.0`. + `source.archipelago-foundation.org/lfg2025/fedimint-clientd:v0.4.0`. ### 5. Unified balance `HomeWalletCard` ecash row = Cashu `wallet.ecash-balance` + Fedimint `wallet.fedimint-balance`. diff --git a/docs/hotfix-process.md b/docs/hotfix-process.md deleted file mode 100644 index 865ceecf..00000000 --- a/docs/hotfix-process.md +++ /dev/null @@ -1,52 +0,0 @@ -# Hotfix Process - -For critical bugs discovered after a tagged release. - -## Severity Classification - -| Level | Response Time | Examples | -|-------|--------------|---------| -| P0 — Critical | < 4 hours | Data loss, security vulnerability, node bricked | -| P1 — High | < 24 hours | App won't start, auth broken, major UI failure | -| P2 — Medium | < 72 hours | Non-critical feature broken, performance regression | -| P3 — Low | Next release | Cosmetic, minor UX, edge cases | - -## Hotfix Workflow - -### 1. Triage -- Reproduce the issue on dev server (192.168.1.228) -- Classify severity (P0-P3) -- P0/P1: proceed immediately. P2/P3: add to the next release (`docs/UNIFIED-TASK-TRACKER.md`). - -### 2. Fix -- Create branch: `hotfix/vX.Y.Z-description` -- Fix the issue with minimal code changes -- Run full test suite: `cd neode-ui && npm test && npm run type-check` -- Deploy to dev server: `./scripts/deploy-to-target.sh --live` -- Verify fix on live server - -### 3. Release -- Merge hotfix branch to `main` -- Tag: `vX.Y.Z` (increment patch version) -- Cut the release with `./scripts/create-release.sh X.Y.Z` (updates - `releases/manifest.json` and signs it) -- Push `main` + tags to the primary Gitea release server so nodes pick it up OTA - -### 4. Communicate -- Update RELEASE-NOTES with hotfix details -- Note in CHANGELOG.md - -## Monitoring Dashboards - -- **Uptime monitor**: `/var/lib/archipelago/uptime-monitor/summary.json` -- **Soak test**: `/tmp/stability-test-*.log` on dev server -- **Health endpoint**: `http://192.168.1.228/health` - -## Rollback - -If a hotfix causes regressions: -1. The updater self-verifies after applying (health check on restart) and rolls the - binary back automatically if the new one fails to come up -2. Point `releases/manifest.json` back at the last-known-good version and push -3. Backend binary backups: `/opt/archipelago/rollback/archipelago.bak` (deploy script) - and `/var/lib/archipelago/update-backup/archipelago.bak` (`self-update.sh`) diff --git a/docs/manifest-hooks-design.md b/docs/manifest-hooks-design.md index 6e0aa17e..262e2889 100644 --- a/docs/manifest-hooks-design.md +++ b/docs/manifest-hooks-design.md @@ -3,7 +3,7 @@ **Status:** implemented through Phase 4 (see §6; updated 2026-07-08) — only declarative `pre_start` remains · originally Task #20 (indeedhub, netbird) off legacy Rust installers. -See `docs/PRODUCTION-MASTER-PLAN.md`, `docs/APP-PACKAGING-MIGRATION-PLAN.md` +See `docs/APP-PACKAGING-MIGRATION-PLAN.md` ("controlled hooks"). --- diff --git a/docs/marketplace-protocol.md b/docs/marketplace-protocol.md index bf96ee9f..b73bd845 100644 --- a/docs/marketplace-protocol.md +++ b/docs/marketplace-protocol.md @@ -10,6 +10,14 @@ purchases). What remains is maturation: publishing tooling and trust UX own flatter format, **not** the runtime `apps/*/manifest.yml` schema (`app-manifest-spec.md`). +> **The DID signature layer is implemented** as of 2026-08-08. `publish` signs +> with the node's Ed25519 identity key, `discover` verifies every manifest +> before caching it, and a manifest whose signature is *present but wrong* is +> dropped rather than listed at a lower score. See +> [Signing Protocol](#signing-protocol) for the exact preimage rules — they are +> normative, and an implementation that canonicalises differently will produce +> signatures this node rejects. + ## Overview Archipelago's community marketplace enables developers to publish app manifests to Nostr relays, where nodes discover and install them without a central app store. Trust is established through DID-signed manifests and community reputation. @@ -37,7 +45,12 @@ Developer Node Nostr Relays User Node ## Manifest Schema -App manifests published to Nostr relays follow the existing `apps/{app-id}/manifest.yml` schema (see `docs/app-manifest-spec.md`), serialized as JSON within a Nostr event. +App manifests published to Nostr relays use the marketplace's own flatter JSON +schema — the `AppManifest` type in `marketplace.rs`, shown below — serialized +into the Nostr event's `content`. It is **not** the runtime +`apps/{app-id}/manifest.yml` schema in +[`app-manifest-spec.md`](app-manifest-spec.md); the two are separate types that +happen to share a name. ### Marketplace Manifest Fields @@ -98,7 +111,7 @@ App manifests published to Nostr relays follow the existing `apps/{app-id}/manif |-------|---------|-------------| | `container.readonly_root` | true | Container root filesystem is read-only | | `container.no_new_privileges` | true | Prevent privilege escalation | -| `container.run_as_user` | 1000 | UID to run as (must be > 1000) | +| `container.run_as_user` | 1000 | UID to run as (must be ≥ 1000) | | `container.capabilities` | [] | Required Linux capabilities (drop all, add only needed) | ## Nostr Event Format @@ -141,13 +154,21 @@ App manifests use **NIP-78 application-specific data** with event kind **30078** ### Publishing a Manifest 1. Developer creates/updates their app manifest -2. Serialize manifest as JSON -3. Compute SHA-256 hash of the serialized manifest -4. Sign the hash with the developer's DID key -5. Embed manifest + signature in Nostr event content -6. Sign the Nostr event with the node's secp256k1 key +2. `author.did` is filled in with the node's own `did:key` if empty. If it is + set to a **different** DID, publishing is refused — the node can only sign as + itself, and broadcasting a manifest every verifier will reject helps nobody +3. Canonicalise the manifest without `signatures` and SHA-256 it (see + [Signing Protocol](#signing-protocol)) +4. Sign the digest with the node's Ed25519 identity key and attach `signatures` +5. Embed the signed manifest as the Nostr event content +6. Sign the Nostr event with the node's secp256k1 Nostr key 7. Publish to all configured Nostr relays +Note the two distinct keys: the **Ed25519 identity key** proves *authorship of +the manifest* and is what `author.did` names; the **secp256k1 Nostr key** proves +*who sent this event*. They are separate on purpose — relaying is not +authorship, and only the first survives being copied between relays. + ### Discovering Manifests 1. Node queries configured relays with filter: @@ -172,13 +193,29 @@ App manifests use **NIP-78 application-specific data** with event kind **30078** Each discovered app receives a trust score (0-100) based on: -| Factor | Weight | Description | -|--------|--------|-------------| -| **DID Verification** | 30 | Manifest is signed by a valid DID key | -| **Relay Consensus** | 20 | Manifest found on multiple independent relays | -| **Federation Trust** | 20 | Developer's DID is in the user's federation network | -| **Version History** | 15 | App has multiple published versions (shows maintenance) | -| **Security Compliance** | 15 | Manifest follows all security requirements | +This table is `calculate_trust_score()` in `marketplace.rs`. What each factor +actually checks: + +| Factor | Max | What is checked | +|--------|-----|-----------------| +| **Identity proven** | 30 | The manifest carries a `valid` DID signature — the author demonstrated control of the key `author.did` encodes. Requires key material; cannot be faked by choosing a string | +| **Relay consensus** | 20 | Graduated, and never zero: 1 relay → 5, 2–3 → 12, 4+ → 20 | +| **Federation trust** | 20 | `author.did` is in the user's federated DID list **and** identity is proven. Both halves are required — see below | +| **Provenance** | 15 | 10 for a 3-part semver `version`, 5 for a non-empty `repo_url`. Nothing counts published versions | +| **Security compliance** | 15 | 15 when `validate_manifest()` returns no issues, 5 when it returns 1–2, 0 otherwise | + +Both identity-derived factors hang off the signature, which is the point: + +- Before, "DID present" was `did.starts_with("did:")`, so an unsigned manifest + with a plausible-looking DID string and a pinned image scored 65 — *Community* + tier — on no cryptography whatsoever. It now scores 35, *Unverified*. +- Federation trust is gated too. An unverified `author.did` is just a string the + publisher chose, so an attacker could otherwise copy the DID of a peer the + user federates with and collect 20 points for impersonating precisely the + party the user trusts most. + +An unsigned publisher is not punished beyond losing those points: `missing` is a +normal state, and such apps still appear. ### Trust Tiers @@ -212,19 +249,44 @@ When a developer's DID appears in the user's federation network (trusted peer), ### Manifest Signing (DID Layer) +**Normative.** These rules define the signed preimage byte-for-byte. An +implementation that canonicalises differently will produce signatures this node +rejects, so they are worth following exactly. + ``` -1. Serialize manifest to canonical JSON (sorted keys, no whitespace) -2. Compute: manifest_hash = SHA-256(canonical_json) -3. Sign: did_signature = Ed25519_Sign(did_private_key, manifest_hash) -4. Attach to manifest: +1. Take the manifest with `signatures` REMOVED (a signature cannot cover the + field that holds it; omit the key entirely rather than setting it null). +2. Canonicalise to JSON: + - every object's keys sorted lexicographically, recursively; + - no insignificant whitespace; + - arrays keep their order. +3. manifest_hash = SHA-256(canonical_json_bytes) +4. did_signature = Ed25519_Sign(author_private_key, manifest_hash) + ^ the signature covers the 32 RAW DIGEST BYTES, not the "sha256:..." + string and not the JSON itself. +5. Attach: { "signatures": { - "manifest_hash": "sha256:", - "did_signature": "" + "manifest_hash": "sha256:<64 lowercase hex chars>", + "did_signature": "" } } ``` +The signing key MUST be the Ed25519 key that `author.did` encodes — `author.did` +is a `did:key` whose multibase body is `0xed01 || <32-byte public key>`. A +publisher signing with any other key produces a manifest that verifies as +`invalid` and is dropped. + +**Why canonicalisation is required and not cosmetic.** `container.env` is a map, +and map iteration order is not stable across processes or implementations. Sign +the serialiser's natural output and the same manifest hashes differently between +runs, so signatures fail at random rather than never — much harder to diagnose +than a clean rejection. Sorting keys removes the ambiguity. + +`archipelago` implements this in `marketplace::canonical_signing_bytes` / +`sign_manifest` / `verify_manifest_signature`. + ### Event Signing (Nostr Layer) Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fields). This is handled by the Nostr client library. @@ -233,16 +295,32 @@ Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fi ``` Receiving Node: - 1. Verify Nostr event signature (NIP-01) → Proves event authenticity - 2. Extract manifest JSON from event content - 3. Compute SHA-256 of manifest content - 4. Compare with manifest.signatures.manifest_hash → Proves content integrity - 5. Resolve DID document for manifest.author.did - 6. Verify did_signature with DID public key → Proves developer identity - 7. Check container.image tag is pinned (not :latest) - 8. Validate security fields meet minimums + 1. Verify Nostr event signature (NIP-01) → event authenticity [IMPLEMENTED] + 2. Extract manifest JSON from event content [IMPLEMENTED] + 3. Canonicalise the manifest without `signatures`, SHA-256 it [IMPLEMENTED] + 4. Compare with manifest.signatures.manifest_hash → content integrity [IMPLEMENTED] + 5. Resolve author.did (did:key) to its Ed25519 public key [IMPLEMENTED] + 6. Verify did_signature over the digest → author identity [IMPLEMENTED] + 7. Check container.image tag is pinned (not :latest) [ADVISORY] + 8. Validate security fields meet minimums [ADVISORY] ``` +Steps 3–6 are `verify_manifest_signature()`, which returns one of three verdicts +rather than a boolean: + +| Verdict | Meaning | What discovery does | +|---|---|---| +| `valid` | Hash matches the content **and** the key named by `author.did` signed it | Listed; earns the identity-derived trust points | +| `missing` | No `signatures` block | Listed, but scores **zero** on identity and federation. An unsigned publisher is unproven, not hostile | +| `invalid` | A `signatures` block is present and wrong — tampered, corrupt, or signed by another key | **Dropped entirely**, with the reason logged. Never cached, never installable | + +That `invalid` handling is deliberate: a broken signature is not a low-quality +manifest, it is a forged or corrupted one, so it fails closed rather than +appearing with a scary badge someone can click past. + +Steps 7–8 still run, but `validate_manifest()` returns a list of *issues* that +feed the trust score — they do not block discovery or installation. + ## RPC Endpoints ### Marketplace Discovery @@ -250,30 +328,67 @@ Receiving Node: | Method | Description | Auth | |--------|-------------|------| | `marketplace.discover` | Query relays for app manifests, verify, score, return sorted | Local | -| `marketplace.publish` | Publish an app manifest to configured relays | Local | +| `marketplace.publish` | Sign the manifest with this node's identity key, then publish to configured relays | Local | | `marketplace.get-manifest` | Get full manifest for a specific app by ID | Local | -| `marketplace.verify` | Verify a manifest's signatures and security compliance | Local | +| `marketplace.verify` | Check a manifest's DID signature and security compliance without publishing it | Local | + +`marketplace.verify` returns the signature verdict separately from the advisory +policy issues, because they mean different things: + +```json +{ + "signature": { "status": "invalid", "reason": "did_signature does not verify against author.did" }, + "signature_valid": false, + "valid": true, // ← policy compliance only; NOT authenticity + "issues": [], + "trust_score": 35, + "trust_tier": "unverified" +} +``` + +`valid` has always meant "passes the advisory security checks". Read +`signature_valid` for authenticity. Discovered apps carry the same verdict in +their `signature` field. ### Manifest Management | Method | Description | Auth | |--------|-------------|------| | `marketplace.list-published` | List manifests published by this node | Local | -| `marketplace.unpublish` | Remove a published manifest from relays | Local | + +### Purchases + +| Method | Description | Auth | +|--------|-------------|------| +| `marketplace.create-invoice` | Create a Lightning BOLT11 invoice for a paid app | Local | +| `marketplace.check-payment` | Poll whether an invoice has settled | Local | + +`marketplace.unpublish` was specified here but **never implemented** — the +string appears nowhere in the codebase, and there is no dispatcher entry. NIP-33 +replaceable events mean an unpublish would have to be a tombstone/replacement +rather than a delete, which is presumably why it stalled. ## Security Requirements ### Container Security Enforcement -Before installing a community app, the node validates: +`validate_manifest()` checks the following and returns them as a list of issues. +**These are score inputs, not gates** — a manifest that fails all of them is +still discoverable and installable, it just scores 0 on the security factor: 1. **No `latest` tag**: Image must use a specific version tag -2. **Read-only root**: `readonly_root` must be true (or explicitly overridden by user) -3. **No root**: `run_as_user` must be > 1000 -4. **No new privileges**: `no_new_privileges` must be true -5. **Minimal capabilities**: Only allowed capabilities are accepted (CHOWN, NET_BIND_SERVICE, etc.) -6. **No host networking**: Apps cannot use `--network host` -7. **Volume restrictions**: Apps cannot mount system paths (/, /etc, /var, /usr) +2. **Read-only root**: `readonly_root` should be true +3. **No root**: `run_as_user` must be **≥ 1000** (the code's bound; the example + manifest above uses exactly `1000`) +4. **No new privileges**: `no_new_privileges` should be true + +Items previously listed here — a capability allow-list, a host-networking ban, +and system-path mount restrictions — are **not** part of marketplace validation. +Those rules exist, but they live in the runtime manifest parser +(`core/container/src/manifest.rs`, see [`app-manifest-spec.md`](app-manifest-spec.md)) +and apply to `apps/*/manifest.yml`, which is a different schema from the +marketplace manifest. Closing that gap is part of the pre-third-party-publishing +work. ### Image Verification @@ -316,22 +431,29 @@ Accessible from Settings or a "Developer" section: ``` /var/lib/archipelago/marketplace/ ├── cache/ - │ ├── manifests.json # Cached discovered manifests - │ └── trust-scores.json # Cached trust scores - ├── published/ - │ └── .json # Manifests published by this node - └── config.json # Marketplace preferences (auto-refresh interval, etc.) + │ └── manifests.json # Cached discovered manifests, trust scores included + └── published/ + └── .json # Manifests published by this node ``` +The earlier version of this tree also listed `cache/trust-scores.json` and +`config.json`. Neither is written: scores live on the cached entries themselves +(`MarketplaceCache`), and there is no marketplace preferences file. + ## Implementation Notes ### Relay Query Strategy -1. Query all enabled relays in parallel (from `nostr_relays.rs` config) +1. Query all enabled relays in parallel (from `nostr_relays.rs` config), with a + 10s connect timeout and a 20s fetch timeout per relay 2. Deduplicate manifests by `app_id` + `version` -3. If same manifest found on multiple relays, boost trust score -4. Cache results with 15-minute TTL -5. Background refresh every 30 minutes +3. If the same manifest is found on multiple relays, boost trust score +4. Write results to `cache/manifests.json` + +Items 4–5 of the original design — a 15-minute cache TTL and a 30-minute +background refresh — are **not implemented**. The cache has no expiry and +nothing refreshes it on a timer; it is rewritten whenever +`marketplace.discover` runs. ### Version Comparison diff --git a/docs/multinode-testing-plan.md b/docs/multinode-testing-plan.md deleted file mode 100644 index bbda64b3..00000000 --- a/docs/multinode-testing-plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# Multinode / Fleet Testing Plan (separate from the single-node gate) - -> **Scope split (2026-06-22):** the production test gate (`docs/PRODUCTION-MASTER-PLAN.md` §5, -> `tests/lifecycle/TESTING.md`) is now a **single-node criterion on .228**. Verifying the same -> lifecycle matrix across the rest of the fleet (.198 and the other testers) lives HERE and is run -> **after** the .228 single-node gate is green. This is intentionally NOT a blocker on the .228 gate. - -## Why split it out - -The lifecycle gate must be **run ON the node under test** — its bitcoin/companion/orphan/endpoint -checks use local `podman`/`systemctl`/`bitcoin-cli`/`curl`, not RPC to a remote host. Running it from -one host against another silently tests the *runner*. So "multinode" isn't "point the harness at N -hosts" — it's "run the on-node gate on each host," plus the genuinely cross-node concerns (federation, -mesh, transport, sync) that a single node can't exercise. - -## How to run the gate on another node - -Bats + jq usually aren't installed on ISO nodes. Bootstrap (one-time per node): - -``` -# from a host that has them (e.g. .116): -dpkg -L bats | grep -E '^/usr/(bin|lib|libexec)' | tar czf /tmp/bats.tgz -P -T - $(which jq) -tar czf /tmp/tests.tgz -C tests/lifecycle -scp /tmp/bats.tgz /tmp/tests.tgz :/tmp/ -# on the node: -sudo tar xzf /tmp/bats.tgz -P -C / # bats (jq here is dynamically linked — may need libs) -sudo curl -fsSL -o /usr/local/bin/jq \ - https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 && sudo chmod +x /usr/local/bin/jq -mkdir -p /tmp/lifecycle-run && tar xzf /tmp/tests.tgz -C /tmp/lifecycle-run -cd /tmp/lifecycle-run/tests/lifecycle -ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=https ARCHY_PASSWORD= \ - ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ITERATIONS=5 nohup ./run-gate.sh > /tmp/gate.log 2>&1 & -``` - -## Per-node preconditions (learned on .228) - -- **Bitcoin must be fully synced + archival** (`initialblockdownload:false`, `pruned:false`). - test 83 reads the *real* `getblockchaininfo`, not the UI's headers-height. A node mid-IBD will - cascade-fail electrumx/lnd/btcpay/mempool even though the apps run. -- **Backends should be proper installs** (in `manifest_ids`), not adopted plain-podman left over - from ad-hoc `package.start`/cascade churn — otherwise companion self-heal and quadlet checks skew. -- **No stale per-app nginx proxy targets.** e.g. `/app/lnd/` must point at the lnd-ui port (18083), - not a stale `8081`. Repo code is correct; old node configs may be stale — re-check + regenerate. -- **No orphan quadlet units** (e.g. a `home-assistant.container` whose ContainerName ≠ the real - `homeassistant` container) — these wedge `systemctl --user` "activating" and fail the quadlet checks. - -## Node roster (carry-over) - -| Node | Role | Notes | -|------|------|-------| -| .228 | **single-node gate** (primary) | 14-app resilience node; bitcoin synced archival; gate GREEN. | -| .198 | fleet verify | was weak/loaded (load ~3–5) + **bitcoin mid-IBD** at split time → must finish syncing first; sshd wedges under concurrent SSH (use ONE session; gate uses HTTPS RPC so fine). | -| .5 / .120 | x250 testers (Tailscale) | flaky cellular; SSH via `tailscale nc` ProxyCommand. | -| .116 | dev/validation | local repo; its own bitcoin may be mid-IBD — do NOT treat as a gate target unless synced. | - -## Cross-node concerns (only a multinode setup can test) - -- Federation sync (Tor/FIPS transports), DID/contact federation, peer file fetch. -- Mesh (Meshtastic/MeshCore) + mesh-AI gating. -- Dual-ecash federation validation + networking-sats routing. -- DHT / iroh swarm distribution (origin-always-wins) once that dep lands. - -## Sequence - -1. Get the **.228 single-node gate green 5×** (master plan §5/§6) — DONE/in progress. -2. THEN: bring each fleet node to the preconditions above; run the on-node gate 5× per node. -3. THEN: the cross-node suites (federation/mesh/transport), tracked here. - -This plan does not gate the v1.7.x single-node criterion; it is the next layer. diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md deleted file mode 100644 index 647288a4..00000000 --- a/docs/operations-runbook.md +++ /dev/null @@ -1,366 +0,0 @@ -# Archipelago Operations Runbook - -Quick reference for common operational tasks on Archipelago nodes. - -**Primary node**: `192.168.1.228` (Arch 1) -**Secondary node**: `192.168.1.198` (Arch 2) -**SSH**: `ssh -i ~/.ssh/archipelago-deploy archipelago@{IP}` -**Sudo**: use the node's sudo password (kept out of this doc — never commit credentials) - ---- - -## 1. Check Node Health - -```bash -# Quick health check (from any machine) -curl http://192.168.1.228/health # Should return "OK" -curl http://192.168.1.198/health - -# Detailed system stats via RPC -curl -s -X POST -H "Content-Type: application/json" \ - -d '{"method":"system.stats"}' \ - http://192.168.1.228:5678/rpc/v1 - -# Check services -ssh archipelago@192.168.1.228 -sudo systemctl status archipelago # Backend service -sudo systemctl status nginx # Web server -sudo systemctl status tor # Tor hidden services -``` - -## 2. Check Container Status - -```bash -# List all containers -podman ps -a - -# Running count -podman ps --format '{{.Names}}' | wc -l - -# Find exited/crashed containers -podman ps -a --filter status=exited - -# Container logs -podman logs {container-name} --tail 50 - -# Container resource usage -podman stats --no-stream -``` - -## 3. Fix Crashed Containers - -```bash -# Restart a specific container -podman restart {container-name} - -# If container won't start, check logs first -podman logs {container-name} --tail 100 - -# Remove and recreate (last resort) -podman rm -f {container-name} -# Then redeploy with: ./scripts/deploy-to-target.sh --live - -# The health monitor auto-restarts containers every 60s -# Check its status: -sudo journalctl -u archipelago --grep="health_monitor" --no-pager -n 20 -``` - -## 4. Add/Remove Federation Peers - -```bash -# Generate invite code (on inviting node) -# Via UI: Federation page > Generate Invite -# Via RPC: -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"federation.invite"}' \ - http://localhost:5678/rpc/v1 - -# Join federation (on joining node) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"federation.join","params":{"invite_code":"{code}"}}' \ - http://localhost:5678/rpc/v1 - -# List peers -curl -s -X POST -H "Content-Type: application/json" \ - -d '{"method":"federation.list-nodes"}' \ - http://localhost:5678/rpc/v1 - -# Remove a peer -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"federation.remove-node","params":{"did":"{peer-did}"}}' \ - http://localhost:5678/rpc/v1 -``` - -## 5. Rotate Tor Address - -```bash -# Delete current hidden service keys -sudo rm -rf /var/lib/tor/hidden_service/ -sudo systemctl restart tor - -# Wait for new hostname -sleep 15 -sudo cat /var/lib/tor/hidden_service/hostname - -# The backend picks up the new address automatically (30s refresh) -# Federation peers need to re-discover via sync -``` - -## 6. Create/Restore Backups - -```bash -# Create encrypted backup (via RPC) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.create","params":{"passphrase":"your-passphrase","description":"manual backup"}}' \ - http://localhost:5678/rpc/v1 - -# List backups -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.list"}' \ - http://localhost:5678/rpc/v1 - -# Verify backup integrity -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.verify","params":{"id":"{backup-id}","passphrase":"your-passphrase"}}' \ - http://localhost:5678/rpc/v1 - -# Restore (warning: overwrites current identity/data) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.restore","params":{"id":"{backup-id}","passphrase":"your-passphrase"}}' \ - http://localhost:5678/rpc/v1 - -# Backup files stored at: /var/lib/archipelago/backups/ -``` - -## 7. Update the Node - -```bash -# From development machine: -./scripts/deploy-to-target.sh --live # Deploy to .228 -./scripts/deploy-to-target.sh --both # Deploy to both nodes -./scripts/deploy-to-target.sh --dry-run --live # Preview changes - -# The deploy script: -# 1. Syncs code to target -# 2. Builds frontend (vue-tsc + vite) -# 3. Builds backend (cargo build --release) -# 4. Deploys binary, frontend, configs -# 5. Restarts services -# 6. Verifies health -``` - -## 8. Diagnose High CPU - -```bash -# Check system load -uptime - -# Find CPU-heavy processes -top -b -n 1 | head -15 - -# Check container CPU usage -podman stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' - -# Common causes: -# - Bitcoin IBD (initial block download): normal, takes days -# - Container crash loops: check `podman ps -a --filter status=exited` -# - mempool-electrs indexing: normal after Bitcoin sync -``` - -## 9. Diagnose High Memory - -```bash -# Check memory -free -h - -# Check swap usage -swapon --show - -# Per-container memory -podman stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}' - -# Check for OOM kills -dmesg --level=err,crit | grep -i oom - -# Add swap if missing -sudo fallocate -l 4G /swapfile -sudo chmod 600 /swapfile -sudo mkswap /swapfile -sudo swapon /swapfile -echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab -``` - -## 10. Diagnose Disk Space - -```bash -# Disk usage overview -df -h / - -# Find large directories -sudo du -h --max-depth=2 /var/lib/archipelago/ | sort -rh | head -20 - -# Container image sizes -podman images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' - -# Clean unused images -podman image prune -a - -# Clean old journal logs -sudo journalctl --vacuum-size=500M -``` - -## 11. Check Tor Connectivity - -```bash -# Tor service status -sudo systemctl status tor - -# Get onion address -sudo cat /var/lib/tor/hidden_service/hostname - -# Test self-connection via Tor -curl --socks5-hostname 127.0.0.1:9050 http://$(sudo cat /var/lib/tor/hidden_service/hostname)/health - -# Test cross-node Tor -curl --socks5-hostname 127.0.0.1:9050 http://{peer-onion}/health -``` - -## 12. Check DWN Sync - -```bash -# DWN status (via RPC, needs auth) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"dwn.status"}' \ - http://localhost:5678/rpc/v1 - -# Trigger manual sync -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"dwn.sync"}' \ - http://localhost:5678/rpc/v1 - -# Check message count -ls /var/lib/archipelago/dwn/messages/ | wc -l -``` - -## 13. Restart Services - -```bash -# Restart backend only -sudo systemctl restart archipelago - -# Restart nginx -sudo systemctl restart nginx - -# Restart Tor -sudo systemctl restart tor - -# Full service restart (backend + nginx) -sudo systemctl restart archipelago nginx - -# Reboot (containers auto-recover via restart policy + health monitor) -sudo reboot -``` - -## 14. View Logs - -```bash -# Backend logs -sudo journalctl -u archipelago --no-pager -n 100 - -# Follow logs in real time -sudo journalctl -u archipelago -f - -# Nginx access log -sudo tail -f /var/log/nginx/access.log - -# Nginx error log -sudo tail -f /var/log/nginx/error.log - -# Container logs -podman logs {container-name} --tail 50 -f -``` - -## 15. Network Diagnostics - -```bash -# Check listening ports -sudo ss -tlnp - -# Check firewall rules -sudo ufw status verbose - -# Required ports: -# 22 - SSH -# 80 - HTTP (nginx) -# 443 - HTTPS (nginx) -# 5678 - Backend API (localhost only, proxied by nginx) -# 8332 - Bitcoin RPC (container network only) -# 9050 - Tor SOCKS proxy (localhost only) - -# If ports are blocked after reboot, re-add UFW rules: -sudo ufw allow ssh -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp -sudo ufw allow from 10.88.0.0/16 # Podman container subnet -sudo ufw allow from 10.89.0.0/16 # Podman container subnet -``` - -## 16. Emergency: Node Won't Boot - -If a node responds to ping but SSH/HTTP are down: - -1. **Check UFW**: After reboot, UFW may block all ports - ```bash - # If you have console access: - sudo ufw allow ssh - sudo ufw allow 80/tcp - sudo ufw allow 443/tcp - sudo ufw reload - ``` - -2. **Check services**: SSH or nginx may not have started - ```bash - sudo systemctl start ssh - sudo systemctl start nginx - sudo systemctl start archipelago - ``` - -3. **Check disk**: If root filesystem is full, services won't start - ```bash - df -h / - sudo journalctl --vacuum-size=200M - podman image prune -a - ``` - -## 17. Run Tests - -```bash -# Production lifecycle gate — run ON the node (uses local podman/systemctl): -tests/lifecycle/run-gate.sh # see tests/lifecycle/TESTING.md -ARCHY_ITERATIONS=5 tests/lifecycle/run-gate.sh - -# Cross-node suites (federation/mesh): -tests/multinode/smoke.sh # see docs/multinode-testing-plan.md - -# E2E / post-install: -./scripts/run-e2e-tests.sh -./scripts/run-post-install-tests.sh -``` diff --git a/docs/phase4-streaming-ecash-plan.md b/docs/phase4-streaming-ecash-plan.md index 16c7c8a3..cb0905c9 100644 --- a/docs/phase4-streaming-ecash-plan.md +++ b/docs/phase4-streaming-ecash-plan.md @@ -1,6 +1,15 @@ # Phase 4+ — Paid swarm streaming & the IndeeHub "Archipelago" source -**Status:** PLAN / design (2026-06-17) · **Branch:** `agent-trust-wip` · not implemented +**Status:** PLAN / design (2026-06-17) · **Branch:** `agent-trust-wip` · +**partly implemented — "not implemented" was stale.** The paid-serving half +landed on main: `core/archipelago/src/swarm/paid.rs` says in its own header that +it is "DHT distribution plan, Phase 4 step F", with `paid_alpn.rs` and +`payment.rs` alongside it, a `streaming::` module, and the +`streaming.list-services` / `configure-service` / `toggle-service` / `pay` / +`prepare-payment` RPCs. It is doubly default-off: the swarm needs the +`iroh-swarm` cargo feature plus `config.swarm_enabled`, and serving stays free +for everyone until the operator enables the `content-download` service. Check +those gates before assuming any step below is live or dead. **Builds on:** `docs/dht-distribution-design.md` (Phases 0–3, swarm + Blossom), the Phase 3 swarm work just landed (`swarm/`, `content_hash.rs`, `trust/`). diff --git a/docs/pine-voice-release-test-plan.md b/docs/pine-voice-release-test-plan.md deleted file mode 100644 index 6b7e024a..00000000 --- a/docs/pine-voice-release-test-plan.md +++ /dev/null @@ -1,60 +0,0 @@ -# Framework PT test plan — Pine voice epic (pre-release gate) - -Target node: **framework-pt** (`100.65.115.109`, LAN 192.168.1.249). Run after -BOTH agents' work is merged, with the dev binary sideloaded and the signed -catalog (pine 1.3.0 + pine-openwakeword) published. Every ❑ must pass before -the release ritual starts. Items marked **(user)** need a human in the room. - -## A. Deploy / prerequisites -- ❑ A1 Dev binary sideloaded, `archipelago` service active, no crash-loop in journal. -- ❑ A2 nginx self-heal added `location /api/pine/status` to every server block; `nginx -t` passes; nginx reloaded. -- ❑ A3 Signed catalog with pine 1.3.0 + pine-openwakeword live at the raw URL; node refreshed it (hourly sweep or "Check for updates"). - -## B. `/api/pine/status` endpoint -- ❑ B1 Public tier through nginx (`curl http://127.0.0.1/api/pine/status`): version, uptime, bitcoin height/sync_percent/peers, mesh peers. `lightning` null, `mesh_message` absent. -- ❑ B2 Wrong bearer token → still public-only (no balances). Correct token (from `/var/lib/archipelago/secrets/pine-status-token`) → lightning balances + latest mesh message present. -- ❑ B3 Reachable from inside the HA container via `host.containers.internal:80`. -- ❑ B4 Token file is 0600, owned by the service user. - -## C. Stack / openwakeword container -- ❑ C1 Reconcile installs `pine-openwakeword` (wyoming-openwakeword 2.1.0), healthy on :10400. -- ❑ C2 Existing pine-whisper / pine-piper / pine were ADOPTED, not recreated — model data dirs untouched. -- ❑ C3 `archipelago` service restart → all four pine containers come back (crash-recovery stack spec). -- ❑ C4 UI: openwakeword listed under Services (no extra store card); Pine card shows 1.3.0. - -## D. Home Assistant seeding -- ❑ D1 configuration.yaml: legacy hand-staged block (bitcoind :18332 + plaintext RPC creds) fully replaced by the bounded token-based block. -- ❑ D2 `custom_sentences/en/archy.yaml` carries all four intents. -- ❑ D3 `.storage/core.config_entries`: wyoming entry for openwakeword (:10400) + `anthropic` entry (Claude, conversation + ai_task subentries). -- ❑ D4 Pipeline: `conversation_engine = conversation.claude_conversation`, `prefer_local_intents: true`. -- ❑ D5 automations.yaml: `archy_mesh_announce` seeded. -- ❑ D6 HA restarts clean — no setup errors for anthropic / wyoming / rest / intent_script in `podman logs homeassistant`. -- ❑ D7 Sensors report real values: archy_block_height, archy_bitcoin_sync, archy_bitcoin_peers, archy_mesh_peers, archy_lightning_balance (or clean unavailable if LND absent), archy_mesh_message. - -## E. Voice / intents (API level first, then live speaker) -- ❑ E1 Exact phrase "what's the block height" → answered by the LOCAL intent (correct height, no Anthropic API call in HA logs). -- ❑ E2 Fuzzy phrase (e.g. "how tall is the chain right now") → Claude routes to the ArchyBlockHeight tool; answer contains the real height. -- ❑ E3 "how many peers", "is the node synced", "what's my lightning balance" → correct spoken-length answers. -- ❑ E4 Off-topic question → Claude answers, 1–2 sentences, no markdown. -- ❑ E5 **(user)** Live speaker: "Hey Jarvis, what's the block height" → audible correct answer. -- ❑ E6 Mesh announce: new received mesh text (or manual `assist_satellite.announce` if no radio) → speaker announces sender + text; no announce storm on HA restart. - -## F. Pine launcher page (1.3.0) -- ❑ F1 Page on :10380→:10381 shows the live node card (version, uptime, block, sync, peers) within ~5s. -- ❑ F2 `/node-status` proxy works (pine nginx resolves host.containers.internal at startup — container must not crash-loop). -- ❑ F3 "Connect Pine to WiFi" provisioner still intact (no JS errors on load). - -## G. Cleanup / regression sweep -- ❑ G1 Both stray socat 18332 forwarders killed; sensors still work via the endpoint. -- ❑ G2 No bitcoind RPC credentials anywhere in HA config. -- ❑ G3 Pre-existing HA function intact: whisper/piper entities, PineVoice satellite pairing, other integrations. -- ❑ G4 nginx regressions: `/health`, `/bitcoin-status`, `/api/app-catalog`, `/proxy/lnd/` all still proxied post-patch. -- ❑ G5 **(user)** Mobile Home: wallet card sits directly under My Apps; desktop layout unchanged. -- ❑ G6 Other agent's changes re-verified after merge (their own checklist). - -## H. Production-readiness (release ritual gate) -- ❑ H1 `cargo test` workspace green; frontend builds; drift check `--release --strict` green. -- ❑ H2 `tests/lifecycle/run-gate.sh` re-run ON .228 (stack membership changed → lifecycle gate rule applies). -- ❑ H3 Catalog regenerated → signed (ceremony) → published via gitea-ai; verified at the raw URL. -- ❑ H4 Changelog (layman-readable) + `scripts/sync-whats-new.py` + version bump; release ritual per v1.7.110 notes (push main via gitea-ai BEFORE publish; sign manifest AFTER create-release). -- ❑ H5 No secrets in any commit; frontend tarball flat + APK policy per release notes. diff --git a/docs/qr-scanner-snappiness-handover.md b/docs/qr-scanner-snappiness-handover.md deleted file mode 100644 index f53cd4a7..00000000 --- a/docs/qr-scanner-snappiness-handover.md +++ /dev/null @@ -1,82 +0,0 @@ -# QR scanner snappiness — research + companion-dev handover - -*2026-07-29. Owner: web side = node repo (this doc's "web" items); native side = -companion app dev (Mac). Backlog origin: UNIFIED-TASK-TRACKER "optimise -companion QR scan (quicker start/decode, low-light)".* - -## Where scanning happens today - -| Path | Stack | Used when | -|---|---|---| -| Web live scan | nimiq `qr-scanner` 1.4.x over `getUserMedia`, in `WalletScanModal.vue` | HTTPS browsers / secure contexts | -| Photo fallback | `` photo → `BarcodeDetector` if present, else `qr-scanner.scanImage` multi-pass (`decodePhotoRobust`) | Plain-http (LAN) where `getUserMedia` doesn't exist | -| Native scan | `ArchipelagoQr` JS bridge → companion's native scanner (0.5.22 fixed dense invoice QRs) | Inside the companion app | - -## What makes it feel slow (ranked) - -1. **Camera cold-start** — the stream starts only after the user reaches the - scan pane; on phones `getUserMedia` + first frame is routinely 600–1500ms, - and the native path pays a similar CameraX bind + ML Kit model cold-start. -2. **Decode cadence** — web live scan was capped at 4 scans/sec (WebView - preview lagged at 10/s when decoding on the JS worker). A hand-held code - therefore waits up to 250ms *after* it's already sharp and centered. -3. **Low light / focus hunting** — no torch control anywhere; no explicit - continuous-focus request. Dense LN invoices need sharpness more than - resolution. -4. **Dense-QR decode budget** — big bolt11/catalog QRs push the JS decoder - hard; the native ML Kit path is far better at these (proven by 0.5.22). - -## Web side (node repo — can be done here) - -- ✅ DONE (2026-07-29): scan at **10/s when `BarcodeDetector` exists** (Chrome/ - Android WebView decode natively — cheap), keep 4/s only for the JS-worker - fallback. -- **Pre-warm the camera**: start `getUserMedia` the moment the modal opens - (action pane), not when the scan pane is reached — hide the preview until - needed. Saves the entire cold-start from the user's perceived timeline. -- **Torch toggle**: `qr-scanner` exposes `hasFlash()/turnFlashOn()` — add a 🔦 - button on the scan pane (it silently no-ops where unsupported). -- **Continuous focus + modest resolution**: pass constraints - `{ focusMode: 'continuous', width: { ideal: 1280 } }` — 720p-class frames - start faster AND decode faster than 1080p+, with no loss for QR density - that matters to us. -- **Don't stop/start between panes**: returning from amount → scan currently - re-inits the scanner; keep the (paused) stream alive while the modal lives. - -## Native side (companion dev handover) - -The `ArchipelagoQr` bridge overlay is the right architecture — these are -tuning items inside the native scanner activity: - -1. **Pre-warm CameraX + ML Kit**: bind the camera provider and instantiate - `BarcodeScanning.getClient(...)` when the WebView *requests* the overlay — - or even when the wallet modal opens (add a `ArchipelagoQr.prewarm()` bridge - method; the web side will call it if present). ML Kit's first-inference - model load is 100–300ms — pay it before the user aims. -2. **Restrict formats**: `BarcodeScannerOptions` with `FORMAT_QR_CODE` only — - skipping the other symbologies measurably cuts per-frame latency. -3. **Analysis resolution ≈ 1280×720** with - `ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST` — never queue stale frames; - decode the newest one only. -4. **Continuous autofocus + tap-to-focus** on the preview, and a **torch - toggle** (low-light was an explicit user complaint). -5. **`zoomRatio` nudge for small codes**: if no hit after ~2s, step zoom to - 1.5× — helps distant/small printed codes without user action. -6. **Success haptic + instant dismiss**: vibrate on decode and close the - overlay immediately; perceived speed is heavily back-loaded. -7. Optional: **ML Kit `enableAllPotentialBarcodes` off** and skip inverted - scans unless first pass fails (inverted QRs are rare; halves work). - -## Acceptance criteria - -- Cold open → first successful scan of a normal invoice QR in **< 2s** on the - companion app, **< 3s** in a mobile browser. -- Dense (700+ char) bolt11 QR decodes in **< 1.5s** once framed, both paths. -- Dim-room scan succeeds with the torch toggle without leaving the scanner. - -## Verification notes for whoever implements - -- Measure with a timestamp log: overlay-requested → camera-first-frame → - decode-success. The three deltas map 1:1 onto items above. -- Web `BarcodeDetector` presence differs per WebView/Play-Services build — - keep the JS-worker fallback path intact. diff --git a/docs/quadlet-compilation.md b/docs/quadlet-compilation.md new file mode 100644 index 00000000..a98aa486 --- /dev/null +++ b/docs/quadlet-compilation.md @@ -0,0 +1,126 @@ +# Manifest → Quadlet unit + +How an app manifest becomes a Podman [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) +`.container` unit that systemd owns, where the unit lands, and how to inspect one. +Source of truth: `core/archipelago/src/container/quadlet.rs`. + +## Why Quadlet + +Containers used to be fire-and-forget `tokio::spawn` blocks. If the daemon +crashed mid-spawn or the kernel reaped a parent cgroup, the container vanished +from `podman ps` and only a manual `podman run` brought it back. Quadlet removes +that whole class of failure: the unit lives on disk, **systemd owns +start/restart, and archipelago is just the provisioner**. This is the path that +runs the companion UI containers today (`archy-bitcoin-ui`, `archy-lnd-ui`, +`archy-electrs-ui`), and the validated path being flipped to default for apps. + +## What gets generated + +`Quadlet::from_manifest(manifest, name)` translates a manifest into a unit, and +`render()` produces the file. Every unit carries a header making clear it is not +hand-edited: + +```ini +# Generated by archipelago. DO NOT EDIT. +# Edits are overwritten on the next reconcile. + +[Unit] +Description= +After=network-online.target +Wants=network-online.target +Requires=.service # one per declared dependency +After=.service + +[Container] +ContainerName= +Image= +Pull=never # image must be present locally already +Network= +User= # when the manifest pins one +DropCapability=ALL # security default +AddCapability= # only capabilities the manifest opts into +PublishPort=::/ +Environment== # non-secret env only +Secret=,type=env,target= # secrets by REFERENCE, never value +Volume=: +ReadOnly=true # when security.readonly_root +NoNewPrivileges=true # when security.no_new_privileges +HealthCmd= # from the health_check block + +[Service] +TimeoutStartSec=0 +Restart= # from the restart policy +RestartSec=10 # 10s backoff caps a crash loop + +[Install] +WantedBy=default.target +``` + +Two things to note in that mapping: + +- **Secrets go in by reference, never by value.** A `secret_env` entry renders as + `Secret=,type=env,target=`, so podman injects the value at run time + from the node's secret store. The plaintext never appears in the unit file. See + [App secrets](secrets.md). +- **`Pull=never` is deliberate.** The provisioner does not pull images from here; + the image must already be local (pre-pulled or built). A missing image surfaces + immediately instead of retrying silently behind systemd's restart loop. +- **`PublishPort` is dropped entirely under `Network=host`.** Podman rejects the + combination and the container crash-loops on exit 125, so declared ports are + omitted rather than rendered. With host networking the container is already on + the host's ports; a manifest that declares both is not an error, the mapping is + just silently unnecessary. + +## Where units land + +Rootless, per-user, under the archipelago service user (uid 1000, with linger +enabled so the units run without an active login): + +``` +~/.config/containers/systemd/.container +``` + +Quadlet's systemd generator translates `.container` into a +`.service` unit at **daemon-reload** time. Everything is `systemctl --user` +— the system bus is never touched from this path. + +## Lifecycle: render → write → enable → disable + +The module does four things and nothing else: + +1. **render** — manifest → unit text (above). +2. **write** — `tempfile + rename` so a partially-written unit is never visible to + systemd, and `write_if_changed` compares bytes first: if the rendered unit + matches what is on disk, nothing is touched — no daemon-reload, no restart + cascade. This is what makes a reconcile tick cheap and non-disruptive. +3. **enable** — `daemon-reload` then start the `.service`. +4. **disable** — stop and remove. + +## Inspecting a unit + +Run these **as the archipelago service user** (the units are in its user bus): + +```bash +# the generated unit +cat ~/.config/containers/systemd/archy-bitcoin-ui.container + +# what systemd made of it +systemctl --user cat archy-bitcoin-ui.service +systemctl --user status archy-bitcoin-ui.service +journalctl --user -u archy-bitcoin-ui.service + +# after editing a unit by hand for debugging (it will be overwritten on reconcile) +systemctl --user daemon-reload +``` + +Because the unit is regenerated on every reconcile, the way to change a +container's shape is to change its **manifest** (and, for a catalog-covered app, +regenerate and re-sign the catalog), never to edit the `.container` file — the +`DO NOT EDIT` header is literal. + +## Related + +- [Container lifecycle](container-lifecycle.md) — the reconciler that drives this +- [App Manifest Specification](app-manifest-spec.md) — the manifest fields mapped above +- [App secrets](secrets.md) — how `Secret=` references resolve +- [ADR-001: Podman over Docker](adr/001-podman-over-docker.md) diff --git a/docs/registry-manifest-design.md b/docs/registry-manifest-design.md index 63f019ea..f501f3c3 100644 --- a/docs/registry-manifest-design.md +++ b/docs/registry-manifest-design.md @@ -9,18 +9,30 @@ signed app-catalog on the registry — **no OS-level code reliance, no OTA-shipped disk manifest required**. Rootless, signed, robust, reboot-survivable. See also: [`docs/dht-distribution-design.md`](dht-distribution-design.md) (this is -its "discovery/authenticity" layer), `MEMORY → project_manifest_driven_north_star`. +its "discovery/authenticity" layer). --- -## 1. Where we are today +## 1. Where we started (the pre-Phase-1 baseline) -Two distinct mechanisms, only one of which is registry-distributed: +This section is the problem statement the design was written against, kept for +context. **It no longer describes the running system** — Phases 1–3 shipped, so +see "Where we are now" below. -| Thing | Source | Reaches node via | Carries | +Two distinct mechanisms, only one of which was registry-distributed: + +| Thing | Source | Reaches node via | Carried | |-------|--------|------------------|---------| -| `apps/*/manifest.yml` (48) | repo working tree | **OTA**: `self-update.sh` rsyncs `apps/ → /opt/archipelago/apps/` | full manifest (the orchestrator's real source of truth) | -| `app-catalog.json` (28) | `releases/app-catalog.json` | **registry HTTP fetch**, hourly, **signed** (`app_catalog::refresh_catalog`) | version + image override only | +| `apps/*/manifest.yml` | repo working tree | **OTA**: `self-update.sh` rsyncs `apps/ → /opt/archipelago/apps/` | full manifest (the orchestrator's real source of truth) | +| `app-catalog.json` | `releases/app-catalog.json` | **registry HTTP fetch**, hourly, **signed** (`app_catalog::refresh_catalog`) | version + image override only | + +### Where we are now + +`releases/app-catalog.json` carries 66 entries, and 56 of them embed a full +`manifest` block — one for every `apps/*/manifest.yml` in the tree. So the +"catalog carries an image override only" gap below is closed for image-only +apps; what remains is build-context apps (Phase 4) and dropping `apps/` from the +OTA rsync (Phase 5). - Orchestrator registry = in-memory `state.manifests: HashMap`, populated by `ProdContainerOrchestrator::load_manifests()` walking the disk dir. @@ -44,7 +56,8 @@ binary OTA, no disk manifest. publisher: apps/*/manifest.yml ──generate──▶ releases/app-catalog.json (embeds + signs) node: refresh_catalog() ──fetch+verify──▶ /app-catalog.json load_manifests() ──merge──▶ state.manifests (catalog wins; disk = fallback) - install(app_id) ──▶ render Quadlet unit (rootless, systemd-managed) + install(app_id) ──▶ create the rootless container (Quadlet unit when + use_quadlet_backends is on; podman create+start otherwise) ``` ## 3. Schema change (`app_catalog::AppCatalogEntry`) @@ -143,6 +156,8 @@ Add a generator (extend `create-release.sh` / a small `scripts/gen-app-catalog`) separate signed blob? Inline is simplest for Phase 1; hashing aligns with the DHT image-by-digest plan and keeps the catalog small. Lean inline now, revisit at Phase 4 when build contexts (large) need addressing anyway. -- `generated_files` with inline content (vs. source-dir) — already supported in the - manifest schema? If so, registry manifests can carry small rendered files inline, - removing another disk dependency. +- ~~`generated_files` with inline content (vs. source-dir) — already supported in + the manifest schema?~~ **Answered: yes.** `app.files[]` takes inline `content` + (with `{{HOST_IP}}` / `{{NETWORK_GATEWAY}}` / `{{secret:NAME}}` rendering), so + registry manifests already carry small rendered files inline and that disk + dependency is gone. See [`app-manifest-spec.md`](app-manifest-spec.md). diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 00000000..5009fce5 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,117 @@ +# App secrets + +How an app declares a secret, how Archipelago materialises it, and how it +reaches the container — with the rules a developer must not break. + +The whole point: **an app never ships a credential.** It declares the *shape* of +the secrets it needs, and the node generates a fresh, per-install value that +never leaves the node and is never logged. Source of truth: +`core/archipelago/src/container/secrets.rs` and the manifest schema in +`core/container/src/manifest.rs`. + +## The two halves + +A secret has a producer and a consumer, and they are separate manifest fields: + +- **`generated_secrets`** — *produce* a random value into a file. +- **`secret_env`** — *inject* a file's contents into the container as an env var. + +An app can use either alone. A generated secret with no consumer is just a file +on the node; a `secret_env` with no matching `generated_secrets` reads a file +that some other component (or the daemon) is expected to have written. + +## Declaring a generated secret + +```yaml +container: + generated_secrets: + - name: btcpay-db-password + kind: hex16 + - name: fedimint-gateway-hash + kind: bcrypt +``` + +`name` is a **bare filename** under the node's secrets directory +(`/var/lib/archipelago/secrets/`). It is validated at manifest-load time — no +`/`, no `..` — so a manifest cannot write outside that directory. + +`kind` chooses how the value is produced. Each kind is deterministic in *shape* +(the orchestrator knows exactly which files it will create) but random in value: + +| `kind` | Value | Files written | Use for | +|---------|-----------------------------------------|-----------------------------------|---------| +| `hex16` | 16 random bytes, lowercase hex (32 ch) | `` | service passwords, API tokens | +| `hex32` | 32 random bytes, lowercase hex (64 ch) | `` | longer keys/cookies | +| `base64`| 32 random bytes, standard base64 (44 ch)| `` | services that base64-decode their key (e.g. netbird relay `authSecret`) | +| `bcrypt`| a random password **and** its bcrypt hash| `` (hash) + `.pw` (plaintext) | server configured with a hash, client needs the plaintext | + +`bcrypt` is the only kind that writes two files: `` holds the bcrypt hash a +server is configured with, and `.pw` holds the plaintext for any client +that must authenticate against it. A `secret_env` injects whichever of the two it +references. + +## Injecting a secret into the container + +```yaml +container: + secret_env: + - key: BTCPAY_DB_PASS + secret_file: btcpay-db-password +``` + +At apply time the orchestrator reads `/var/lib/archipelago/secrets/` +and makes it available in the container as ``. It does **not** do this by +adding `KEY=value` to the environment — that value would show up in +`podman inspect` output and, on the Quadlet path, as a plaintext `Environment=` +line in a unit file on disk. Instead the resolved pairs are registered as podman +secrets named `archy-env--` and referenced by name, so the value +never lands in the manifest, a unit file, `podman inspect`, or a log line. + +**Interpolation taints.** A plain `environment` entry that interpolates a secret +— e.g. BTCPay's `ConnectionString=...Password=${BTCPAY_DB_PASS}` — is treated as +secret-bearing itself and travels the same protected path, rather than being +left in the clear because it was declared under `environment`. So you can build +connection strings from secrets without leaking them. + +## How materialisation works + +`ensure_generated_secrets()` runs on **every install and reconcile tick**, before +`secret_env` is resolved. It is idempotent and self-healing: + +1. **Fast path.** If every target file for a secret already exists, is readable + by the service user, and is non-empty, it is left untouched. A secret is + generated **once** and then persists across restarts, updates and reinstalls — + this is what makes credentials stable (migrations never regenerate a working + secret out from under a database). +2. **Self-heal.** A target file that exists but is unreadable or empty — e.g. + left root-owned by a botched earlier write — is removed and recreated, owned + by the service user. The unlink uses the secrets directory's own write bit, so + recovery needs no privilege escalation. +3. **Write.** New values are written through an atomic `0600` writer: a temp file + in the same directory, fsynced, then renamed over the target, so a reader never + sees a half-written secret and the file is only ever readable by its owner. + +Because it runs every tick and no-ops when the secret is healthy, calling it is +always safe; there is no separate "provision secrets" step to forget. + +## Rules a developer must not break + +- **Never hardcode a credential**, in the manifest or in code, even as a + fallback. A shared fallback password means everyone holding a copy of the repo + holds that credential. Declare `generated_secrets` instead. +- **Never log a secret.** `secret_env` values and the files under the secrets + directory stay out of logs, error messages and status output. +- **One canonical name.** The orchestrator, first-boot script, reconcile path and + any deploy tooling must all reference a secret by the *same* filename. A + producer writing `-password` while the consumer reads `-hash` yields a + service that authenticates against a credential nothing generated. +- **Pick the encoding the service expects.** `hex*` and `base64` decode to + different bytes; a service that base64-decodes its configured key must be given + a `base64` secret, or it will run with the wrong key material. + +## Related + +- [App Manifest Specification](app-manifest-spec.md) — the full manifest schema +- [ADR-009: Manifest-Level Container Security](adr/009-manifest-container-security.md) +- [Entropy Enforcement (KEY-05)](security/KEY-05-ENTROPY-ENFORCEMENT.md) — why secret + generation draws from an explicitly-named CSPRNG diff --git a/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md b/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md index f3ef0ef8..55b5cc1b 100644 --- a/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md +++ b/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md @@ -1,7 +1,7 @@ # The Bitcoin RPC proxy that stayed open after it was fixed **Status:** code fix committed (`f6b5245b`); on-node verification recorded below. -**Found:** 2026-08-02, archi-dev-box, while verifying `a05956c4` instead of assuming it. +**Found:** 2026-08-02, a test node, while verifying `a05956c4` instead of assuming it. **Severity:** critical on any affected node — unauthenticated control of Bitcoin Core RPC through a proxy that injects the node's own credentials. @@ -25,9 +25,9 @@ cookies, no credentials: | Probe | Result | |---|---| -| `GET http://192.168.63.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}` — **closed** | -| `POST http://192.168.63.240:8334/bitcoin-rpc/` (`getblockcount`) | `200` — `{"result":960774,"error":null}` — **OPEN** | -| `OPTIONS http://192.168.63.240:8334/bitcoin-rpc/` | `204` with `Access-Control-Allow-Origin: *` — **OPEN** | +| `GET http://192.0.2.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}` — **closed** | +| `POST http://192.0.2.240:8334/bitcoin-rpc/` (`getblockcount`) | `200` — `{"result":960774,"error":null}` — **OPEN** | +| `OPTIONS http://192.0.2.240:8334/bitcoin-rpc/` | `204` with `Access-Control-Allow-Origin: *` — **OPEN** | The rendered config on disk, `/var/lib/archipelago/bitcoin-ui/nginx.conf`, was dated **2026-06-30** — the pre-fix version, with no `auth_request` and with the wildcard CORS @@ -79,7 +79,7 @@ Deliberately narrow: survived. A new regression test pins the whole chain: stale conf in, gate present out, container restarted, nothing created. -## What actually closed it on archi-dev-box — and what that does NOT prove +## What actually closed it on a test node — and what that does NOT prove Sequence, from file mtimes, container start times and the daemon journal: @@ -88,7 +88,7 @@ Sequence, from file mtimes, container start times and the daemon journal: | 18:33 | Probe: `POST /bitcoin-rpc/` → `200` with a real block height. Exposure confirmed live. | | 18:36 | A **separate rebuild of bitcoin-ui**, done outside this work, rendered the fixed conf and recreated `archy-bitcoin-ui`. `:8334` closes here. | | 19:06 | The binary carrying `f6b5245b` is installed and the daemon restarted. | -| 19:12 | Probe: `POST /bitcoin-rpc/` → `401`. `OPTIONS` now returns `Access-Control-Allow-Origin: http://192.168.63.240:8334`, not `*`. | +| 19:12 | Probe: `POST /bitcoin-rpc/` → `401`. `OPTIONS` now returns `Access-Control-Allow-Origin: http://192.0.2.240:8334`, not `*`. | So the node is closed, and the fixed template is proven to work end to end on real hardware — but **the reconcile fix itself was never exercised.** By the time it was @@ -104,7 +104,7 @@ Tracked as broken window 15 — **since closed by the controlled test below.** ## Proving the delivery path on real hardware -Run on archi-dev-box, 2026-08-02 20:00–20:03 EDT, with operator approval. The point was to +Run on a test node, 2026-08-02 20:00–20:03 EDT, with operator approval. The point was to prove the thing the incidental rebuild had made unprovable: that **reconcile itself** repairs this state, unaided. @@ -118,7 +118,7 @@ which mechanism produced it. | 2 | Probe with no cookies | `POST /bitcoin-rpc/` → **`200`**, `{"result":960790}`; `Allow-Origin: *`. **Genuinely re-exposed** | | 3 | Start the daemon (20:00:36) and touch nothing further | — | | 4 | Reconcile pass at **20:02:19** | `bitcoin_ui: nginx.conf rendered auth_hash=51f2b5af`, then `WARN prod_orchestrator: rewrote config for a user-uninstalled app whose container is still RUNNING (systemd/Quadlet keeps it alive independently of reconcile) — restarting so it picks the new config up app_id=bitcoin-ui container=archy-bitcoin-ui` | -| 5 | Probe again | `POST /bitcoin-rpc/` → **`401`**; `Allow-Origin: http://192.168.63.240:8334` | +| 5 | Probe again | `POST /bitcoin-rpc/` → **`401`**; `Allow-Origin: http://192.0.2.240:8334` | | 6 | Compare state | Conf **byte-identical** to the pre-test known-good; container healthy | Step 2 is what makes steps 4–6 mean anything: without a confirmed `200`, the later `401` @@ -134,12 +134,21 @@ The operator's call, recorded here so it is not silently re-litigated: **no LND rotation, and no Bitcoin RPC password rotation.** The reasoning was that there is no evidence of exploitation and the vulnerability is being closed rather than lived with. -`scripts/security/rotate-lnd-macaroon.sh` stays in the tree as a tool. It has been -exercised in detect mode only, and has never rotated anything on any node. Its ordering +`scripts/security/rotate-lnd-macaroon.sh` stays in the tree as a tool. Its ordering guard (refuses to rotate on a binary lacking the fix) remains the right shape for whenever rotation is wanted — including for the Bitcoin RPC password, which has no equivalent tool yet. +**Amended 2026-08-08.** This section said the script "has never rotated anything on any +node"; that is no longer true. A rotation was performed on a development node while +responding to the BTCPay Server advisory (that node had been running an affected +`btcpayserver:2.3.9`), and it exposed a gap the script did not cover: BTCPay's inline copy +of the macaroon was left stranded, so its Lightning payments failed silently while both +apps reported healthy. Rotation is now a first-class, password-confirmed dashboard action +that repairs that copy as part of the run — see +[`LND-MACAROON-ROTATION.md`](LND-MACAROON-ROTATION.md). The fleet decision recorded above +is unchanged: no fleet-wide rotation for this leak. + What this decision accepts: any macaroon or RPC password read through either hole before it was closed stays valid. That is a deliberate, informed trade, not an oversight. diff --git a/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md b/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md deleted file mode 100644 index 41b6e55b..00000000 --- a/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md +++ /dev/null @@ -1,1006 +0,0 @@ -# Entropy & Seed-Generation Security Audit — 2026-07-31 - -**Trigger:** the Coinkite COLDCARD entropy incident, disclosed 2026-07-30 (see -`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`, -"T1"). That defect silently rebound seed generation to a non-cryptographic PRNG through a -build-time macro guard, reducing effective seed entropy to ≤2^32 and enabling a ~1,082 BTC -sweep. This audit asks the same question of Archipelago. - -**Why the stakes here are higher than a hardware wallet's.** Archipelago derives its *entire* -key hierarchy from one 24-word BIP-39 mnemonic (`core/archipelago/src/seed.rs:1-18`): the node -Ed25519 `did:key`, the node Nostr key, the FIPS mesh transport key, per-identity keys, the -BIP-84 Bitcoin wallet, the LND aezeed entropy — **and the fleet release-root signing key** -(`core/archipelago/src/seed.rs:143-146`). A Coldcard-class entropy defect here would not merely -drain wallets; it would let an attacker forge signed release manifests and catalogs for every -node in the fleet. - -**Headline verdict:** **no Coldcard-class entropy defect exists in this codebase.** Every -first-party key-generation call site draws from a genuine CSPRNG, and the code does several -things better than most implementations. The findings below are (a) one structural pattern -that is the *exact shape* of T1 and should be closed cheaply, (b) one **Critical** -access-control defect found while tracing the secret classes — unrelated to entropy but far -more immediately exploitable than anything entropy-related — and (c) a set of Medium/Low -hygiene items. - ---- - -## 1. Scope and method - -### Directories covered - -| Path | Coverage | -|---|---| -| `core/*/src/**/*.rs` | full RNG-API grep sweep + call-graph trace of every secret class | -| `neode-ui/src/**/*.{ts,vue}` | full browser-RNG grep sweep | -| `scripts/**/*.{sh,py}` | RNG / secret-material grep sweep | -| `image-recipe/**` | entropy, seed-file, machine-id, host-key and first-boot ordering evidence | -| `~/.cargo/registry/src/*/bip39-2.1.0/`, `argon2-0.5.3/` | vendored-dependency default-RNG / default-parameter reads | -| `docs/adr/005-chacha20-backup-encryption.md` | Argon2 parameter cross-check | - -### Explicitly excluded, and why - -- **`core/target/`** — build output, not source. Excluded from every grep (the pipeline used - `core/*/src`, which cannot reach it). -- **`image-recipe/_archived/` — NOT excluded, contrary to the original scoping assumption.** - This is a correction the next auditor should not have to re-derive: - `image-recipe/build-debian-iso.sh:19-40` is a thin wrapper that copies - `image-recipe/_archived/build-auto-installer-iso.sh` to a temp path, rewrites its relative - paths, and `exec`s it (`image-recipe/build-debian-iso.sh:40`). **The "archived" auto-installer - IS the live ISO build path.** Treating `_archived/` as dead code would have made [ARCHY-3] - unanswerable. It is therefore in scope and is the primary [ARCHY-3] evidence surface. -- `image-recipe/_archived/build/auto-installer/installer-iso/...` — a stale *build output* tree - under `_archived/`, superseded by the generator above. Its `/dev/urandom` hits - (`image-recipe/_archived/build/auto-installer/installer-iso/archipelago/scripts/first-boot-containers.sh:182`) - are duplicates of the live `scripts/first-boot-containers.sh` and are not separately assessed. - -### Greps run - -``` -grep -rnE 'SmallRng|seed_from_u64|::from_seed\(|rand::rngs::mock|StdRng' core/*/src --include=*.rs -grep -rnE 'OsRng|thread_rng|rand::random|getrandom|SystemRandom' core/*/src --include=*.rs -grep -rn -B3 -A3 -E 'SystemTime::now|as_nanos|Instant::now' core/*/src --include=*.rs \ - | grep -iE 'key|seed|nonce|salt|token|secret|password|mnemonic' -grep -rn -B2 -A2 -E 'Math\.random|getRandomValues|crypto\.subtle|jsbn|SecureRandom\(' \ - neode-ui/src --include=*.ts --include=*.vue -grep -rnE '\$RANDOM|/dev/urandom|/dev/random|openssl rand|uuidgen|random\.random|random\.randint|shuf ' \ - scripts/ image-recipe/ --include=*.sh --include=*.py -grep -rniE 'random-seed|urandom|jitterentropy|haveged|rng-tools|rngd|crng' image-recipe/ \ - --include=*.sh --include=*.service --include=*.conf -find image-recipe -name 'random-seed' -o -name '*.seed' -grep -rniE '(info|warn|error|debug|trace)!\(.*(mnemonic|seed|privkey|private_key|passphrase|aezeed)' \ - core/*/src --include=*.rs -grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs \ - core/archipelago/src/credentials/store.rs -cargo tree -i rand@0.8.5 -p archipelago ; cargo tree -i rand@0.9.2 -p archipelago -``` - -Results of the two negative greps, stated so they count as findings rather than silence: - -- `find image-recipe -name 'random-seed' -o -name '*.seed'` returned **nothing**. No seed file - is checked into the image recipe. -- `grep -cE 'haveged|jitterentropy|rng-tools|rngd' image-recipe/_archived/build-auto-installer-iso.sh` - returned **0**. No userspace entropy daemon is installed by the image. -- `grep -rnE 'SmallRng|seed_from_u64|rand::rngs::mock|StdRng' core/*/src` returned **no RNG - hits at all** — the four matches are `NodeIdentity::from_seed(...)` calls - (`core/archipelago/src/api/rpc/seed_rpc.rs:122`, `:255`; - `core/archipelago/src/identity.rs:608`, `:634`), which is Archipelago's own - seed-to-identity function, not `rand`'s `from_seed`. **No non-cryptographic PRNG and no - deterministic seeding exists anywhere in the Rust workspace.** - -### Not performed - -- `cargo audit` — **`cargo-audit` is not installed on this host** (`command -v cargo-audit` - fails). No RustSec snapshot was taken. This is recorded as gap **F-07**; the research's - recommendation stands that `cargo audit`/`cargo deny` belongs in CI rather than in a - point-in-time audit. -- Anything requiring real hardware — see §6, the UNVERIFIED on-node checklist. - -### Concurrent-work caveat - -`core/archipelago/src/container/secrets.rs` and `neode-ui/src/views/OnboardingSeedGenerate.vue` -had **uncommitted third-party changes** on disk at audit time (another agent working in the -same tree). They were read as-is and not modified. Line numbers cited for those two files are -against the working-tree state of 2026-07-31, not against `HEAD`. - ---- - -## 2. Executive summary - -Archipelago's entropy path is structurally sound. Every first-party call site that produces key -material draws from `rand::rngs::OsRng` (a direct `getrandom(2)` wrapper) or from -`rand::random`/`rand::thread_rng` on `rand 0.8.5`, which is `ReseedingRng` -— a real CSPRNG that still carries fork protection in the 0.8 series. There is no Mersenne -Twister, no clock-seeded key, no `SmallRng`, no `seed_from_u64`, and no `Math.random()` in any -browser key path. The master-seed function is preceded by a genuinely good, non-blocking -CSPRNG-readiness probe (`core/archipelago/src/seed.rs:52-91`) that most implementations lack, -and the derivation is domain-separated, zeroized, and pinned by known-answer tests. - -Three things nonetheless warrant action, in this order: - -1. **The most urgent finding is not about entropy at all.** While tracing secret classes (3) - and (4), the audit found that `seed.generate` and `seed.restore` are in the - **unauthenticated** RPC allowlist (`core/archipelago/src/api/rpc/middleware.rs:25-27`), carry - **no onboarding-complete gate and no rate limit**, and unconditionally overwrite a live - node's Ed25519 identity, Nostr key and FIPS mesh key - (`core/archipelago/src/identity.rs:79-114`). The endpoint is proxied to the LAN over - plaintext HTTP (`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and is - also reachable by mesh peers (`core/archipelago/src/server.rs:2080`). A guard function for - exactly this already exists and is simply never called - (`core/archipelago/src/identity.rs:117`). **Critical — F-01.** -2. **The T1-shaped structural risk is real but currently benign.** - `bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92` delegates its entropy - source to a transitive dependency default. Not a vulnerability today; exactly the pattern - that produced T1. **Medium — F-02**, and the one code change this audit applies. -3. **The one-ISO-many-nodes story is better than feared but has a fail-open hole.** No - `random-seed` file is baked, and per-device TLS/SSH regeneration exists — but the rootfs is - a cached container export shared by every node, the regeneration is fail-open, and its - completion marker is set even when regeneration failed, so a single failure leaves fleet-wide - shared SSH host keys and TLS private key permanently. **High — F-03.** - -Nothing in this audit suggests any existing Archipelago node has a weak master seed. No user -action of the "your seed may be predictable, migrate now" kind is warranted — a point §7 of -`docs/security/PSBT-SIGNING-ARCHITECTURE.md` depends on and must not overstate. - ---- - -## 3. Findings - -| ID | Severity | Title | Primary evidence | -|---|---|---|---| -| F-01 | **Critical** | Unauthenticated, unrated `seed.generate`/`seed.restore` overwrite a live node's identity keys | `core/archipelago/src/api/rpc/middleware.rs:25`, `core/archipelago/src/identity.rs:79` | -| F-02 | **Medium** | Master mnemonic's entropy source is a transitive-dependency default, not a call-site argument (T1 shape) | `core/archipelago/src/seed.rs:92` | -| F-03 | **High** | First-boot per-device secret regeneration is fail-open and never retried, over a fleet-shared cached rootfs | `image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`, `:1663` | -| F-04 | **Medium** | Master mnemonic crosses the RPC boundary and is held in memory for 10 min, deliberately un-cleared, over plaintext-capable HTTP | `core/archipelago/src/api/rpc/seed_rpc.rs:147`, `:205-211` | -| F-05 | **Medium** | `Argon2::default()` is 19 MiB / t=2, not ADR-005's stated 64 MB / 3 iterations | `core/archipelago/src/seed.rs:249`, `docs/adr/005-chacha20-backup-encryption.md:31` | -| F-06 | **Medium** | Release master mnemonic is passed via env var / stdout in the signing ceremony | `core/archipelago/src/ceremony.rs:71-77`, `:149-160` | -| F-07 | **Medium** | No `cargo audit`/`cargo deny` in CI; two `rand` majors coexist in the graph | `core/archipelago/Cargo.toml:68` | -| F-08 | **Low** | 24-word master mnemonic persisted in browser `sessionStorage` during onboarding | `neode-ui/src/views/OnboardingSeedGenerate.vue:330` | -| F-09 | **Low** | Modulo bias in TOTP backup-code generation | `core/archipelago/src/totp.rs:305` | -| F-10 | **Low** → **see F-10a** | Container `generated_secrets` use `thread_rng()` rather than an explicit `OsRng` (same T1 shape as F-02, smaller blast radius) | `core/archipelago/src/container/secrets.rs:92`, `:101` | -| F-10a | **Medium** | **Scope correction to F-10 (2026-08-02):** the defaulted-RNG surface is crate-wide — **41 raw matches across 15 files**, not 2 — and includes ecash key material and an AEAD nonce | `core/archipelago/src/wallet/bdhke.rs:133`,`:139` (Cashu secret + blinding factor), `storage_crypto.rs:39` (nonce), `session.rs` (4 prod), +12 more files — full table in §F-10a | -| F-11 | **Informational** | `Math.random()` inside a seed-handling view (benign — UX challenge selection only) | `neode-ui/src/views/OnboardingSeedVerify.vue:159` | -| F-12 | **Informational** | Identical default OS credentials on every flashed node | `image-recipe/archipelago-scripts/install-to-disk.sh:205` | -| F-13 | **High** | BIP-84 account **private** key is imported into Bitcoin Core's wallet, duplicating the spending key outside the encrypted envelope | `core/archipelago/src/api/rpc/bitcoin.rs:203`, `:229-231` | - ---- - -### F-01 — Unauthenticated `seed.generate` / `seed.restore` overwrite a live node's identity keys — **Critical** - -**Evidence.** -- `core/archipelago/src/api/rpc/middleware.rs:24-28` places `seed.generate`, `seed.verify`, - `seed.restore` and `seed.save-encrypted` in `UNAUTHENTICATED_METHODS`, under the comment - "Onboarding flow (before user has a session)". -- `core/archipelago/src/api/rpc/mod.rs:263-265` — membership in that list skips the entire - session check; `:295` skips RBAC; `:326` skips CSRF. -- `core/archipelago/src/api/rpc/seed_rpc.rs:93-159` (`handle_seed_generate`) and `:226-305` - (`handle_seed_restore`) contain **no** check that onboarding is already complete or that a - node key already exists. -- `core/archipelago/src/identity.rs:79-114` (`NodeIdentity::from_seed`) writes `node_key`, - `node_key.pub` and, via `write_fips_key_from_seed` (`:108`), the FIPS mesh key — - **unconditionally, with no existence check.** `seed_rpc.rs:130-131` and `:261-266` likewise - overwrite `nostr_secret` / `nostr_pubkey`. -- The guard already exists and is never called on this path: - `core/archipelago/src/identity.rs:117-119` (`NodeIdentity::key_exists`). Its only callers are - `core/archipelago/src/server.rs:63` and `core/archipelago/src/api/rpc/seed_rpc.rs:343` - (read-only status). -- No rate limit: `core/archipelago/src/rate_limit.rs:60-97` enumerates per-method limits and - contains **no `seed.*` entry**, while explicitly acknowledging at `:96` that - "Inter-node federation RPCs (unauthenticated, need stricter limits)". -- Reachability: `image-recipe/configs/nginx-archipelago.conf:11` and `:15` bind port 80 as - `default_server` (plaintext, LAN); `:165-175` proxies `/rpc/v1` and `:192-195` proxies - `/rpc/` to `127.0.0.1:5678`. The FIPS mesh peer listener applies a path filter - (`core/archipelago/src/server.rs:1375`, `:1270`) but that filter **allows** `/rpc/v1` — - asserted at `core/archipelago/src/server.rs:2080`. - -**Exploitability.** No credentials, no session, no CSRF token, no rate limit. A single -unauthenticated JSON-RPC POST from anywhere on the LAN — or from any peer that can reach the -mesh listener — is sufficient. `seed.restore` is the worse of the two because the attacker -supplies the mnemonic: they then hold the node's Ed25519 signing key, its Nostr node key and -its FIPS transport key. `seed.generate` is a pure destructive primitive: it mints a mnemonic -nobody ever sees and overwrites the node's identity with it. - -**Blast radius.** Node identity takeover or permanent identity destruction. Downstream: the -node's `did:key` changes, so every federation trust relationship keyed on that DID breaks; the -FIPS mesh key changes, so mesh peering breaks; the Nostr node key changes, so discovery -announcements are signed by a key the fleet does not recognise. This does **not** by itself -expose the user's Bitcoin funds (the on-disk `master_seed.enc` envelope is not overwritten by -these handlers) — but do not read that as reassurance: an attacker who controls the node's -identity keys controls how that node presents itself to the federation. - -**This is not an entropy defect.** It surfaced because Step B of this audit required tracing -secret classes (3) and (4) end-to-end rather than only checking where their bits come from. -It is reported here because it is the most serious thing found and suppressing it until a -"more appropriate" document would be indefensible. - -**Remediation (concrete).** In `handle_seed_generate` and `handle_seed_restore`, bail early -when `NodeIdentity::key_exists(&identity_dir)` is true *and* the in-memory onboarding mnemonic -is absent — i.e. this is a booted, already-provisioned node rather than an onboarding retry. -Prefer additionally gating on `auth_manager.is_onboarding_complete()` -(`core/archipelago/src/auth.rs:182`). Add `seed.generate` / `seed.restore` to -`rate_limit.rs`'s table at the strictness of `auth.changePassword` (3 per 300s). Consider -removing `/rpc/v1` from `is_peer_allowed_path` for seed methods specifically, or filtering by -method rather than path. Needs its own plan — see Backlog R-01. - ---- - -### F-02 — Mnemonic entropy source is a transitive-dependency default — **Medium** — [ARCHY-1], **FIXED IN THIS AUDIT** - -**Evidence.** `core/archipelago/src/seed.rs:92`: - -```rust -let mnemonic = bip39::Mnemonic::generate(24) -``` - -Resolved against the vendored crate: -`~/.cargo/registry/src/index.crates.io-.../bip39-2.1.0/src/lib.rs:311-313` → -`generate_in` at `:296-298`, whose body is -`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)` → -`generate_in_with` at `:267-283`, which is generic over `R: RngCore + CryptoRng` and fills the -entropy buffer at `:281`. - -So the entropy backend for Archipelago's whole key hierarchy — including the release-root -signing key — was selected by `bip39`'s default, not stated at Archipelago's call site. - -**Exploitability.** **None today.** `rand::thread_rng()` on `rand 0.8.5` -(`core/archipelago/Cargo.toml:68`) is `ReseedingRng`: seeded from -`getrandom(2)`, reseeded every 64 KiB, `CryptoRng`, and still fork-protected in the 0.8 series. -The mnemonic is genuinely 256-bit. This finding is about *future* exploitability, not present. - -**Blast radius (if it ever rebinds).** Total. Every key in `seed.rs:1-18`, including the fleet -release-root signing key at `:143-146`. That is strictly larger than a hardware wallet's, -because it includes the ability to forge signed release manifests. - -**Why it is worth fixing anyway.** This is the precise structural shape of T1: a call whose -entropy backend is fixed by dependency/build configuration rather than by the calling code, -with no compile error if it changes. `bip39` is pinned `=2.1.0` -(`core/archipelago/Cargo.toml:74`) which contains the exposure today, and a future `rand` bump -to 0.9+ removes fork protection (upstream changelog, 2025-01-27) without touching a line of -Archipelago source. - -**Remediation — applied.** `seed.rs` now routes generation through an internal helper that -takes `&mut (impl CryptoRng + RngCore)` and calls `bip39::Mnemonic::generate_in_with` -explicitly, with the production caller passing `OsRng`, plus a known-answer test that drives -generation from a deterministic RNG and asserts the resulting words. That test is impossible -to write against the pre-change code, because there was no seam to inject through. See §7. - ---- - -### F-03 — Fail-open, never-retried first-boot secret regeneration over a fleet-shared rootfs — **High** — part of [ARCHY-3] - -**Evidence.** -- The installed root filesystem is a **container image exported to a tar** - (`image-recipe/_archived/build-auto-installer-iso.sh:717-726`), cached across builds - (`:267`), shipped on the ISO (`:1094`) and extracted verbatim onto every target disk - (`:2303`, `tar -xf "$ROOTFS_TAR" -C /mnt/target`). Every node flashed from one ISO therefore - starts from a byte-identical filesystem. -- That rootfs installs `openssh-server` (`:345`). Debian's `openssh-server` postinst generates - host keys at install time — i.e. **inside the container build** — so SSH host keys are baked - into the shared tar. -- It also bakes a self-signed RSA-2048 TLS keypair at `:463-469` - (`openssl req -x509 -nodes -days 3650 -newkey rsa:2048 ... /etc/archipelago/ssl/archipelago.key`). -- The mitigation exists and is correct in intent: `archipelago-first-boot-secrets.service` - (`:1599-1614`) runs `first-boot-secrets.sh` (`:1616-1665`), which regenerates the TLS keypair - (`:1635-1648`) and the full SSH host-key set via `ssh-keygen -A` into a staging dir and swaps - on success (`:1651-1662`). It is installed at `:2587-2593` and enabled at `:3336`. -- **The hole:** both branches are fail-open — `:1647` "WARNING: TLS regeneration failed, - keeping baked key" and `:1659` "WARNING: ssh-keygen -A failed, keeping baked host keys" — and - `touch "$MARKER"` at `:1663` runs **unconditionally, outside both `if` blocks**. The unit's - `ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` (`:1605`) and the script's - own `[ -f "$MARKER" ] && exit 0` (`:1625`) then guarantee it **never runs again**. -- Timing: the unit declares `DefaultDependencies=no` and only `After=local-fs.target` - (`:1603-1604`), so it runs very early — precisely when a freshly-flashed headless machine has - the least accumulated entropy, and it is the first consumer of the pool. - -**Exploitability.** One transient failure at first boot (a full disk, a slow-to-seed pool -causing a timeout, an `openssl`/`ssh-keygen` hiccup) permanently leaves that node running the -**image-wide shared** SSH host key and TLS private key. An attacker who obtains one copy of the -ISO — which is a published artifact — holds the SSH host key and TLS private key of every node -that hit that failure path, enabling transparent MITM of the web UI and undetectable SSH host -impersonation. The failure is logged only to `/var/log/archipelago-first-boot-secrets.log` and -surfaces nowhere in the UI. - -**Blast radius.** Per-node, but silently and permanently, and correlated fleet-wide by ISO -build. - -**Remediation.** Move `touch "$MARKER"` inside a success branch that requires *both* -regenerations to have succeeded; on failure, leave the marker absent so the oneshot retries on -the next boot, and surface the condition (a `system.stats`/doctor field, not just a log file). -Additionally add `After=systemd-random-seed.service` — harmless today (no seed file is baked, -see [ARCHY-3]) and correct if one is ever introduced. Independently, strip the baked SSH host -keys and TLS key from the rootfs tar at build time so a regeneration failure degrades to "no -key / service refuses to start" rather than "shared key, silently". - ---- - -### F-04 — Master mnemonic crosses the RPC boundary and lingers in memory — **Medium** — [ARCHY-4] - -**Evidence.** -- `core/archipelago/src/api/rpc/seed_rpc.rs:147` builds `words: Vec` from the mnemonic - and `:156-158` returns it as the JSON-RPC result. -- Held server-side in a process-global `LazyLock>>>` - (`:13-19`) under a 10-minute TTL (`:27`). -- **Deliberately not cleared at verify time** — `:205-211` documents the reasoning (the web - client aborts at 15s and retries; clearing would make a retried verify fail). The rationale is - sound; the residual risk is real and should be named rather than assumed away. -- `save_pending_seed_encrypted` (`:42-57`) deliberately ignores the TTL, documented at `:35-39`. -- Plaintext HTTP is a supported deployment: `core/archipelago/src/api/rpc/mod.rs:227-241` - sets the session cookie's `Secure` flag **only** when `X-Forwarded-Proto: https` is present, - with the comment "On LAN HTTP, Secure flag prevents browsers from sending cookies back" — - i.e. plaintext LAN is an expected mode, corroborated by - `image-recipe/configs/nginx-archipelago.conf:11` binding `:80` as `default_server`. - -**Exploitability.** Passive: anyone with LAN traffic visibility during the ~1-2 minutes of -onboarding reads the 24 words in cleartext. This unlocks the Bitcoin wallet, the node identity, -and — if the same mnemonic is ever used as a release master seed — the fleet signing key. -Requires being on-path during onboarding, which bounds it. - -**Blast radius.** Total for that node's key hierarchy. - -**Mitigating factors (real, and worth stating).** `OnboardingMnemonicState` implements `Drop` -with `zeroize` (`:21-25`); the words are never logged; and `seed.reveal` — the *post*-onboarding -path — is properly gated (see §5). The exposure is confined to the onboarding window. - -**Remediation.** Confine seed-bearing methods to loopback or require TLS for them specifically; -shrink `MNEMONIC_TTL`; clear on a *successful, acknowledged* verify with a short grace window -rather than never. Deferred to a plan — Backlog R-04. - ---- - -### F-05 — `Argon2::default()` does not match ADR-005 — **Medium** - -**Evidence.** `docs/adr/005-chacha20-backup-encryption.md:31` specifies "Argon2id with high -memory cost (64MB) and iterations (3)". The code uses `Argon2::default()` at -`core/archipelago/src/seed.rs:249` and `:285` (the master-seed and aezeed envelope), -`core/archipelago/src/backup/identity.rs:38` and `:93`, and -`core/archipelago/src/backup/full.rs:618` and `:650`. - -From the vendored crate `argon2-0.5.3`: `impl Default for Argon2` (`src/lib.rs:176-180`) uses -`Params::default()`, whose constants are `DEFAULT_M_COST = 19 * 1024` KiB = **19 MiB** -(`src/params.rs:42`), `DEFAULT_T_COST = 2` (`:52`), `DEFAULT_P_COST = 1` (`:61`). - -**Actual: Argon2id, v0x13, m=19456 KiB, t=2, p=1. ADR-005 states: 64 MB, 3 iterations.** The -algorithm choice (Argon2id) is correct; the cost parameters are roughly 3.4× weaker in memory -and 1.5× weaker in time than the ADR claims. The defaults are the current OWASP minimum, so -this is a documentation-vs-code divergence and a modest hardening gap, not a break. - -**Exploitability.** Offline brute force of `master_seed.enc` / backup blobs by an attacker who -already has file read access, at a lower cost than the ADR promises. - -**Remediation.** Either construct `Argon2::new(Algorithm::Argon2id, Version::V0x13, -Params::new(65536, 3, 1, None)?)` in one shared helper and use it everywhere, **or** amend -ADR-005 to state the real parameters. Do **not** silently change the parameters on the -master-seed envelope without a migration path: an existing `master_seed.enc` was encrypted -under the old parameters and would fail to decrypt. That constraint is what makes this a -backlog item rather than a quick fix. - ---- - -### F-06 — Release master mnemonic passed by env var / printed to stdout — **Medium** - -**Evidence.** `core/archipelago/src/ceremony.rs:70-78` (`cmd_gen`) prints -`RELEASE_MASTER_MNEMONIC="<24 words>"` to **stdout** via `println!`. `:149-153` -(`load_release_root_key`) reads the phrase via `read_mnemonic()`, which at `:157-160` prefers -the `RELEASE_MASTER_MNEMONIC` environment variable and falls back to stdin. - -**Exploitability.** An environment variable is readable from `/proc//environ` by the same -user and lands in shell history if set inline; stdout lands in terminal scrollback, tmux -buffers, CI logs and `script`/asciinema captures. This is the seed that derives the **fleet -release-root signing key** (`core/archipelago/src/seed.rs:143-146`) — compromise means forging -signed manifests for every node. - -**Mitigating factors.** The ceremony is a deliberate, human-operated, offline procedure, the -tool prints a prominent warning at `ceremony.rs:73-75`, and the stdin path exists and is the -documented practice (project memory: "sign via user TTY"). The env-var path is a convenience -affordance, not the intended default. - -**Remediation.** Make stdin/TTY the only supported input for `sign`/`pubkey` and remove or -feature-gate the env-var branch; for `gen`, write the mnemonic to a `0600` file on explicitly -named removable media rather than stdout, or require an interactive confirmation. Low effort, -but it touches the signing ceremony — schedule it deliberately, not opportunistically. - ---- - -### F-07 — No dependency-advisory gate in CI; two `rand` majors in the graph — **Medium** - -**Evidence.** `cargo-audit` is not installed on this host, so no RustSec check was run. -`cargo tree -i rand@0.8.5 -p archipelago` and `-i rand@0.9.2 -p archipelago` show **both** -majors resolved into the same binary: - -- `rand 0.8.5` — direct (`core/archipelago/Cargo.toml:68`), plus `archipelago-security`, - `bip39 2.1.0`, `mainline 2.0.1`, `secp256k1 0.29.1`, `tungstenite 0.20.1`. -- `rand 0.9.2` — transitively via `totp-rs 5.7.0` and `tungstenite 0.26.2` (through - `tokio-tungstenite` → `async-wsocket` → `nostr-relay-pool` → `nostr-sdk 0.44.1`). - -**No Archipelago-authored key-generation call site uses `rand 0.9.x`** — the direct dependency -is pinned to `0.8.5` and every first-party `OsRng`/`thread_rng`/`rand::random` call resolves -against it. But `rand 0.9.0` removed fork protection from `ThreadRng`, and the orchestrator -forks and spawns constantly, so the day a `rand` bump lands the T1 shape in F-02 and F-10 -becomes materially worse. `getrandom` is likewise split across `0.2.17` and `0.3.4`. - -**Remediation.** Add `cargo audit` (or `cargo deny check advisories bans`) to CI, with a `bans` -rule that fails on duplicate `rand` majors so the split is visible rather than silent. Before -any `rand` 0.9+ bump, convert every key-generation site to explicit `OsRng` (F-02, F-10) — after -which the fork-protection removal is irrelevant to Archipelago. - ---- - -### F-08 — 24-word master mnemonic persisted in browser `sessionStorage` — **Low** - -**Evidence.** `neode-ui/src/views/OnboardingSeedGenerate.vue:330` writes the full word list: -`sessionStorage.setItem('_seed_words', JSON.stringify(words.value))`; it is re-read at `:297` -and at `neode-ui/src/views/OnboardingSeedVerify.vue:165`. The mnemonic itself arrives from -`seed.generate` at `OnboardingSeedGenerate.vue:256-258`. - -**Mitigating factors.** It **is** removed on successful verify -(`neode-ui/src/views/OnboardingSeedVerify.vue:251`), and its exclusion from the logout -cache-purge is a deliberate, test-pinned decision -(`neode-ui/src/stores/__tests__/resourcesClear.test.ts:213`, `:231`) — onboarding must survive a -reload. So this is a considered trade-off, not an oversight. - -**Residual risk.** A user who abandons onboarding mid-flow leaves the master mnemonic in -plaintext `sessionStorage` for the lifetime of the tab. On the node's own kiosk browser, that -tab may stay open indefinitely. Any XSS in the UI during that window reads it directly. - -**Remediation.** Clear `_seed_words` on route-leave from the onboarding flow as well as on -verify, and add a wall-clock expiry to the stored blob mirroring the server's `MNEMONIC_TTL`. - ---- - -### F-09 — Modulo bias in TOTP backup-code generation — **Low** — [ARCHY-5] - -**Evidence.** `core/archipelago/src/totp.rs:305`: - -```rust -let idx = (rand::random::() as usize) % charset.len(); -``` - -with `charset` = 32 characters (`:298`). **32 divides 256 exactly**, so in the *current* code -the bias is **zero** — the research's [ARCHY-5] framing of "classic modulo bias" is correct as a -pattern but the concrete instance is presently unbiased. The defect is latent: any future edit -to the charset (adding a symbol, removing an ambiguous letter) silently introduces bias with no -test to catch it. Reported as Low on that basis, not on present harm. - -**Remediation.** Replace with `rand::seq::SliceRandom::choose(&mut OsRng)`, which is -unbiased for any charset length, and add an assertion or test that pins the property. Left to -the backlog rather than applied here: the entropy source is already correct and the present -bias is nil, so it does not meet this plan's bar for a code change. - ---- - -### F-10 — Container `generated_secrets` use `thread_rng()` — **Low** - -**Evidence.** `core/archipelago/src/container/secrets.rs:90-93` (`random_hex`) and `:98-102` -(`random_base64`) both use `rand::thread_rng().fill_bytes(&mut buf)`. These materialise -manifest-declared `generated_secrets` for every app (Bitcoin RPC password, DB passwords, -netbird store encryption key, the Fedimint gateway credential at `:135-...`). - -**Assessment.** Cryptographically fine on `rand 0.8.5` for the same reason as F-02, and the same -T1-shaped structural objection applies with a smaller blast radius (per-app credentials rather -than the master key hierarchy). File permissions were verified rather than assumed: -`core/archipelago/src/container/secrets.rs:207` sets `.mode(0o600)` on creation, and `:269` and -`:307` are tests asserting `mode == 0o600` for the written files. **CLAUDE.md's "0600/rootless" -invariant holds and is test-enforced.** - -*(This file carried uncommitted third-party changes at audit time — line numbers are against the -2026-07-31 working tree.)* - -**Remediation.** Swap both helpers to `rand::rngs::OsRng` when F-02's pattern is generalised. -One-line change each; batched into the same backlog item. - ---- - -### F-10a — Scope correction: the defaulted-RNG surface is crate-wide — **Medium** - -> **Added 2026-08-02, after the original audit.** F-10 above reported this defect as two call -> sites in one file. That was **understated**. This section records the true scope with evidence. -> F-10's own text and remediation are left unedited above so the correction is auditable rather -> than retroactive. - -**Evidence.** `grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs` returns -**43 matches across 16 files**. Two of those (`seed.rs:87`, `:671`) are comments in the -already-remediated F-02 file, leaving **41 matches across 15 files**: - -| File | Matches | Generates | -|---|---|---| -| `core/archipelago/src/session.rs` | 16 → **4 prod + 12 test** | session tokens (`#[cfg(test)]` begins `:470`) | -| `core/archipelago/src/api/rpc/package/pine_ha.rs` | 6 | app credentials | -| `core/archipelago/src/wallet/bdhke.rs` | 4 → **2 prod + 2 test** | **Cashu proof secret (`generate_secret`, `:133`) and blinding factor (`random_blinding_factor`, `:139`) — genuine key material** (`#[cfg(test)]` begins `:143`) | -| `core/archipelago/src/mesh/x3dh.rs` | 2 | ~~X3DH key agreement — key material~~ **CORRECTED: `u32` prekey *identifiers*** (`spk_id` `:100`, `otk_id` `:114`). The X25519 secrets come from `crypto::generate_x25519_ephemeral()` at `:99`/`:113` and are **not** in this table | -| `core/archipelago/src/container/secrets.rs` | 2 | `generated_secrets` (the original F-10) | -| `core/archipelago/src/api/rpc/package/install.rs` | 2 | install-time secrets | -| `core/archipelago/src/storage_crypto.rs` | 1 | **ChaCha20-Poly1305 nonce — reuse breaks the AEAD** | -| `core/archipelago/src/credentials/store.rs` | 1 | credential store material | -| `core/archipelago/src/device_tokens.rs` | 1 | device tokens | -| `core/archipelago/src/federation/invites.rs` | 1 | federation invites | -| `core/archipelago/src/bitcoin_rpc.rs` | 1 | Bitcoin RPC password | -| `core/archipelago/src/totp.rs` | 1 | TOTP backup codes (also F-09) | -| `core/archipelago/src/transport/chunking.rs` | 1 | chunk identifiers | -| `core/archipelago/src/fips/dial.rs` | 1 | dial jitter/identifiers | -| `core/archipelago/src/api/rpc/auth.rs` | 1 | auth-path material | - -**Per-site production-vs-test classification is deliberately NOT asserted here** — it is the -first task of the remediation (KEY-05 Task 1), not an assumption of this correction. The counts -above are raw matches. - -> **Self-correction, 2026-08-02 (same day, before any KEY-05 work began).** The first version of -> this table asserted semantics in its right-hand column that two entries did not support, and -> the KEY-05 planner caught it against the code. Both are fixed inline above, struck rather than -> silently rewritten: -> - **`mesh/x3dh.rs` was wrong.** Called "X3DH key agreement — key material"; the two sites are -> `u32` prekey *identifiers*. The X25519 secrets are drawn elsewhere and were never in scope. -> - **`session.rs` was misleading.** 16 raw matches read as 16 production token sites; it is 4 -> production and 12 test. -> -> **The Medium rating still holds, on narrower grounds.** It now rests on `wallet/bdhke.rs`'s two -> production sites (the Cashu proof secret and the blinding factor — a predictable blinding -> factor breaks the ecash unlinkability guarantee and is genuinely key material) and on -> `storage_crypto.rs:39`'s AEAD nonce. It does **not** rest on x3dh. Recorded because the -> correction to F-10 was made on the grounds that understatement misleads the next reader — -> overstatement does exactly the same, and this table did both within one day. - -**Assessment.** Unchanged from F-10 in kind: `rand::random()` and `thread_rng()` are backed by -ChaCha12 seeded from `getrandom(2)` on `rand 0.8.5`, so **nothing in this table is broken -today**. What changes is the *blast radius* of the T1 structural objection. F-10 rated this Low -on the basis of "per-app credentials rather than the master key hierarchy". That justification -does not survive the true scope: `wallet/bdhke.rs:133`/`:139` generate the Cashu proof secret and -blinding factor — genuine key material, where a predictable blinding factor breaks ecash -unlinkability — and `storage_crypto.rs:39` draws an AEAD nonce, where a silent rebinding to a -non-cryptographic PRNG would be catastrophic rather than merely undesirable. Re-rated **Medium** -on those two grounds. (An earlier version of this sentence also cited `mesh/x3dh.rs`; that was -incorrect and is struck in the table above — those sites are prekey identifiers.) - -**Why the original audit missed it.** F-10 was reached by tracing the *manifest secrets* path -(secret class 4). No step enumerated defaulted-RNG use across the whole crate independently of -the traced paths — so files outside those traces were never in scope to be looked at. Recorded -here because the same blind spot would recur in the next audit run under the same method. - -**Remediation → tracked as KEY-05 in Phase 10** (`.planning/ROADMAP.md`), which supersedes R-13: -a sealed allowlist trait so only approved RNGs can be passed at key-generation seams; a -`clippy.toml` `disallowed-methods` ban on `rand::thread_rng` / `rand::random` crate-wide so the -default cannot be inherited by *new* code either; `cargo-deny` failing on duplicate `rand` -majors (R-05, the mechanism by which a bump could silently rebind); a degenerate-entropy runtime -check before key generation; and persisting the CSPRNG-readiness verdict (R-09) that -`seed.rs:59` already computes but discards. - ---- - -### F-11 — `Math.random()` inside a seed-handling view — **Informational (benign)** - -**Evidence.** `neode-ui/src/views/OnboardingSeedVerify.vue:157-163`, `pickRandomIndices` uses -`Math.floor(Math.random() * max)` to choose which of the 24 words the user is quizzed on. - -**Assessment: benign, and annotated here so the next auditor does not re-derive it.** The -indices select a UX challenge only. They are not key material, not a nonce, not a salt, and not -a secret: an attacker who predicts perfectly which words will be quizzed learns nothing — the -words themselves are what they would need, and those are already on the user's screen. The -verification is a *user*-facing "did you write it down" check, not an authentication boundary -(the server compares against its own held copy at -`core/archipelago/src/api/rpc/seed_rpc.rs:190-194`). - -Other `Math.random()` sites, all confirmed non-security: -`neode-ui/src/api/rpc-client.ts:183`, `:206`, `:215` (retry jitter); -`neode-ui/src/views/Login.vue:317` (progress bar); -`neode-ui/src/components/BootScreen.vue:112`, `:123` (starfield animation). - -**No remediation required.** Optionally add a one-line comment at the call site so this stays -annotated in the code rather than only in this document. - ---- - -### F-12 — Identical default OS credentials on every flashed node — **Informational** - -**Evidence.** `image-recipe/archipelago-scripts/install-to-disk.sh:205` sets -`archipelago:archipelago` via `chpasswd`, and `:367-371` prints the credentials with a -"Please change the password after first login!" warning. - -**Assessment.** Not an entropy defect and a known, documented alpha-stage default. Recorded here -only because it belongs to the same one-image-many-nodes correlation theme as [ARCHY-3]: it is -the one identity artefact that is *deliberately* identical across the fleet, and unlike the SSH -host key and TLS key (F-03) there is no first-boot regeneration for it. Out of scope to fix; -in scope to name. - ---- - -### F-13 — BIP-84 account **private** key is imported into Bitcoin Core — **High** - -**Evidence.** `core/archipelago/src/api/rpc/bitcoin.rs:161-294` -(`handle_bitcoin_init_wallet_from_seed`): - -- `:203` passes `disable_private_keys = false` to `createwallet`. -- `:188-189` derives the BIP-84 account **xprv** (`crate::seed::derive_bitcoin_xprv`, - `core/archipelago/src/seed.rs:207-224`) and stringifies it. -- `:229-231` builds `wpkh({xprv}/0/*)` and `wpkh({xprv}/1/*)`. -- `:278-281` imports those descriptors via `importdescriptors`. - -**Assessment.** The node's Bitcoin spending key is therefore persisted **twice**: once in the -daemon's Argon2 + ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`, `0600` via -`:318-324`), and once in Bitcoin Core's `wallet.dat`, which has neither the Argon2 passphrase -protection nor the same ownership story — it lives in the Bitcoin Core container's data volume. -The wallet is created with an **empty** encryption passphrase (`bitcoin.rs:205`), so Core's own -wallet encryption is not engaged either. - -**Not an entropy defect**, and reported here because Step B required tracing secret class (1), -the user Bitcoin/LND wallet seed, from generation to consumer — and this is where that trace -ends up. - -**Credit where due:** the in-memory handling of the xprv string is careful — it is zeroized on -both the error path (`bitcoin.rs:222`) and the success path (`:284`) — and the wallet is a -*descriptor* wallet (`:207`), which is the correct foundation. The defect is which key goes -into it. - -**Secondary defect, same lines.** The descriptors at `:230-231` carry **no key-origin -annotation** (`[fingerprint/derivation]`). Without it, no hardware signer can locate its key in -a PSBT — so the current wallet could not be converted to an external-signer setup even if the -private key were removed. - -**Exploitability.** Requires read access to the Bitcoin Core data volume. That is a lower bar -than the encrypted envelope: a container escape, a backup of the Bitcoin volume, or a -misconfigured bind mount exposes it, whereas `master_seed.enc` additionally requires the user's -password. - -**Blast radius.** The node's entire on-chain Bitcoin balance at `m/84'/0'/0'`. It does **not** -extend to the other key classes — the release-root key, node identity and FIPS keys are HKDF -siblings, not children of the BIP-84 branch, so an attacker with the account xprv cannot climb -back to the master seed. - -**Remediation.** Pass `disable_private_keys = true`; import the account **xpub** with a -key-origin annotation instead of the xprv; sign via the daemon (or an external signer) rather -than via Core. This is Phase 1 of `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8, including -the migration that verifies balance/UTXO parity before removing the private-key-bearing wallet. - ---- - -## 4. [ARCHY-1] … [ARCHY-4] adjudication - -### [ARCHY-1] — **CONFIRMED** - -The research's claim that `bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92` -resolves its entropy source through a transitive default is **exactly right**, and the citation -is accurate: `bip39-2.1.0/src/lib.rs:296-298` is `generate_in`, whose body is -`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)`. The full chain is -`generate` (`:311-313`) → `generate_in` (`:296-298`) → `generate_in_with` (`:267-283`). - -**The entropy source is chosen by the dependency, not at the call site.** The injectable seam -exists and is public — `generate_in_with` — so closing this costs -almost nothing. It is **not** a vulnerability today (`rand 0.8.5`'s `thread_rng` is a -fork-protected ChaCha12 CSPRNG seeded from `getrandom(2)`), but it is the structural shape of -T1. **Fixed in this audit — see §7 and F-02.** - -### [ARCHY-2] — **CONFIRMED (as a positive finding)** - -`kernel_csprng_ready()` at `core/archipelago/src/seed.rs:58-75` calls -`libc::getrandom(..., libc::GRND_NONBLOCK)` (`:62-67`), maps a 1-byte success to `Some(true)` -(`:68-69`), `EAGAIN` to `Some(false)` (`:70-71`), and anything else to `None` (`:73`). The -single byte it draws is **discarded** — `byte` is never read again. It is used only by -`MasterSeed::generate` at `:85-91` to emit `info!` or `warn!`. - -**No key material is drawn from the non-blocking path.** The actual mnemonic entropy comes from -`bip39::Mnemonic::generate(24)` at `:92`, i.e. `getrandom(2)` **without** `GRND_NONBLOCK`, which -blocks until the pool is initialised. The doc comment at `:52-57` states this reasoning -correctly. The research's assessment — "exactly right and better than most implementations" — -holds. The two hardening notes it raised also hold and are carried to the backlog: the -invariant depends on the `getrandom` crate using the blocking syscall (worth a test, not just a -comment), and the `warn!` should be persisted as a structured, durable event so a node can -answer post-hoc "was the pool ready when this seed was born?" — the question Coldcard owners -cannot answer today. - -### [ARCHY-3] — **PARTIALLY CONFIRMED; the tree answers three of four sub-questions, the fourth is UNVERIFIED** - -First, a scoping correction the research could not have known: `image-recipe/_archived/` is -**not** dead. `image-recipe/build-debian-iso.sh:19-40` execs -`image-recipe/_archived/build-auto-installer-iso.sh`. That file is the ISO builder. - -| Sub-question | Verdict | Evidence | -|---|---|---| -| Does the build bake a populated seed file into the image? | **NO** | `find image-recipe -name 'random-seed' -o -name '*.seed'` → empty. The rootfs is a container export (`build-auto-installer-iso.sh:717-726`); `systemd-random-seed.service` never runs inside a container build, so `/var/lib/systemd/random-seed` is never created. The installer extracts that tar (`:2303`) and adds no seed file. | -| Is there a first-boot regeneration unit? | **YES, for TLS + SSH host keys — but it is fail-open and never retried** | `archipelago-first-boot-secrets.service` at `:1599-1614`, script at `:1616-1665`, installed `:2587-2593`, enabled `:3336`. Hole documented as **F-03** (`:1647`, `:1659`, `:1663`). It does **not** touch `/etc/machine-id` or any random-seed file. | -| Does the image install `jitterentropy-rngd` / `haveged` / `rng-tools`? | **NO** | `grep -cE 'haveged\|jitterentropy\|rng-tools\|rngd' image-recipe/_archived/build-auto-installer-iso.sh` → `0`. The rootfs package list at `:330-352` and following contains no entropy daemon. Kernel ≥5.6's in-kernel jitter source is therefore the only supplemental source on headless hardware. | -| Can onboarding key generation run before the kernel CSPRNG is initialised? | **NO — it can be *delayed* by it, but never weakened** | `bip39` fills entropy via `rand`'s `OsRng`/`ThreadRng` seeding, i.e. blocking `getrandom(2)`. `core/archipelago/src/seed.rs:52-57` documents exactly this and the probe at `:85-91` makes the ordering visible in the logs. The failure mode is a hang, not a weak key — the correct trade. | - -**What remains genuinely UNVERIFIED.** Whether `/etc/machine-id` is empty (regenerated per node) -or populated (shared) in the exported rootfs tar; whether SSH host keys are in fact present in -that tar as the `openssh-server` install at `:345` implies; the real `crng init done` timestamp -relative to seed generation on freshly-flashed hardware; and whether N nodes flashed from one -ISO actually produce N distinct seeds. **None of these is answerable from this environment.** -They are the on-node checklist in §6 and must not be reported as verified. - -**Net assessment.** The most-feared version of [ARCHY-3] — a baked, credited `random-seed` -giving every node a correlated pool — **does not exist**. The real exposure is narrower and -different from what the research predicted: fleet-shared SSH host keys and a fleet-shared TLS -private key in the cached rootfs, protected by a regeneration step that fails open and never -retries (F-03). - -### [ARCHY-4] — **CONFIRMED, and worse than described** - -Every specific claim checks out: - -- The mnemonic is returned to the web client as `words: Vec` — - `core/archipelago/src/api/rpc/seed_rpc.rs:147`, returned at `:156-158`. (The research cited - "~line 147"; exact.) -- 10-minute in-memory TTL — `MNEMONIC_TTL` at `:27`, state struct at `:16-19`. -- Deliberately **not** cleared at verify time, with a documented rationale — `:205-211`. - (Research cited `:205-209`; the comment block runs `:205-211`.) -- Plaintext HTTP is a live mode — `core/archipelago/src/api/rpc/mod.rs:227-241` conditions the - cookie `Secure` flag on `X-Forwarded-Proto: https` and comments explicitly on "LAN HTTP"; - `image-recipe/configs/nginx-archipelago.conf:11`, `:15` bind `:80` as `default_server` and - `:165-195` proxy `/rpc/v1` and `/rpc/` to the daemon. - -**Worse than described:** the research treated this as a confidentiality exposure. It is also an -**integrity and availability** exposure, because the same four seed methods are in -`UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:24-28`) with no -onboarding gate and no rate limit, and the handlers overwrite live identity keys -unconditionally. That is **F-01**, severity Critical. - -### [ARCHY-5] — **CONFIRMED as a pattern, REFUTED as a present defect** - -The line is exactly as cited (`core/archipelago/src/totp.rs:305`) but the charset at `:298` is -32 characters, and 32 divides 256 exactly, so the current distribution is **uniform — there is -no bias today**. The research's characterisation ("classic modulo bias whenever -`charset.len()` does not divide 256") is technically precise; its implied conclusion that this -instance is biased is not. Recorded as **F-09**, Low, on latent-defect grounds only. Stated -plainly rather than quietly dropped, per this audit's honesty rule. - -### Open question 9 (Argon2 parameters) — **DIVERGENCE CONFIRMED** - -`Argon2::default()` = Argon2id, v0x13, **m=19456 KiB (19 MiB), t=2, p=1** -(`argon2-0.5.3/src/params.rs:42`, `:52`, `:61`; `src/lib.rs:176-180`). -`docs/adr/005-chacha20-backup-encryption.md:31` states **64 MB and 3 iterations**. The code does -not match the ADR. Full detail and the migration constraint are in **F-05**. - -### Also noted from the research, confirmed benign - -`core/archipelago/src/storage_crypto.rs:39` and `core/archipelago/src/credentials/store.rs:69` -draw 96-bit ChaCha20-Poly1305 nonces via `rand::random()`. CSPRNG-backed; fine. The -random-nonce birthday bound (~2^32 messages per key) is not approached by either use. Same for -`core/archipelago/src/mesh/crypto.rs:70` (explicit `OsRng`, with a correct explanatory comment -at `:64`), `core/archipelago/src/fips/dial.rs:75` (a 16-bit dial ID, not a secret), and -`core/archipelago/src/wallet/bdhke.rs:133`, `:139`. - ---- - -## 5. What we do right - -Credit where the code is correct — each with evidence, so a future refactor that removes any of -these is visibly a regression. - -1. **The CSPRNG-readiness probe.** `core/archipelago/src/seed.rs:52-91`. Uses `GRND_NONBLOCK` - *as a probe only*, discards the byte, and logs the pool state immediately before generating - the master seed. The doc comment reasons correctly about why blocking `getrandom(2)` makes a - weak seed impossible. This is better than most wallet implementations and is precisely the - audit trail Coldcard owners now wish they had. -2. **Zeroization is real, not decorative.** `MasterSeed` is `#[derive(Zeroize, ZeroizeOnDrop)]` - (`core/archipelago/src/seed.rs:47-50`); the Argon2-derived key is explicitly zeroized on both - the encrypt and decrypt paths (`:262`, `:292`); the aezeed plaintext join is zeroized after - use (`:384`, `:401`); the in-memory onboarding mnemonic zeroizes on `Drop` - (`core/archipelago/src/api/rpc/seed_rpc.rs:21-25`); the reveal path zeroizes the password on - every exit (`:396`, `:430`, `:441`, `:465`). -3. **No `#[derive(Debug)]` on any secret-bearing type.** - `grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs - core/archipelago/src/credentials/store.rs` returns **nothing** — the classic accidental-log - escape is closed by construction. -4. **No secret is logged.** The secret-logging grep across `core/*/src` returned only - non-secret status lines. The most sensitive one, - `core/archipelago/src/seed.rs:86` ("kernel CSPRNG initialized; generating master seed"), - contains no material. `core/archipelago/src/identity.rs:103-106` logs only the first 16 hex - chars of a **public** key. The file-level invariant at `core/archipelago/src/seed.rs:18` - ("Never log mnemonic or seed material at any level") is actually honoured. -5. **Encrypted-at-rest envelope with per-blob salt and nonce from `OsRng`.** - `core/archipelago/src/seed.rs:243-246`, AEAD at `:253-260`, and every identity blob written - `0600` via a single shared helper (`:318-324`). One implementation, not five. -6. **24-word enforcement on restore.** `core/archipelago/src/seed.rs:111-114` rejects any word - count other than 24, so a 12-word (128-bit) mnemonic cannot be smuggled into a hierarchy that - assumes 256 bits. -7. **Domain-separated derivation, pinned by known-answer tests.** Distinct HKDF info strings - per key class (`core/archipelago/src/seed.rs:37-41`), with KATs that pin the exact bytes: - `:764-779` (node key, cross-checked against `scripts/verify-seed-derivation.py`) and - `:800-816` (release-root private *and* public key). A derivation change cannot land silently. -8. **An existing non-determinism regression guard.** `core/archipelago/src/seed.rs:597-622` - generates 64 mnemonics and asserts both uniqueness and word-distribution spread, with a - comment naming exactly the failure it guards against. This is a genuinely good instinct that - predates the Coldcard incident — it would have caught a Yasmarang-class collapse. -9. **`seed.reveal` is properly gated.** `core/archipelago/src/api/rpc/seed_rpc.rs:360-369`: - authenticated session required (it is deliberately *not* in the unauthenticated allowlist), - password re-verification, replay-protected TOTP when 2FA is on, and separate backup-passphrase - decryption. The contrast with F-01's ungated `seed.generate`/`seed.restore` is what makes - F-01 look like an oversight rather than a design position. -10. **Correct browser RNG at the call sites that matter.** - `neode-ui/src/views/OnboardingVerify.vue:105-109` uses `crypto.getRandomValues` for the - 32-byte signing challenge; `neode-ui/src/views/web5/Web5.vue:183-185` does the same, and - guards on `crypto.subtle` being absent — which is exactly right, because `subtle` is - undefined in an insecure context while `getRandomValues` keeps working over plain HTTP. -11. **Container secret file modes are test-enforced, not assumed.** - `core/archipelago/src/container/secrets.rs:207` sets `0o600`; `:269` and `:307` are tests - asserting it. CLAUDE.md's invariant is mechanically defended. -12. **The release-root key is derived, not stored, and nodes hold only the public half.** - `core/archipelago/src/seed.rs:133-146` documents the publisher-only derivation; - `core/archipelago/src/trust/anchor.rs:34` pins the public key. Fleet nodes never hold the - signing key. -13. **The FIPS mesh peer listener is path-filtered.** `core/archipelago/src/server.rs:1375`, - `:1270`. The mechanism is right even though its current allowlist is too permissive for - seed methods (F-01). - ---- - -## 6. On-node verification checklist — **UNVERIFIED** - -**Every item below is UNVERIFIED.** None was executed. Real hardware — a freshly-flashed node, -`.228`, or the dev-box — is not reachable from the environment this audit ran in. Do not treat -any of these as checked until an operator has run them and recorded the output. - -**Run on a *freshly flashed* node, before completing onboarding, unless noted.** - -### C-1 — Was the kernel CSPRNG ready when keys were generated? ([ARCHY-3]) - -```bash -journalctl -b | grep -iE 'crng init|random: ' -journalctl -b -u archipelago | grep -i 'kernel CSPRNG' -cat /proc/sys/kernel/random/entropy_avail -systemd-analyze blame | grep -iE 'random|archipelago-first-boot-secrets' -``` -**Pass:** `crng init done` timestamp strictly precedes the -`kernel CSPRNG initialized; generating master seed` line from -`core/archipelago/src/seed.rs:86`. A `not yet initialized` warn line from `:87-89` is the -signal to escalate. - -### C-2 — Is a seed file present, and is `machine-id` unique? ([ARCHY-3]) - -```bash -ls -l /var/lib/systemd/random-seed /var/lib/urandom/random-seed 2>&1 -cat /etc/machine-id -``` -**Pass:** either no seed file at first boot, or one created *after* first boot with a -current mtime. `machine-id` must differ between two nodes flashed from the same ISO — run on -both and compare. - -### C-3 — Are SSH host keys and the TLS key per-node? (**F-03**, the highest-value check here) - -On two nodes flashed from the same ISO: -```bash -for f in /etc/ssh/ssh_host_*_key.pub; do echo "$f: $(ssh-keygen -lf "$f")"; done -openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256 -cat /var/lib/archipelago/.secrets-regenerated 2>&1; ls -l /var/lib/archipelago/.secrets-regenerated -grep -i warning /var/log/archipelago-first-boot-secrets.log -``` -**Fail:** any fingerprint matching between the two nodes, or any `WARNING:` line in the log -alongside an existing `.secrets-regenerated` marker (that combination is exactly the fail-open -path at `image-recipe/_archived/build-auto-installer-iso.sh:1647`/`:1659`/`:1663`). - -### C-4 — Does the shipped rootfs tar contain identity artefacts? (**F-03**, run on the *build host*) - -```bash -tar -tvf /archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago.key' -``` -**Expected:** SSH host keys and the TLS key **present** (they are baked — see -`build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id` absent -or zero-length. Anything else changes F-03's severity. - -### C-5 — Cross-node same-ISO seed collision test (the empirical proof that would have caught T1) - -Flash N ≥ 3 nodes from one ISO. On each, without user interaction: -```bash -curl -s -X POST http://127.0.0.1:5678/rpc/v1 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"seed.generate","params":null}' \ - | sha256sum -``` -**Pass:** N distinct digests. **Handle the output as key material** — these are real mnemonics; -compare digests only, never the words, and re-provision every node used for this test. -Do **not** run this against a node in real use — per F-01 it overwrites the node's identity. - -### C-6 — Is the RPC endpoint reachable unauthenticated from the LAN? (**F-01**) - -From a *different* machine on the same LAN, against a **disposable** node: -```bash -curl -s -o /dev/null -w '%{http_code}\n' -X POST http:///rpc/v1 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"seed.status","params":null}' -``` -**Fail:** `200`. Use `seed.status` (read-only), **never** `seed.generate`/`seed.restore`, -to probe this. Repeat over the Tor onion address and over the FIPS mesh ULA to establish the -full exposure surface. - -### C-7 — Is the daemon's memory swappable? - -```bash -systemctl cat archipelago.service | grep -E 'MemoryDenyWriteExecute|LimitMEMLOCK' -swapon --show -``` -Informational: the onboarding mnemonic lives in process memory for up to 10 minutes (F-04) and -`image-recipe/archipelago-scripts/install-to-disk.sh:226-236` creates a 2-8 GB swapfile on -every install. - ---- - -## 7. `ARCHY-1` remediation status — **APPLIED** - -The injectable-RNG-seam refactor described in F-02 was applied to -`core/archipelago/src/seed.rs`. Scope, precisely: - -- A new private helper `generate_mnemonic_with(rng: &mut R)` - calls `bip39::Mnemonic::generate_in_with(rng, Language::English, 24)` — the **injectable** - bip39 entry point — instead of the defaulting `Mnemonic::generate(24)`. -- `MasterSeed::generate()` passes `&mut rand::rngs::OsRng` explicitly. -- A doc comment at the helper pins the rationale to this audit and to T1, so a future - `rand`/`bip39` bump cannot rebind the entropy source without someone reading why it matters. -- Two tests added to the existing module. - -**Nothing else changed.** The derivation paths, the 24-word count, the empty-BIP-39-passphrase -decision, the at-rest encryption envelope, and every existing test are untouched. - -**Tests.** `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` → -**25 passed, 0 failed.** - -- `mnemonic_generation_uses_injected_rng` — drives generation from a deterministic - `CryptoRng + RngCore` test RNG and asserts (a) the result equals - `bip39::Mnemonic::from_entropy()`, which is the direct proof - that the **injected** RNG — not bip39's transitive `rand::thread_rng()` default — is the one - actually consumed; (b) a known-answer word list; (c) determinism across two identical RNG - states. **This test is impossible to write against the pre-change code**, because - `Mnemonic::generate(24)` exposes no seam through which the RNG can be observed or substituted. -- `mnemonic_generation_is_256_bit` — the production `OsRng` path yields 24 words and two - successive productions differ. - -**The residual risk this does not close.** Making the source explicit does not make the *fix* -retroactive: mnemonics generated before this change came from `rand::thread_rng()`. That was -and remains a genuine CSPRNG (F-02, "Exploitability: none today"), so no existing seed is -weakened — but the guarantee for those seeds rests on `rand 0.8.5`'s behaviour, not on this -call site. Reviewers should read this as *removing a future failure mode*, not as repairing a -past one. - -Everything else in this document is queued in §8, not implemented. - ---- - -## 8. Remediation Backlog - -Prioritised by severity × effort, most valuable per unit of work first. **Only R-00 was -implemented by this audit.** Everything else is queued here and mirrored into -`docs/UNIFIED-TASK-TRACKER.md` so it is not stranded in a document nobody re-reads. - -**Effort scale:** S = under an hour; M = half a day; L = a day or more; **PHASE** = needs its -own `/gsd-plan-phase`, not an opportunistic edit. - -| # | Closes | Change | Files | Effort | Hardware? | -|---|---|---|---|---|---| -| **R-00** | F-02 / [ARCHY-1] | **DONE in this audit.** Route mnemonic generation through an injectable-RNG helper; production passes `OsRng`; known-answer test proves the injected RNG is consumed | `core/archipelago/src/seed.rs` | S | no | -| **R-01** | **F-01 (Critical)** | Gate `seed.generate` / `seed.restore` on onboarding being incomplete; add rate limits; narrow the mesh peer path filter | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../middleware.rs`, `.../rate_limit.rs`, `server.rs` | M | yes (re-onboard + federation re-verify) | -| **R-02** | F-03 (High) | Move `touch "$MARKER"` inside a both-succeeded branch so a failed regeneration retries next boot; surface the failure beyond a log file | `image-recipe/_archived/build-auto-installer-iso.sh` | S | **yes** (ISO rebuild + fresh flash) | -| **R-03** | F-03 (High) | Strip baked SSH host keys and the TLS keypair from the rootfs tar at build time, so a regeneration failure degrades to "no key" not "shared key" | `image-recipe/_archived/build-auto-installer-iso.sh` | M | **yes** | -| **R-04** | **F-13 (High)** | `disable_private_keys=true`; import the **xpub** with a `[fingerprint/derivation]` key origin; migrate with balance/UTXO parity verification | `core/archipelago/src/api/rpc/bitcoin.rs` | **PHASE** | **yes** (node with real UTXO history) | -| **R-05** | F-07 (Medium) | Add `cargo audit` / `cargo deny check advisories bans` to CI, with a `bans` rule failing on duplicate `rand` majors | CI config | S | no | -| **R-06** | F-05 (Medium) | Reconcile `Argon2::default()` (19 MiB / t=2) with ADR-005 (64 MB / 3) — either raise the params behind a versioned envelope with a migration, or amend the ADR | `core/archipelago/src/seed.rs`, `backup/full.rs`, `backup/identity.rs`, `docs/adr/005-...` | M | no | -| **R-07** | F-04 (Medium) | Confine seed-bearing RPCs to loopback/TLS; shrink `MNEMONIC_TTL`; clear on acknowledged verify with a short grace window | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../mod.rs`, nginx config | **PHASE** | yes | -| **R-08** | F-06 (Medium) | Make stdin/TTY the only mnemonic input for `ceremony sign`/`pubkey`; stop printing the mnemonic to stdout in `ceremony gen` | `core/archipelago/src/ceremony.rs` | S | no (but schedule deliberately — it is the signing ceremony) | -| **R-09** | [ARCHY-2] hardening | Persist the CSPRNG-readiness verdict as a durable structured event, so any node can answer post-hoc "was the pool ready when this seed was born?" | `core/archipelago/src/seed.rs` | S | no | -| **R-10** | [ARCHY-2] hardening | Add a test asserting the `getrandom` crate uses the **blocking** syscall, so the invariant is mechanical rather than a comment | `core/archipelago/src/seed.rs` | S | no | -| **R-11** | F-08 (Low) | Clear `_seed_words` on route-leave from onboarding, not only on successful verify; add a wall-clock expiry mirroring `MNEMONIC_TTL` | `neode-ui/src/views/OnboardingSeedGenerate.vue`, `OnboardingSeedVerify.vue` | S | no | -| **R-12** | F-09 (Low) | Replace `% charset.len()` with `SliceRandom::choose(&mut OsRng)` and pin the uniformity property with a test | `core/archipelago/src/totp.rs` | S | no | -| **R-13** | F-10 (Low) | ~~Swap `random_hex` / `random_base64` from `thread_rng()` to explicit `OsRng`~~ — **SUPERSEDED 2026-08-02 by R-16**; this file is 2 of 41 sites | `core/archipelago/src/container/secrets.rs` | S | no | -| **R-16** | **F-10a (Medium)** | Crate-wide enforcement so a defaulted RNG cannot be inherited anywhere: sealed allowlist trait at key-generation seams; `clippy.toml` `disallowed-methods` ban on `rand::thread_rng`/`rand::random` (compile-time, CI-enforced); `cargo-deny` on duplicate `rand` majors; degenerate-entropy runtime check; persist the CSPRNG-readiness verdict (absorbs R-05, R-09, R-13) | 15 files — see §F-10a | **PHASE** — tracked as **KEY-05**, Phase 10 | no | -| **R-14** | F-11 (Informational) | One-line comment at `pickRandomIndices` recording that the `Math.random()` is a UX challenge selector, not key material | `neode-ui/src/views/OnboardingSeedVerify.vue` | S | no | -| **R-15** | §6 checklist | Run the on-node verification checklist — especially C-3 (per-node SSH/TLS keys) and C-5 (cross-node collision test) | — | M | **yes** (2+ nodes from one ISO) | - -### Explicitly NOT implemented in this task, and why - -- **R-01, R-04, R-07** need their own phase. R-01 changes an authentication boundary on a live - fleet; R-04 moves the spending key out of a wallet holding real funds; R-07 changes the - onboarding transport. Each needs a migration story and real-node verification that an - audit-and-spec task cannot provide. -- **R-02, R-03** require rebuilding the ISO and flashing at least two machines to verify. Not - reachable from this environment. -- **R-13** is blocked purely by tree hygiene: `core/archipelago/src/container/secrets.rs` had - another agent's uncommitted changes at audit time and this plan's invariant is that no commit - it authors touches their files. Trivial once that work lands. -- **The whole of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`** is a rollout, not a fix. It is - queued as a spec for `/gsd-plan-phase`, not implemented anywhere. - ---- - -## 9. Related documents - -- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — the signing architecture this audit's - conclusions feed into (watch-only descriptors, PSBT, multisig, honest LND limits). -- `docs/hardware-signer-design.md` — exploratory TROPIC01 air-gapped signer. -- `docs/adr/005-chacha20-backup-encryption.md` — the ADR that F-05 diverges from. -- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md` - — the incident analysis, the T1-T7 catalogue, and the audit checklist this document executed. diff --git a/docs/security/KEY-01-ON-NODE-VERIFICATION.md b/docs/security/KEY-01-ON-NODE-VERIFICATION.md deleted file mode 100644 index ae5109c9..00000000 --- a/docs/security/KEY-01-ON-NODE-VERIFICATION.md +++ /dev/null @@ -1,224 +0,0 @@ -# KEY-01 on-node verification — audit item C-6 and the F-01 refusal proof - -**Status: INCOMPLETE — C-6 is NOT yet verified.** -**Opened:** 2026-08-02 · **Phase:** 10 (key-material hardening) · **Plan:** 10-02 -**Probe:** `scripts/security/rpc-exposure-probe.sh` - -This document records on-node evidence for -`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §6 item **C-6** ("Is the RPC endpoint -reachable unauthenticated from the LAN?") and for the KEY-01 / F-01 refusal shipped by -plan 10-01 (`core/archipelago/src/api/rpc/onboarding_gate.rs`, commit `879de59e`). - -Nothing below is recorded unless it was actually executed and its output observed. Rows -marked **NOT MEASURED** are open work, not assumptions. Per threat T-10-13 this document -records node **labels** and status codes only — never raw LAN addresses, onion addresses -or mesh ULAs, because this repository is being prepared for open-sourcing. - ---- - -## Probe-method correction - -**The audit's own C-6 command cannot detect the condition it claims to test. Do not -re-derive this; it has now been checked against the code twice.** - -`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901` probes with `seed.status` and declares -`200` a failure. But `seed.status` is **not** in `UNAUTHENTICATED_METHODS` -(`core/archipelago/src/api/rpc/middleware.rs:5-38`, which lists `seed.generate`, -`seed.verify`, `seed.restore` and `seed.save-encrypted` — not `seed.status`). An -unauthenticated `seed.status` is therefore rejected at -`core/archipelago/src/api/rpc/mod.rs:293` with a **401 by design**. The audit's "Fail: -200" criterion can never fire, so the probe would report the unauthenticated surface as -closed while F-01's actual door stands open. - -`scripts/security/rpc-exposure-probe.sh` measures the two facts separately: - -| Signal | Method | Why | Reading | -|---|---|---|---| -| **Exposure** | `auth.isOnboardingComplete` | genuinely unauthenticated (`middleware.rs:9`), read-only, no side effects | `200` = the unauthenticated RPC surface is reachable from this vantage point. This is the honest C-6 result. | -| **Session enforcement** | `seed.status` | deliberately *not* allowlisted | `401` = the session check is working. Anything else is a worse finding than C-6 and halts the phase. | - -The probe reports a reachable unauthenticated surface as `EXPOSED`, not `FAIL`: on the LAN -this is the current expected posture, and the purpose of C-6 is to **measure** the surface, -not to assert it is already closed. - ---- - -## C-6 — unauthenticated RPC reachability - -### Result table - -| Transport | Label | `health` | `auth.isOnboardingComplete` (exposure) | `seed.status` (enforcement) | Status | -|---|---|---|---|---|---| -| Loopback | `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 | -| Node's own LAN address, probed *from the node itself* | `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 | -| LAN, from a second machine | `lan` | — | — | — | **NOT MEASURED** | -| Tor onion | `tor` | — | — | — | **NOT MEASURED** | -| FIPS mesh ULA, from a peer node | `mesh` | — | — | — | **NOT MEASURED** | - -**`seed.status` returned `401` on every vantage point actually tested.** No -stop-the-plan condition was observed. - -### Why the two measured rows are NOT a C-6 result - -Both runs originated **on the node under test**. Packets to the node's own addresses are -delivered by the local stack and never traverse the LAN, so neither run exercises the -external path an attacker would use, and neither run passes through any host or upstream -filtering that applies only to foreign packets. They are recorded because they establish -two real facts — the probe works against a live daemon, and session enforcement is intact -— but C-6 asks specifically whether a **different machine** can reach the surface, and -that question is still open. - -### Verbatim probe output (measured rows) - -``` -$ bash scripts/security/rpc-exposure-probe.sh --target 127.0.0.1 --scheme http --port 80 --label loopback -RPC exposure probe — label=loopback endpoint=http://127.0.0.1:80 - audit item C-6 · KEY-01 (F-01) · read-only mode - -[loopback] health 200 REACHABLE endpoint answers from this vantage point -[loopback] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result) -[loopback] seed.status 401 PASS session enforcement active for non-allowlisted methods -[loopback] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200 -exit=0 -``` - -``` -$ bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label self-lan-ip -RPC exposure probe — label=self-lan-ip endpoint=http://:80 - audit item C-6 · KEY-01 (F-01) · read-only mode - -[self-lan-ip] health 200 REACHABLE endpoint answers from this vantage point -[self-lan-ip] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result) -[self-lan-ip] seed.status 401 PASS session enforcement active for non-allowlisted methods -[self-lan-ip] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200 -exit=0 -``` - -### Corroborating host state (observed, but NOT a substitute for the LAN measurement) - -Recorded because it predicts the LAN result and tells the operator what to expect: - -- nginx listens on **`0.0.0.0:80` and `[::]:80`** (`ss -ltn`), i.e. on every interface, - not on loopback only. The daemon itself is bound loopback-only on `127.0.0.1:5678`, so - all external reachability is via nginx. -- The host packet filter does **not** block port 80: `iptables -S INPUT` is - `-P INPUT ACCEPT` with a single jump into Tailscale's chain, and the `nft` ruleset - contains only Tailscale's `ts-input`/`ts-forward` chains — no rule matching tcp/80. - -Together these make an `EXPOSED` LAN result very likely. **That is a prediction, not a -measurement, and C-6 stays open until a second machine produces the status code.** - -### Incidental finding — `/rpc/` is not a second door - -`auth.isOnboardingComplete` on nginx's `location /rpc/` block -(`image-recipe/configs/nginx-archipelago.conf:192`) returned **404** from both vantage -points. The block proxies the full URI to the backend, which only routes `/rpc/v1`, so -the unauthenticated surface is reachable through exactly one path. This narrows F-01's -exposure surface by one path and should be re-checked if the nginx config changes. - ---- - -## KEY-01 refusal check — NOT PERFORMED - -**Requirement:** on a node running 10-01's gate, an unauthenticated `seed.restore` -carrying attacker-supplied words is refused, and `identity/node_key` and -`identity/nostr_secret` are byte-identical afterwards. - -**Blocker — no node in the fleet is running 10-01's gate yet.** Verified on the dev-box -rather than assumed: - -``` -$ ls -l /usr/local/bin/archipelago --rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago -$ git log -1 --format='%H %ci' 879de59e -879de59eccb489d590c8e0fca6ae79098df68200 2026-08-02 13:05:35 -0400 -$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago \ - && echo PRESENT || echo ABSENT -ABSENT -``` - -The installed binary was built at 06:37; 10-01 landed at 13:05 the same day, and the -gate's refusal string is absent from the running binary. A `--destructive` run against -this node would therefore **not** be refused — it would replace the node's identity. The -dev-box is a live dev-pair deploy target in real use, so the run was not made. - -**This check is blocked on deployment, which the phase brief explicitly excludes from -this plan.** It cannot be closed by any amount of work inside the repository. - ---- - -## Fresh-node onboarding non-regression — NOT PERFORMED - -**Requirement:** a genuinely un-onboarded instance completes the whole wizard with 10-01's -gate in place (the anti-brick proof for correctness trap 1 and the D-03a signal -correction), then refuses `seed.restore` immediately afterwards. - -**Blocker — no un-onboarded instance exists.** The intended harness is shape (A) of -`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` -(a second daemon under its own `ARCHIPELAGO_DATA_DIR`/`ARCHIPELAGO_BIND`/ -`ARCHIPELAGO_PORT_OFFSET`), and that todo is still **pending** — the harness has not been -built. It would additionally need a binary built from `879de59e` or later, which the -running daemon is not. - -Note for whoever builds it: that todo records that several constants ignore -`ARCHIPELAGO_DATA_DIR` and point at `/var/lib/archipelago` literally -(`bitcoin_rpc.rs:10`, `container/lnd.rs:131`, `electrs_status.rs:15`, -`api/rpc/package/pine_ha.rs:34-36`, `bootstrap.rs:242`, `disk_monitor.rs:41`), so a shape-A -instance must not install Bitcoin, LND, electrumx or Pine/HA — it would read and write the -live node's files. The onboarding walkthrough this check needs does not install apps, so -the hazard is avoidable, not blocking. - ---- - -## Pre-OTA fleet check carried over from 10-01 - -10-01's summary records a state that its gate makes unrecoverable: a node with -`onboarding.json = {"complete": true}` but **no** `user.json` can no longer call -`auth.setup`, and the recovery path needs a session it cannot create. Recovery is one SSH -command (`rm /var/lib/archipelago/onboarding.json`), but the fleet must be checked -**before** the OTA ships (D-10). - -| Node label | `user.json` | `onboarding.json` | Verdict | -|---|---|---|---| -| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally; the gate refuses re-keying, which is the intent | -| rest of fleet | — | — | **NOT CHECKED** | - -Command to run per node: - -```bash -ls -l /var/lib/archipelago/user.json /var/lib/archipelago/onboarding.json 2>&1 -cat /var/lib/archipelago/onboarding.json 2>/dev/null -``` - -A node is at risk only if `onboarding.json` says `complete: true` **and** `user.json` is -absent. - ---- - -## What is still required to close C-6 and KEY-01 - -Every item below needs an operator with fleet access; none can be done from the repository. - -1. **LAN exposure.** From a second machine on the node's LAN: - `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label lan` -2. **Tor exposure.** `torsocks bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label tor` -3. **Mesh exposure.** From a peer node over the FIPS mesh ULA: - `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label mesh` - (the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296` — so a - `200` confirms the mesh half of F-01's reachability claim). An unreachable transport is - recorded as `UNREACHABLE` with its error, never omitted. -4. **Deploy 10-01 to a disposable node**, then, from a second machine: - `bash scripts/security/rpc-exposure-probe.sh --target --destructive --label refusal` - with `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret` - captured on the node immediately before and after. The response must carry the - `Not supported:` prefix and the two digests must match character for character. -5. **Build shape (A)** and walk the wizard end to end on a 10-01 binary - (intro → options → path → seed → seed-verify → did → identity → backup → verify → done, - then set the password), reloading once on the seed screen to confirm the same 24 words - return. No `Not supported:` and no `Rate limit exceeded` may appear at any point. Then - re-run step 4 against that same instance to confirm the door closed behind onboarding. -6. **Check the remaining fleet** for the `onboarding.json`-without-`user.json` state above. - -Until items 1–3 are done, audit item **C-6 remains UNVERIFIED**. Until item 4 is done, the -KEY-01 refusal is proven only by 10-01's unit tests against temp directories, never against -a running daemon over HTTP. diff --git a/docs/security/KEY-02-FLEET-ROTATION.md b/docs/security/KEY-02-FLEET-ROTATION.md deleted file mode 100644 index 1b2c515e..00000000 --- a/docs/security/KEY-02-FLEET-ROTATION.md +++ /dev/null @@ -1,244 +0,0 @@ -# KEY-02 — fleet host-secret detection and rotation (F-03, deployed half) - -Phase 10 plan 10-04. Companion to `docs/security/KEY-02-ROOTFS-EVIDENCE.md`, which covers the -build half (10-03). - -10-03 stopped the exposure growing: the ISO no longer bakes SSH host keys or a TLS keypair into -the shared rootfs, and first-boot regeneration now fails closed instead of setting its completion -marker on a failed run. That does **nothing** for nodes already in the field, which is exactly -where the exposure sits — a node that hit the old fail-open path is running the SSH host key and -TLS private key that every downloader of that ISO also holds, and it will never try again. - -This document records the two human decisions that govern the deployed half. - ---- - -## D-06 rotation trigger - -**Chosen option: `detect-report-then-apply`** — recorded 2026-08-02. - -Verbatim option id as written in `10-04-PLAN.md`: **`detect-report-then-apply`** -("Detect and report on boot; rotate only when an operator runs the script with an explicit apply -flag"). - -### Why - -Rotating an SSH host key is one-way. Every `known_hosts` entry for that node breaks, on every -machine that has ever connected to it, and the old private key is destroyed by the swap. The -fleet is reached over Tailscale for day-to-day work and several nodes are remote — `.228` is at -a remote site and is in real use (CLAUDE.md). `auto-on-boot` would fire that rotation on many -nodes simultaneously during an OTA rollout, with no advance notice and no operator holding the -new fingerprints. A node whose only access path is SSH and whose tooling pins the host key -becomes unreachable until someone clears the entry; a rotation that fails partway on a remote -node needs physical console access to recover, which for `.228` means a site visit. - -Against that, the cost of `detect-report-then-apply` is that exposure persists on any node whose -operator does not act. That cost is bounded by making the verdict **visible**: detection runs at -boot on every node and the verdict reaches `system.stats`, so an exposed node shows up in the -dashboard without shell access. The exposure becomes measured rather than assumed, and the list -of nodes still to rotate is a fact on a screen rather than a guess. - -This also matches the project's standing policy that changes are verified on the dev pair -(archi-dev-box + x250-dev) before they reach the fleet (CLAUDE.md, `feedback_dev_pair_before_ota`). -A rotation that fires unattended on first boot after an OTA cannot be dev-paired — by the time it -has been observed on the dev pair it has already run everywhere. - -### What this decision binds - -- `scripts/security/host-secrets-audit.sh` defaults to `--detect`, which is read-only. -- `--apply` **without** `--yes` prints its plan and exits 0 having touched nothing, so a mistyped - invocation is inert. -- `image-recipe/configs/archipelago-host-secrets-audit.service` ships in **detect-only** mode. - It contains no apply path. Making the boot unit rotate would require editing the unit, which is - a deliberate act, not a default. -- `--apply --yes` refuses to do anything unless the detect pass returned `shared`. A node whose - verdict is `per-node` cannot have its keys rotated by this script even by explicit command — - the guard against "operator runs it on the wrong node" is structural, not procedural. - -### Consequence recorded honestly - -Any node whose verdict comes back `shared` and which is never revisited stays exposed -indefinitely. The mitigation is the visibility, not the automation. The list under -"Nodes with a `shared` verdict, deliberately not rotated" below exists so that no such node is -quietly forgotten, and it is part of this plan's acceptance criteria that the list is kept. - ---- - -## How a node decides - -Four on-disk signals, evaluated in this precedence order by -`scripts/security/host-secrets-audit.sh --detect`. Every verdict carries the evidence strings -that produced it, and each evidence string names the file it was read from. - -| # | Signal | Source | -|---|---|---| -| 1 | mtime of each host key / the TLS key against the first-boot anchor | `/var/lib/archipelago/.secrets-regenerated`, falling back to `/root/.luks-archipelago.key` then `/etc/machine-id` | -| 2 | The fail-open fingerprint: marker present **and** a `WARNING:` line in the first-boot log | `/var/log/archipelago-first-boot-secrets.log` | -| 3 | 10-03's durable failure record | `/var/lib/archipelago/first-boot-secrets.failed` | -| 4 | Rootfs provenance | `/opt/archipelago/rootfs-identity-stripped` | - -Verdicts: `per-node`, `shared`, `fail-closed-missing`, `unknown`. - -**`per-node` is never reported on the strength of an absent signal.** With no anchor at all the -verdict is `unknown`, and while a durable failure record stands the verdict is `unknown` rather -than `per-node` — the node's own generator most recently reported failure, so a clean-looking -mtime is not evidence of success. - -Signal 4 changes the meaning of missing material rather than adding to the shared/per-node -question: on a node flashed from a 10-03-or-later ISO the rootfs shipped identity-free, so an -absent host key is a **fail-closed** state (generation never succeeded), not a shared one. - ---- - -## C-3 — per-node host key and TLS uniqueness - -Audit checklist item C-3 (`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §855), described -there as "the highest-value check here". - -### Status: **FAILED — with finding.** Recorded 2026-08-02. - -> **This section names live fleet nodes that are still running shared key material. -> Review it before this repository is made public** (`docs/OPEN-SOURCE-READINESS-PLAN.md`). -> Digests below are truncated; the fingerprints of public keys are public data — every SSH -> handshake offers them — but there is no reason to make a target list convenient. - -**Three distinct live fleet nodes share all three of their SSH host keys. Two of those three -also share their TLS certificate, and therefore their TLS private key.** This is not a -theoretical exposure: it is F-03 in production, today. - -#### Method - -Gathered **remotely and read-only** — no node was logged into, nothing was written to any node, -nothing was rotated. Host keys came from `ssh-keyscan`, which is what every SSH client does -before it decides whether to trust a host, and certificates from an anonymous TLS handshake: - -```bash -ssh-keyscan -T 6 | ssh-keygen -lf - -openssl s_client -connect :443 /dev/null \ - | openssl x509 -noout -fingerprint -sha256 -subject -``` - -This is a deliberately weaker instrument than the checklist's on-node commands, and it was chosen -because it needs no access and can therefore cover the whole reachable fleet rather than two -nodes. What it can prove is exactly the FAIL condition: *any fingerprint appearing on two nodes*. - -#### Result - -| Node label | SSH host keys (ECDSA/ED25519/RSA, truncated) | TLS cert sha256 (truncated) | Cert CN | -|---|---|---|---| -| `archipelago-1` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` | -| `archy-x250-beta` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` | -| `archipelago` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `7C:6B:CD:98…` | `austin-sapien` | -| `archipelago-5` | `/bmgd6jS…` / `SpaNfLLf…` / `hhVFABi3…` | `95:FE:EB:C7…` | `archipelago.local` | -| `archi-dev-box` | `8hFU7QGM…` / `GAxNAcgX…` / `Tv7AfaVp…` | (no :443 listener) | — | -| `archy-dev-pa` | `JtD/RM0a…` / `XD2A5OVL…` / `esIBpbWk…` | not probed | — | -| `framework-pt` | `oicpsj3Y…` / `zxA1/kRU…` / `oxi+tMli…` | `88:85:CE:CC…` | `framework-pt` | -| `shorty-s` (`.228`) | `YVsgrv8M…` / `D/5n851i…` / `YMFLUerk…` | `4D:98:D4:9B…` | `shorty-s` | - -Unreachable at scan time, so **UNVERIFIED**: `archy-x250-dev`, `archy-x250-pa`, `archy-x250-r2`, -`quantumterminal`. - -#### That the three are genuinely different machines, not one host seen three times - -The obvious alternative explanation for identical host keys is a single machine registered on the -tailnet more than once. Ruled out: - -- All three answered a live TCP connection on port 22 within the same minute. One `tailscaled` - instance serves one tailnet identity, so three simultaneously-live addresses are three hosts. -- `tailscale ping` resolves them to **different physical endpoints**: `archy-x250-beta` answers - from `178.38.147.13` (and over the Frankfurt DERP), while `archipelago-1` and `archipelago` - answer from `45.20.199.86` on different source ports — a different continent for the first, - and two distinct machines behind one NAT for the other two. -- They are owned by different tailnet accounts. - -#### Why `archipelago` has a different TLS cert but the same SSH keys - -Its cert CN is `austin-sapien`, not the image default `archipelago`. That is the signature of a -node that was **renamed** through `server.set-name`, which re-mints the TLS cert via -`regenerate_tls_cert()` so the SAN matches the new hostname — and touches nothing else. - -This is worth stating plainly because it is a trap: **TLS uniqueness alone is not evidence that -a node's key material is per-node.** Any renamed node gets a unique certificate for free while -its SSH host keys stay exactly as the image shipped them. Had C-3 been checked on TLS -fingerprints only, `archipelago` would have looked clean. The SSH host key is the reliable -signal, and this is why the audit script treats the two classes separately and reports which one -is shared rather than issuing a single node-level verdict. - -#### What this does NOT establish — UNVERIFIED - -| Claim | Status | Evidence still needed | -|---|---|---| -| The three nodes were flashed from the **same ISO** | UNVERIFIED | Not required for the FAIL — shared host keys are the exposure however they got there — but the ISO build id would tell us how many other downloads carry the same keys. Needs on-node `/opt/archipelago/` provenance. | -| The audit script's verdict on those three nodes | UNVERIFIED | `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect` on each. Requires the OTA carrying this plan's runtime payload to land, or the script to be hand-staged. Predicted `shared`; predicted is not observed. | -| A rotation preserves the operator's own session | UNVERIFIED **on hardware** | Checkpoint steps 4–6: run `--apply --yes` on one disposable node from a session you are willing to lose, confirm that session survives, confirm a second connection shows the expected mismatch. The harness proves the script's ordering and its abort path; it cannot prove that `systemctl reload ssh` keeps a real forked session alive. | -| `host_secrets` reaches `system.stats` on a real node | UNVERIFIED | Needs a build carrying this plan deployed to the dev pair, then a `system.stats` call. Proven in unit tests against the file contract only. | -| The four unreachable nodes | UNVERIFIED | Re-run the scan when they come back online. | - -#### Consequence - -`archipelago-1`, `archy-x250-beta` and `archipelago` are a **confirmed live F-03 instance**. -Anyone holding a copy of the ISO these nodes were flashed from holds their SSH host private keys, -and for the first two, their TLS private key as well — enough for undetectable SSH host -impersonation and transparent MITM of the web UI. - -None of them was rotated as part of this verification, and that is deliberate: this checkpoint -verifies, it does not remediate, and remediating a node inside a verification task is how a -verification task takes a node offline. They are recorded below. - ---- - -## Nodes with a `shared` verdict, deliberately not rotated - -Any node that reports `shared` and is not rotated in the same session MUST be added here with the -date and the reason, so that the standing consequence of `detect-report-then-apply` is a visible -list rather than an assumption. - -| Node label | Date detected | Why not rotated | Next step | -|---|---|---|---| -| `archipelago-1` | 2026-08-02 | Detected by remote fingerprint comparison during C-3, not by an operator running the script. In real use; rotating it inside a verification task is exactly what the task forbids. | Stage the script, run `--detect`, then rotate from a session the operator is willing to lose. | -| `archy-x250-beta` | 2026-08-02 | Same. Also shares its **TLS private key** with `archipelago-1`, so it is the more urgent of the two. Reached over a DERP relay from another continent — the least recoverable node in the set if a rotation goes wrong. | Rotate from physical or console access if available; otherwise rotate TLS first, confirm, then SSH. | -| `archipelago` | 2026-08-02 | Same. TLS is already unique (the node was renamed, which re-mints the cert); only its SSH host keys are shared. | `--apply --yes` will rotate SSH only — the detect pass flags the classes separately, so this node's already-unique TLS pair is left alone. | - -**Nobody has been told their `known_hosts` is about to break.** Three nodes here are in real use; -the rotation is one-way and every existing entry for them dies with it. Sequencing that is an -operator decision, which is the whole content of D-06. - ---- - -## Operator runbook — rotating one node - -Run this from a session you are willing to lose, on **one node at a time**. Never on `.228` or -any node in real use without arranging access recovery first. - -```bash -# 1. Detect. Read-only; safe on any node, including production. -sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect -cat /var/lib/archipelago/host-secrets-audit.json - -# 2. Dry run. Prints the plan, touches nothing, exits 0. -sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply - -# 3. Rotate. Only proceeds if the verdict is `shared`. -sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes - -# 4. WITHOUT closing that session, prove it survived: -echo still-here - -# 5. From a second terminal, expect a host-key mismatch warning. That is the -# correct outcome. Update known_hosts against the fingerprints printed by -# step 3 (also in /var/lib/archipelago/host-key-rotation.json), never by -# blindly accepting whatever is offered. -ssh-keygen -R -ssh - -# 6. The web UI will present a new self-signed cert. A fresh browser trust -# prompt is expected and is the correct outcome. -``` - -The script reloads sshd rather than restarting it. A reload re-execs the listener while -already-forked session children keep running, which is why the operator's own SSH session -survives its own rotation. `restart` would kill it, and on a remote node with no console that is -unrecoverable. - -Old fingerprints are written to `/var/lib/archipelago/host-key-rotation.json` **before** the -swap, so an operator who loses access anyway can still identify what changed. diff --git a/docs/security/KEY-02-ROOTFS-EVIDENCE.md b/docs/security/KEY-02-ROOTFS-EVIDENCE.md deleted file mode 100644 index 7e0b06f1..00000000 --- a/docs/security/KEY-02-ROOTFS-EVIDENCE.md +++ /dev/null @@ -1,208 +0,0 @@ -# KEY-02 — build-host evidence for the rootfs identity strip - -**Audit item:** C-4 of `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (§868), which -belongs to finding **F-03** (fail-open, never-retried first-boot secret regeneration over -a fleet-shared rootfs). - -**Status: ⛔ UNVERIFIED — awaiting a run on a real ISO build host.** - -The code change is committed and unit-tested; the tar listing that proves its effect on a -real build has not been produced yet, because it requires a build host with podman/docker -and enough disk for a full rootfs rebuild. Do not read anything below the "Result" heading -as a passing check until it is filled in. - -| Field | Value | -|---|---| -| Builder commit (Task 1) | `21043096` — fail-closed first-boot regeneration | -| Builder commit (Task 2) | `408b328c` — rootfs identity strip | -| Builder commit (follow-up) | single-producer unification, build-time generator assertion, self-heal timer | -| Builder file | `image-recipe/_archived/build-auto-installer-iso.sh` (LIVE; `image-recipe/build-debian-iso.sh` execs it) | -| Build host | _to be recorded_ | -| Date run | _to be recorded_ | -| RECIPE_HASH observed | _to be recorded — read it from the stamp file, see the caveat below_ | - ---- - -## The expectation is deliberately INVERTED relative to the audit - -This is the single most important thing to understand when comparing this document with the -audit, and the reason it is stated before the commands rather than after. - -The audit's C-4 entry says: - -> **Expected:** SSH host keys and the TLS key **present** (they are baked — see -> `build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id` -> absent or zero-length. Anything else changes F-03's severity. - -That expectation described the **broken** state the audit found, and recording it was how the -audit measured the size of F-03. Phase 10 plan 10-03 Task 2 removed that material. So: - -**After this change, the audit's stated expectation is the FAILURE condition.** If SSH host -keys or the TLS private key still appear in the tar, the strip layer did not run — most -likely because a cached `archipelago-rootfs.tar` was reused. That is not a regression in the -check; it is the check working. - -The two negative findings the audit recorded are unchanged and must still hold: -`var/lib/systemd/random-seed` absent, `etc/machine-id` absent or zero-length. - ---- - -## Commands to run - -Run all of these **on the build host**, from the repo root, on a checkout that contains -commits `21043096` and `408b328c`. - -### 1. Force a full rebuild - -The strip layer lives inside the `RECIPE_HASH` region (between the `# STEP 1: Build complete -root filesystem` and `# STEP 2: Build minimal installer` markers), so the hash changes and the -cached tar is invalidated automatically. `--rebuild` is passed anyway so that a stale tar -cannot mask the result for any reason: - -```bash -UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild -``` - -`UNBUNDLED=1` is mandatory per `CLAUDE.md` and project memory — the default env silently -builds the wrong full-bundle variant. - -### 2. List the identity artefacts in the shipped tar - -`WORK_DIR` is `image-recipe/build/auto-installer`, so: - -```bash -tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \ - | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago' -``` - -### 3. Expected result after this plan - -- **no** `etc/ssh/ssh_host_*` entries at all -- **no** `etc/archipelago/ssl/archipelago.key` and **no** `archipelago.crt` - (the `etc/archipelago/ssl/` **directory** must still be present — the first-boot staging - swap needs somewhere to land) -- **no** `var/lib/systemd/random-seed` -- `etc/machine-id` present with size **0**, or absent. Either satisfies "not shared"; record - which one was actually observed rather than generalising. - -Note on the TLS keypair specifically: it is now absent for two independent reasons, not one. -The Dockerfile no longer generates it at all (that layer was removed so there is a single -producer), *and* the strip layer still deletes it as belt-and-braces in case a future layer -starts baking one. Seeing it present therefore means both defences were bypassed. - -### 4. Confirm the provenance file rode along - -```bash -tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar | grep rootfs-identity-stripped -``` - -Expected: one entry, `opt/archipelago/rootfs-identity-stripped`. Its absence means the strip -layer did not execute and the whole check is void. - -### 5. Confirm the regeneration path and its self-heal timer are still shipped - -This is the brick check, and it is not optional. A stripped rootfs whose first-boot -generation script failed to ship would leave every flashed node with no SSH host key and -nothing to create one. The timer is part of the same check: without it, a node whose -generators fail every in-boot retry has no unattended way back. - -```bash -ls -l image-recipe/build/auto-installer/installer-iso/archipelago/scripts/first-boot-secrets.sh \ - image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service \ - image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.timer -``` - -Expected: all three present, `first-boot-secrets.sh` executable. - -### 5b. Confirm the build-time generator assertion actually ran - -The rootfs build fails outright if `openssl` or `ssh-keygen` is missing or non-executable, -because that is the one way first-boot generation can fail deterministically — retries and -reboots would never fix it, so it must never reach a node. A successful build therefore -already proves the generators are present, and the build log says so: - -```bash -grep 'first-boot secret generators present' -``` - -If you did not capture the log, assert it against the tar instead: - -```bash -tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \ - | grep -E 'usr/bin/(openssl|ssh-keygen)$' -``` - -Expected: both present and mode `-rwxr-xr-x`. - -### 6. Record the RECIPE_HASH the builder actually used - -```bash -cat image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256 -``` - -**Caveat — do not compute this hash from the repo file.** `image-recipe/build-debian-iso.sh` -copies the archived builder to a temp path and rewrites its relative paths before exec'ing it, -and `RECIPE_HASH` hashes `"$0"` — the rewritten copy. The hashed region contains 35 such -rewritten path expressions, and `SCRIPT_DIR` is substituted with an absolute path, so the hash -is specific to the build host and checkout location. For reference, hashing the region of the -committed repo file directly gives `d2dc4df5427fe73d48227aab08cdf6debfe8dd554e6b18e3718f8d37ea9d675c`, -which is **expected to differ** from the stamp above. - ---- - -## Result - -_Paste the raw output of steps 2, 4, 5 and 6 here, then set the status at the top of this -document to VERIFIED with the date and build-host label._ - -```text -(pending — not yet run on a build host) -``` - -**Verdict:** _pending_ - ---- - -## What this does and does not prove - -**Proves (once run):** the rootfs tar extracted verbatim onto every disk flashed from the ISO -carries no SSH host key, no TLS private key and no populated machine-id — so a first-boot -regeneration failure degrades to "no key, the service refuses to start" rather than -"fleet-shared key, silently", which is the substance of F-03. - -**Does not prove:** that two nodes flashed from the same ISO actually end up with different -keys. That is audit item **C-3** (§779) and needs two physical machines; it remains -separately UNVERIFIED. C-4 is a build-host check only. - -### Guidance for C-3: SSH and TLS are now equally sharp signals - -An earlier revision of this document said SSH host keys were the sharper divergence signal for -C-3, because the installer had a per-install TLS fallback that would produce a differing cert -even if first-boot generation had failed. **That asymmetry no longer exists.** - -There is now exactly one producer of each secret — `gen_tls()` and `gen_ssh()` inside -`first-boot-secrets.sh` — and no other code in the ISO build creates either. The Dockerfile no -longer bakes a TLS keypair and the installer's "ensure SSL cert exists" block is gone. So for -C-3, treat both the same way: - -```bash -# on each node -ssh-keyscan -t ed25519 localhost 2>/dev/null | ssh-keygen -lf - -openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256 -``` - -**Pass:** both fingerprints differ between the two nodes. **Fail:** either matches — a matching -TLS fingerprint is now exactly as damning as a matching host key, whereas before it could have -been explained away by the fallback. - -Also check, on each node, that the run actually succeeded rather than merely being quiet: - -```bash -ls -l /var/lib/archipelago/.secrets-regenerated # present on a healthy node -cat /var/lib/archipelago/first-boot-secrets.failed 2>&1 # absent on a healthy node -systemctl status archipelago-first-boot-secrets.timer # enabled; the self-heal path -``` - -The audit's original C-3 fail condition — a `WARNING:` line in the log alongside an existing -marker — can no longer occur by construction: the marker is only written when both generators -succeeded. If you ever see that combination, the fix has been reverted. diff --git a/docs/security/KEY-03-SIGNING-POSTURE.md b/docs/security/KEY-03-SIGNING-POSTURE.md deleted file mode 100644 index f63bf3df..00000000 --- a/docs/security/KEY-03-SIGNING-POSTURE.md +++ /dev/null @@ -1,448 +0,0 @@ -# KEY-03 — Signing posture after the Bitcoin Core wallet deletion - -> **What this document is.** The evidence-backed record of how Archipelago's Bitcoin signing -> posture stands after Phase 10 KEY-03. It supersedes, for the Bitcoin Core wallet specifically, -> the target state described in `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 1 — that -> phase planned to *convert* Core's wallet to watch-only; **D-07b deleted the path instead.** -> -> **Governing decisions:** `.planning/phases/10-key-material-hardening/10-CONTEXT.md` -> **D-07b** (final KEY-03 scope — delete, do not migrate) and **D-07c** (the deferred BDK cold -> vault, recorded so it is not lost with the code). D-07b supersedes D-07 and D-07a's conditional -> migration. -> -> **Audit finding closed:** F-13 (High) — -> `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:604`, remediation register R-04. - ---- - -## Bitcoin Core wallet path — deleted (D-07b) - -### What was deleted - -| Symbol | Kind | Location before deletion | -|---|---|---| -| `handle_bitcoin_init_wallet_from_seed` | `async fn` | `core/archipelago/src/api/rpc/bitcoin.rs:161-295` | -| `"bitcoin.init-wallet-from-seed"` | JSON-RPC dispatch arm | `core/archipelago/src/api/rpc/dispatcher.rs:122-124` | - -### The defect (F-13) - -The handler loaded the encrypted seed, derived the **BIP-84 account extended private key** -(`crate::seed::derive_bitcoin_xprv`, `bitcoin.rs:188`), stringified it (`:189`), and imported -`wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` (`:230-231`) into a Bitcoin Core descriptor wallet created -with `disable_private_keys = false` (`:203`) and an **empty** wallet passphrase (`:205`). - -The result was a **second copy of the node's spending key**, persisted in Core's `wallet.dat` -inside the Bitcoin container's data volume, with no Argon2 passphrase — while the first copy sits -in the daemon's Argon2 + ChaCha20-Poly1305 envelope written `0600` -(`core/archipelago/src/seed.rs:238-269`, `:318-324`). That duplication, into weaker protection, -was the entire finding. - -### Evidence that deletion was the right close (re-established for this task, not inherited) - -The four D-07a evidence points, verified again against the tree before anything was removed: - -**1. No caller anywhere.** Repo-wide search across `core/`, `neode-ui/src`, `scripts/`, `web/`, -`apps/`, `tests/` and `docs/`, excluding `core/target`, `node_modules` and `.git`: - -``` -$ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/ -core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => { - -$ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/ -core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed( -core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await -docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes -docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`): -docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`). -``` - -Exactly one occurrence of the method name (its own dispatcher registration) and two of the symbol -in code (its definition and the dispatcher call). The three remaining symbol hits are prose in -documentation — the audit, the task tracker, and the PSBT architecture spec — not callers. No -frontend, script, test or other Rust module invoked it. - -**2. LND is the wallet the product actually drives.** Across all of `neode-ui/src`, every -`bitcoin.*` RPC call is read-only status: `bitcoin.getinfo` (14 call sites), -`bitcoin.prune-status` (3), `bitcoin.onion` (1). There are **no** `bitcoin.*` wallet operations. -The wallet UI (`Web5Wallet.vue`, `SendBitcoinModal.vue`) sends via `lnd.sendcoins`, estimates via -`lnd.estimatefee`, and reads balance via `lnd.getinfo`. - -**3. The wallet it creates never existed on the reference node.** Verified live on -**archi-dev-box, 2026-08-02**, against the running `bitcoin-knots` container (read-only RPCs -only — see the census section for the exact commands and the standing ban on -`listdescriptors true`): - -``` -listwalletdir → { "wallets": [ "gatewayd-02004b91…", "gatewayd-03443c0c…", "" ] } -listwallets → [ "" ] -``` - -**There is no wallet named `archipelago`** — the handler's default `wallet_name` -(`bitcoin.rs:170-173`). It has never run on this node. `getwalletinfo` on the one loaded wallet -(the unnamed default) reports: - -``` -walletname: "" blank: true keypoolsize: 0 -txcount: 0 balance: 0.00000000 -descriptors: true private_keys_enabled: true -``` - -`blank: true` with `keypoolsize: 0` and `txcount: 0` is Bitcoin Core's own statement that **no -key was ever imported into it and no transaction ever touched it**. The two `gatewayd-*` entries -are Fedimint gateway wallets, unrelated to the BIP-84 path. The `wallet.dat` at the datadir root -is Core's own legacy default-wallet location, not this handler's output. - -**This is one node.** The same check was subsequently run across the reachable fleet — see the -census below: **4 nodes examined and clear, 6 unreachable and therefore unknown.** - -**Supporting history evidence:** `git log -S "init-wallet-from-seed"` scoped to -`core/archipelago/src/api/rpc/dispatcher.rs` and `neode-ui/src` returns exactly one commit — -`19dcfd4f feat: BIP-39 master seed for unified key derivation`, the commit that **added** it. No -frontend wrapper was ever written: it was built and never wired up. - -**4. It was never remotely reachable.** The endpoint is absent from `UNAUTHENTICATED_METHODS` -(`core/archipelago/src/api/rpc/middleware.rs:5-40`) — so it required an authenticated session — -**and** it additionally re-verified the user's password before touching the seed -(`self.auth_manager.verify_password(password)`, `bitcoin.rs:176-179`). **F-13 was therefore -key-at-rest duplication, not an exposed endpoint.** That is why it was rated High rather than -Critical, and why deleting it is a hardening measure rather than an incident response. - -### What was *not* wrong with it - -Worth stating so the record is fair, and so the next reader does not mistake the lesson. The -in-memory handling of the xprv string was **careful**: it was zeroized on the error path -(`bitcoin.rs:222`) and on the success path (`:284`), matching the standard set elsewhere in -`seed.rs`. The wallet type was also correct — `createwallet` already passed `descriptors = true` -(`:207`), which is the right foundation. - -**The defect was which key went into the wallet, not how the key was held in memory or what kind -of wallet it was.** A watch-only rewrite (xpub + `[fingerprint/derivation]` key origin) would -have been a legitimate fix. Deletion was chosen over rewrite because the endpoint had no caller, -no consumer, and no product role: rewriting it would have produced a correct implementation of -something nothing uses, and left a wallet-creating code path to be maintained and re-audited -forever. - -### How F-13 is closed - -**By removal, not by conversion to watch-only.** After this change there is no code path in the -daemon that writes the BIP-84 account private key into Bitcoin Core. The only on-node copy of -that key is the daemon's Argon2 + ChaCha20-Poly1305 envelope. - -**No migration was performed and none is planned.** D-07's parity-proof migration and its -one-way checkpoint are **withdrawn** (D-07b) — there is no wallet to migrate. If a fleet node is -ever found holding a descriptor wallet this handler created, that is a **finding to surface and -stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked by hand and that -node's spending key is duplicated in Core, which deserves a human decision rather than an -automated rewrite of a wallet that may hold funds. - -### This deletion removes code, not wallets - -Stated explicitly so nobody reading the change later has to wonder whether it was destructive: - -> **Nothing on disk is touched.** No `wallet.dat` is modified, unloaded or removed. No funds -> move. No LND state, secret, descriptor or seed is altered. The change removes a Rust function -> and a `match` arm — the *path* by which a private key could be imported into Bitcoin Core — -> and nothing else. - -This holds even on a hypothetical node where the endpoint had been invoked by hand: deleting the -handler destroys nothing there either. It closes the door; it does not clean the room. Cleaning -up such a wallet, if one is ever found, is a separate human decision (see the census below), and -CLAUDE.md's **"migrations never destroy data"** invariant is not engaged by this change because -there is no migration. - -### What deletion does to D-08 and D-09 - -Neither decision lapses; both are satisfied by a different mechanism. - -- **D-08** asked that the spending key exist in exactly one place, with an opt-in air-gapped - path. Deleting the Core import achieves the first half outright. The opt-in path is LND's - existing PSBT round trip, not a Core watch-only wallet — see the next section, including the - recorded verdict on how far that actually goes today. -- **D-09** required a `[fingerprint/derivation]` key origin on emitted descriptors so a hardware - signer can locate its key. With Core's descriptors deleted there are **no Archipelago-emitted - descriptors left to annotate**, so D-09's actual protection moves to the PSBT itself. That is - why `lnd.create-psbt` now inspects and reports the key-origin data its PSBT carries - (`psbt_key_origin_report`, `core/archipelago/src/api/rpc/lnd/wallet.rs`). - -### `derive_bitcoin_xprv` is retained deliberately (D-07c) - -`crate::seed::derive_bitcoin_xprv` (`core/archipelago/src/seed.rs:231`) lost its only non-test -caller and was **kept**, marked `#[allow(dead_code)]` with the reason in its doc comment. It is -covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation **D-07c's deferred -BDK cold vault** — a descriptor wallet in the daemon using the node's own ElectrumX app -(`apps/electrumx`, `electrs_status.rs`) as chain source — will need. - -D-07c was considered and deliberately deferred out of Phase 10 (it needs its own phase: a new -dependency and a new UI surface). It is recorded here, and in the function's doc comment, so the -option is not quietly lost along with the code that was deleted. The alternative shape — LND -watch-only via `importaccount` plus remote signing — was considered and rejected for coupling -cold storage to LND's upgrade path. - ---- - -## LND PSBT round trip — what is covered - -With Core's wallet deleted, LND is the only wallet Archipelago has, and its PSBT round trip is -the only external-signer path that exists. This section records what that path actually consists -of, what is tested, and — the question that decides whether any of it is an air gap — whether an -externally-held signer can sign a default node's PSBT at all. - -### Per-step coverage map - -Round trip: **fund → export → sign offline → import → finalize → broadcast.** - -| # | Step | Where it lives | `file:line` | Automated test coverage | -|---|---|---|---|---| -| 1 | **Fund** — build a funded PSBT via LND WalletKit `/v2/wallet/psbt/fund` | `lnd.create-psbt` handler | `core/archipelago/src/api/rpc/lnd/wallet.rs:605`; dispatch arm `api/rpc/dispatcher.rs:136` | **Untested.** No LND mock exists; the handler's request/response handling is exercised only by hand. | -| 1a | **Inspect** — report BIP-32 key origin on the funded PSBT | `psbt_key_origin_report` + wiring | `lnd/wallet.rs:1186` (fn), `:1169` (struct), `:705` (call site), `:737` (response field) | **Tested.** 3 unit tests, below. | -| 2 | **Export** — hand the base64 PSBT to the user | UI renders `psbt_base64` for copy | `neode-ui/src/api/rpc-client.ts:407-423`; `neode-ui/src/views/web5/Web5SendReceiveModals.vue:308` | **Partial.** `neode-ui/src/api/__tests__/rpc-client.test.ts:319-323` asserts only that the client calls the method `lnd.create-psbt`; it does not test the payload or the rendering. | -| 3 | **Sign offline** — external signer produces a signed PSBT | **Not in this repo.** No first-party signer ships today. | — | N/A | -| 4 | **Import** — user pastes the signed PSBT back | textarea → `signedPsbtInput` | `Web5SendReceiveModals.vue:102`, `:419-424` | **Untested.** | -| 5 | **Finalize** — `/v2/wallet/psbt/finalize` | `lnd.finalize-psbt` handler | `lnd/wallet.rs:743`; dispatch arm `dispatcher.rs:137` | **Untested.** | -| 6 | **Broadcast** — `/v2/wallet/tx`, in the same handler | `handle_lnd_finalize_psbt` tail | `lnd/wallet.rs:795` | **Untested.** | -| — | **Rate limiting** — both endpoints at 5 calls / 300s | `RateLimiter` defaults | `core/archipelago/src/rate_limit.rs:68-69` | **Untested for these two methods specifically.** | - -**Stated plainly, because an untested path must not be described as verified:** of the six steps, -**one** (the key-origin inspection added by this plan) has automated coverage in the Rust -crate. Steps 1, 4, 5 and 6 have **none** — no test exercises the LND REST calls, the finalize -handler, or the broadcast. Step 2's only test asserts a method name. **No end-to-end test of the -round trip exists**, and none of it has been verified against a real hardware signer. - -There is also **no air-gap transport**: no animated QR encode/decode, no `.psbt` file -download/upload. Export and import are copy-paste of base64 in a textarea. The BC-UR v2 / BBQr -design in `PSBT-SIGNING-ARCHITECTURE.md` §4 is unimplemented. - -### New tests added by this plan - -In `core/archipelago/src/api/rpc/lnd/wallet.rs`'s `mod tests`, with fixtures built -programmatically from the `bitcoin` crate rather than pasted as opaque base64: - -| Test | Asserts | -|---|---| -| `psbt_without_derivations_reports_no_key_origin` | A one-input unsigned PSBT with no `bip32_derivation` reports `inputs_with_key_origin: 0` and `all_inputs_have_key_origin: false`. | -| `psbt_with_derivations_reports_key_origin` | The same PSBT with a `(Fingerprint, DerivationPath)` inserted on input 0 reports `1/1` and `true`. | -| `malformed_psbt_is_an_error_not_a_panic` | Non-base64, truncated-PSBT and empty inputs all return `Err`, never panic. | - -``` -running 3 tests -test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok -test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok -test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out -``` - -`lnd.create-psbt` now returns an additive `key_origin` field: - -```json -"key_origin": { "input_count": 1, "inputs_with_key_origin": 0, "all_inputs_have_key_origin": false } -``` - -It is computed **best-effort**: a decode failure degrades to `null` and logs a warning, never to -an error — a user's send must not fail because an inspection helper could not parse something. -When `all_inputs_have_key_origin` is false the handler emits a `tracing::warn!` with the counts, -because that is the exact condition under which a hardware signer refuses the PSBT. Existing -response fields are unchanged; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (the -sibling that deliberately auto-signs with LND's hot keys) were not touched. - -### Can an external signer actually sign a default node's PSBT? — **No, not today** - -This is the question that separates "we have PSBT plumbing" from "we have air-gapped custody", -and the two must not be allowed to blur. - -**Verdict: on a default Archipelago node, an externally-held signer cannot meaningfully sign a -PSBT produced by `lnd.create-psbt`.** The evidence: - -1. **The PSBT is funded from LND's own wallet.** `lnd.create-psbt` POSTs to LND's WalletKit - `/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`), which selects UTXOs belonging to **LND's** - wallet. The keys for those inputs are the keys LND holds. -2. **LND's wallet on every node is a full key-holding wallet, created locally.** - `container::lnd::ensure_wallet_initialized` (`core/archipelago/src/container/lnd.rs:86`) calls - `init_wallet_via_rest`, which POSTs `/v1/initwallet` with a `cipher_seed_mnemonic` - (`container/lnd.rs:504-516`) and persists the aezeed backup (`:523-525`). That is a normal - wallet with private keys, not a watch-only one. -3. **No node's `lnd.conf` carries a remote-signing block.** The config Archipelago generates - (`container/lnd.rs:64-79`) contains `bitcoin.node=bitcoind` and the bitcoind RPC settings, and - **no `remotesigner.*` keys at all**. -4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`, - `core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and - `nochainbackend` returns **zero matches**. There is no code path, script or manifest that sets - any node up this way. - -An external signer could only sign these inputs if LND were first provisioned **watch-only -against that signer** — `remotesigner.*` on the node plus `lncli createwatchonly` from the -signer's exported accounts, with the level-3 accounts and the p2tr import step described in -`PSBT-SIGNING-ARCHITECTURE.md` §5.1-5.2. **No fleet node is so provisioned.** - -**What therefore ships today is the PSBT *transport*, not air-gapped custody.** The round trip is -real and rate-limited, and it is genuinely useful for signing a PSBT whose inputs belong to some -*other* wallet — but on a default node the signer that holds the input keys is LND itself, so -routing the PSBT out to an external device and back adds a step without moving custody anywhere. -The gap between here and D-08's opt-in air-gapped path is **provisioning, not plumbing**, and -that provisioning is out of scope for Phase 10 (it is `PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 6). - -Nothing in the UI currently claims otherwise, and nothing added by this plan does either. If -copy is ever written for this flow, it must not describe it as cold storage on the strength of -the PSBT round trip alone. - -### Lightning channel, revocation and HTLC keys are not air-gappable — at all - -This is a standing constraint, not a caveat, and it survives every change in this document. - -> **A Lightning node's channel, revocation and HTLC keys must sign in real time to answer -> counterparty commitments. They cannot be air-gapped.** A routing node cannot tolerate a -> human-in-the-loop signing step: a delayed response to a commitment update risks a force-close, -> and a missing revocation risks loss. LND remote signing **relocates** these keys to a hardened -> host — it does **not** cool them. There is no configuration, present or future, in which a -> live Lightning node's channel keys are cold. - -This is the same limit stated in `PSBT-SIGNING-ARCHITECTURE.md` §5.1 ("Air-gap channel / -revocation / HTLC keys — **No**") and §5.4, whose honesty table remains correct and unmodified. - -The consequence for user-facing copy, quoted from §5.4 and repeated here so it cannot be lost: - -> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a -> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely -> protected by an offline signer.* - -**No wording in this document, or in any document this phase touches, may imply that Lightning -funds can be held cold.** A user who believes their Lightning balance is cold will keep more in -it than they otherwise would, which is exactly the miscalibration that turns an incident into a -loss. - ---- - -## Fleet census — Core descriptor wallets - -**Status: run 2026-08-02 — 4 nodes examined and CLEAR, 6 nodes UNCHECKED. No escalation.** - -This section answers one question per node: *does this node hold a Bitcoin Core descriptor wallet -that the deleted wallet-init handler created, and does it hold private keys?* It is recorded per -node rather than assumed, because deletion closes the door but does not tell us whether anyone -walked through it before. - -The nodes that could **not** be examined are listed with their reasons, not omitted. A census -that quietly drops its failures is worthless — an auditor must be able to see exactly which -machines were looked at and which were not. - -### Hard constraint on every command in this census - -> **Never run `listdescriptors true`.** The `true` argument makes Bitcoin Core return the -> descriptors **including private keys**, which would print an xprv to a terminal and into a -> transcript — creating the exact exposure this census exists to measure. -> `listwalletdir`, `listwallets`, `getwalletinfo` and `listdescriptors` **with no second -> argument** answer the question completely. -> -> If any output unexpectedly contains a string beginning `xprv`, **stop immediately, do not -> paste it**, and report only that it occurred. - -### Commands (re-runnable by an auditor) - -Per node, against the Bitcoin Core / Knots container: - -```bash -# 0. Does the handler's wallets directory exist at all? An absent directory is -# itself a complete answer for that node — paste the output as-is. -ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1 - -# bitcoin-cli is NOT on $PATH inside the container. On archi-dev-box (Knots -# 29.3) it lives at: -# /opt/bitcoin-29.3.knots20260210/bin/bitcoin-cli -# The RPC user is `archipelago`; the password is read from -# /var/lib/archipelago/secrets/bitcoin-rpc-password -# — reference that path, never the value, and prefer -stdinrpcpass so the -# password never appears in a process list or shell history. - -# 1. Every wallet on disk, loaded or not. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwalletdir - -# 2. Currently loaded wallets. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwallets - -# 3. Per wallet returned: record walletname, private_keys_enabled, descriptors, -# blank, keypoolsize, txcount, balance. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet= getwalletinfo - -# 4. ONLY for a wallet with private_keys_enabled: true — NOTE: no second argument. -# Record descriptor prefixes (`wpkh(...`) only, never a full key string. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet= listdescriptors - -# 5. Which Bitcoin app and version. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass getnetworkinfo | head -``` - -### Results — examined, 2026-08-02 (4 nodes, all CLEAR) - -Run by the operator over Tailscale, read-only RPCs only. - -| Node | Tailscale IP | Container | `listwalletdir` | `listwallets` | `archipelago` wallet? | Default wallet state | Verdict | -|---|---|---|---|---|---|---|---| -| **archi-dev-box** | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | `[ "" ]` | **No** | `blank: true`, `keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` | **CLEAR** | -| **shorty-s** (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** | -| **archy-x250-beta** | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** | -| **archy-x250-pa** | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** | - -On every examined node there is **no wallet named `archipelago`** — the deleted handler's default -`wallet_name`. The only named wallets are Fedimint `gatewayd-*`, unrelated to the BIP-84 path. - -The one loaded wallet on each node is Core's unnamed default. It does report -`private_keys_enabled: true`, but also `blank: true` with `keypoolsize: 0`, `txcount: 0` and -`balance: 0.00000000` — **Bitcoin Core's own statement that no key was ever imported into it and -no transaction ever touched it.** It is not the deleted handler's output, and it holds nothing. - -**The result holds across two container vintages** — `bitcoin-knots` on two nodes and -`bitcoin-core` on two others. That matters: it is not four copies of one image behaving -identically, so the finding is a property of the fleet rather than an artefact of a single build. - -**No key material appeared in any output, and `listdescriptors true` was never run.** - -### Not examined, 2026-08-02 (6 nodes, with reasons) - -| Node | Tailscale IP | Why not checked | -|---|---|---| -| framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held | -| archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` | -| archipelago | `100.70.96.88` | `Permission denied (publickey,password)` | -| archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` | -| archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange | -| archy-x250-dev | `100.113.100.55` | Offline — Tailscale reports last seen 2 days prior | - -**Password authentication was deliberately not attempted on any of these.** Several fleet nodes -lock PAM quickly on a wrong password, and locking an in-use production node out is a worse -outcome than an incomplete census. These are recorded as UNCHECKED, **not** as clear. - -### Conclusion, at the strength the evidence supports - -> **No examined node holds a wallet created by the deleted handler, and no examined node holds -> any wallet with keys or funds.** Four nodes, across two container vintages, on 2026-08-02. - -**This is deliberately not a claim that "the fleet is clear."** Six nodes were not examined, and -an unexamined node is unknown, not safe. F-13 is closed **by deletion** — the code that could -create such a wallet is gone from every future build, which is true regardless of the census — -and the census adds that no such wallet was found where anyone could look. - -### Standing item — finish the census - -The six unchecked nodes remain open. **Homed in `docs/UNIFIED-TASK-TRACKER.md`** (the project's -canonical "what's open" list) as *"Finish the Core-wallet fleet census — 6 nodes unchecked"*, -rather than only here, so it is visible to someone who is not already reading a security -document. It is flagged there as a natural fold-in for **KEY-04's on-node work**, which needs -node access anyway — but it is tracked independently so it does not vanish if KEY-04 is -re-scoped. - -Re-run the read-only procedure above when credentials or connectivity allow. - -### Standing rule if a wallet is found - -If any node reports a wallet named `archipelago` (or any descriptor wallet with -`private_keys_enabled: true` that this handler plausibly created), that is a **finding**: - -1. **Stop.** Record it here with the node label and wallet name. -2. **Raise it as a blocker.** KEY-03 does not close until a human decides what to do about it. -3. **Do not migrate, unload, rescan or modify it.** D-07b withdrew the migration deliberately. - Rewriting a wallet that might hold funds is exactly the kind of decision that belongs to a - human, and CLAUDE.md's "migrations never destroy data" invariant applies the moment anyone - touches it. - -Such a wallet would mean the endpoint was invoked manually before this plan deleted it, and that -node's spending key is duplicated in Core outside the Argon2 envelope. diff --git a/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md b/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md index 71be9513..92df28cb 100644 --- a/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md +++ b/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md @@ -1,13 +1,18 @@ # KEY-05 — Entropy enforcement: per-site classification and mechanism record -**Requirement:** ROADMAP `KEY-05`. **Plan:** `.planning/phases/10-key-material-hardening/10-06-PLAN.md`. +**Requirement:** ROADMAP `KEY-05`. **Supersedes:** backlog `R-13`. **Absorbs:** `R-05` (duplicate-`rand` visibility) and `R-09` -(CSPRNG-readiness record). **Resolves:** `F-10a` in -`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`, which recorded raw match counts and -**deliberately declined to classify them**. +(CSPRNG-readiness record). **Resolves:** `F-10a` from the internal entropy and +seed-generation audit, which recorded raw match counts and **deliberately declined +to classify them**. **Tree state this document was derived against:** `HEAD = c5a82cba` (2026-08-02). +**Update:** every `migrate` disposition in the table below has since been applied. +No `rand::random()` / `rand::thread_rng()` call remains in production `archipelago` +code — each draws through `entropy::draw_key_bytes` from a named `OsRng`, and +`core/clippy.toml` now bans both APIs, so a regression fails the build. + --- ## Nothing here is broken today @@ -44,7 +49,7 @@ they are enabled wrongly. Both are gated behind Task 5, a `gate="blocking-human" ## Source precedence -`.planning/phases/10-key-material-hardening/10-CONTEXT.md` (2026-08-01) lists **F-07 / R-05** +The Phase 10 hardening work lists **F-07 / R-05** and **F-10 / R-13** under `## Deferred Ideas`. KEY-05 was added to the ROADMAP on **2026-08-02**, after that context was gathered, and explicitly absorbs R-05 and supersedes R-13. The ROADMAP requirement is the later and governing artifact. @@ -206,8 +211,8 @@ lines are `core/archipelago/src/mesh/x3dh.rs:99` and `:113` — produced there; `:100` and `:114` draw only the `u32` `id` fields of `SignedPrekey` and `OneTimePrekey`. They remain in scope — they are values that go on the wire — but the characterisation "X3DH key agreement — key material" overstates these two specific lines. -(The audit has since been corrected in place at `ENTROPY-SEED-AUDIT-2026-07-31.md:508`; this -section records the derivation independently.) +(The internal audit has since been corrected; this section records the derivation +independently.) --- diff --git a/docs/security/LND-MACAROON-ROTATION.md b/docs/security/LND-MACAROON-ROTATION.md new file mode 100644 index 00000000..3ae6e47e --- /dev/null +++ b/docs/security/LND-MACAROON-ROTATION.md @@ -0,0 +1,191 @@ +# Rotating this node's Lightning credentials + +A Lightning macaroon is a **bearer token**: whoever holds one can spend from the +node's wallet. There is no revocation list and no expiry. If a macaroon is ever +read by something you do not control — a leaked endpoint, a screenshot, a phone +that has since been lost, an app that ran a version with a published +vulnerability — that ability persists until the macaroons are rotated. + +Rotation is therefore a **routine operator action**, not an emergency procedure. +Two paths do the same work: + +| Path | Use when | +|---|---| +| **Dashboard** — Settings → *Lightning credentials* | Normal case. Password-confirmed, shows progress, repairs BTCPay for you. | +| **`scripts/security/rotate-lnd-macaroon.sh`** | No dashboard reachable, or you want a detect-only report. | + +## What rotation actually does + +LND derives every macaroon it issues from a root key in `macaroons.db`. Remove +that root key plus the issued `*.macaroon` files, restart, and LND mints a fresh +root key and a fresh set of macaroons when the wallet unlocks. Every macaroon +issued before that moment — including any an attacker holds — stops verifying. + +## Why your funds and channels survive + +Macaroons are bearer tokens, not keys. Coins live in `wallet.db` and channel +state in `channel.db`; channels are secured by the node's identity and channel +keys, none of which are derived from the macaroon root key. Neither database is +opened, moved or deleted. + +Both paths **prove** this rather than asserting it: they record the node's +identity pubkey and its channel census before rotating, and refuse to report +success if either differs afterwards. + +Two details in that check are deliberate and should not be "tightened": + +- **Channels are compared as a total, not as `num_active_channels`.** The active + count only counts channels whose peer is currently online, so it legitimately + dips for minutes after *any* restart while peers reconnect. Asserting on it + alone would abort a perfectly healthy rotation. +- **`wallet.db` is not compared byte-for-byte.** btcwallet records chain-sync + progress inside it, so the file changes on every start. Asserting byte-identity + would fire a frightening false alarm on a completely healthy rotation. + +## What it never does + +- No macaroon **content** reaches a response, an error, a log line, or the + progress feed the dashboard polls. Everything reported is a SHA-256 digest or a + byte count — enough to prove the material changed without disclosing it to + whoever is reading the screen. +- No path from "rotate my credentials" to "delete my wallet". LND's boot path + self-heals a wallet no candidate password can open by wiping and recreating it; + correct for an unattended boot, catastrophic here. Rotation unlocks through + `container::lnd::unlock_existing_wallet_no_wipe`, so a wallet whose password + this node does not hold surfaces as a **failed rotation** with the wallet + intact. + +## Nothing else may touch LND mid-rotation + +Between "stop LND" and "start LND" the rotation owns a stopped container whose +credential material is being deleted. Two background actors would step in there +unasked: the **health monitor** restarts any container it finds stopped, and the +**reconciler** starts one whose unit is enabled. Either brings LND back up +mid-deletion — and LND re-mints `macaroons.db` on unlock, so the deletion loop +would race a live process writing that file, or "succeed" against material that +had already been regenerated. The operator would be told they had rotated while +the old root key was still in service. + +The rotation therefore holds `app_ops::op_lock("lnd")` for its whole duration. +That is the lock both actors already consult (`lifecycle_op_in_flight`, reached +in the health monitor via `lifecycle_op_covers_container`), and it also +serialises against the `package.start`/`stop`/`restart` workers, so an operator +hitting "Restart" on Lightning mid-rotation queues rather than interleaving. A +rotation requested while one of those is running fails fast with a short +explanation instead of waiting silently. + +Deliberately **not** the `user-stopped` marker that `recreate_wallet_destructively` +uses for its own window: that marker is a file on disk, so a rotation that died +between marking and clearing would leave Lightning suppressed *permanently* — +fixable only by finding and editing JSON on the node. The lock guard releases +when it drops, on every path including a panic. + +## The BTCPay coupling — the part that bites + +**BTCPay Server keeps its own inline copy of the admin macaroon**, and it cannot +self-heal. LND's data directory is owned by its container's mapped uid, so BTCPay +cannot bind-mount the macaroon file (EACCES across the userns boundary). The +connection string therefore carries the macaroon as hex: + +``` +type=lnd-rest;server=https://lnd:8080/;macaroon=;certthumbprint= +``` + +delivered as the `btcpay-lnd-connection` secret file. Rotate the macaroons and +that copy becomes a dead credential. Nothing notices on its own, because the +daemon only regenerates this secret when LND's **TLS cert thumbprint** changes — +and macaroon rotation does not touch the cert. + +The resulting state is the dangerous one: **BTCPay is up, LND is up, both report +healthy, and every Lightning invoice BTCPay tries to create fails.** + +Repair needs two things, and one without the other is cosmetic: + +1. **Rewrite the secret** (`container::lnd::rewrite_btcpay_lnd_connection_secret`). + This is what makes the change visible: `secret_env_hash` is derived from the + resolved secret contents, so a changed file reads as label drift on the + running container. +2. **Recreate the container.** `btcpay-server` is on the restart-sensitive list, + and the reconcile loop runs in `ExistingOnly` mode *always* — boot and + periodic alike — where env drift on a restart-sensitive app is detected and + then deliberately skipped. Rewriting the secret alone therefore changes + nothing that is running. Observed directly on a development node, once per + tick, for half an hour: + + ``` + container drift detected during boot reconcile; leaving running + restart-sensitive app untouched app_id=btcpay-server + ``` + + The dashboard path calls + `ContainerOrchestrator::mark_credential_rotated("btcpay-server")`, which is + the flag the drift check consults to override restart-sensitivity. It is the + same carve-out FED-07 added for the Fedimint gateway, and the reasoning is + identical: restart sensitivity protects apps that are *working*, and this one + is working only in appearance. + +**The shell script cannot set that in-process flag**, so it does the equivalent +from outside: it deletes the secret (the daemon regenerates it within a tick), +then removes the `btcpay-server` container so the orchestrator's own +desired-state recovery rebuilds it around unchanged data. That recovery is what +makes this safe rather than a hand-rolled remove-and-run — it fires because the +app is still installed and was in the last running-containers snapshot. The +script then prints the commands to confirm it actually happened, because a +failure here is invisible. + +## Slow nodes: the unlock budget + +LND opens `channel.db`, `graph.db` and `wallet.db` before it serves the unlocker +at all, and on a busy node that is genuinely slow — **2m38s measured on a box +running 30 containers**. The unlock helper used to give up after ~60s, which on +such a node could never succeed. + +That timeout was not a harmless retry. Reconcile records the post-start hook as +failed, restarts LND, and the slow database open starts over: a restart loop that +leaves the wallet permanently locked and every LND-dependent app (BTCPay's +internal node included) broken, on exactly the nodes least able to afford it. + +The not-ready budget is now ~10 minutes (`UNLOCK_NOT_READY_ATTEMPTS`). Waiting +longer costs nothing, because a genuinely wrong password still exits on the first +pass through the candidate list — the `all_rejected` fast path is untouched. + +## Verifying a rotation + +The dashboard shows all of this. From a shell: + +```bash +# 1. Fingerprint changed (digest only — never print the macaroon) +sudo sha256sum /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon + +# 2. Same node, same channels +podman exec lnd lncli --network=mainnet getinfo \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["identity_pubkey"], \ + d["num_active_channels"] + d["num_inactive_channels"], d["num_pending_channels"])' + +# 3. BTCPay is carrying the CURRENT macaroon, not the rotated-out one +CUR=$(sudo od -An -v -tx1 /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon | tr -d ' \n') +SEC=$(sudo sed -n 's/.*macaroon=\([0-9a-f]*\).*/\1/p' /var/lib/archipelago/secrets/btcpay-lnd-connection) +[ "$CUR" = "$SEC" ] && echo "current" || echo "STALE — BTCPay's Lightning is broken" + +# 4. BTCPay was actually recreated (a silent failure looks like success) +podman inspect btcpay-server --format '{{.Created}}' +``` + +Check 3 is the one people skip, and it is the one that fails. + +## Afterwards + +- **Re-pair every wallet app**, Zeus most importantly. Open the Lightning app in + the dashboard and scan the pairing QR again; it serves the new macaroon. +- **Delete the backup once re-pairing is done.** Both paths back the old material + up to `/var/lib/archipelago/lnd/macaroon-rotation-` (0700) so a mistake + is recoverable. That directory holds the **old root key** and is still + sensitive: `sudo rm -rf `. + +## Related + +- `docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md` — the leak that first made + rotation necessary, and the operator decision not to rotate the fleet for it. +- `scripts/security/rotate-lnd-macaroon.sh` — the shell path, including its + ordering guard (it refuses to rotate on a binary that still leaks + `/lnd-connect-info`, since the new macaroon would leak within seconds). diff --git a/docs/security/PHASE-10-VERIFICATION-GUIDE.md b/docs/security/PHASE-10-VERIFICATION-GUIDE.md deleted file mode 100644 index a0a8dd09..00000000 --- a/docs/security/PHASE-10-VERIFICATION-GUIDE.md +++ /dev/null @@ -1,267 +0,0 @@ -# Phase 10 — Independent Verification Guide - -**Audience:** third-party security auditors, and the Archipelago team. -**Purpose:** verify the Phase 10 security claims *independently*, without trusting the -project's own test harness. -**Status:** LIVING — sections are marked ✅ verifiable now, ⏳ pending a plan still in -execution, or 🔒 hardware-gated. Do not read an unmarked absence as a passing result. - ---- - -## 0. How to use this document - -Every claim below follows the same four-part structure, and **all four parts matter**: - -| Part | Why it exists | -|---|---| -| **Claim** | Stated so it can be falsified. A claim you cannot disprove is not a security claim. | -| **Reproduce the defect** | Check out the parent commit and demonstrate the bug. *A test that passes on both the fixed and unfixed code proves nothing.* | -| **Verify the fix** | Command + expected output, runnable without our harness wherever possible. | -| **Negative control** | Break the fix deliberately; confirm the check goes red on **exactly** that and nothing else. This is what separates verification from demonstration. | - -**Do not skip "Reproduce the defect".** It is the only step that proves the fix addresses -something real, and it is the step most often omitted in security theatre. - -### Trust posture - -Where a claim can be checked from *outside* the codebase — an HTTP request from another host, -a `tar` listing, a file comparison across two machines — **prefer that over running our tests.** -Our tests are offered as convenience and as evidence of intent, not as proof. Every claim below -that can be externally checked says so explicitly. - ---- - -## 1. Scope - -### In scope — what Phase 10 claims - -| ID | Claim | Severity | Status | -|---|---|---|---| -| KEY-01 | An already-provisioned node refuses every unauthenticated RPC that can mutate identity or credentials | **Critical** | ⏳ `10-01` in execution | -| KEY-02 | First-boot per-device secret generation is fail-closed, retried, self-healing, and has exactly one producer; the shipped rootfs contains no fleet-shared identity material | **High** | ✅ partially landed (`21043096`, `408b328c`), ⏳ single-producer + self-heal in progress | -| KEY-03 | The BIP-84 account private key is never imported into Bitcoin Core; the dead import path is deleted | **High** | ⏳ `10-05` in execution | -| KEY-04 | On-node evidence for C-3 / C-4 / C-6 | — | 🔒 hardware-gated | -| KEY-05 | A defaulted RNG cannot be inherited anywhere in the crate | Medium | ⏳ `10-06` not started | - -### Explicitly NOT claimed - -State these plainly so an auditor is not left inferring them: - -- **Lightning custody is not air-gappable.** Channel, revocation and HTLC keys must sign in real - time to answer counterparty commitments. LND remote signing *relocates* those keys; it does not - make them cold. Any document implying otherwise is wrong. -- **No claim against a compromised kernel CSPRNG**, a malicious dependency in the supply chain, - memory disclosure on a running node, or physical access. -- **KEY-05 fixes a structural risk, not a live vulnerability.** `rand::random()`/`thread_rng()` - are ChaCha12 seeded from `getrandom(2)`; nothing in that finding is exploitable today. The - mitigation targets *future silent rebinding* of the entropy source. -- **Findings F-04 through F-12 are out of scope** for this phase and remain open. See - `ENTROPY-SEED-AUDIT-2026-07-31.md` remediation register (R-05..R-15) and - `docs/UNIFIED-TASK-TRACKER.md`. - ---- - -## 2. Provenance - -```bash -# The audit that motivated this phase -docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md # 103 file:line references - -# The entropy fix that preceded the phase -git show 8b51b7e2 # seed.rs — explicit OsRng at the call site - -# Phase 10 plans and locked decisions -.planning/phases/10-key-material-hardening/ -``` - -`.planning/` is committed deliberately: an auditor can read *why* each decision was made, -including the ones that were reversed. `10-CONTEXT.md` records D-01..D-11 plus three -in-flight corrections (D-03a, D-07a/b/c) where our own earlier reasoning was wrong. - ---- - -## 3. Tier 0 — verifiable on any checkout, no node required ✅ - -No hardware, no deploy. Start here. - -### 3.1 First-boot secrets are fail-closed (KEY-02) - -**Claim.** If per-device secret generation fails, the completion marker is **not** written and -the boot does not proceed as if it had succeeded. - -**Reproduce the defect:** -```bash -git log --oneline -1 21043096 # the fix commit -git show 21043096^:image-recipe/_archived/build-auto-installer-iso.sh > /tmp/pre-fix.sh -grep -n 'touch .*MARKER' /tmp/pre-fix.sh -# Observe: the marker write is NOT inside the success branch — it runs regardless of outcome. -``` - -**Verify the fix:** -```bash -bash tests/first-boot-secrets/run-tests.sh -# Expect: passed: 3 failed: 0 (more cases once the self-heal work lands) -``` -The harness extracts the heredoc body **from the builder itself**, so it exercises the bytes -that ship rather than a copy. Confirm that for yourself: -```bash -grep -n 'extracted .* lines from the builder' tests/first-boot-secrets/run-tests.sh -``` - -**Negative control:** -```bash -# Move `touch "$MARKER"` outside the success branch in the builder, then: -bash tests/first-boot-secrets/run-tests.sh -# Expect: FAIL: openssl fails every attempt -> MARKER-SET-ON-FAILURE -# passed: 2 failed: 1 EXIT=1 -git checkout image-recipe/_archived/build-auto-installer-iso.sh -``` -It must fail on **that case only**. A negative control that reddens everything is measuring -nothing. - -### 3.2 Master-seed entropy is explicit (F-02, shipped) - -**Claim.** Mnemonic generation draws from an explicitly-passed `OsRng`, not a -transitive-dependency default, and a test proves the injected RNG is the one consumed. - -```bash -git show 8b51b7e2 -- core/archipelago/src/seed.rs # ~6 lines of production change -cd core && cargo test -p archipelago seed:: # expect 25 passed; 0 failed -``` - -**Reproduce the defect:** on `8b51b7e2^`, `MasterSeed::generate` calls -`bip39::Mnemonic::generate(24)`, which resolves to `&mut rand::thread_rng()` *inside* the bip39 -crate — there is no seam to inject through, so the proving test cannot be written at all. - -**Note for auditors:** the test module implements `rand::CryptoRng` for a counter RNG. That is a -deliberately false marker-trait promise, confined to `#[cfg(test)]` (`seed.rs:502`). KEY-05 -retires it. Confirm containment: -```bash -grep -n 'CountingRng' core/archipelago/src/seed.rs # all hits must be after the cfg(test) at :502 -``` - -### 3.3 Unauthenticated method inventory (KEY-01 context) - -Read the authoritative list rather than trusting prose: -```bash -sed -n '/UNAUTHENTICATED_METHODS/,/];/p' core/archipelago/src/api/rpc/middleware.rs -``` -Every entry is reachable without a session, RBAC check, or CSRF token. KEY-01's claim is that -those which can mutate identity or credentials refuse once the node is provisioned. - ---- - -## 4. Tier 1 — requires a running node ⏳ - -Pending `10-01` and `10-02`. `10-02` produces `scripts/security/rpc-exposure-probe.sh` and -`docs/security/KEY-01-ON-NODE-VERIFICATION.md`. - -**The external check that matters most (C-6).** From a *different host* on the same network, -against a node that has completed onboarding: - -```bash -curl -sS -X POST http:///rpc \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"seed.restore","params":{"words":["<24 words>"]}}' -``` - -- **Before the fix:** the node accepts attacker-supplied words and overwrites `node_key`, - `nostr_secret` and the FIPS mesh key. This is the Critical finding. -- **After the fix:** refused, and the node's identity is byte-identical afterwards. - -Verify byte-identity yourself rather than trusting a log line: -```bash -sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret -# run before and after the request; the hashes must be unchanged -``` - -> ⚠️ **Do not run the "before" case against a node you care about.** It really does overwrite the -> identity. Use a disposable node — see `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` -> for standing up an isolated instance without flashing an ISO. - -**Do not probe with `seed.status`.** The original audit's C-6 command used it; `seed.status` is -**not** in `UNAUTHENTICATED_METHODS`, so it returns 401 by design and would report the surface -closed while the real door stands open. Probe with a method that is genuinely on the -unauthenticated list. - -**Non-regression, equally important:** a *fresh, un-onboarded* node must still complete -onboarding. The gate distinguishes provisioned from fresh; a fix that refuses on a fresh node -bricks first boot fleet-wide. - ---- - -## 5. Tier 2 — ISO build host 🔒 - -Full procedure: `docs/security/KEY-02-ROOTFS-EVIDENCE.md` (C-4). - -```bash -UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild -# then follow steps 2/4/5/6 in KEY-02-ROOTFS-EVIDENCE.md -``` - -**Claim.** The shipped rootfs tar contains no SSH host keys, no TLS private key, and no -machine-id — so no two nodes flashed from one image can share them. - -**Gotcha, recorded because it will waste your afternoon:** read `RECIPE_HASH` from -`image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256`, **not** by hashing the -repo file. The wrapper rewrites 35 path expressions and absolutises `SCRIPT_DIR` before exec, -so the hash is host- and checkout-specific. - -**Note the inverted expectation.** The original audit expected these artefacts to be *present*. -This check passes when they are *absent*. - ---- - -## 6. Tier 3 — two physical nodes 🔒 - -**C-3 — host-key uniqueness.** Flash two machines from the *same* ISO, then compare: -```bash -# on each node -sha256sum /etc/ssh/ssh_host_*_key.pub -sha256sum /etc/ssl/private/ # path per the nginx config -cat /etc/machine-id -``` -Every value must differ between the two nodes. Any match is a finding. - -SSH host keys and the TLS key are equally sharp signals once the single-producer work lands -(before it, TLS had an installer fallback and SSH did not — see `KEY-02-ROOTFS-EVIDENCE.md`). - ---- - -## 7. Tier 4 — pre-release gate - -```bash -# ON the node, not over RPC — it uses local podman/systemctl/bitcoin probes -ARCHY_ITERATIONS=5 bash tests/lifecycle/run-gate.sh -``` -Install / UI / stop / start / restart / reinstall / reboot-survive / -archipelago-restart-survive / uninstall, 5× green. See `tests/lifecycle/TESTING.md`. - -Frontend: `cd neode-ui && npm run test` (vitest) and `npm run build`. -Rust: `cd core && cargo test -p archipelago`. - ---- - -## 8. Known-accepted risks - -Recorded so an auditor does not have to discover them by reading commit messages. - -| Risk | Decision | Where | -|---|---|---| -| A node whose first-boot secret generation can never succeed will not serve TLS | Accepted. Mitigated by a build-time assertion on generator binaries, retry-with-backoff, and self-heal on subsequent boots — leaving genuinely-broken hardware as the residual | `10-03` | -| Rotating host keys on already-deployed nodes invalidates `known_hosts` fleet-wide | Accepted, rated one-way, gated behind a decision checkpoint | D-06, `10-04` | -| KEY-01's fix ships on the next scheduled OTA, not an emergency release | Deliberate. The Critical finding stays live on the fleet until that OTA | D-10 | -| `#[cfg(test)]` code implements `rand::CryptoRng` falsely | Accepted until KEY-05 retires it; contained to test builds | `seed.rs:656` | - ---- - -## 9. Reporting a finding - -If any check above fails, or you find something not covered: the audit format that produced this -work is `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — evidence as `file:line`, an explicit -severity, and a stated confidence. Findings that cannot be verified without hardware belong in an -UNVERIFIED section rather than being asserted. - -Two corrections in that document are worth reading as calibration, because both were ours: F-10 -**understated** its scope by a factor of 20, and the correction to it then **overstated** the -severity of two files within a day. Both are struck in place rather than rewritten. diff --git a/docs/security/PSBT-SIGNING-ARCHITECTURE.md b/docs/security/PSBT-SIGNING-ARCHITECTURE.md index d1b64b84..71e0a2bf 100644 --- a/docs/security/PSBT-SIGNING-ARCHITECTURE.md +++ b/docs/security/PSBT-SIGNING-ARCHITECTURE.md @@ -23,7 +23,7 @@ > - **§5 (LND) is unaffected and remains accurate**, including **§5.4's honesty table**, which is > correct as written and unchanged. > -> **For the current state, read `docs/security/KEY-03-SIGNING-POSTURE.md`** — it records the +> **Note:** the current signing-posture record is maintained internally. It records the > deletion with its evidence, an honest per-step coverage map of the LND PSBT round trip, and the > verdict on whether an external signer can sign a default node's PSBT today (it cannot: no fleet > node is provisioned watch-only). Phases 2-7 below are unaffected as design targets. @@ -32,15 +32,14 @@ > a phased rollout that a future `/gsd-plan-phase` can consume directly. It deliberately > contains no code, adds no dependencies, and changes no wallet or signing behaviour. > -> **Companion document:** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the entropy and -> seed-generation audit that motivated this spec. **Cross-linked design:** +> **Companion document:** the internal entropy and seed-generation audit that +> motivated this spec. **Cross-linked design:** > `docs/hardware-signer-design.md` — the exploratory TROPIC01 air-gapped signer, which this > architecture treats as the future *first-party* signer, not as a competing design. **Provenance rules used throughout.** Every architectural claim is grounded in either (a) a `file:line` from this tree, or (b) RESEARCH.md Part C -(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`, -which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the +(which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from neither is marked `[UNVERIFIED]`. @@ -426,7 +425,7 @@ nagged-at that users stop reading warnings. Concretely: - **A software fix does not repair an already-generated seed.** If a seed was produced by a defective RNG, updating the software leaves it exactly as guessable. This is why Coinkite told users to migrate rather than merely update. -- **The audit found no such defect in Archipelago.** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` +- **The audit found no such defect in Archipelago.** The internal entropy audit's §2 and §4 record that every first-party key-generation call site draws from a genuine CSPRNG, that the mnemonic is a real 256-bit value, and that `[ARCHY-1]` is a *structural* risk with no present exploitability. @@ -610,11 +609,10 @@ current security posture and should not wait for the rest. ## 9. Related documents -- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit motivating this spec; see F-05 +- The internal entropy and seed-generation audit — motivating this spec; see F-05 (Argon2 parameters) and the F-13 addendum on the xprv-in-Core issue. - `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above answers two of its open items. - `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses. -- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md` — Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap format comparison. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 70079225..c024c189 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,6 @@ # Archipelago Troubleshooting Guide -This guide covers the 20 most common issues you may encounter with Archipelago, along with diagnostic commands and solutions. +This guide covers the most common issues you may encounter with Archipelago, along with diagnostic commands and solutions. ## Connection & Access @@ -46,7 +46,11 @@ curl -s -X POST http://localhost:5678/rpc/v1 \ ``` **Solutions**: -- Default password is `password123` — change it after first login +- There is no default password — the password is the one you created on this + node's first-boot "Set Up Your Node" screen. Password recovery requires SSH + access to the node; note that simply deleting `/var/lib/archipelago/user.json` + does **not** work, because the onboarding gate refuses `auth.setup` once the + node is provisioned - Clear browser cookies and try again (stale session cookie) - Restart the backend: `sudo systemctl restart archipelago` - Check if the database is accessible: `ls -la /var/lib/archipelago/` @@ -107,9 +111,12 @@ df -h /var/lib/archipelago **Solutions**: - If the image is missing: reinstall the app from the Marketplace -- If disk is full: run disk cleanup from Settings, or manually `podman system prune` +- If disk is full: run disk cleanup from the **Server** page (`/server`), or manually `podman system prune` - If the container exits immediately: check logs for the root cause (usually missing config or permissions) -- Restart podman: `sudo systemctl restart podman` +- Restart the Podman socket. Archipelago runs **rootless** Podman as the + `archipelago` user, so this is a `--user` unit — `sudo systemctl restart podman` + would restart the unrelated root socket: + `systemctl --user restart podman.socket` ### 6. App shows "unhealthy" status @@ -142,20 +149,34 @@ podman logs --tail 100 | grep -i error # Check Bitcoin logs podman logs bitcoin-knots --tail 50 -# Check if Bitcoin is connected to peers -podman exec bitcoin-knots bitcoin-cli -datadir=/data getpeerinfo | grep -c '"addr"' +# Check if Bitcoin is connected to peers. +# The datadir inside the container is /home/bitcoin/.bitcoin, and the RPC +# credentials live in the generated /tmp/rpc.conf (the manifest's entrypoint +# writes it from the BITCOIN_RPC_USER/BITCOIN_RPC_PASS secrets) — bitcoin-cli +# needs both flags or it can't authenticate. +podman exec bitcoin-knots bitcoin-cli \ + -datadir=/home/bitcoin/.bitcoin -conf=/tmp/rpc.conf \ + getpeerinfo | grep -c '"addr"' # Check sync progress -podman exec bitcoin-knots bitcoin-cli -datadir=/data getblockchaininfo | grep -E "blocks|headers|verificationprogress" +podman exec bitcoin-knots bitcoin-cli \ + -datadir=/home/bitcoin/.bitcoin -conf=/tmp/rpc.conf \ + getblockchaininfo | grep -E "blocks|headers|verificationprogress" ``` **Solutions**: - Initial sync takes 1-7 days depending on hardware — be patient - Ensure the server has a stable internet connection -- Check disk space: Bitcoin requires 600GB+ for full chain +- Check disk space. The manifest picks the mode from the disk it's given: under + 1000 GB it runs **pruned** (`-prune=550`, a few GB); at 1000 GB or more it runs + a full `-txindex=1` archival node, which needs 600 GB+ and growing - If stuck: restart the container `podman restart bitcoin-knots` - If peers = 0: check firewall allows port 8333 outbound -- Add manual peers: edit bitcoin.conf to add `addnode=` entries +- Editing `bitcoin.conf` in the data directory has **no effect** — the + entrypoint runs bitcoind with an explicit `-conf=/tmp/rpc.conf` and logs + "ignoring legacy datadir bitcoin.conf". Flags come from the app manifest, so + persistent changes belong there (and, for catalog-covered apps, in the signed + catalog entry that overrides the on-disk manifest) ### 8. LND won't connect to Bitcoin @@ -250,7 +271,9 @@ df -h / - Ensure at least 2GB free disk space - If update hangs: wait 10 minutes, then restart the backend - Do NOT power off during an update — this can corrupt the system -- If system is in a bad state after failed update: boot from the USB installer and select "Repair" +- If the system is in a bad state after a failed update, recover over SSH — the + USB installer has no repair mode (its boot menu offers only "Install + Archipelago", "Install Archipelago (verbose)" and "Boot from local disk") ### 12. Server won't boot after update @@ -259,8 +282,11 @@ df -h / **Solutions**: - Wait 5 minutes — the first boot after update may take longer - If still unresponsive: connect a monitor/keyboard to check boot messages -- Try the recovery mode: boot from USB installer and select "Repair" -- As a last resort: reflash the USB and restore from backup +- If it's a bootloader problem rather than a disk problem, boot the USB and pick + "Boot from local disk" to chainload the installed system +- As a last resort: reinstall from USB and restore from backup. The installer is + interactive — it asks for the target disk and requires typing `yes` — so + booting it does not by itself destroy the existing install --- @@ -308,21 +334,35 @@ xrandr --query 2>/dev/null || echo "No display server" **Symptoms**: Settings shows "Tor: Not configured" or the .onion address is missing +Tor is **not** a container — it's the host's Debian `tor` package, running as +`debian-tor`. Archipelago never touches it directly: it stages a torrc and asks +`archipelago-tor-helper` (a `.path` unit watching +`/var/lib/archipelago/tor-config/tor-action`) to install it and restart Tor. + **Diagnosis**: ```bash -# Check Tor container -podman ps --filter "name=tor" -podman logs tor --tail 20 +# Check the host Tor service and the helper that drives it +sudo systemctl status tor +sudo journalctl -u archipelago-tor-helper --since "10 minutes ago" -# Check if Tor hostname file exists -cat /var/lib/archipelago/tor/hidden_service/hostname 2>/dev/null +# Is the SOCKS port up? (this is the liveness check the backend itself uses) +nc -z 127.0.0.1 9050 && echo "Tor SOCKS OK" + +# The readable hostname copy the backend actually reads +cat /var/lib/archipelago/tor-hostnames/archipelago + +# The hidden-service dir itself (root-owned 0700 — needs sudo) +sudo cat /var/lib/tor/hidden_service_archipelago/hostname 2>/dev/null \ + || sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname ``` **Solutions**: - Tor takes 30-60 seconds to bootstrap — wait and refresh -- If Tor container is stopped: start it from the Apps page -- Check that the Tor data directory exists and has correct permissions -- Restart Tor: `podman restart tor` +- If `/var/lib/archipelago/tor-hostnames/archipelago` is missing but the + hidden-service dir has a `hostname`, the readable copy didn't sync — the + helper's `sync-hostnames` action rewrites it +- Check that the Tor data directory exists and is owned by `debian-tor` +- Restart Tor: `sudo systemctl restart tor` ### 16. Peers can't reach my node @@ -330,11 +370,11 @@ cat /var/lib/archipelago/tor/hidden_service/hostname 2>/dev/null **Diagnosis**: ```bash -# Check if Tor is running (needed for peer connectivity) -podman ps --filter "name=tor" +# Check if Tor is running (the fallback transport for peer connectivity) +sudo systemctl status tor # Check your Tor address -cat /var/lib/archipelago/tor/hidden_service/hostname +cat /var/lib/archipelago/tor-hostnames/archipelago # Test connectivity from the server side curl -s http://localhost:5678/rpc/v1 \ @@ -343,10 +383,13 @@ curl -s http://localhost:5678/rpc/v1 \ ``` **Solutions**: -- Ensure Tor is running (required for peer-to-peer communication) +- Tor is the last-resort transport, not the only one: peering prefers mesh + radio, then LAN, then FIPS, and only falls back to Tor. A peer stuck on + "unreachable" with Tor healthy usually means the higher transports are all + down too — check the FIPS anchor first - Tor circuits can be slow — connections may take 30+ seconds - Share your correct .onion address with peers -- Both nodes must have Tor running and be on the same federation +- Both nodes must be on the same federation ### 17. DNS resolution issues @@ -366,7 +409,7 @@ podman exec bitcoin-knots nslookup seed.bitcoin.sipa.be ``` **Solutions**: -- Configure DNS from Settings > Network: try Cloudflare (1.1.1.1) or Google (8.8.8.8) +- Configure DNS from the **Server** page (`/server`): try Cloudflare (1.1.1.1) or Google (8.8.8.8) - If using custom DNS, verify the server addresses are correct - Restart networking: `sudo systemctl restart systemd-resolved` @@ -414,7 +457,7 @@ podman system df ``` **Solutions**: -- Run disk cleanup from Settings +- Run disk cleanup from the **Server** page (`/server`) - Remove unused app data: `podman system prune -a` (WARNING: removes all stopped containers and unused images) - Move Bitcoin data to external drive if chain data is too large - Check for large log files: `du -sh /var/log/*/ | sort -rh` @@ -556,7 +599,8 @@ If the system is completely unresponsive: 1. **Power cycle**: Hold power button for 10 seconds, then turn back on 2. **Wait 5 minutes**: Services take time to start, especially if containers need to recover 3. **SSH in**: If web UI is down but SSH works, restart services manually -4. **USB recovery**: Boot from the Archipelago USB installer and select "Repair" +4. **Chainload the installed system**: Boot the Archipelago USB and pick "Boot + from local disk" — this rules out a broken bootloader 5. **Clean install + restore**: As last resort, do a fresh install and restore from backup ### Collecting Diagnostic Information diff --git a/docs/user-walkthrough.md b/docs/user-walkthrough.md index 502cce4f..b50b1860 100644 --- a/docs/user-walkthrough.md +++ b/docs/user-walkthrough.md @@ -91,13 +91,21 @@ The auto-installer handles everything: 2. Tap or click anywhere to proceed 3. A typing animation welcomes you: "Welcome, Noderunner" -### Step 8: Login Screen +### Step 8: Create Your Password -> **Screenshot**: The login screen with a password field and glass-morphism design. +> **Screenshot**: The "Set Up Your Node" screen with password and confirm-password fields, glass-morphism design. -1. Enter the default password: `password123` -2. Click "Login" -3. You'll be prompted to change this password immediately +**There is no default web password.** A freshly installed node has no user +account at all, so this screen shows a password-creation form rather than a +login form: + +1. Enter a password (minimum 8 characters) +2. Confirm it in the second field +3. Click "Set Up Node" + +Every boot after this one shows the normal login form and asks for the password +you chose here. Store it somewhere you can get back to — recovering it requires +SSH access to the node. ### Step 9: Choose Your Path (Onboarding) diff --git a/docs/workstream-b-signing-runbook.md b/docs/workstream-b-signing-runbook.md index 29c4ee00..c32b9747 100644 --- a/docs/workstream-b-signing-runbook.md +++ b/docs/workstream-b-signing-runbook.md @@ -1,12 +1,28 @@ # Workstream B — Signed app-catalog: completion runbook -**Status (2026-06-28):** The registry-distributed manifest pipeline is live — nodes fetch +**Status: ✅ COMPLETE** (runbook retained for re-running the ceremony — key +rotation, a new publisher, or a fresh release root). + +The ceremony described below has been performed. Verified 2026-08-08: + +- The anchor is **pinned** — `trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is a + `Some(...)`, not `None`. +- `releases/app-catalog.json` carries a `signature` and a `signed_by` did:key. + +Everything below therefore describes how to *do* the ceremony, not work that is +outstanding. The one-way-door warning in "Why this is gated on you" still +applies in full to any re-run: once a binary pins an anchor, a catalog signed by +a different key is hard-rejected fleet-wide. + +--- + +**Original status (2026-06-28):** The registry-distributed manifest pipeline is live — nodes fetch `releases/app-catalog.json` from the OTA mirror and embed manifests (origin-wins, disk fallback). What remains for Workstream B is **authenticity**: pin the release-root anchor and ship a *signed* catalog so nodes can cryptographically verify the publisher. Today the catalog is **accepted unsigned** ("migration window") and the anchor is **unpinned** -(`core/archipelago/src/trust/anchor.rs:21` → `RELEASE_ROOT_PUBKEY_HEX = None`). Completing B is +(`core/archipelago/src/trust/anchor.rs` → `RELEASE_ROOT_PUBKEY_HEX = None`). Completing B is a coordinated ceremony that **only the publisher can run** — it needs the offline `RELEASE_MASTER_MNEMONIC`, which is not (and must not be) stored on any node or build host. diff --git a/image-recipe/INTEGRATION-GUIDE.md b/image-recipe/INTEGRATION-GUIDE.md deleted file mode 100644 index 7ebb0ced..00000000 --- a/image-recipe/INTEGRATION-GUIDE.md +++ /dev/null @@ -1,195 +0,0 @@ -# Live Server to ISO Build Integration Guide - -This document explains how to keep the ISO build synchronized with the live development server. - -## Development Workflow - -### 1. Develop and Test on Live Server - -```bash -# Make changes locally -vim core/archipelago/src/... - -# Deploy to live server for testing -./scripts/deploy-to-target.sh --live - -# Test at http://192.168.1.228 -# Check logs: ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f' -``` - -### 2. Capture System Changes - -When you make system-level changes on the live server (nginx config, systemd service, etc.): - -```bash -cd image-recipe -./sync-from-live.sh -``` - -This automatically captures: -- `/etc/systemd/system/archipelago.service` → `configs/archipelago.service` -- `/etc/nginx/sites-available/archipelago` → `configs/nginx-archipelago.conf` -- `/etc/logrotate.d/archipelago` → `configs/logrotate.conf` - -### 3. Build New ISO - -```bash -# Build backend and frontend -./scripts/build-backend.sh -./scripts/build-frontend.sh - -# Build ISO with latest changes -./build-debian-iso.sh - -# Test in QEMU -./test-iso-qemu.sh -``` - -### 4. Verify Integration - -The ISO build script should: -1. Copy `configs/archipelago.service` to `/etc/systemd/system/` -2. Copy `configs/nginx-archipelago.conf` to `/etc/nginx/sites-available/archipelago` -3. Create symlink: `/etc/nginx/sites-enabled/archipelago` -4. Enable the service: `systemctl enable archipelago` -5. Install backend to `/usr/local/bin/archipelago` -6. Install frontend to `/opt/archipelago/web-ui/` - -## Critical Configuration Settings - -### Backend Service (archipelago.service) - -**Must-have settings**: -```ini -[Service] -User=root # Required for root Podman access -Environment="ARCHIPELAGO_BIND=127.0.0.1:5678" # Backend API port -Environment="ARCHIPELAGO_DEV_MODE=true" # Enable container auto-detection -``` - -**Why root?**: The backend must run as root to access containers started with `sudo podman`. Containers in root Podman context are invisible to rootless Podman. - -### Nginx Configuration (nginx-archipelago.conf) - -**Must-have proxies**: -```nginx -location /rpc/ { - proxy_pass http://127.0.0.1:5678; # Backend RPC endpoint -} - -location /ws { - proxy_pass http://127.0.0.1:5678; # WebSocket for real-time updates - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; -} -``` - -## File Paths Reference - -### Build Artifacts -- `build/backend/archipelago` - Compiled Rust backend -- `build/frontend/` - Built Vue.js frontend -- `configs/` - System configuration files -- `results/` - Built ISO images - -### Live Server Paths -- `/usr/local/bin/archipelago` - Backend binary -- `/opt/archipelago/web-ui/` - Frontend files -- `/etc/systemd/system/archipelago.service` - Service definition -- `/etc/nginx/sites-available/archipelago` - Nginx config -- `/var/lib/archipelago/` - Application data - -### ISO Installation Paths -Same as live server (above) - the ISO must replicate the exact file structure. - -## Container Management - -### Root vs Rootless Podman - -**Current approach**: Root Podman -- Containers started with: `sudo podman run ...` -- Backend runs as: `root` user (in systemd) -- Container detection: Works automatically in dev mode - -**Why not rootless?** -- Would require `User=archipelago` in systemd service -- All containers must be started as `archipelago` user -- More complex permission management - -### Container Detection - -The backend automatically detects running containers when: -1. `ARCHIPELAGO_DEV_MODE=true` is set -2. Backend runs with same privileges as container runtime -3. Containers exist in accessible Podman context - -## Troubleshooting - -### Issue: Containers not detected in ISO - -**Cause**: Backend not running as root, or dev mode disabled - -**Fix**: -1. Check `configs/archipelago.service` has `User=root` -2. Check `Environment="ARCHIPELAGO_DEV_MODE=true"` is set -3. Rebuild ISO and test - -### Issue: UI not loading - -**Cause**: Nginx config not copied or frontend files missing - -**Fix**: -1. Verify `configs/nginx-archipelago.conf` exists -2. Check frontend built to `build/frontend/` -3. Verify ISO build script copies these files - -### Issue: Backend won't start - -**Cause**: Binary permissions or missing dependencies - -**Fix**: -1. Check backend binary is executable: `chmod +x /usr/local/bin/archipelago` -2. Check dependencies installed (Podman, nginx) -3. Review systemd logs: `journalctl -u archipelago` - -## Testing Checklist - -Before releasing an ISO, verify: - -- [ ] Boot ISO in QEMU -- [ ] Systemd service starts: `systemctl status archipelago` -- [ ] Backend responds: `curl http://localhost:5678/health` -- [ ] UI accessible: Open browser to `http://localhost` -- [ ] Container detection: `sudo podman run -d --name test nginx` → Shows in UI -- [ ] RPC works: Test login and API calls -- [ ] WebSocket connects: Check browser console - -## Automated Build Pipeline (Future) - -To automate this workflow: - -1. **CI/CD Integration** - - Trigger on main branch commits - - Run `sync-from-live.sh` with credentials - - Build backend and frontend - - Build ISO - - Upload to releases - -2. **Version Management** - - Tag releases with semantic versions - - Include git commit hash in ISO metadata - - Track which configs were included - -3. **Testing Automation** - - Boot ISO in headless QEMU - - Run API tests - - Verify container detection - - Generate test report - -## Resources - -- Development Workflow Rules: `.cursor/rules/Development-Workflow.mdc` -- Build Checklist: `ISO-BUILD-CHECKLIST.md` -- Architecture Docs: `.cursor/rules/Architecture.mdc` -- Deployment Scripts: `scripts/deploy-to-target.sh` diff --git a/image-recipe/README.md b/image-recipe/README.md index e45d193f..a62632cb 100644 --- a/image-recipe/README.md +++ b/image-recipe/README.md @@ -8,7 +8,6 @@ Build scripts for creating bootable Debian Linux OS images for Archipelago Bitco ```bash # 1. Sync latest configs from live dev server -./sync-from-live.sh # 2. Build components ./scripts/build-backend.sh diff --git a/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml b/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml index a05c7cd8..e674ead3 100644 --- a/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml +++ b/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml @@ -112,7 +112,7 @@ jobs: run: | sudo mkdir -p /etc/containers/registries.conf.d echo '[[registry]] - location = "146.59.87.168:3000" + location = "source.archipelago-foundation.org" insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf - name: Build unbundled ISO @@ -243,7 +243,7 @@ jobs: # Build download base URL (FileBrowser serves from /Builds/) HOST=$(hostname -I 2>/dev/null | awk '{print $1}') - BASE_URL="http://${HOST:-192.168.1.228}:8083/Builds/releases/v${VERSION}" + BASE_URL="http://${HOST:-192.0.2.10}:8083/Builds/releases/v${VERSION}" # Generate manifest JSON python3 -c " diff --git a/image-recipe/_archived/BUILD-ISO-STATUS.md b/image-recipe/_archived/BUILD-ISO-STATUS.md index d90b3007..cd4fda97 100644 --- a/image-recipe/_archived/BUILD-ISO-STATUS.md +++ b/image-recipe/_archived/BUILD-ISO-STATUS.md @@ -18,7 +18,7 @@ The script will automatically: ```bash # From your Mac (captures from remote dev server): cd image-recipe -DEV_SERVER=archipelago@192.168.1.228 sudo bash build-auto-installer-iso.sh +DEV_SERVER=archipelago@192.0.2.10 sudo bash build-auto-installer-iso.sh # From the dev server itself: cd ~/archy/image-recipe @@ -40,7 +40,7 @@ BUILD_FROM_SOURCE=1 sudo bash build-auto-installer-iso.sh ```bash # Instead of building on the server, build from your Mac: cd ~/Projects/archy/image-recipe -DEV_SERVER=archipelago@192.168.1.228 sudo bash build-auto-installer-iso.sh +DEV_SERVER=archipelago@192.0.2.10 sudo bash build-auto-installer-iso.sh ``` ### Issue: Podman registry not configured @@ -49,7 +49,7 @@ DEV_SERVER=archipelago@192.168.1.228 sudo bash build-auto-installer-iso.sh **Fix**: ```bash -ssh archipelago@192.168.1.228 +ssh archipelago@192.0.2.10 sudo tee -a /etc/containers/registries.conf </dev/null && cat > "$REGCONF_DIR/archipelago.conf" 2>/dev/null <<'REGCONF' [[registry]] -location = "146.59.87.168:3000" +location = "source.archipelago-foundation.org" insecure = true REGCONF then @@ -241,7 +241,7 @@ mkdir -p "$OUTPUT_DIR" container_pull() { local image="$1" - if [[ "$CONTAINER_CMD" == podman* && "$image" == 146.59.87.168:3000/* ]]; then + if [[ "$CONTAINER_CMD" == podman* && "$image" == source.archipelago-foundation.org/* ]]; then $CONTAINER_CMD pull --tls-verify=false --platform "$CONTAINER_PLATFORM" "$image" else $CONTAINER_CMD pull --platform "$CONTAINER_PLATFORM" "$image" @@ -304,7 +304,7 @@ ENV DEBIAN_FRONTEND=noninteractive # - libnftnl-dev, libmnl-dev, clang, libclang-dev: rustables → # bindgen (the gateway feature enables rustables for nftables # integration). bindgen panics without libclang.so. -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apt-get -o Acquire::ForceIPv4=true update && apt-get -o Acquire::ForceIPv4=true install -y --no-install-recommends \ git ca-certificates build-essential pkg-config dpkg-dev \ libdbus-1-dev libssl-dev \ clang libclang-dev libnftnl-dev libmnl-dev \ @@ -343,7 +343,7 @@ RUN echo "deb http://deb.debian.org/debian trixie main non-free-firmware" > /etc rm -f /etc/apt/sources.list.d/debian.sources # Install all packages we need including nginx, podman, tor, and openssl (for self-signed certs) -RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends \ +RUN apt-get -o Acquire::ForceIPv4=true update && apt-get -o Acquire::ForceIPv4=true -y full-upgrade && apt-get -o Acquire::ForceIPv4=true install -y --no-install-recommends \ DOCKERFILE_HEAD # The ONLY build-time interpolation in the entire Dockerfile: the kernel and @@ -438,7 +438,7 @@ RUN find /usr/share/doc -depth -type f ! -name copyright -delete 2>/dev/null || # Install Tailscale from official repo RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null && \ curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list && \ - apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends tailscale && \ + apt-get -o Acquire::ForceIPv4=true update && apt-get -o Acquire::ForceIPv4=true -y full-upgrade && apt-get -o Acquire::ForceIPv4=true install -y --no-install-recommends tailscale && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Install FIPS mesh daemon from the .deb built in stage 1. apt-get install @@ -834,10 +834,21 @@ _INSTALLER_ENV_SCRIPT="$WORK_DIR/_installer-env.sh" cat > "$_INSTALLER_ENV_SCRIPT" <<'INSTALLER_ENV_EOF' set -e -apt-get update -qq -apt-get install -y -qq debootstrap squashfs-tools initramfs-tools dosfstools mtools \ +# This build host (and its containers) blackhole IPv6: deb.debian.org +# answers AAAA first, wget tries v6 with long timeouts, and debootstrap's +# per-package fetch fails ("Couldn't download packages", repro'd twice on +# 2026-08-07). Force v4 for every retrieval tool this script drives. +APT_V4='-o Acquire::ForceIPv4=true' + +apt-get $APT_V4 update -qq +apt-get $APT_V4 install -y -qq debootstrap squashfs-tools initramfs-tools dosfstools mtools \ grub-efi-amd64-bin grub-pc-bin grub-common isolinux syslinux-common +# wget gets its v4 pin AFTER its package is installed — editing /etc/wgetrc +# beforehand makes dpkg's conffile prompt fatal on a non-interactive shell +# ("end of file on stdin at conffile prompt", repro'd on build #189). +echo 'inet4_only = on' >> /etc/wgetrc + echo " [container] Running debootstrap --variant=minbase..." # ifupdown + isc-dhcp-client added because live-boot's /init writes # /etc/network/interfaces on the target — without ifupdown, /etc/network/ @@ -860,9 +871,11 @@ cp /etc/resolv.conf /installer/etc/resolv.conf 2>/dev/null || true mount --bind /proc /installer/proc mount --bind /sys /installer/sys mount --bind /dev /installer/dev -chroot /installer apt-get update -qq -chroot /installer apt-get -y -qq full-upgrade -chroot /installer apt-get install -y --no-install-recommends live-boot live-boot-initramfs-tools +# Same v6-blackhole discipline inside the chroot (its apt reads the +# chroot's own config, not the container's). +chroot /installer apt-get -o Acquire::ForceIPv4=true update -qq +chroot /installer apt-get -o Acquire::ForceIPv4=true -y -qq full-upgrade +chroot /installer apt-get -o Acquire::ForceIPv4=true install -y --no-install-recommends live-boot live-boot-initramfs-tools chroot /installer apt-get clean umount /installer/dev 2>/dev/null || true umount /installer/sys 2>/dev/null || true @@ -1257,7 +1270,7 @@ fi # copies everything in archipelago/bin/ to /usr/local/bin, and the mesh # listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios — # a node imaged without it can never connect a Reticulum stick -# (framework-pt, 2026-07-22: silent connect failures until hand-copied). +# (a test node, 2026-07-22: silent connect failures until hand-copied). RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}" if [ -f "$RETICULUM_DAEMON" ]; then cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon" @@ -1270,7 +1283,7 @@ fi # archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware # (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node # imaged without it fails every RNode flash with "No such file or directory" -# (framework-pt, 2026-07-29, v1.7.117). +# (a test node, 2026-07-29, v1.7.117). RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}" if [ -f "$RNODECONF" ]; then cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf" @@ -1317,11 +1330,11 @@ fi # Extract nostr-rs-relay binary from container image (native system service for VPN signaling) echo " Extracting nostr-rs-relay binary..." -RELAY_IMAGE="$($CONTAINER_CMD images -q 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)" +RELAY_IMAGE="$($CONTAINER_CMD images -q source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)" if [ -z "$RELAY_IMAGE" ]; then - $CONTAINER_CMD pull 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true + $CONTAINER_CMD pull source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true fi -RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true +RELAY_CONTAINER=$($CONTAINER_CMD create source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true if [ -n "$RELAY_CONTAINER" ]; then # The relay image builds to its WORKDIR /usr/src/app and execs # ./nostr-rs-relay from there (not /usr/local/bin — that path was from an @@ -1346,7 +1359,7 @@ if [ -n "$MISSING_VPN_BINARIES" ]; then echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)" else echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES" - echo " The registry (146.59.87.168:3000) must be reachable and hold the images," + echo " The registry (source.archipelago-foundation.org) must be reachable and hold the images," echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling." exit 1 fi @@ -1424,35 +1437,48 @@ fi # Include AIUI web app (Claude chat interface) AIUI_INCLUDED=0 -# Search multiple locations for a pre-built AIUI app. -# demo/aiui is the canonical AIUI bundle checked into the repo and is -# tried first so ISO builds on a fresh clone work without needing any -# external AIUI checkout. +# Pick the NEWEST valid AIUI build across the candidate locations, not the +# first: demo/aiui is the checked-in fallback so fresh clones can build an +# ISO, but on a dev box it is older than the tree's dist and silently won — +# 2026-08-07's RC1 shipped an index.html pointing at the stale demo bundle +# while the fresh one sat unused beside it. The merge also needs --delete, +# or yesterday's hashed assets linger next to today's forever. +AIUI_SRC="" +AIUI_NEWEST=0 for AIUI_DIR in \ - "$SCRIPT_DIR/../../demo/aiui" \ + "$SCRIPT_DIR/../../aiui/packages/app/dist" \ "$SCRIPT_DIR/../../AIUI/packages/app/dist" \ "$HOME/AIUI/packages/app/dist" \ "/home/archipelago/AIUI/packages/app/dist" \ "/opt/archipelago/web-ui/aiui" \ - "/home/archipelago/archy/AIUI/packages/app/dist"; do + "/home/archipelago/archy/AIUI/packages/app/dist" \ + "$SCRIPT_DIR/../../demo/aiui"; do if [ -d "$AIUI_DIR" ] && [ -f "$AIUI_DIR/index.html" ]; then - echo " Including AIUI from $AIUI_DIR..." - mkdir -p "$ARCH_DIR/web-ui/aiui" - # Use rsync to handle same-file (CI workspace == /opt/archipelago) gracefully - if command -v rsync >/dev/null 2>&1; then - rsync -a "$AIUI_DIR/" "$ARCH_DIR/web-ui/aiui/" - else - cp -r "$AIUI_DIR/"* "$ARCH_DIR/web-ui/aiui/" 2>/dev/null || true + m="$(stat -c %Y "$AIUI_DIR/index.html" 2>/dev/null || echo 0)" + if [ "$m" -gt "$AIUI_NEWEST" ]; then + AIUI_NEWEST="$m" + AIUI_SRC="$AIUI_DIR" fi - echo " ✅ AIUI included ($(du -sh "$ARCH_DIR/web-ui/aiui" | cut -f1))" - AIUI_INCLUDED=1 - break fi done +if [ -n "$AIUI_SRC" ]; then + echo " Including AIUI from $AIUI_SRC (newest of the candidates)..." + mkdir -p "$ARCH_DIR/web-ui/aiui" + # Use rsync to handle same-file (CI workspace == /opt/archipelago) gracefully + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "$AIUI_SRC/" "$ARCH_DIR/web-ui/aiui/" + else + rm -rf "${ARCH_DIR:?}/web-ui/aiui" + mkdir -p "$ARCH_DIR/web-ui/aiui" + cp -r "$AIUI_SRC/"* "$ARCH_DIR/web-ui/aiui/" 2>/dev/null || true + fi + echo " ✅ AIUI included ($(du -sh "$ARCH_DIR/web-ui/aiui" | cut -f1))" + AIUI_INCLUDED=1 +fi if [ "$AIUI_INCLUDED" = "0" ]; then echo " ⚠️ AIUI not found — build it first:" - echo " cd ~/AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build" - echo " Searched: demo/aiui, ~/AIUI, /home/archipelago/AIUI, /opt/archipelago/web-ui/aiui" + echo " cd aiui/packages/app && VITE_BASE_PATH=/aiui/ npx vite build" + echo " Searched: aiui/packages/app/dist, AIUI/packages/app/dist, ~/AIUI, /opt/archipelago/web-ui/aiui, demo/aiui" fi # Copy app manifests @@ -2486,7 +2512,7 @@ if [ -f /var/lib/archipelago/tor-hostnames/bitcoin ]; then BOOTSTRAP_ONION=$(cat /var/lib/archipelago/tor-hostnames/bitcoin 2>/dev/null) fi if [ -n "$BOOTSTRAP_RPC_PASS" ]; then - DEV_IP="${DEV_SERVER:-192.168.1.228}" + DEV_IP="${DEV_SERVER:-192.0.2.10}" cat > "$ARCH_DIR/bootstrap.conf" < /mnt/target/home/archipelago/.config/containers/registries.conf <<'REGCONF unqualified-search-registries = ["docker.io"] [[registry]] -location = "146.59.87.168:3000" +location = "source.archipelago-foundation.org" insecure = true [[registry]] -location = "146.59.87.168:3000" +location = "source.archipelago-foundation.org" insecure = true REGCONF chown -R 1000:1000 /mnt/target/home/archipelago/.config @@ -3016,8 +3042,8 @@ mkdir -p /mnt/target/var/lib/archipelago/config cat > /mnt/target/var/lib/archipelago/config/registries.json <<'DYNREG' { "registries": [ - {"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0}, - {"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10} + {"url": "source.archipelago-foundation.org/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0}, + {"url": "source.archipelago-foundation.org/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10} ] } DYNREG @@ -3191,7 +3217,7 @@ if [ -d "$REPO_DIR/.git" ]; then exit 0 # Already cloned fi echo "[update] Cloning Archipelago repo for self-updates..." -su - archipelago -c "git clone https://146.59.87.168:3000/lfg2025/archy $REPO_DIR" 2>/dev/null || { +su - archipelago -c "git clone https://source.archipelago-foundation.org/lfg2025/archy $REPO_DIR" 2>/dev/null || { echo "[update] Git clone failed (network?). Updates will retry on next boot." exit 0 } @@ -3240,7 +3266,8 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then if [ -n "$IP" ]; then echo -e " ${W}web ui http://$IP${N}" echo -e " ${W}ssh archipelago@$IP${N}" - echo -e " ${W}password archipelago (SSH) / password123 (Web)${N}" + echo -e " ${W}password archipelago (SSH)${N}" + echo -e " ${OD}web ui asks you to create a password on first visit${N}" else echo -e " ${OD}Waiting for network...${N}" fi @@ -4076,7 +4103,7 @@ p "${ORANGE} http://${NC}" echo "" p "${WHITE} SSH ssh archipelago@${NC}" p "${WHITE} Password archipelago${NC}" -p "${WHITE} Web Login password123${NC}" +p "${WHITE} Web Login create your password on first visit${NC}" echo "" hrule echo "" diff --git a/image-recipe/_archived/build-unbundled-iso.sh b/image-recipe/_archived/build-unbundled-iso.sh index 5019f6c0..5f2c8ef2 100755 --- a/image-recipe/_archived/build-unbundled-iso.sh +++ b/image-recipe/_archived/build-unbundled-iso.sh @@ -16,14 +16,14 @@ # # Usage: # sudo ./build-unbundled-iso.sh -# DEV_SERVER=archipelago@192.168.1.228 sudo ./build-unbundled-iso.sh +# DEV_SERVER=archipelago@192.0.2.10 sudo ./build-unbundled-iso.sh # BUILD_FROM_SOURCE=1 sudo ./build-unbundled-iso.sh # set -e # Configuration -DEV_SERVER="${DEV_SERVER:-archipelago@192.168.1.228}" +DEV_SERVER="${DEV_SERVER:-archipelago@192.0.2.10}" BUILD_FROM_SOURCE="${BUILD_FROM_SOURCE:-0}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/image-recipe/configs/archipelago-host-secrets-audit.service b/image-recipe/configs/archipelago-host-secrets-audit.service index a4a87ab6..bcf07e2d 100644 --- a/image-recipe/configs/archipelago-host-secrets-audit.service +++ b/image-recipe/configs/archipelago-host-secrets-audit.service @@ -13,7 +13,7 @@ Type=oneshot # /var/lib/archipelago/host-secrets-audit.json, all of which are root-owned. User=root # DETECT ONLY. D-06 chose detect-report-then-apply -# (docs/security/KEY-02-FLEET-ROTATION.md): rotation is one-way and must never +# rotation is one-way and must never # fire unattended across the fleet during an OTA. There is deliberately NO # --apply here. Adding one is a decision, not a configuration change. ExecStart=-/opt/archipelago/scripts/security/host-secrets-audit.sh --detect diff --git a/image-recipe/configs/archipelago-kiosk.service b/image-recipe/configs/archipelago-kiosk.service index 2b53a8e0..421ed5f8 100644 --- a/image-recipe/configs/archipelago-kiosk.service +++ b/image-recipe/configs/archipelago-kiosk.service @@ -34,7 +34,7 @@ RestartSec=5 # also binds the chromium/Xorg children in this unit's cgroup. # CPUQuota=75% (0.75 cores) was too tight even for normal playback — the kiosk # was throttled ~40% of the time, which is what caused choppy HDMI audio on -# archy-x250-exp (2026-06-28 incident). 200% (2 cores) gives enough headroom. +# a test node (2026-06-28 incident). 200% (2 cores) gives enough headroom. Delegate=yes CPUQuota=200% # Raised from 1500M/1200M: a Framework (Tiger Lake) kiosk sat at 806M used / diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index e3dd33b7..e99af66f 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -32,7 +32,7 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:a ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh ExecStart=/usr/local/bin/archipelago # always (not on-failure): the OTA restart path once stopped the daemon -# cleanly and the queued start never fired (framework-pt, v1.7.114->115, +# cleanly and the queued start never fired (a test node, v1.7.114->115, # 2026-07-26) — the node sat dead all night behind "server starting up". # Restart=always self-heals any lost start job; an explicit # `systemctl stop` is still honored (systemd never auto-restarts after diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index c84cd389..375ad0ef 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -11,7 +11,7 @@ server { listen 80 default_server; # IPv6 listener is REQUIRED: companion phones reach this node over the # FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that - # address can never connect (found live 2026-07-23, framework-pt). + # address can never connect (found live 2026-07-23, a test node). listen [::]:80 default_server; server_name _; @@ -46,9 +46,37 @@ server { } # AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing + # + # /aiui/-scoped CSP (AIUI-04, D-19 unaffected — this is a build-time/ + # runtime property, not a repository-location one): this header governs + # ONLY the document served from this location (it replaces, not adds to, + # the site-wide policy above — nginx add_header does not inherit from + # the previous level once the current level declares its own, same as + # the Cache-Control line below already does at this location). Its + # connect-src is scoped to the AIUI path prefix, so AIUI's own + # JavaScript is browser-prevented from issuing a same-origin fetch to + # /rpc/v1 with the ambient session cookie. This makes AIUI-04's + # "sandboxed by construction" an enforced boundary rather than the + # code-discipline convention the old proxy comment further down + # mistakenly implied. It does NOT split AIUI onto a different origin — + # DOM, storage, and cookies are still shared with the rest of the site; + # only what this policy polices (script/style/connect/etc. sources) is + # restricted. The residual risk (a browser that ignores or partially + # enforces CSP) is named, not silently assumed away, in 13-AI-SPEC.md + # §6 and mitigated by G-B3's rate limit on assistant.chat (13-12). location /aiui/ { try_files $uri $uri/ /aiui/index.html; add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob: data:; connect-src $scheme://$host:*/aiui/ blob: data:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; + } + + # AIUI's own JS reaching a paid/relay path directly (openrouter was + # deleted outright in 13-02 — no proxy_pass to openrouter.ai survives + # anywhere in this config) must not silently 200 via the SPA catch-all + # below. Explicit here rather than bolted onto 13-02 after the fact + # (13-02's Task 3 checkpoint, operator-accepted 2026-08-03). + location /aiui/api/openrouter/ { + return 404; } # AIUI assets fallback — AIUI may reference /assets/ without /aiui/ prefix @@ -57,12 +85,21 @@ server { add_header Cache-Control "public, max-age=31536000, immutable"; } - # AIUI Claude API proxy (API key managed by proxy, no session gate needed) + # AIUI Claude API proxy — re-pointed to the Rust daemon (127.0.0.1:5678), + # which enforces the session cookie itself and reads the node's single + # key ledger (data_dir/secrets/claude-api-key). The old comment here said + # "API key managed by proxy, no session gate needed" — that confuses key + # *secrecy* with spend *authorization* and is the reasoning error that + # made this an unauthenticated door into a paid API (T-13-08/T-13-09). + # Do not point this at a standalone process again. No trailing path on + # proxy_pass: nginx forwards the request URI unmodified so the daemon's + # own prefix match sees the full /aiui/api/claude/... path. location /aiui/api/claude/ { - proxy_pass http://127.0.0.1:3142/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; @@ -70,25 +107,18 @@ server { proxy_send_timeout 120s; } - # AIUI OpenRouter API proxy (API key managed by proxy, no session gate needed) - location /aiui/api/openrouter/ { - set $upstream_1 "https://openrouter.ai/api/"; - - proxy_pass $upstream_1; - proxy_http_version 1.1; - proxy_set_header Host openrouter.ai; - proxy_ssl_server_name on; - proxy_connect_timeout 120s; - proxy_read_timeout 120s; - proxy_send_timeout 120s; - } - - # AIUI Ollama (local AI) proxy — localhost:11434 + # AIUI Ollama (local AI) proxy — same daemon, same session gate as above. + # The standalone AIUI OpenRouter relay that used to live here is deleted + # outright: the node holds no key for that backend, it is not in the + # model backend chain, and an unauthenticated proxy_pass to a paid + # third-party API from the node's IP was a plain open relay (T-13-10). + # AIUI's own standalone/dev mode keeps its own proxy and is unaffected. location /aiui/api/ollama/ { - proxy_pass http://127.0.0.1:11434/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; @@ -96,12 +126,16 @@ server { proxy_send_timeout 120s; } - # AIUI web search proxy — SearXNG on port 8888 + # AIUI web search — session-gated through the daemon's model proxy + # (S4; it re-derives auth from the session cookie and forces JSON + # upstream). Never proxy straight to SearXNG: that left an open search + # relay attributing arbitrary queries to this node's IP. location /aiui/api/web-search { - proxy_pass http://127.0.0.1:8888/search; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_connect_timeout 30s; proxy_read_timeout 30s; error_page 502 503 =503 @searxng_unavailable; @@ -972,15 +1006,35 @@ server { } # AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing + # + # /aiui/-scoped CSP — see the HTTP server block above for the full + # rationale (AIUI-04, D-19 unaffected). Both server blocks must carry + # this header — a change applied to only one leaves AIUI's JS able to + # reach /rpc/v1 with the ambient session cookie on whichever block + # actually serves the request, same class of gap as T-13-15. location /aiui/ { try_files $uri $uri/ /aiui/index.html; add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob: data:; connect-src $scheme://$host:*/aiui/ blob: data:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always; } + + # AIUI's own JS reaching a paid/relay path directly must not silently + # 200 via the SPA catch-all below — see the HTTP server block above. + location /aiui/api/openrouter/ { + return 404; + } + + # See the HTTP server block above for the full rationale: re-pointed to + # the session-gated Rust daemon (T-13-08/T-13-09), OpenRouter relay + # deleted outright (T-13-10). Both server blocks must carry this fix — + # a change applied to only one leaves the exposure live on whichever + # block actually serves the request (T-13-15). location /aiui/api/claude/ { - proxy_pass http://127.0.0.1:3142/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; @@ -988,26 +1042,28 @@ server { proxy_send_timeout 120s; } location /aiui/api/ollama/ { - proxy_pass http://127.0.0.1:11434/; + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; proxy_buffering off; proxy_cache off; proxy_connect_timeout 120s; proxy_read_timeout 300s; proxy_send_timeout 120s; } - location /aiui/api/openrouter/ { - set $upstream_6 "https://openrouter.ai/api/"; - - proxy_pass $upstream_6; + # Session-gated web search (S4) — same rationale and shape as the HTTP + # server block above; both blocks must carry it (T-13-15). + location /aiui/api/web-search { + proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; - proxy_set_header Host openrouter.ai; - proxy_ssl_server_name on; - proxy_connect_timeout 120s; - proxy_read_timeout 120s; - proxy_send_timeout 120s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; + proxy_connect_timeout 30s; + proxy_read_timeout 30s; + error_page 502 503 =503 @searxng_unavailable; } # Icons, favicon, manifest — always revalidate (no heuristic caching) diff --git a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf index 7ded1871..83f86cac 100644 --- a/image-recipe/configs/snippets/archipelago-https-app-proxies.conf +++ b/image-recipe/configs/snippets/archipelago-https-app-proxies.conf @@ -148,6 +148,12 @@ location /app/photoprism/ { location /app/mempool/ { proxy_pass http://127.0.0.1:4080/; proxy_http_version 1.1; + # mempool's UI is websocket-driven (/api/v1/ws). Without forwarding the + # upgrade, the page loads but never connects — the backend can be fully + # healthy and every REST probe green while the user sees a dead UI + # (2026-08-09). 101 through this proxy is the only honest health signal. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/image-recipe/dev-branding.sh b/image-recipe/dev-branding.sh index 57bef6a4..4a8f7522 100755 --- a/image-recipe/dev-branding.sh +++ b/image-recipe/dev-branding.sh @@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" WORK="/tmp/archipelago-dev-branding" PATCHED="$SCRIPT_DIR/results/archipelago-dev-patched.iso" CACHED_ISO="$SCRIPT_DIR/results/archipelago-dev-base.iso" -DEV_SERVER="archipelago@192.168.1.228" +DEV_SERVER="${DEV_SERVER:?DEV_SERVER must be set, e.g. archipelago@}" SSH_KEY="$HOME/.ssh/archipelago-deploy" echo "" diff --git a/image-recipe/scripts/install-podman.sh b/image-recipe/scripts/install-podman.sh index e772a4e0..c252ce20 100755 --- a/image-recipe/scripts/install-podman.sh +++ b/image-recipe/scripts/install-podman.sh @@ -40,10 +40,10 @@ EOF # Configure registries (use Docker Hub and quay.io) mkdir -p /home/archipelago/.config/containers/registries.conf.d cat > /home/archipelago/.config/containers/registries.conf < "$CONFIG_DIR/archipelago.service" -echo " ✅ Saved to configs/archipelago.service" - -# Sync nginx configuration -echo "📋 Capturing nginx configuration..." -ssh "$TARGET_HOST" 'sudo cat /etc/nginx/sites-available/archipelago' > "$CONFIG_DIR/nginx-archipelago.conf" -echo " ✅ Saved to configs/nginx-archipelago.conf" - -# Sync logrotate if it exists -if ssh "$TARGET_HOST" 'sudo test -f /etc/logrotate.d/archipelago'; then - echo "📋 Capturing logrotate configuration..." - ssh "$TARGET_HOST" 'sudo cat /etc/logrotate.d/archipelago' > "$CONFIG_DIR/logrotate.conf" - echo " ✅ Saved to configs/logrotate.conf" -fi - -# Check for custom scripts -echo "" -echo "📋 Checking for custom scripts..." -if ssh "$TARGET_HOST" 'sudo test -d /opt/archipelago/scripts'; then - SCRIPT_COUNT=$(ssh "$TARGET_HOST" 'sudo ls /opt/archipelago/scripts/ 2>/dev/null | wc -l' | tr -d ' ') - if [ "$SCRIPT_COUNT" -gt 0 ]; then - echo " ⚠️ Found $SCRIPT_COUNT script(s) in /opt/archipelago/scripts/" - echo " Review and manually sync if needed" - ssh "$TARGET_HOST" 'sudo ls -lh /opt/archipelago/scripts/' - else - echo " ✅ No custom scripts found" - fi -else - echo " ✅ No custom scripts directory" -fi - -# Summary -echo "" -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Sync Complete! ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo "Configuration files captured:" -ls -lh "$CONFIG_DIR" -echo "" -echo "Next steps:" -echo " 1. Review the captured configurations" -echo " 2. Build backend: ./scripts/build-backend.sh" -echo " 3. Build frontend: ./scripts/build-frontend.sh" -echo " 4. Update integration script to use these configs" -echo " 5. Build ISO: ./build-debian-iso.sh" -echo "" diff --git a/indeedhub b/indeedhub deleted file mode 160000 index 1e72c254..00000000 --- a/indeedhub +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1e72c254bc1688a2106b295aa4d71d64e730a22a diff --git a/loop/plan.md b/loop/plan.md deleted file mode 100644 index 1d45fb27..00000000 --- a/loop/plan.md +++ /dev/null @@ -1,232 +0,0 @@ -# Overnight Plan — Container Resilience: Zero Failures - -> Deploy → pull apps → read logs → find failures → fix code → redeploy → retest → repeat until ZERO failures. -> Target: .228 (`ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`). -> DO NOT PUSH — CI build in progress. Commit locally only. -> Follow CLAUDE.md strictly. Production-quality code. No unwrap(), no TODO, no hacks, no garbage. -> Every code change must be clean, well-structured, properly typed, and follow existing patterns. - ---- - -## Cycle 1: Baseline — Deploy and Discover Every Failure - -- [x] **C1-DEPLOY — Deploy current codebase to .228**: Run `./scripts/deploy-to-target.sh --target 192.168.1.228` from macOS. If deploy script fails, read the error, fix the script, retry. After deploy succeeds, SSH to .228 and verify backend is alive: `sudo systemctl status archipelago` and `curl -s http://127.0.0.1:5678/health`. If backend is not running, check `journalctl -u archipelago --no-pager -n 100` and fix whatever is wrong. Do not mark done until: deploy succeeds AND backend returns health JSON. - -- [x] **C1-CONTAINERS — Check every single container**: SSH to .228. Run `podman ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'` to see ALL containers. For EVERY container that is not `running`: run `podman logs --tail 100` and record the error. For every container showing `(unhealthy)`: run `podman logs --tail 100` and record why. For containers that don't exist yet but should (bitcoin-knots, lnd, electrumx, archy-bitcoin-ui, archy-lnd-ui, archy-electrs-ui): note them as missing. Write a summary of ALL issues found as a comment at the bottom of this plan file under `## Issue Log`. Do not fix anything yet — just diagnose. Mark done when you have a complete picture of every container's state. - -- [x] **C1-APPS — Pull and start every Bitcoin stack app**: SSH to .228. For each app in the Bitcoin stack, ensure it exists and is running. Check: (1) `podman ps -a --filter name=bitcoin-knots` — if missing or stopped, check if the image exists (`podman images | grep bitcoin-knots`), if not pull it. Start or create the container using the spec from `scripts/container-specs.sh`. (2) Same for `lnd`. (3) Same for `electrumx`. (4) Same for `archy-bitcoin-ui`, `archy-lnd-ui`, `archy-electrs-ui`. After starting each container, immediately read its logs: `podman logs --tail 50`. Record every error. If a container won't start, record the exact error. If it starts but crashes within 30 seconds, record the crash log. Do not mark done until you have attempted to start ALL 6 containers and recorded the outcome of each. - -- [x] **C1-HEALTH — Deep health check of every running container**: SSH to .228. For each running Bitcoin stack container: (1) **bitcoin-knots**: `podman exec bitcoin-knots bitcoin-cli getblockchaininfo 2>&1` — record if RPC works or fails. Check `podman logs bitcoin-knots --tail 50` for any warnings/errors. (2) **lnd**: Check if it connects to Bitcoin backend — `podman logs lnd --tail 50 | grep -i 'error\|fail\|disconnect\|unable'`. (3) **electrumx**: Check if it connects to Bitcoin — `podman logs electrumx --tail 50 | grep -i 'error\|fail\|disconnect\|unable'`. (4) **archy-bitcoin-ui**: `curl -sf http://localhost:8334/ > /dev/null && echo OK || echo FAIL`. (5) **archy-lnd-ui**: `curl -sf http://localhost:8081/ > /dev/null && echo OK || echo FAIL`. (6) **archy-electrs-ui**: Find its port (`podman port archy-electrs-ui 2>/dev/null || echo 'not running'`) and curl it. Record EVERY failure. Do not mark done until every container has been health-checked and all results recorded in the Issue Log below. - ---- - -## Cycle 2: Fix Every Issue Found — Redeploy — Retest - -- [x] **C2-FIX — Fix every issue from Cycle 1**: Read the Issue Log at the bottom of this file. For EACH issue listed: (1) Read the relevant source code. (2) Understand the root cause. (3) Write a proper, production-quality fix — clean code, proper error handling, no hacks. (4) Commit with `fix: description`. Address ALL issues — do not cherry-pick. If a fix requires changing Rust code, make the change locally (it will be compiled on .228 during deploy). If a fix requires changing container specs, update `scripts/container-specs.sh`. If a fix requires changing a Dockerfile, update the relevant `docker/*/Dockerfile`. If a fix requires changing image versions, update `scripts/image-versions.sh`. If a fix requires changing nginx configs, update the relevant config file. Do not mark done until every issue from the log has a fix committed. - -- [x] **C2-DEPLOY — Redeploy with all fixes**: Run `./scripts/deploy-to-target.sh --target 192.168.1.228`. If deploy fails, fix the deploy error and retry. After deploy, SSH to .228 and rebuild any UI containers that changed: `cd ~/archy/docker/bitcoin-ui && podman build -t bitcoin-ui:local . && podman stop archy-bitcoin-ui 2>/dev/null; podman rm archy-bitcoin-ui 2>/dev/null` — then recreate from spec. Same for lnd-ui and electrs-ui if their Dockerfiles changed. Do not mark done until deploy succeeds and backend health check passes. - -- [x] **C2-RETEST — Test everything again**: SSH to .228. Run the EXACT same checks as C1-CONTAINERS, C1-APPS, and C1-HEALTH. For EVERY container: `podman ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}'`. For every running container, read logs: `podman logs --tail 50 | grep -i 'error\|fail\|panic\|crash\|unable\|refused\|timeout'`. Curl every UI. Check every RPC endpoint. **If ANY new issues are found**: fix them right here — edit code, commit, redeploy to .228, and retest. Keep looping (fix → deploy → test) within this single task until ALL containers are running, ALL health checks pass, ALL UIs respond, ALL logs are clean. Do not mark done until: `podman ps -a --format '{{.Names}} {{.State}}' | grep -v running` returns ZERO non-running containers in the Bitcoin stack, and every curl returns 200, and every log tail has no errors. - ---- - -## Cycle 3: Resilience — Kill Every Container and Verify Recovery - -- [x] **C3-RESTART-BITCOIN — Kill Bitcoin Knots, verify auto-restart**: SSH to .228. Run `podman stop bitcoin-knots`. Wait 15 seconds. Check `podman ps --filter name=bitcoin-knots --format '{{.Names}} {{.State}}'`. It MUST be `running` (restarted by restart policy). If not running: (1) Check `podman inspect bitcoin-knots --format '{{.HostConfig.RestartPolicy.Name}}'` — must be `unless-stopped` or `always`. (2) If restart policy is wrong, fix `scripts/container-specs.sh`, recreate the container with correct policy. (3) Retest until bitcoin-knots auto-restarts after stop. After it restarts, verify RPC works: `podman exec bitcoin-knots bitcoin-cli getblockchaininfo`. Check logs for crash messages. **Loop fix → recreate → kill → verify until it works.** Do not mark done until bitcoin-knots survives a stop and auto-restarts within 30 seconds. - -- [x] **C3-RESTART-LND — Kill LND, verify auto-restart**: Same process. `podman stop lnd`. Wait 15 seconds. Verify it auto-restarts. Verify it reconnects to bitcoin-knots (check logs: `podman logs lnd --tail 20`). If it doesn't restart or can't reconnect: fix, recreate, retest. Loop until it works. Do not mark done until lnd auto-restarts and reconnects to Bitcoin. - -- [x] **C3-RESTART-ELECTRUMX — Kill ElectrumX, verify auto-restart**: Same. `podman stop electrumx`. Wait 15 seconds. Verify auto-restart. Verify it reconnects to bitcoin-knots. Fix → recreate → retest loop. Do not mark done until electrumx auto-restarts and reconnects. - -- [x] **C3-RESTART-UIS — Kill all UI containers, verify auto-restart**: `podman stop archy-bitcoin-ui archy-lnd-ui archy-electrs-ui`. Wait 15 seconds. Run `podman ps --format '{{.Names}} {{.State}}' | grep -E 'bitcoin-ui|lnd-ui|electrs-ui'` — all three must be `running`. Curl each UI endpoint — all must return 200. If any doesn't restart: fix restart policy, recreate, retest. Loop until all three survive kill and auto-restart. - -- [x] **C3-CASCADE — Kill Bitcoin, watch everything, restart, verify full recovery**: This is the critical test. `podman stop bitcoin-knots`. Wait 60 seconds. Check LND and ElectrumX: they should either stay running (waiting for Bitcoin) or enter unhealthy/restarting state — NOT crash permanently. Run `podman ps -a --format '{{.Names}} {{.State}} {{.Status}}' | grep -E 'bitcoin|lnd|electrumx'`. Now start Bitcoin: `podman start bitcoin-knots`. Wait 120 seconds for Bitcoin RPC to come up. Check ALL containers: `podman ps --format '{{.Names}} {{.State}} {{.Status}}' | grep -E 'bitcoin|lnd|electrumx'`. ALL must be `running`. Read logs of each: `podman logs lnd --tail 30` and `podman logs electrumx --tail 30` — should show reconnection, not permanent failure. If ANY container is stuck in a crash loop or permanently dead: read logs, diagnose root cause, fix the code/config, redeploy, retest the entire cascade. **Loop until the full cascade works**: stop Bitcoin → dependents survive → restart Bitcoin → everything recovers. Do not mark done until this passes cleanly. - -- [x] **C3-BACKEND-CRASH — Kill Archipelago backend, verify containers survive**: `sudo systemctl kill -s SIGKILL archipelago`. Wait 10 seconds. (1) Check backend restarted: `sudo systemctl status archipelago` — must be `active`. (2) Check containers: `podman ps --format '{{.Names}} {{.State}}' | grep -E 'bitcoin|lnd|electrumx'` — ALL must still be `running` (containers are independent of backend). (3) Check crash recovery: `journalctl -u archipelago --no-pager -n 50 | grep -i crash` — should show crash detected. (4) Check health endpoint: `curl -s http://127.0.0.1:5678/health` — should return JSON. If any of these fail: read full journal logs, find the error, fix the backend code, redeploy, retest. Loop until backend crash recovery works cleanly. - ---- - -## Cycle 4: Full Retest — Deploy Clean, Test Everything, Zero Failures - -- [x] **C4-CLEAN-DEPLOY — Fresh deploy with all accumulated fixes**: Run `./scripts/deploy-to-target.sh --target 192.168.1.228`. Rebuild UI containers on .228 if any Dockerfiles changed. Restart backend: `sudo systemctl restart archipelago`. Wait 30 seconds. This is the "clean slate" deploy with everything fixed from previous cycles. - -- [x] **C4-FULL-TEST — Complete test suite, fix anything that fails, loop until perfect**: SSH to .228. Run EVERY check below. If ANY fails, fix → redeploy → rerun ALL checks. Repeat until every single line passes: - - **Container state** (all must show `running`): - ``` - podman ps -a --format '{{.Names}} {{.State}}' | grep -E 'bitcoin-knots|lnd|electrumx|bitcoin-ui|lnd-ui|electrs-ui' - ``` - - **Container health** (none should show `unhealthy`): - ``` - podman ps --format '{{.Names}} {{.Status}}' | grep -E 'bitcoin-knots|lnd|electrumx' - ``` - - **Bitcoin RPC** (must return JSON with blockheight): - ``` - podman exec bitcoin-knots bitcoin-cli getblockchaininfo 2>&1 | head -5 - ``` - - **LND connection** (must show no errors): - ``` - podman logs lnd --tail 30 2>&1 | grep -i 'error\|fail\|unable\|refused' | head -10 - ``` - - **ElectrumX connection** (must show no errors): - ``` - podman logs electrumx --tail 30 2>&1 | grep -i 'error\|fail\|unable\|refused' | head -10 - ``` - - **UI endpoints** (all must return HTTP 200): - ``` - curl -sf http://localhost:8334/ > /dev/null && echo "bitcoin-ui OK" || echo "bitcoin-ui FAIL" - curl -sf http://localhost:8081/ > /dev/null && echo "lnd-ui OK" || echo "lnd-ui FAIL" - ``` - For electrs-ui, find port: `podman port archy-electrs-ui 2>/dev/null` - - **Backend health** (must return JSON): - ``` - curl -s http://127.0.0.1:5678/health - ``` - - **Restart policies** (all must be `unless-stopped` or `always`): - ``` - for c in bitcoin-knots lnd electrumx archy-bitcoin-ui archy-lnd-ui archy-electrs-ui; do - echo "$c: $(podman inspect $c --format '{{.HostConfig.RestartPolicy.Name}}' 2>/dev/null || echo 'NOT FOUND')" - done - ``` - - **Memory limits** (all must show non-zero): - ``` - for c in bitcoin-knots lnd electrumx archy-bitcoin-ui archy-lnd-ui archy-electrs-ui; do - echo "$c: $(podman inspect $c --format '{{.HostConfig.Memory}}' 2>/dev/null || echo 'NOT FOUND')" - done - ``` - - **Clean logs** (zero errors in last 30 lines of each): - ``` - for c in bitcoin-knots lnd electrumx; do - echo "=== $c ===" - podman logs $c --tail 30 2>&1 | grep -i 'error\|panic\|fatal\|crash' | head -5 - done - ``` - - **Kill-restart test** (all must auto-restart): - ``` - podman stop bitcoin-knots && sleep 20 && podman ps --filter name=bitcoin-knots --format '{{.State}}' - podman stop lnd && sleep 20 && podman ps --filter name=lnd --format '{{.State}}' - podman stop electrumx && sleep 20 && podman ps --filter name=electrumx --format '{{.State}}' - ``` - - **IF ANY CHECK FAILS**: Read the logs, find the root cause, fix the code properly (clean, well-structured, typed, following CLAUDE.md), commit with `fix:` prefix, redeploy to .228, and run ALL checks again from the top. Keep looping. Do not mark done until EVERY SINGLE CHECK above passes in a single clean run with zero failures. - ---- - -## Cycle 5: Soak — Let It Run, Watch for Drift - -- [x] **C5-SOAK — Wait 5 minutes, recheck everything**: SSH to .228. Wait 5 minutes (`sleep 300`). Then rerun every check from C4-FULL-TEST. Containers that pass immediately but fail after 5 minutes have stability issues (memory leaks, connection timeouts, health check flaps). If ANYTHING changed state or went unhealthy during the 5-minute window: read logs (`podman logs --since 5m`), find the issue, fix it, redeploy, wait 5 minutes again, recheck. Loop until everything stays healthy for a full 5-minute soak. Do not mark done until a clean 5-minute soak passes with zero state changes. - -- [x] **C5-FINAL — Record final state**: SSH to .228. Run and paste output of: (1) `podman ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}'` (2) `curl -s http://127.0.0.1:5678/health` (3) `for c in bitcoin-knots lnd electrumx; do echo "=== $c ==="; podman logs $c --tail 5 2>&1; done`. Record this as the final passing state in the Issue Log at the bottom of this file. Mark the overall result: **PASS** or note any accepted limitations. Do not mark done until the final state is recorded. - ---- - -## Cycle 6: Code Quality Gate - -- [x] **C6-QUALITY — Verify all code changes meet production standards**: Review every commit made during this overnight run. For each changed file: (1) Rust files: `grep -n 'unwrap()\|expect(' | grep -v test | grep -v 'unwrap_or\|unwrap_err'` — zero results. `grep -n 'TODO\|FIXME\|HACK' ` — zero results. (2) TypeScript/Vue files: `cd neode-ui && npx vue-tsc -b --noEmit` — zero errors. (3) Shell scripts: `bash -n ` — syntax OK for every changed script. (4) No hardcoded credentials, no `:latest` tags, no `sudo podman`. If ANY quality issue is found: fix it properly, commit, redeploy, and rerun the relevant tests from C4-FULL-TEST to confirm the quality fix didn't break anything. Do not mark done until all code is production-quality AND all tests still pass. - ---- - -## Issue Log - -### Cycle 1 Findings (2026-03-30 21:03 UTC) - -**Bitcoin Stack Issues:** - -1. **electrumx — EXITED (0), unhealthy** - - Error: `plyvel._plyvel.IOError: b'IO error: utxo/LOCK: Permission denied'` - - Volume `/var/lib/archipelago/electrumx` → `/data` owned by 100000:100000 (correct for container root) - - Container runs as root, `--read-only=false`, restart policy `unless-stopped` - - Root cause: Stale LOCK file from prior crash OR container user mismatch. Need to investigate further. - -2. **lnd — RUNNING but UNHEALTHY** - - Health check: `curl -sf --insecure https://localhost:8080/v1/getinfo` — fails with "expected 1 macaroon, got 0" - - LND itself is functioning: gossip syncing, peer connections active, no critical errors - - Root cause: Health check needs macaroon auth. The health check command is wrong. - - Also: Some Tor SOCKS connection refused errors (transient, non-critical) - -3. **bitcoin-knots — RUNNING, HEALTHY** ✅ - - Uses rpcauth (not rpcuser/rpcpassword). `bitcoin-cli` exec needs cookie or rpcuser auth. - - Port 8332-8333 mapped correctly. - -4. **archy-bitcoin-ui — RUNNING** ✅ - - Host network mode, nginx proxies on port 8334. Curl OK. - -5. **archy-lnd-ui — RUNNING** ✅ - - Port 8081->80. Curl OK. - -6. **archy-electrs-ui — RUNNING** ✅ - - Host network mode, no direct port mapping visible. Served via nginx. - -**Non-Bitcoin Stack Issues (lower priority):** - -7. **grafana — EXITED (1), unhealthy** - - Error: `unable to open database file: permission denied` / `GF_PATHS_DATA is not writable` - - Container has `--read-only` rootfs. Volume perms correct (100472:100472). - - Likely needs tmpfs mounts for `/tmp` and `/var/log/grafana`. - -8. **nextcloud — EXITED (1)** - - Data version 29.0.16.1 > image version 28.0.14.1. Cannot downgrade. Image needs upgrade. - -9. **homeassistant — RUNNING, UNHEALTHY** (not in Bitcoin stack scope) -10. **searxng — RUNNING, UNHEALTHY** (not in Bitcoin stack scope) -11. **onlyoffice — RUNNING, UNHEALTHY** (not in Bitcoin stack scope) -12. **fedimint — CREATED** (never started, not in scope) - -**All restart policies**: `unless-stopped` ✅ -**All memory limits**: Set for all 6 Bitcoin stack containers ✅ - -### Health Check Results (C1-HEALTH) - -| Container | Status | Health | Details | -|-----------|--------|--------|---------| -| bitcoin-knots | running | healthy | RPC OK, blocks=942975, fully synced | -| lnd | running | **unhealthy** | Health check needs macaroon. LND itself works (gossip syncing, peers connected). Only gossip noise errors. | -| electrumx | **crash-loop** | unhealthy | 130+ restarts, `utxo/LOCK: Permission denied` — `--cap-drop=ALL` with empty `SPEC_CAPS` removes `DAC_OVERRIDE` needed for rootless volume writes | -| archy-bitcoin-ui | running | n/a | Curl OK via nginx :8334 | -| archy-lnd-ui | running | n/a | Curl OK on :8081 | -| archy-electrs-ui | running | n/a | Host network, no direct port (served via nginx) | - -**Root causes fixed in Cycle 2:** -1. ✅ electrumx `SPEC_CAPS=""` → added `DAC_OVERRIDE` -2. ✅ lnd health check → replaced curl with `lncli` using readonly macaroon -3. ✅ grafana `SPEC_CAPS` → added `DAC_OVERRIDE` -4. ✅ electrumx health check → replaced missing curl with python3 socket check -5. ✅ container-doctor conmon cleanup → fixed root/rootless podman mismatch (was killing active conmon) -6. ✅ container-doctor restart → added stopped core container recovery for rootless restart policy workaround - -### Final State (2026-03-30 22:33 UTC) — **PASS** - -| Container | State | Health | Notes | -|-----------|-------|--------|-------| -| bitcoin-knots | running | healthy | Block 942982, 13 peers | -| lnd | running | healthy | Gossip syncing, peer connections active | -| electrumx | running | healthy | Caught up to daemon, accepting connections | -| archy-bitcoin-ui | running | n/a | Curl OK on :8334 | -| archy-lnd-ui | running | n/a | Curl OK on :8081 | -| archy-electrs-ui | running | n/a | Curl OK on :50002 | -| grafana | running | healthy | | - -Backend: `{"status":"ok","crash_recovery_complete":true,"version":"1.2.0-alpha","uptime_seconds":1063}` - -**Resilience tests passed:** -- Kill bitcoin-knots → LND/ElectrumX survive, Bitcoin auto-restarts, dependents reconnect -- Kill LND → auto-restarts, reconnects to Bitcoin -- Kill ElectrumX → auto-restarts, reconnects to Bitcoin -- Kill all UI containers → all auto-restart within 30s -- Kill backend (SIGKILL) → systemd restarts, crash recovery runs, all containers unaffected -- 5-minute soak → zero state changes, zero critical errors - -**Fixed this session:** -- UI container specs: added CHOWN/SETUID/SETGID caps (nginx chown failure), NET_BIND_SERVICE for lnd-ui (port 80 bind) - -**Known limitation:** Rootless Podman `unless-stopped` restart policy does not auto-restart containers after `podman stop`. Recovery relies on the backend health monitor + reconcile-containers.sh (runs on boot and periodically). diff --git a/loop/prompt.md b/loop/prompt.md deleted file mode 100644 index add10f4d..00000000 --- a/loop/prompt.md +++ /dev/null @@ -1,49 +0,0 @@ -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 - -## Critical Rules - -- **Deploy-test-fix LOOPS**: Many tasks require you to deploy, test, find failures, fix them, redeploy, and retest. Do NOT mark a task complete until ALL tests in that task pass. If a fix introduces a new failure, fix that too. Keep looping. -- **Read logs obsessively**: After every deploy, read `journalctl`, `podman logs`, and curl output. The logs tell you what's broken. -- **Fix the root cause**: Don't patch symptoms. If a container won't restart, find out WHY (wrong restart policy? health check failing? missing dependency?) and fix the actual cause. -- 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 fix (multiple commits per task is fine) -- DO NOT PUSH -- a CI build is in progress, we will push manually later -- Deploy to .228 -- `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` -- Run Rust builds/checks on .228, NOT macOS -- Production-quality code only -- no shortcuts, no TODO comments, no unwrap() - -## SSH Quick Reference - -```bash -SSH="ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228" -# Deploy from macOS: -./scripts/deploy-to-target.sh --target 192.168.1.228 -# Build Rust on .228: -$SSH "cd ~/archy/core && cargo clippy --all-targets --all-features && cargo test --all-features" -# Check containers: -$SSH "podman ps -a --format '{{.Names}} {{.State}} {{.Status}}' | sort" -# Read container logs: -$SSH "podman logs bitcoin-knots --tail 30" -# Check backend: -$SSH "journalctl -u archipelago --no-pager -n 50" -``` diff --git a/neode-ui/README.md b/neode-ui/README.md index 41f89556..3c9c4449 100644 --- a/neode-ui/README.md +++ b/neode-ui/README.md @@ -148,7 +148,6 @@ State management via Pinia stores. WebSocket patches applied automatically. - **Dev build**: `../web/dist/neode-ui/` - **Docker build**: `dist/` (deployed to nginx) -- **Production deploy**: via `scripts/deploy-to-target.sh --live` ## License diff --git a/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts b/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts index 1a0b3624..b25913aa 100644 --- a/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts +++ b/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts @@ -1,7 +1,7 @@ // keepalive-remount-probe.spec.ts — 02-09 gap-closure Task 1, Step B. // // Standalone, re-runnable Playwright spec that logs into the deployed -// archi-dev-box build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS, +// a test node build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS, // performs a visit -> away (to the neutral /dashboard/settings tab) -> // return round trip, reporting whether the component instance survived. // @@ -284,7 +284,7 @@ interface RoundTripResult { * next click — mirrors measure.ts's own `dismissOverlays()` (this spec * deliberately does not import from measure.ts, so the logic is duplicated * here rather than shared, per the "don't edit the frozen 02-01 harness" - * constraint). archi-dev-box currently runs at 85% disk (02-FINDINGS.md + * constraint). a test node currently runs at 85% disk (02-FINDINGS.md * Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast * live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper * has no `pointer-events: none`, so it silently intercepts clicks on diff --git a/neode-ui/e2e/perf/profile-revisit.spec.ts b/neode-ui/e2e/perf/profile-revisit.spec.ts index d6476054..f02f600d 100644 --- a/neode-ui/e2e/perf/profile-revisit.spec.ts +++ b/neode-ui/e2e/perf/profile-revisit.spec.ts @@ -12,7 +12,7 @@ // profiling evidence instead of guessed from source reading alone. // // Usage: -// ARCHY_BASE_URL=http://archi-dev-box ARCHY_PASSWORD=*** \ +// ARCHY_BASE_URL=http://a test node ARCHY_PASSWORD=*** \ // npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line import { expect, test, type Page } from '@playwright/test' import { SURFACES, type Surface } from './surfaces' @@ -112,7 +112,7 @@ interface CpuProfile { } // Production build has no sourcemaps deployed (confirmed: assets/*.js.map -> -// 404 on archi-dev-box), so minified function names inside vendor-*.js/ +// 404 on a test node), so minified function names inside vendor-*.js/ // index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME // instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled // together; index = app entry/shared code; per-route chunk name = that diff --git a/neode-ui/e2e/perf/surface-perf.spec.ts b/neode-ui/e2e/perf/surface-perf.spec.ts index f47ab22f..18d1db13 100644 --- a/neode-ui/e2e/perf/surface-perf.spec.ts +++ b/neode-ui/e2e/perf/surface-perf.spec.ts @@ -89,7 +89,7 @@ test('surface-perf: measure every D-09 surface and write the baseline artifact', } const header = { - baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228', + baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://localhost:8100', takenAt: new Date().toISOString(), commit: currentCommit(), runs: RUNS, diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 75b67e4f..0efb1afd 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -2877,11 +2877,11 @@ app.post('/rpc/v1', (req, res) => { 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: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.0.2.10/24'] }, + { name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.0.2.14/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'] }, + { name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.64.0.63/32'] }, ], }, }) @@ -3075,11 +3075,11 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { nodes: [ - { did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.168.1.50' }, - { did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.168.1.51' }, - { did: 'did:key:z6MkfV2sQpXm4d8YtR1nWc7uHb3eKj9gLa5xPzD6oTiN8rEw', onion: 'disc3mn04pq15rs26tu37vw48xy59za60bc71de.onion', pubkey: 'disc3pub', node_address: '192.168.1.72' }, - { did: 'did:key:z6MkrJ8pWx2yNc5vT9qLb4eHu7dKf1gMa3sPzE6oXiQ8nRvw', onion: 'disc4fg82hi93jk04lm15no26pq37rs48tu59vw.onion', pubkey: 'disc4pub', node_address: '100.72.19.44' }, - { did: 'did:key:z6MkhT4wQn8xPc2vL6sRb9eYu3dJf7gKa1mNzD5oWiE8tXvq', onion: 'disc5xy60za71bc82de93fg04hi15jk26lm37no.onion', pubkey: 'disc5pub', node_address: '100.101.7.23' }, + { did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.0.2.50' }, + { did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.0.2.51' }, + { did: 'did:key:z6MkfV2sQpXm4d8YtR1nWc7uHb3eKj9gLa5xPzD6oTiN8rEw', onion: 'disc3mn04pq15rs26tu37vw48xy59za60bc71de.onion', pubkey: 'disc3pub', node_address: '192.0.2.72' }, + { did: 'did:key:z6MkrJ8pWx2yNc5vT9qLb4eHu7dKf1gMa3sPzE6oXiQ8nRvw', onion: 'disc4fg82hi93jk04lm15no26pq37rs48tu59vw.onion', pubkey: 'disc4pub', node_address: '100.64.0.6' }, + { did: 'did:key:z6MkhT4wQn8xPc2vL6sRb9eYu3dJf7gKa1mNzD5oWiE8tXvq', onion: 'disc5xy60za71bc82de93fg04hi15jk26lm37no.onion', pubkey: 'disc5pub', node_address: '100.64.0.23' }, ], }, }) @@ -3604,7 +3604,7 @@ app.post('/rpc/v1', (req, res) => { name: 'archy-198', trust_level: 'trusted', mesh_contact_id: 1, - lan_address: '192.168.1.198:5678', + lan_address: '192.0.2.11:5678', onion_address: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', preferred_transport: 'lan', available_transports: ['mesh', 'lan', 'tor'], @@ -3640,7 +3640,7 @@ app.post('/rpc/v1', (req, res) => { name: 'office-node', trust_level: 'trusted', mesh_contact_id: null, - lan_address: '192.168.1.42:5678', + lan_address: '192.0.2.42:5678', onion_address: 'peer4mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion', preferred_transport: 'lan', available_transports: ['lan', 'tor'], @@ -3704,6 +3704,48 @@ app.post('/rpc/v1', (req, res) => { }) } + // Lightning credential rotation (Settings → Lightning credentials). + // Stateful so the dev preview can exercise the whole arc without a node: + // request a rotation and the steps advance on each poll, ending with the + // BTCPay reconnect. Digests only — the real daemon never returns a + // macaroon and neither does this. + case 'lnd.macaroon-status': { + return res.json({ + result: { + installed: true, + admin_macaroon_sha256: '52219e90aeba8ac6a98fdac1cc754fe0a2e8407ae6758ffe2cf92039503f5575', + issued_at: '2026-08-08 06:03:11', + identity_pubkey: '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + channels_open: 5, + channels_pending: 1, + lnd_error: null, + btcpay_uses_internal_lnd: true, + // Flip to false to see the "BTCPay is holding an old credential" + // warning this feature exists to prevent. + btcpay_credential_current: true, + rotation: macaroonRotationSnapshot(), + }, + }) + } + + case 'lnd.rotate-macaroons': { + if (!params?.password) { + return res.json({ error: { code: -1, message: 'Node password required to rotate Lightning credentials' } }) + } + if (params.password !== userState.passwordHash && params.password !== MOCK_PASSWORD) { + return res.json({ error: { code: -1, message: 'Password verification failed' } }) + } + if (macaroonRotation.running) { + return res.json({ error: { code: -1, message: 'A macaroon rotation is already running on this node' } }) + } + startMacaroonRotation() + return res.json({ result: { status: 'started' } }) + } + + case 'lnd.macaroon-rotation-progress': { + return res.json({ result: macaroonRotationSnapshot() }) + } + case 'lnd.gettransactions': { const pending = walletState.transactions.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3).length return res.json({ @@ -4438,7 +4480,7 @@ app.post('/rpc/v1', (req, res) => { ssid: 'CasaDelSol-5G', assoc_ssid: 'CasaDelSol-5G', encryption: 'psk2', - ip: '192.168.1.187', + ip: '192.0.2.15', internet: true, radio0_disabled: false, sta_iface: 'wifinet1', @@ -6075,6 +6117,66 @@ const walletState = sessionBucketProxy('walletState') const userState = sessionBucketProxy('userState') const mockState = sessionBucketProxy('mockState') +// ── Lightning macaroon rotation (mock) ────────────────────────────────────── +// Mirrors the daemon's `RotationProgress`: same step keys and the same five +// states, so the Settings section can be driven end-to-end without a node. +// Advances one step per poll rather than on a timer, which keeps it +// deterministic and makes each intermediate state actually observable. +const MACAROON_ROTATION_STEPS = [ + ['preflight', 'Check LND is healthy and record what must survive', '5 channel(s) open, 1 pending — these must be identical afterwards'], + ['backup', 'Back up the current macaroon material', '8 file(s) copied to /var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z'], + ['stop', 'Stop Lightning', null], + ['remove', 'Remove the old root key and issued macaroons', '8 file(s) removed'], + ['start', 'Start Lightning and unlock the wallet', 'Lightning is up with freshly minted credentials'], + ['verify', 'Confirm the node and its channels are unchanged', 'same node, same 5 channel(s)'], + ['btcpay', 'Reconnect BTCPay Server to the new credentials', 'Connection string updated. BTCPay restarts itself within a minute or two to pick it up.'], +] + +const macaroonRotation = { running: false, done: 0, ok: null, startedAt: null, finishedAt: null } + +function startMacaroonRotation() { + macaroonRotation.running = true + macaroonRotation.done = 0 + macaroonRotation.ok = null + macaroonRotation.startedAt = new Date().toISOString() + macaroonRotation.finishedAt = null +} + +function macaroonRotationSnapshot() { + if (macaroonRotation.running) { + macaroonRotation.done += 1 + if (macaroonRotation.done >= MACAROON_ROTATION_STEPS.length) { + macaroonRotation.done = MACAROON_ROTATION_STEPS.length + macaroonRotation.running = false + macaroonRotation.ok = true + macaroonRotation.finishedAt = new Date().toISOString() + } + } + const started = macaroonRotation.startedAt !== null + return { + running: macaroonRotation.running, + ok: macaroonRotation.ok, + started_at: macaroonRotation.startedAt, + finished_at: macaroonRotation.finishedAt, + error: null, + steps: MACAROON_ROTATION_STEPS.map(([key, label, detail], i) => { + let state = 'pending' + if (started) { + if (i < macaroonRotation.done) state = 'done' + else if (i === macaroonRotation.done && macaroonRotation.running) state = 'running' + } + return { key, label, state, detail: state === 'done' ? detail : null } + }), + backup_path: started ? '/var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z' : null, + identity_pubkey: started ? '03a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456' : null, + channels_before: started ? 5 : null, + channels_after: macaroonRotation.ok ? 5 : null, + new_admin_macaroon_sha256: macaroonRotation.ok + ? '8c19b99de4a8f5c3145d8189500089829174909ca09b48f55e0464239bd8d412' + : null, + } +} + // Seed for the per-session Tor services demo state (tor.list-services / // tor.create-service / tor.delete-service round-trip against this). function defaultTorServices() { diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 6436b050..099781c2 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.125-alpha", + "version": "1.7.127-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.125-alpha", + "version": "1.7.127-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index dbcb2192..0d7dfd62 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.125-alpha", + "version": "1.7.127-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/neode-ui/playwright.config.ts b/neode-ui/playwright.config.ts index 10bd34bf..dc416849 100644 --- a/neode-ui/playwright.config.ts +++ b/neode-ui/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ timeout: 10_000, }, use: { - baseURL: process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228', + baseURL: process.env.ARCHY_BASE_URL ?? 'http://localhost:8100', viewport: { width: 1440, height: 900 }, screenshot: 'only-on-failure', trace: 'off', diff --git a/neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf b/neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf deleted file mode 100644 index 68c33948..00000000 Binary files a/neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf and /dev/null differ diff --git a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Bold.ttf b/neode-ui/public/assets/fonts/Courier_New/CourierNew-Bold.ttf deleted file mode 100644 index 19f74291..00000000 Binary files a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Bold.ttf and /dev/null differ diff --git a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Regular.ttf b/neode-ui/public/assets/fonts/Courier_New/CourierNew-Regular.ttf deleted file mode 100644 index ebb3361a..00000000 Binary files a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Regular.ttf and /dev/null differ diff --git a/neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf b/neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf deleted file mode 100644 index 3bc1fe32..00000000 Binary files a/neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf and /dev/null differ diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index b6020cc1..59b0f61b 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -1,7 +1,7 @@ { "version": 2, "updated": "2026-04-22T00:00:00Z", - "registry": "146.59.87.168:3000/lfg2025", + "registry": "source.archipelago-foundation.org/lfg2025", "featured": { "id": "indeedhub", "banner": "/assets/img/featured/indeedhub-banner.jpg", @@ -19,7 +19,7 @@ "author": "Bitcoin Knots", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest", + "dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest", "repoUrl": "https://github.com/bitcoinknots/bitcoin" }, { @@ -31,7 +31,7 @@ "author": "Bitcoin Core contributors", "category": "money", "tier": "optional", - "dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4", + "dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin:28.4", "repoUrl": "https://github.com/bitcoin/bitcoin" }, { @@ -43,7 +43,7 @@ "author": "Lightning Labs", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta", + "dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta", "repoUrl": "https://github.com/lightningnetwork/lnd", "requires": [ "bitcoin-knots" @@ -52,13 +52,13 @@ { "id": "btcpay-server", "title": "BTCPay Server", - "version": "2.3.9", + "version": "2.4.2", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", "icon": "/assets/img/app-icons/btcpay-server.png", "author": "BTCPay Server Foundation", "category": "commerce", "tier": "core", - "dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9", + "dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2", "repoUrl": "https://github.com/btcpayserver/btcpayserver", "requires": [ "bitcoin-knots" @@ -73,7 +73,7 @@ "author": "Mempool", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1", "repoUrl": "https://github.com/mempool/mempool", "requires": [ "bitcoin-knots", @@ -89,7 +89,7 @@ "author": "Luke Childs", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0", "repoUrl": "https://github.com/spesmilo/electrumx", "requires": [ "bitcoin-knots" @@ -103,7 +103,7 @@ "icon": "/assets/img/app-icons/indeedhub.png", "author": "IndeeHub", "category": "community", - "dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0", "repoUrl": "https://github.com/indeedhub/indeedhub" }, { @@ -114,7 +114,7 @@ "icon": "/assets/img/app-icons/botfights.svg", "author": "BotFights", "category": "community", - "dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.2.11", + "dockerImage": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11", "repoUrl": "https://botfights.net", "containerConfig": { "ports": [ @@ -172,7 +172,7 @@ "author": "File Browser", "category": "data", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0", "repoUrl": "https://github.com/filebrowser/filebrowser", "containerConfig": { "ports": [ @@ -223,7 +223,7 @@ "author": "Vaultwarden", "category": "data", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine", + "dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine", "repoUrl": "https://github.com/dani-garcia/vaultwarden", "containerConfig": { "ports": [ @@ -243,7 +243,7 @@ "author": "SearXNG", "category": "data", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest", + "dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest", "repoUrl": "https://github.com/searxng/searxng", "containerConfig": { "ports": [ @@ -262,7 +262,7 @@ "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "category": "money", - "dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint" }, { @@ -274,7 +274,7 @@ "author": "Fedimint", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1", "repoUrl": "https://github.com/minmoto/fmcd" }, { @@ -285,7 +285,7 @@ "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "category": "money", - "dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint", "containerConfig": { "ports": [ @@ -306,7 +306,7 @@ "icon": "/assets/img/app-icons/bark.png", "author": "Second", "category": "money", - "dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0", "repoUrl": "https://gitlab.com/ark-bitcoin/bark", "containerConfig": { "ports": [ @@ -325,7 +325,7 @@ "icon": "/assets/img/app-icons/jellyfin.webp", "author": "Jellyfin", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13", + "dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13", "repoUrl": "https://github.com/jellyfin/jellyfin", "containerConfig": { "ports": [ @@ -345,7 +345,7 @@ "icon": "/assets/img/app-icons/immich.png", "author": "Immich", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release", + "dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release", "repoUrl": "https://github.com/immich-app/immich" }, { @@ -356,7 +356,7 @@ "icon": "/assets/img/app-icons/homeassistant.png", "author": "Home Assistant", "category": "home", - "dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3", + "dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3", "repoUrl": "https://github.com/home-assistant/core", "containerConfig": { "ports": [ @@ -414,7 +414,7 @@ "author": "Tailscale", "category": "networking", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable", + "dockerImage": "source.archipelago-foundation.org/lfg2025/tailscale:stable", "repoUrl": "https://github.com/tailscale/tailscale", "containerConfig": { "ports": [ @@ -442,7 +442,7 @@ "author": "Portainer", "category": "development", "tier": "optional", - "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1", "repoUrl": "https://github.com/portainer/portainer", "containerConfig": { "ports": [ @@ -487,7 +487,7 @@ "author": "Uptime Kuma", "category": "data", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/uptime-kuma:1", "repoUrl": "https://github.com/louislam/uptime-kuma", "containerConfig": { "ports": [ @@ -514,7 +514,7 @@ "icon": "/assets/img/app-icons/photoprism.svg", "author": "PhotoPrism", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915", + "dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915", "repoUrl": "https://github.com/photoprism/photoprism", "containerConfig": { "ports": [ @@ -537,7 +537,7 @@ "icon": "/assets/img/app-icons/nextcloud.webp", "author": "Nextcloud", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29", + "dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29", "repoUrl": "https://github.com/nextcloud/server", "containerConfig": { "ports": [ diff --git a/neode-ui/public/packages/atob.s9pk b/neode-ui/public/packages/atob.s9pk deleted file mode 100644 index 1c2c829e..00000000 Binary files a/neode-ui/public/packages/atob.s9pk and /dev/null differ diff --git a/neode-ui/public/packages/wireguard.apk b/neode-ui/public/packages/wireguard.apk deleted file mode 100644 index 962f7b75..00000000 Binary files a/neode-ui/public/packages/wireguard.apk and /dev/null differ diff --git a/neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts b/neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts new file mode 100644 index 00000000..f098b611 --- /dev/null +++ b/neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts @@ -0,0 +1,98 @@ +/** + * Regression pin for T-13-39 — `streamUrl` used to append `?auth=` to + * the raw-file URL, leaking the filebrowser JWT into browser history, + * `Referer` headers and access logs. 13-CONTEXT.md names this "the known + * leak to fix rather than propagate"; this file pins the fix so it cannot + * silently regress. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// FileBrowserClient reads window.location.origin in its constructor. +Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' }, + writable: true, +}) + +const { fileBrowserClient } = await import('../filebrowser-client') + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + blob: () => Promise.resolve(new Blob([JSON.stringify(body)])), + headers: new Headers({ 'content-type': 'application/json' }), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => jsonResponse(body, status), + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + formData: () => Promise.resolve(new FormData()), + bytes: () => Promise.resolve(new Uint8Array()), + } +} + +describe('FileBrowserClient.streamUrl', () => { + beforeEach(() => { + mockFetch.mockReset() + ;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = false + document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT' + }) + + it('resolves to a same-origin raw-file URL with no query component', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'super-secret-jwt-token' } })) + + const url = await fileBrowserClient.streamUrl('/Music/song.m4a') + + expect(url).toBe('http://localhost/app/filebrowser/api/raw/Music/song.m4a') + expect(url).not.toContain('?') + }) + + it('never embeds the filebrowser JWT anywhere in the returned string', async () => { + const token = 'super-secret-jwt-token-value-12345' + mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token } })) + + const url = await fileBrowserClient.streamUrl('/Videos/movie.mp4') + + expect(url).not.toContain(token) + expect(url).not.toMatch(/[?&]auth=/) + }) + + it('awaits authentication (sets the cookie the media request relies on) before returning', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-abc' } })) + + await fileBrowserClient.streamUrl('/Videos/movie.mp4') + + // The cookie login() sets is what the same-origin media request depends + // on now that the URL itself carries no credential — assert it's really + // there by the time the caller has the URL in hand. + expect(document.cookie).toContain('auth=jwt-abc') + }) + + it('does not re-authenticate when a valid session already exists', async () => { + ;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true + document.cookie = 'auth=already-authed' + + const url = await fileBrowserClient.streamUrl('/a.mp3') + + expect(mockFetch).not.toHaveBeenCalled() + expect(url).toBe('http://localhost/app/filebrowser/api/raw/a.mp3') + }) + + it('still resolves traversal via sanitizePath — a path cannot escape root', async () => { + ;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true + document.cookie = 'auth=already-authed' + + const url = await fileBrowserClient.streamUrl('/Music/../../etc/passwd') + + expect(url).toBe('http://localhost/app/filebrowser/api/raw/etc/passwd') + expect(url).not.toContain('..') + }) +}) diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 42faffb4..ac58ad91 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -165,15 +165,25 @@ class FileBrowserClient { } /** - * Get a direct streaming URL with auth token in query string. - * Use for video/audio where browser needs to stream (range requests). - * The token is a short-lived JWT so exposure in URL is acceptable. + * Get a direct streaming URL for video/audio `` where the browser + * needs to make Range requests. + * + * Carries NO credential in the query string (T-13-39, fixed 2026-08-03 — + * this was "the known leak to fix rather than propagate", per + * 13-CONTEXT.md). `login()` already sets the filebrowser JWT as a + * `path=/` cookie on this page's own origin, `baseUrl` is that same + * origin, and the browser attaches the cookie to the same-origin media + * subresource request automatically — the same mechanism filebrowser's + * own web UI relies on. Putting the token in the URL too was redundant, + * and it reached browser history, `Referer` headers and any access log on + * the path. The cookie itself is unchanged by this fix: it is still a + * 24-hour JWT, now confined to the cookie jar rather than also appearing + * in the URL. */ async streamUrl(path: string): Promise { await this.ensureAuth() - const token = this.getAuthCookie() const safePath = sanitizePath(path) - return `${this.baseUrl}/api/raw${safePath}?auth=${token}` + return `${this.baseUrl}/api/raw${safePath}` } /** diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 6bb70fff..d411fad7 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -1084,6 +1084,16 @@ class RPCClient { relay_count: number first_seen: string nostr_pubkey: string + /** + * Whether the author proved control of the key their `author.did` names. + * `invalid` manifests are dropped during discovery and should never + * appear here; typed anyway so the UI fails safe rather than falling + * through to "signed" if that ever changes. + */ + signature?: + | { status: 'valid' } + | { status: 'missing' } + | { status: 'invalid'; reason: string } }> relay_count: number }> { @@ -1148,6 +1158,69 @@ class RPCClient { }) } + /** This node's Lightning credential state. Digests and counts only — the + * backend never returns macaroon content, so nothing here is sensitive. */ + async lndMacaroonStatus(): Promise { + return this.call({ method: 'lnd.macaroon-status', timeout: 30000 }) + } + + /** Begin a macaroon rotation. Returns as soon as the job is accepted; the + * work takes minutes (LND has to close and reopen its databases), so poll + * `lndMacaroonRotationProgress` for the outcome. */ + async lndRotateMacaroons(password: string): Promise<{ status: string }> { + return this.call({ + method: 'lnd.rotate-macaroons', + params: { password }, + timeout: 30000, + }) + } + + async lndMacaroonRotationProgress(): Promise { + return this.call({ method: 'lnd.macaroon-rotation-progress' }) + } +} + +export type RotationStepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped' + +export interface LndRotationStep { + key: string + label: string + state: RotationStepState + detail: string | null +} + +export interface LndRotationProgress { + running: boolean + /** null while running, then the verdict. Lets the UI tell "in progress" + * apart from "finished and failed". */ + ok: boolean | null + started_at: string | null + finished_at: string | null + error: string | null + steps: LndRotationStep[] + /** Holds the OLD root key, so it is still secret. The UI tells the operator + * to delete it once every wallet app has been re-paired. */ + backup_path: string | null + identity_pubkey: string | null + channels_before: number | null + channels_after: number | null + new_admin_macaroon_sha256: string | null +} + +export interface LndMacaroonStatus { + installed: boolean + admin_macaroon_sha256: string | null + /** When LND last minted these credentials, host local time. */ + issued_at: string | null + identity_pubkey: string | null + channels_open: number | null + channels_pending: number | null + /** Why LND could not be asked, when it could not. */ + lnd_error: string | null + btcpay_uses_internal_lnd: boolean + /** null when BTCPay has no internal Lightning node — an absence, not a fault. */ + btcpay_credential_current: boolean | null + rotation: LndRotationProgress } export const rpcClient = new RPCClient() diff --git a/neode-ui/src/components/SpotlightSearch.vue b/neode-ui/src/components/SpotlightSearch.vue index a1c3c83a..b5c491a5 100644 --- a/neode-ui/src/components/SpotlightSearch.vue +++ b/neode-ui/src/components/SpotlightSearch.vue @@ -73,9 +73,40 @@ {{ item.section }}
-
+
No results for "{{ query }}"
+ + +
+ +
+ +