diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index da470f96..25acf261 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -40,7 +40,19 @@ signed/decentralized registry and a user installs it on their node. 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 -**Plans**: TBD +**Plans**: 10 plans + +Plans: +- [ ] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01) +- [ ] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04) +- [ ] 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) **UI hint**: yes ### Phase 2: UI Performance @@ -131,7 +143,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Federation & Mesh Hardening | 0/TBD | Not started | - | +| 1. Federation & Mesh Hardening | 0/10 | Planned | - | | 2. UI Performance | 0/TBD | Not started | - | | 3. Multinode Verification Pass | 0/TBD | Not started | - | | 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | diff --git a/.planning/phases/01-federation-mesh-hardening/01-01-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-01-PLAN.md new file mode 100644 index 00000000..c916a840 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-01-PLAN.md @@ -0,0 +1,253 @@ +--- +phase: 01-federation-mesh-hardening +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - core/archipelago/src/federation/storage.rs +autonomous: true +requirements: [FED-01] + +must_haves: + truths: + - "A federation removal issued while an auto-sync pass is in flight leaves the peer removed — a sync's pre-removal node-list snapshot can no longer re-save the removed peer (FED-01 adjacency edge)" + - "Two concurrent federation node writes both persist — neither silently loses the other's update (the lost-update class behind the reported 'removed nodes reappear' symptom)" + - "Removing the last remaining federated node succeeds, leaves an empty node list, and returns Ok with an empty Vec (FED-01 empty edge)" + - "A removal whose tombstone write fails returns Err to the caller instead of reporting success (FED-01 failure-surfacing)" + - "The tombstone is durably written before the filtered node list is saved, so an interruption between the two never resurrects the removed peer (FED-01 ordering edge)" + - "A partially-written federation node list can never be observed by a concurrent reader — the list is written to a sibling temp file and renamed into place" + prohibitions: + - statement: "Removing a federation node MUST NOT delete or destroy that peer's local data — no app data directory under /var/lib/archipelago, no mesh message history, no credential store is erased by unfederating; removal revokes trust, it never destroys operator data" + category: safety + artifacts: + - path: core/archipelago/src/federation/storage.rs + provides: "Serialized, crash-safe federation node store" + contains: "FEDERATION_STORE_LOCK" + key_links: + - from: core/archipelago/src/federation/storage.rs + to: core/archipelago/src/federation/sync.rs + via: "update_node_state acquires FEDERATION_STORE_LOCK for its whole load-mutate-save cycle, so a sync pass cannot interleave with remove_node" + pattern: "FEDERATION_STORE_LOCK" +--- + + +Close the concurrency race that lets a removed federation node come back: serialize every +read-modify-write against `federation/nodes.json` behind one async lock, and make the node-list +write atomic. + +Purpose: FED-01 — "removing a federation node sticks" is the reason this phase exists. RESEARCH.md +identifies an unlocked read-modify-write on `federation/nodes.json` as the primary suspect: the 90s +auto-sync loop, the 1800s auto-sync loop, `federation.sync-state`, and `federation.remove-node` all +load → mutate → save the same file with zero coordination, so a sync task holding a pre-removal +snapshot silently re-saves the peer the operator just removed — with no error logged anywhere. +Output: `federation/storage.rs` with a module-level `FEDERATION_STORE_LOCK`, inner/outer function +split to avoid re-entrancy deadlock, an atomic temp-file+rename node-list write, and three new +regression tests that fail without the lock. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@core/archipelago/src/federation/storage.rs +@core/archipelago/src/update.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `FEDERATION_STORE_LOCK` | `static tokio::sync::Mutex<()>` | `core/archipelago/src/federation/storage.rs` | +| `save_nodes_inner` | private async fn (no lock; atomic temp+rename write) | same | +| `load_nodes_inner` | private async fn (no lock) | same | +| `tombstone_did_inner` / `untombstone_did_inner` | private async fns (no lock) | same | +| `test_concurrent_writes_do_not_lose_updates` | `#[tokio::test]` | same (`mod tests`) | +| `test_remove_survives_concurrent_state_sync` | `#[tokio::test]` | same (`mod tests`) | +| `test_remove_last_node_leaves_empty_list` | `#[tokio::test]` | same (`mod tests`) | +| `test_remove_errors_when_tombstone_write_fails` | `#[tokio::test]` | same (`mod tests`) | + +Public function signatures of `load_nodes`, `save_nodes`, `add_node`, `remove_node`, +`set_trust_level`, `update_node`, `update_node_state`, `record_peer_transport`, `tombstone_did`, +`untombstone_did`, `load_removed_dids` are **unchanged** — callers in `sync.rs`, `handlers.rs`, +`server.rs`, and `mesh/mod.rs` compile untouched. + + + + + Task 1: End-to-end — a removal survives an in-flight sync (lock + atomic write) + A module-private lock and a temp+rename write are internal + to storage.rs behind unchanged public signatures; reverting is a single-file change with no + on-disk format change. + core/archipelago/src/federation/storage.rs + + - `core/archipelago/src/federation/storage.rs` — the whole file (532 lines). Note in particular: + `load_nodes` (L51), `record_peer_transport` (L120), `save_nodes` (L149), `add_node` (L161, + calls `untombstone_did`), `remove_node` (L180, calls `tombstone_did`), `tombstone_did` (L214), + `untombstone_did` (L237), `set_trust_level` (L256), `update_node` (L272), `update_node_state` + (L292), and the existing `#[cfg(test)] mod tests` (L341) with its `make_node(did, onion)` + helper and `tempfile::tempdir()` convention. + - `core/archipelago/src/update.rs` lines 25-40 — `UPDATE_OP_LOCK`, the in-repo precedent for + "two async call sites race on one on-disk resource". Copy its doc-comment style (name the + concrete historical incident, then state the acquisition policy). + - `.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md` — section "Async static lock + for a racy on-disk resource" and "core/archipelago/src/federation/storage.rs (locking fix)". + + + - `test_concurrent_writes_do_not_lose_updates`: under `#[tokio::test(flavor = "multi_thread", worker_threads = 4)]`, + seed one node, then `tokio::join!` an `add_node(B)` with a `set_trust_level(A, Observer)`; + afterwards `load_nodes` returns 2 nodes AND node A's trust level is Observer. Without the + lock one of the two writes is lost. + - `test_remove_survives_concurrent_state_sync`: under the multi-thread flavor, loop 50 times: + fresh tempdir, seed nodes A and B, `tokio::join!(remove_node(A), update_node_state(A, snapshot))`, + then assert `load_nodes` contains no entry whose `did` is A and `load_removed_dids` contains A. + Without the lock this reliably fails within 50 iterations. + - `test_remove_last_node_leaves_empty_list`: seed exactly one node, remove it, assert the + returned Vec is empty, `load_nodes` returns an empty Vec (not an error), and the DID is + tombstoned. + + + Write the three tests FIRST in the existing `mod tests` block and confirm they fail (run the + verify command and capture the failure) before writing the fix. + + Then add at module scope, immediately after the existing `use` block: + `static FEDERATION_STORE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());` + with a doc comment that names the failure it prevents — a `federation.remove-node` RPC racing + the 90s auto-sync pass, whose pre-removal `load_nodes` snapshot re-saves the removed peer with + no error logged — and states the acquisition policy: acquire with `.lock().await`, never + `try_lock`. Reject-on-contention is wrong here: a rejected federation write reproduces the very + lost-write symptom the lock exists to stop, unlike `update.rs` where rejecting a second + concurrent download is the desired UX. + + Restructure so no public function can deadlock on itself. For each function that both takes the + lock and calls another locking function, extract the body into a private `*_inner` fn that does + NOT acquire, and make the public fn a thin wrapper: acquire the guard, call the inner(s), drop. + Required inner fns for this task: `load_nodes_inner`, `save_nodes_inner`, `tombstone_did_inner`, + `untombstone_did_inner`. `remove_node` (which calls `tombstone_did`) and `add_node` (which calls + `untombstone_did`) must call the `*_inner` variants under a single held guard so tombstone + + node-list save are one critical section. + + Convert the node-list write in `save_nodes_inner` to atomic replace: serialize to a sibling path + formed by appending a `.tmp` suffix to the resolved nodes file path in the same directory, write + it with `tokio::fs::write`, then `tokio::fs::rename` it onto the real path. Keep the existing + `.context(...)` error strings so callers' messages are unchanged. Same-directory rename is + required — a cross-filesystem rename is not atomic. + + In THIS task route `load_nodes`, `save_nodes`, `remove_node`, `tombstone_did`, + `untombstone_did`, and `update_node_state` through the lock. The remaining mutators are Task 2. + + Preserve `remove_node`'s existing ordering exactly: the retain/`bail!`-on-not-found check, then + the tombstone write with its failure propagated via `.context("persist removal tombstone")?`, + then the node-list save. Do not weaken that ordering or its error propagation. + + Build gotcha from CLAUDE.md: if the build hits `rust-lld: undefined hidden symbol`, that is + incremental-cache corruption — re-run with `CARGO_INCREMENTAL=0`. + + + cd core && cargo test -p archipelago federation::storage + + + - `cd core && cargo test -p archipelago federation::storage` exits 0. + - `grep -c 'FEDERATION_STORE_LOCK' core/archipelago/src/federation/storage.rs` is at least 7. + - `grep -c 'tokio::sync::Mutex::const_new' core/archipelago/src/federation/storage.rs` equals 1. + - `grep -c 'fs::rename' core/archipelago/src/federation/storage.rs` is at least 1. + - `grep -Eq 'async fn test_remove_survives_concurrent_state_sync' core/archipelago/src/federation/storage.rs` succeeds. + - `grep -Eq 'async fn test_concurrent_writes_do_not_lose_updates' core/archipelago/src/federation/storage.rs` succeeds. + - `grep -Eq 'async fn test_remove_last_node_leaves_empty_list' core/archipelago/src/federation/storage.rs` succeeds. + - `grep -Eq 'flavor = "multi_thread"' core/archipelago/src/federation/storage.rs` succeeds (the + race tests are useless on the single-threaded default runtime). + - The SUMMARY records the captured pre-fix failure output for at least one of the three tests. + + The removal-vs-sync race is closed at the storage layer and proven by a test that fails without the lock; the node list is written atomically. + + + + Task 2: Bring every remaining federation mutator under the lock + surface tombstone-write failure + core/archipelago/src/federation/storage.rs + + - `core/archipelago/src/federation/storage.rs` as left by Task 1 — specifically the four + mutators not yet routed through the lock: `record_peer_transport` (L120 pre-change), + `add_node`, `set_trust_level`, `update_node`. + - The existing test `test_remove_nonexistent_node_errors` (L445 pre-change) — mirror its + assertion style for the new failure test. + + + Route `add_node`, `set_trust_level`, `update_node`, and `record_peer_transport` through + `FEDERATION_STORE_LOCK` using the same wrapper + `*_inner` split established in Task 1. Every + public function in this module that performs a load → mutate → save cycle must hold the guard + for the whole cycle; none may call another lock-acquiring public function while holding it. + + Add `test_remove_errors_when_tombstone_write_fails`: seed a node, then make the tombstone write + fail by pre-creating the removed-nodes path as a directory (a directory cannot be replaced by a + file write), call `remove_node`, and assert the result is `Err` AND that `load_nodes` still + contains the node — a removal whose tombstone never landed must not have half-applied. This is + the FED-01 "a failed removal surfaces an error instead of silently no-opping" criterion at the + storage layer. + + Do not change any public signature and do not touch `load_invites`/`save_invites` (a separate + file with no cross-writer). + + + cd core && cargo test -p archipelago federation + + + - `cd core && cargo test -p archipelago federation` exits 0. + - `grep -Eq 'async fn test_remove_errors_when_tombstone_write_fails' core/archipelago/src/federation/storage.rs` succeeds. + - `grep -c 'FEDERATION_STORE_LOCK.lock().await' core/archipelago/src/federation/storage.rs` is at least 9. + - `cd core && cargo build -p archipelago` exits 0 with no new warnings in `federation::storage` + (dead-code warnings on unused `*_inner` fns mean a mutator was missed). + - `cd core && cargo test -p archipelago` exits 0 — no caller in `sync.rs`, `handlers.rs`, + `server.rs`, or `mesh/mod.rs` was broken by the refactor. + + Every federation node-store mutator is serialized; a tombstone-write failure is proven to surface as an error with no half-applied removal. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| federated peer → `federation::sync` → node store | A remote peer's state snapshot crosses into local persisted trust state | +| operator RPC (`federation.remove-node`) → node store | An authenticated local operator action mutates trust membership | +| process → `federation/nodes.json` on disk | Multiple concurrent async tasks write one file; a crash can leave it partial | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-01 | Tampering | `federation::storage` concurrent read-modify-write | high | mitigate | `FEDERATION_STORE_LOCK` held across the whole load-mutate-save cycle in every mutator (Tasks 1-2); regression test proves a removal survives a concurrent sync | +| T-01-02 | Elevation of Privilege | a removed (untrusted) peer regaining federation membership via the race | high | mitigate | Same lock; plus the pre-existing tombstone check in `merge_transitive_peers` and `handle_federation_peer_joined` is left intact and re-verified by `cargo test -p archipelago federation` | +| T-01-03 | Denial of Service | a partial `nodes.json` write on crash making the node list unreadable | medium | mitigate | Atomic temp-file + same-directory `fs::rename` in `save_nodes_inner` (Task 1) | +| T-01-04 | Denial of Service | lock contention stalling the federation RPC surface | low | accept | Federation writes are infrequent (90s loop + operator actions); `.lock().await` queues rather than rejects, and every critical section is a bounded file read+write | + + + +- `cd core && cargo test -p archipelago federation` — green. +- `cd core && cargo test -p archipelago` — green (no caller regressions). +- The pre-fix failure of `test_remove_survives_concurrent_state_sync` is recorded in the SUMMARY as + evidence the test is fail-first and not vacuous. + + + +- Every read-modify-write in `federation/storage.rs` is serialized behind one module-level async mutex with no re-entrancy path. +- The node list is written atomically (temp file + same-directory rename). +- Four new tests exist and pass; at least one is demonstrated to fail without the lock. +- Public signatures unchanged; the full `archipelago` test suite is green. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md` when done. +Commit with `git add` by explicit path (another agent shares this tree — never `git add -A`), then +push per CLAUDE.md: `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-02-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-02-PLAN.md new file mode 100644 index 00000000..8d58e6d0 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-02-PLAN.md @@ -0,0 +1,301 @@ +--- +phase: 01-federation-mesh-hardening +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - neode-ui/mock-backend.js + - neode-ui/scripts/mock-rpc-parity.mjs + - neode-ui/package.json +autonomous: true +requirements: [FED-04] + +must_haves: + truths: + - "Every mesh.* and federation.* RPC method the neode-ui frontend calls has a matching handler in mock-backend.js — the demo never answers a UI call with 'Method not found'" + - "Renaming a mesh peer on the demo persists: mesh.contacts-save then mesh.contacts-list returns the saved alias, mirroring the daemon's handle_mesh_contacts_save/list behavior" + - "A reaction, reply, edit, delete, or forward performed on the demo mutates the demo message store and is visible on the next mesh.messages read — it is not a bare ok acknowledgement" + - "The demo's transport decision for an attachment matches the daemon's size tiers (auto under 1024 bytes, chooser in the 1024..2300 band, tor-only above 2300) — no demo-only chooser modal" + - "An automated parity check fails when a UI-called mesh.*/federation.* method has no mock-backend handler, so the gap class is caught before manual demo testing" + prohibitions: + - statement: "The demo/mock backend MUST NOT gain behavior that diverges from the real daemon — it must never invent a demo-only modal, a demo-only response shape, or a success path a real node does not produce; every mirrored handler cites the daemon source file and line range it mirrors" + category: transparency + artifacts: + - path: neode-ui/scripts/mock-rpc-parity.mjs + provides: "Static UI-call vs mock-handler cross-reference plus a live RPC smoke sequence" + min_lines: 60 + - path: neode-ui/mock-backend.js + provides: "mesh.contacts-list/save, stateful message-mutation handlers, and the 10 previously-missing UI-called methods" + contains: "mesh.contacts-list" + key_links: + - from: neode-ui/scripts/mock-rpc-parity.mjs + to: neode-ui/mock-backend.js + via: "spawns mock-backend.js on MOCK_BACKEND_PORT and posts a scripted JSON-RPC sequence" + pattern: "MOCK_BACKEND_PORT" +--- + + +Finish demo/real mesh parity: the demo backend answers every mesh and federation RPC the UI calls, +and the message-mutation calls actually mutate demo state instead of returning a bare acknowledgement. + +Purpose: FED-04. Attachment-send parity already landed on main (`c2ce71c6`) — `mesh.send-content-inline` +/ `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` now mirror the daemon's tier +logic. RESEARCH.md and a fresh cross-reference of `neode-ui/src/**` against `mock-backend.js` show +what remains: **12 methods the UI calls that have no case at all** (they fall through to a +`Method not found` error the frontend swallows in `try/catch`), and **six ack-only stubs** that +never touch the demo message store, so reactions/edits/deletes silently do not render on the demo. +Output: those gaps closed, plus a repeatable parity harness so this class of drift is caught by a +command instead of by squinting at the browser console. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@neode-ui/mock-backend.js +@core/archipelago/src/api/rpc/mesh/typed_messages.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `neode-ui/scripts/mock-rpc-parity.mjs` | new node script (static cross-reference + live smoke) | new file | +| `test:mock-parity` | npm script | `neode-ui/package.json` | +| `MOCK_BACKEND_PORT` | env var override for the mock's listen port | `neode-ui/mock-backend.js` | +| `mesh.contacts-list`, `mesh.contacts-save` | new mock RPC cases | `neode-ui/mock-backend.js` | +| `mesh.clear-all`, `mesh.schedule-message`, `mesh.list-scheduled`, `mesh.cancel-scheduled`, `mesh.assistant-status`, `mesh.assistant-configure` | new mock RPC cases | same | +| `federation.nodes`, `federation.dwn-status`, `federation.notify-did-change`, `federation.cancel-request` | new mock RPC cases | same | +| `store.mesh.contacts`, `store.mesh.scheduled` | new per-session mock store buckets | same | + + + + + Task 1: End-to-end — alias a mesh peer on the demo and it sticks, proven by a parity harness + neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs, neode-ui/package.json + + - `neode-ui/mock-backend.js` lines 4300-4500 — the `mesh.transport-advice` case and the comment + block above it (the house convention: mirror the daemon and cite the source file), the + `mesh.send-content-inline` case for how a handler mutates `currentStore().mesh.dynamic`, and + the ack-only stub block at the end of the mesh cases. + - `neode-ui/mock-backend.js` lines 5495-5530 — the per-session store shape (`mesh: { dynamic: [], blobs: {} }`) + and `currentStore()`. + - `neode-ui/mock-backend.js` lines 80-90 and 5710-5730 — the `PORT` constant and the `server.listen` call. + - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` — `handle_mesh_contacts_list` (from L1218) + and `handle_mesh_contacts_save` (from L1253): the real merge of `state.contacts` (a + `ContactEntry` map with `alias`, `notes`, `pinned`, `blocked`) over `state.peers`, and the + exact response shape the UI consumes. + - `neode-ui/src/api/rpc-client.ts` lines 795-820 — the `mesh.contacts-list` / `mesh.contacts-save` + wrappers and their params shape. + - `neode-ui/src/views/Mesh.vue` — the two call sites (on mount, and on peer rename) to confirm + which response fields are read. + + + Add a `contacts` bucket (a plain object keyed by peer contact id) to the per-session mock store + alongside the existing `dynamic` and `blobs` keys. + + Implement `mesh.contacts-save`: accept the same params the daemon's handler takes, upsert + `{ alias, notes, pinned, blocked }` for the given peer key into the session `contacts` bucket, + and return the same result shape the real handler returns. Implement `mesh.contacts-list`: + merge the session `contacts` bucket over the demo's `mesh.peers` list exactly as the daemon + merges `state.contacts` over `state.peers`, and return the same field names. Follow the house + convention already used above `mesh.transport-advice`: a comment naming + `typed_messages.rs handle_mesh_contacts_list` / `handle_mesh_contacts_save` as the source of + truth, so a future reader knows where to re-check parity. + + Change the hardcoded listen port to read an env override first, defaulting to the existing + value, so a harness can bind an ephemeral port without colliding with a running dev preview. + Use the env var name `MOCK_BACKEND_PORT`. + + Create `neode-ui/scripts/mock-rpc-parity.mjs` with two stages and a non-zero exit on any failure: + (1) STATIC — scan `neode-ui/src/**` for every `'mesh.'` / `'federation.'` string + literal, scan `mock-backend.js` for every `case '':`, and report methods called by the UI + with no mock case. Print the offending method names. (2) LIVE — spawn `node mock-backend.js` + with `MOCK_BACKEND_PORT` set to a free port, poll `/rpc/v1` until ready (bounded ~10s), then POST + a scripted JSON-RPC sequence and assert on the responses: `mesh.contacts-save` with an alias, + then `mesh.contacts-list` returns that alias for that peer. Kill the child in a `finally` block. + Do not use `|| echo`-style fallbacks anywhere in the script or its npm wiring — a failed spawn, + a failed fetch, or a missing field must propagate as a non-zero exit, never a passing run that + measured nothing. + + Register it as the `test:mock-parity` npm script in `neode-ui/package.json`. + + In this task the STATIC stage is expected to still report the other missing methods; make it + print them and exit non-zero only when the LIVE stage fails or when a method from an explicit + `KNOWN_GAPS` array is missing. Task 2 empties `KNOWN_GAPS` to zero entries. + + + cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs + + + - `cd neode-ui && node --check mock-backend.js` exits 0. + - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 and its output contains the alias + round-trip assertion result. + - `grep -c "case 'mesh.contacts-list'" neode-ui/mock-backend.js` equals 1. + - `grep -c "case 'mesh.contacts-save'" neode-ui/mock-backend.js` equals 1. + - `grep -c 'MOCK_BACKEND_PORT' neode-ui/mock-backend.js` is at least 1. + - `grep -c 'typed_messages.rs' neode-ui/mock-backend.js` is at least 2 (the pre-existing + transport-advice citation plus the new contacts citation). + - `node -e "process.exit(require('./neode-ui/package.json').scripts['test:mock-parity'] ? 0 : 1)"` exits 0. + - Killing the harness leaves no stray listener: `cd neode-ui && node scripts/mock-rpc-parity.mjs && node scripts/mock-rpc-parity.mjs` exits 0 twice in a row. + + Peer aliasing works end-to-end on the demo and a single command proves it, with the remaining method gaps enumerated by name. + + + + Task 2: Close the remaining ten UI-called methods with no mock handler + neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs + + - The STATIC-stage output from Task 1 — the authoritative live list. As of planning it is: + `mesh.clear-all`, `mesh.schedule-message`, `mesh.list-scheduled`, `mesh.cancel-scheduled`, + `mesh.assistant-status`, `mesh.assistant-configure`, `federation.nodes`, + `federation.dwn-status`, `federation.notify-did-change`, `federation.cancel-request`. + - `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the real handler names each of + these methods dispatches to, so the mock mirrors the right handler. + - `neode-ui/mock-backend.js` — the existing `federation.list-nodes`, `federation.list-pending-requests`, + `federation.approve-request`, and `federation.reject-request` cases, for the response shapes + the sibling federation methods must match. + + + Add a case for each remaining method, mirroring the real handler's response shape (read the + Rust handler named by the dispatcher before writing each one) and citing it in a comment the way + the contacts handlers do. + + Behavioral requirements, not bare acknowledgements: `mesh.clear-all` empties the session + `dynamic` message array; `mesh.schedule-message` pushes into a new session `scheduled` bucket + and returns the created entry's id; `mesh.list-scheduled` returns that bucket; + `mesh.cancel-scheduled` removes by id and reports whether an entry was actually removed; + `federation.nodes` returns the same node array `federation.list-nodes` returns (the UI treats + them as aliases); `federation.cancel-request` removes the request from the pending-requests + bucket the existing approve/reject cases operate on. + + Then set the harness's `KNOWN_GAPS` array to empty so the STATIC stage exits non-zero on ANY + UI-called method without a mock case, and extend the LIVE stage with one assertion per newly + stateful method that has observable state: schedule a message then list it and assert it is + present; cancel it and assert it is gone; clear-all then read `mesh.messages` and assert the + dynamic messages are gone. + + + cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs + + + - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero reported missing methods. + - `cd neode-ui && grep -c 'KNOWN_GAPS' scripts/mock-rpc-parity.mjs` is at least 1 and the array + literal it is assigned is empty. + - Each of the ten method names appears exactly once as a `case '':` in `mock-backend.js`. + - Deliberately deleting one `case` line makes `node scripts/mock-rpc-parity.mjs` exit non-zero + (fail-first proof); restore the line afterward and record the check in the SUMMARY. + + The demo answers every mesh and federation RPC the UI calls, and the parity harness is proven to fail when it does not. + + + + Task 3: Make the message-mutation stubs mutate demo state + neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs + + - `neode-ui/mock-backend.js` — the ack-only stub block covering `mesh.send-reaction`, + `mesh.send-reply`, `mesh.send-read-receipt`, `mesh.edit-message`, `mesh.delete-message`, + `mesh.forward-message`, `mesh.send-channel` (currently a shared bare-acknowledgement case), + and the `mesh.send-content-inline` case above it for the message-object shape pushed into + `currentStore().mesh.dynamic`. + - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-976 — reply / reaction / + read-receipt / forward handlers, and lines 1065-1180 — edit / delete. Note the stable + `sender_pubkey` + `sender_seq` message key these operate on, not the local `id`. + - `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` field set the demo + objects must match (`id`, `direction`, `peer_contact_id`, `peer_name`, `plaintext`, + `timestamp`, `delivered`, `encrypted`, `transport`, `message_type`, `typed_payload`, + `sender_pubkey`, `sender_seq`). + - `neode-ui/src/views/Mesh.vue` and `neode-ui/src/stores/mesh.ts` — how the UI reads reactions, + edited text, and deleted markers, so the mutated shape is the one that renders. + + + Split the shared acknowledgement case into individual cases that mutate `currentStore().mesh.dynamic`: + + `mesh.send-reaction` — locate the target message by the same key the daemon uses and append or + toggle the emoji in its reactions collection. `mesh.send-reply` — push a new message whose + payload carries the replied-to message key, so the UI renders the quote block. + `mesh.send-read-receipt` — mark the target message read. `mesh.edit-message` — replace the + target's text and set the edited marker the UI reads. `mesh.delete-message` — apply the same + deletion representation the daemon applies (tombstone marker vs removal — read the handler and + mirror it, do not choose independently). `mesh.forward-message` — push a copy addressed to the + destination peer. `mesh.send-channel` — push a channel-addressed message. + + Leave `mesh.refresh` and `mesh.reboot-radio` as acknowledgements — the daemon's handlers have no + message-store effect either, so mirroring means leaving them alone. Add a comment on that pair + stating why they remain acknowledgements, so a later reader does not "fix" them into divergence. + + Extend the harness's LIVE stage: send a message, react to it, and assert `mesh.messages` shows + the reaction; edit it and assert the text changed and the edited marker is set; delete it and + assert the daemon-matching representation; forward it and assert a copy exists for the + destination peer. + + + cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs + + + - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with the reaction, edit, delete, and + forward assertions all reported as passing. + - `grep -c "case 'mesh.send-reaction':" neode-ui/mock-backend.js` equals 1 and that case is no + longer part of a shared fall-through group with `mesh.refresh`. + - `grep -c 'typed_messages.rs' neode-ui/mock-backend.js` is at least 4 (each mirrored family + cites its daemon source). + - `cd neode-ui && npm run build` exits 0 (the mock is dev-only, but the build must not regress). + + Reactions, replies, edits, deletes, and forwards render on the demo exactly as on a real node, proven by the live harness. + + + + +## Planner Assumptions (flagged, unresolved) + +- **FED-04 / spec-less probe, category `unclassified`:** the probe could not classify an edge for + FED-04, and no acceptance criterion was invented for it. The parity harness covers the *known* + drift class (missing handler, non-mutating handler); it does NOT cover response-shape drift where + a mock case exists and returns a differently-shaped success object than the daemon. That residual + class is surfaced here rather than silently dropped, and is a candidate finding for the FED-03 + review in plan 01-07. + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → mock backend `/rpc/v1` | Developer-local demo surface; accepts unauthenticated JSON-RPC on a loopback-bound dev port | +| harness child process → mock backend | The parity script spawns and drives the mock on an ephemeral port | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-05 | Spoofing | mock backend impersonating real daemon behavior in a way that hides a real-node bug | medium | mitigate | Every mirrored handler cites the daemon file and line range it mirrors; the parity harness asserts observable state transitions, not acknowledgements | +| T-01-06 | Information Disclosure | mock backend binding a non-loopback interface on a developer machine | low | accept | Pre-existing `0.0.0.0` bind is unchanged by this plan; the mock serves only synthetic demo data and ships in no release artifact | +| T-01-07 | Tampering | the parity harness leaving an orphaned server process holding a port | low | mitigate | The child is killed in a `finally` block and the acceptance criteria require two consecutive clean runs | +| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No packages are added by this plan — the harness uses only Node built-ins (`node:child_process`, `fetch`, `node:fs`). If any dependency becomes necessary, stop and run the Package Legitimacy Gate before installing | + + + +- `cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods. +- `cd neode-ui && npm run build` — green. +- Fail-first proof recorded: deleting a `case` line makes the harness exit non-zero. + + + +- Zero mesh.*/federation.* methods called by the UI lack a mock handler. +- Peer aliasing, reactions, replies, edits, deletes, and forwards all change demo state and render. +- A single command reproduces the parity verdict and is proven fail-first. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` when done. +Commit staged by explicit path only (a second agent shares this tree), then `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-03-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-03-PLAN.md new file mode 100644 index 00000000..d53e6b45 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-03-PLAN.md @@ -0,0 +1,246 @@ +--- +phase: 01-federation-mesh-hardening +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - neode-ui/src/components/ScreensaverRing.vue + - neode-ui/src/components/SendBitcoinModal.vue + - neode-ui/src/components/WalletScanModal.vue + - neode-ui/src/components/__tests__/ScreensaverRing.test.ts + - neode-ui/src/components/__tests__/PaidTick.test.ts +autonomous: true +requirements: [FED-06] + +must_haves: + truths: + - "The payment-success tick in SendBitcoinModal renders the screensaver EQ-segment ring, not a CSS ripple burst" + - "The payment-success tick in WalletScanModal renders the same EQ-segment ring, so the paid tick is identical on every surface it appears" + - "ScreensaverRing exposes a third badge size variant sized 160px on mobile and 192px from 768px up, with --viz-radius 80px/96px, alongside the untouched default and compact variants" + - "The badge ring fits inside the modal card without clipping — the success pane's ring container is no larger than the badge diameter at either breakpoint" + - "The success amount numerals and SENT / Done copy are unchanged — only the ring geometry behind the checkmark changes" + - "SystemDangerZone and Screensaver continue to render the compact and default variants unchanged" + - statement: "ScreensaverRing's segment animation is disabled under prefers-reduced-motion for every size variant including the new badge, matching the site-wide reduced-motion convention" + verification: backstop + prohibitions: + - statement: "The paid-tick change MUST NOT alter what the success pane asserts about the payment — the ring is decoration; it must never render a success state for a payment that has not actually settled, and no success-gating condition may be relaxed to make the animation easier to trigger" + category: safety + artifacts: + - path: neode-ui/src/components/ScreensaverRing.vue + provides: "badge size variant + reduced-motion guard" + contains: "viz-ring-badge" + - path: neode-ui/src/components/__tests__/PaidTick.test.ts + provides: "Assertions that both paid-tick surfaces render the badge ring" + min_lines: 25 + key_links: + - from: neode-ui/src/components/SendBitcoinModal.vue + to: neode-ui/src/components/ScreensaverRing.vue + via: "success pane renders layered under the checkmark core" + pattern: "ScreensaverRing" + - from: neode-ui/src/components/WalletScanModal.vue + to: neode-ui/src/components/ScreensaverRing.vue + via: "success pane renders in place of the plain circle" + pattern: "ScreensaverRing" +--- + + +Make the invoice/payment "paid" tick on-brand: the circle around the checkmark becomes the +screensaver ring with its outer EQ-segment lines, everywhere the paid tick appears. + +Purpose: FED-06, locked by the user in CONTEXT.md — the paid-tick circle is the ScreensaverRing +style, applied consistently to every paid/success tick surface. RESEARCH.md flagged that a naive +drop-in overflows the modal card (the existing compact variant is 240-320px against a 96-112px +badge); 01-UI-SPEC.md resolved that by deciding on a new `badge` size variant rather than a +transform hack, and also recorded that `ScreensaverRing` has no `prefers-reduced-motion` guard at +all today — a real gap this phase must close. +Output: a third size variant plus a reduced-motion guard in the shared component, both paid-tick +call sites swapped, and component tests pinning the result. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@neode-ui/src/components/ScreensaverRing.vue +@neode-ui/src/components/Screensaver.vue + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `size: 'default' \| 'compact' \| 'badge'` | widened prop union | `neode-ui/src/components/ScreensaverRing.vue` | +| `.viz-ring-badge` | CSS class (160px / 192px, `--viz-radius` 80px / 96px) | same | +| reduced-motion media guard on `.viz-segment` | CSS | same | +| `neode-ui/src/components/__tests__/ScreensaverRing.test.ts` | new vitest suite | new file | +| `neode-ui/src/components/__tests__/PaidTick.test.ts` | new vitest suite | new file | + + + + + + + Task 1: End-to-end — the badge ring variant renders as the payment-success tick + neode-ui/src/components/ScreensaverRing.vue, neode-ui/src/components/SendBitcoinModal.vue, neode-ui/src/components/__tests__/ScreensaverRing.test.ts, neode-ui/src/components/__tests__/PaidTick.test.ts + + - `neode-ui/src/components/ScreensaverRing.vue` — the whole file (about 115 lines): the + `withDefaults(defineProps<{ size?: ... }>())` union, the `sizeClass` computed, the two + existing size CSS classes with their `min-width: 768px` breakpoints and `--viz-radius` + custom properties, and the `segment-pulse` keyframes. + - `neode-ui/src/components/SendBitcoinModal.vue` lines 1-30 (the success pane markup: the + success-burst container, its three ripple span elements, and the core circle plus checkmark) + and lines 680-740 (the corresponding CSS block, including the existing + `@media (prefers-reduced-motion: reduce)` rule — copy that exact media-query syntax into + ScreensaverRing). + - `neode-ui/src/components/Screensaver.vue` — the existing `ScreensaverRing` + `ScreensaverLogo` + centred-absolute layering pattern (`position: relative` wrapper, `position: absolute; inset: 0` + inner content) to reuse for the checkmark core. + - `neode-ui/src/components/__tests__/BaseModal.test.ts` — the house vitest + `@vue/test-utils` + conventions for mounting a component in this repo. + - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-06 Sizing Decision" + table (exact diameters and radii) and the "UI Considerations" rows for the paid-tick ring. + + + - `ScreensaverRing.test.ts`: mounting with `size="badge"` puts `viz-ring-badge` on the root + element; mounting with `size="compact"` still yields `viz-ring-compact`; the default mount + still yields `viz-ring-default`; the rendered segment count matches the `segmentCount` prop. + - `PaidTick.test.ts`: SendBitcoinModal driven into its payment-success state renders exactly one + `ScreensaverRing` with `size="badge"`, renders the checkmark core, and renders zero ripple + elements; the success amount text is unchanged. + + + Write both test files first and confirm they fail before implementing. + + In `ScreensaverRing.vue`: widen the `size` prop union with a third member `'badge'`, extend + `sizeClass` to map it to `viz-ring-badge`, and add a `.viz-ring-badge` CSS rule following the + exact shape of the existing two — `width`/`height` 160px and `--viz-radius: 80px` at mobile, + then a `@media (min-width: 768px)` block with 192px and `--viz-radius: 96px`. Do not touch + `.viz-ring-default` or `.viz-ring-compact`; `Screensaver.vue` and `SystemDangerZone.vue` must + keep their current rendering. + + Also inside `ScreensaverRing.vue`, add the missing motion guard so it applies to every variant: + a `@media (prefers-reduced-motion: reduce)` block that sets `animation: none` and a static + reduced opacity on `.viz-segment`. Use the same media-query syntax as the guard already present + in `SendBitcoinModal.vue` so the two read identically. + + In `SendBitcoinModal.vue`'s payment-success pane: import `ScreensaverRing`, replace the three + ripple span elements with ``, keep the existing core circle and + checkmark markup untouched, and wrap the pair in the Screensaver-style layering (a + `position: relative` container sized to the badge diameter, with the core absolutely centred over + the ring). Remove the ripple elements' now-dead CSS rules and their keyframes; keep the core and + checkmark rules, and keep the existing reduced-motion rule but drop the clause that referenced + the removed elements. Do not change the success amount numerals, the SENT copy, the Done button, + or any condition that decides when the success pane is shown. + + + cd neode-ui && test -f src/components/__tests__/ScreensaverRing.test.ts && test -f src/components/__tests__/PaidTick.test.ts && npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts + + + - Both test files exist and `npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts` exits 0 (the explicit `test -f` guards are required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would otherwise pass vacuously). + - `grep -c 'viz-ring-badge' neode-ui/src/components/ScreensaverRing.vue` is at least 2 (computed mapping + CSS rule). + - `grep -c 'prefers-reduced-motion' neode-ui/src/components/ScreensaverRing.vue` equals 1. + - `grep -Eq '160px' neode-ui/src/components/ScreensaverRing.vue` and `grep -Eq '192px' neode-ui/src/components/ScreensaverRing.vue` both succeed. + - `grep -c 'viz-ring-compact' neode-ui/src/components/ScreensaverRing.vue` is unchanged from before the edit (the compact variant is untouched). + - `grep -c 'ScreensaverRing' neode-ui/src/components/SendBitcoinModal.vue` is at least 2 (import + usage). + - `grep -c 'burst-ring' neode-ui/src/components/SendBitcoinModal.vue` equals 0. + - `cd neode-ui && npx vitest run` exits 0 — no existing suite regressed. + - `cd neode-ui && npm run build` exits 0 and `grep -rq 'viz-ring-badge' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md: the build can silently no-op, so grep the built bundle for the new string). + + The badge variant exists, the send-payment success tick renders it, and both are pinned by tests that failed before the change. + + + + Task 2: Bring the scan-modal paid tick to the same ring + neode-ui/src/components/WalletScanModal.vue, neode-ui/src/components/__tests__/PaidTick.test.ts + + - `neode-ui/src/components/WalletScanModal.vue` around line 232 (the success circle markup — a + fixed 24-unit inline-flex circle with the success-ring class) and around line 861 (its CSS + rule). Note it has no ripple animation at all today, unlike the send modal. + - `neode-ui/src/components/SendBitcoinModal.vue` as left by Task 1 — the layering wrapper to + copy verbatim. + - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the FED-06 sizing table row + confirming this call site also uses the badge variant. + + + Replace WalletScanModal's fixed success circle with the same composition Task 1 established: + a `position: relative` container sized to the badge diameter holding `` + with the existing checkmark content absolutely centred over it. Import `ScreensaverRing`. Drop + the now-unused fixed-size utility classes and the plain-circle CSS rule; keep the checkmark + glyph, its colour, and the surrounding copy exactly as they are. + + Extend `PaidTick.test.ts` with a WalletScanModal case asserting its success state renders one + `ScreensaverRing` with `size="badge"` and still renders the checkmark. + + Verify on the dev preview before considering this done, per the user requirement recorded in + CONTEXT.md: run the dev preview and confirm neither ring is clipped by the modal card's + scrolling container at a narrow viewport and at desktop width. Record the observation in the + SUMMARY. The blocking human sign-off for this is consolidated into plan 01-07. + + + cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts && npm run build + + + - `cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts` exits 0 and the suite contains both a SendBitcoinModal case and a WalletScanModal case. + - `grep -c 'ScreensaverRing' neode-ui/src/components/WalletScanModal.vue` is at least 2. + - `grep -c 'success-ring' neode-ui/src/components/WalletScanModal.vue` equals 0. + - `cd neode-ui && npx vitest run` exits 0. + - `cd neode-ui && npm run build` exits 0. + - The SUMMARY records the dev-preview observation for both surfaces at a narrow and a desktop viewport. + + Both paid-tick surfaces render the identical branded ring, with no clipping at either breakpoint. + + + + +## Planner Assumptions (flagged, unresolved) + +- **FED-06 / spec-less probe, category `unclassified`:** the probe surfaced an unclassified edge for + FED-06 that no defensible acceptance criterion covers. Surfaced rather than dropped: the phase + requirement says the ring applies "everywhere the paid tick appears", and a repo-wide grep found + exactly two paid-tick surfaces (`SendBitcoinModal.vue`, `WalletScanModal.vue`). If a third + success-tick surface is added between planning and execution — or exists under markup this grep + did not match — it will not be covered by this plan. The FED-03 review in plan 01-07 re-runs the + grep as a check. + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| payment result → success pane render | The only security-relevant edge: what the UI asserts about a payment's settlement | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-08 | Spoofing | success pane rendered for a payment that has not settled | high | mitigate | This plan changes decoration only; the acceptance criteria forbid touching any condition that gates the success pane, and `npx vitest run` on the existing suites must stay green | +| T-01-09 | Denial of Service | 48 animated segments rendered inside a modal degrading low-power devices | low | mitigate | The badge variant is the smallest of the three; the new `prefers-reduced-motion` guard disables the animation entirely for users who ask for it | +| T-01-10 | Repudiation | the success amount or recipient text changing as a side effect of the swap | medium | mitigate | Tests assert the success amount text is unchanged; the action forbids touching the numerals and copy | + + + +- `cd neode-ui && npx vitest run` — green. +- `cd neode-ui && npm run build` — green, and the built bundle contains the new class name. +- Dev-preview observation recorded for both modals at narrow and desktop widths. + + + +- A third `badge` size variant exists on the shared ring component; existing variants and their consumers are untouched. +- Both paid-tick surfaces render the branded ring with the checkmark layered centred. +- A reduced-motion guard covers every variant. +- Component tests pin all of the above and were proven to fail before the change. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md` when done. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-04-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-04-PLAN.md new file mode 100644 index 00000000..49522c45 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-04-PLAN.md @@ -0,0 +1,295 @@ +--- +phase: 01-federation-mesh-hardening +plan: 04 +type: execute +wave: 1 +depends_on: [] +files_modified: + - core/archipelago/src/api/rpc/lnd/info.rs + - core/archipelago/src/mesh/message_types.rs + - core/archipelago/src/mesh/types.rs + - core/archipelago/src/mesh/listener/dispatch.rs + - core/archipelago/src/api/rpc/mesh/typed_messages.rs + - core/archipelago/src/api/rpc/dispatcher.rs +autonomous: true +requirements: [FED-05] + +must_haves: + truths: + - "lnd.getinfo returns this node's Lightning identity_pubkey and its advertised connection URIs, so the UI has something real to copy and share" + - "A node with no reachable LND, or an LND that advertises no URI, yields an absent identity rather than a fabricated one — the caller can tell 'not available' from 'available'" + - "A meshed peer that advertises Lightning is recorded with its URI on the mesh peer record and is listed by mesh.lightning-peers" + - "mesh.lightning-peers returns an empty list, not an error, when no meshed peer has advertised Lightning (FED-05 empty edge, mesh half)" + - "A peer that advertises Lightning twice appears once in mesh.lightning-peers, with the most recent URI (FED-05 adjacency edge, mesh half)" + - "mesh.lightning-peers returns peers in a deterministic order so the picker list does not reshuffle between reads (FED-05 ordering edge, mesh half)" + - "An inbound Lightning advertisement whose URI is not well-formed is rejected and does not overwrite a previously known good URI for that peer" + prohibitions: + - statement: "A node's Lightning URI MUST NOT be advertised to parties the operator has not chosen to reach — the advertisement is sent on an explicit send, never auto-broadcast to every radio contact in range, and a received URI is never re-broadcast onward to third parties" + category: privacy + artifacts: + - path: core/archipelago/src/api/rpc/lnd/info.rs + provides: "identity_pubkey + uris on the lnd.getinfo response" + contains: "identity_pubkey" + - path: core/archipelago/src/mesh/message_types.rs + provides: "LightningInfo typed message + payload" + contains: "LightningInfo" + key_links: + - from: core/archipelago/src/mesh/listener/dispatch.rs + to: core/archipelago/src/mesh/types.rs + via: "inbound LightningInfo envelope writes MeshPeer.lightning_uri" + pattern: "lightning_uri" + - from: core/archipelago/src/api/rpc/dispatcher.rs + to: core/archipelago/src/api/rpc/mesh/typed_messages.rs + via: "mesh.lightning-peers and mesh.send-lightning-info match arms" + pattern: "mesh.lightning-peers" +--- + + +Give the platform the two Lightning facts the channel-open UI needs from the mesh side: **this node's +own shareable URI**, and **which meshed peers have Lightning installed and what their URI is**. + +Purpose: FED-05, whose scope is LOCKED in CONTEXT.md — the "public/other" list in the channel-open +picker is *meshed peer nodes that have Lightning installed*, not `lnd listpeers`, not a curated +directory, not a live LN-graph query. That requires peers to advertise a Lightning capability plus +their URI over the mesh. RESEARCH.md Pitfall 5 confirms neither datum exists today: `handle_lnd_getinfo` +fetches LND's `/v1/getinfo` but its response struct does not deserialize `identity_pubkey` or `uris`, +and PATTERNS.md records that mesh peer capability advertisement has **no analog** in the codebase — +it is genuinely new surface, to be built on the existing typed-envelope pattern. +Output: an extended `lnd.getinfo`, a new `LightningInfo` typed mesh message, a `lightning_uri` field +on `MeshPeer`, and two new RPCs (`mesh.lightning-peers`, `mesh.send-lightning-info`). + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md +@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@core/archipelago/src/mesh/message_types.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `LndInfo.identity_pubkey: Option` | new response field on `lnd.getinfo` | `core/archipelago/src/api/rpc/lnd/info.rs` | +| `LndInfo.uris: Vec` | new response field on `lnd.getinfo` | same | +| `LndGetInfoResponse.identity_pubkey` / `.uris` | new deserialized LND REST fields | same | +| `MeshMessageType::LightningInfo = 26` (label `lightning_info`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` | +| `LightningInfoPayload { uri, alias }` | new CBOR payload struct | same | +| `MeshPeer.lightning_uri: Option` | new optional peer field | `core/archipelago/src/mesh/types.rs` | +| `handle_mesh_lightning_peers` | new RPC handler (`mesh.lightning-peers`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` | +| `handle_mesh_send_lightning_info` | new RPC handler (`mesh.send-lightning-info`) | same | +| `mesh.lightning-peers`, `mesh.send-lightning-info` | dispatcher match arms | `core/archipelago/src/api/rpc/dispatcher.rs` | + + + + + Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary + Two additive optional fields on an internal RPC response; no + consumer breaks if they are removed again. + core/archipelago/src/api/rpc/lnd/info.rs + + - `core/archipelago/src/api/rpc/lnd/info.rs` lines 1-110 — the `LndInfo` serialize struct, the + `LndGetInfoResponse` deserialize struct (which currently declares only `alias`, + `num_active_channels`, `num_peers`, `synced_to_chain`, `block_height`), and how + `handle_lnd_getinfo` maps one into the other with `unwrap_or_default()`. + - `core/archipelago/src/api/rpc/lnd/channels.rs` around `handle_lnd_openchannel` (from L238) — + the sibling handler's pubkey validation (66 hex chars) and error-shaping style to mirror. + + + - Deserializing an LND `/v1/getinfo` body that contains `identity_pubkey` and a non-empty `uris` + array yields both on the mapped response. + - Deserializing a body with neither field present succeeds and yields `identity_pubkey: None` + and an empty `uris` vector — never a fabricated or placeholder identity. + - A body whose `identity_pubkey` is not 66 hex characters yields `identity_pubkey: None` rather + than propagating a malformed key that `lnd.openchannel` would later reject. + + + Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a + `serde_json::from_str::(...)` over three fixture bodies (full, empty, + malformed pubkey) plus the mapping function. Extract the `LndGetInfoResponse` → `LndInfo` + identity mapping into a small pure function so it is testable without an HTTP call; keep the + existing HTTP flow otherwise untouched. + + Add `identity_pubkey: Option` and `uris: Vec` to `LndGetInfoResponse` with + `#[serde(default)]`, and the corresponding `identity_pubkey: Option` and + `uris: Vec` to the serialized `LndInfo`. Validate the pubkey shape the same way + `handle_lnd_openchannel` does (66 hexadecimal characters) before forwarding it; on failure + forward `None`, and log at `warn!` naming the field. + + Do not change any existing `LndInfo` field name or type — `HomeWalletCard.vue`, `Server.vue`, + and `Web5Wallet.vue` all read this response. + + + cd core && cargo test -p archipelago lnd::info + + + - `cd core && cargo test -p archipelago lnd::info` exits 0 with at least 3 test cases. + - `grep -c 'identity_pubkey' core/archipelago/src/api/rpc/lnd/info.rs` is at least 4. + - `grep -c 'uris' core/archipelago/src/api/rpc/lnd/info.rs` is at least 3. + - `cd core && cargo build -p archipelago` exits 0. + - The SUMMARY records the pre-implementation failing output of the fixture tests. + + `lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests. + + + + Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored + `MeshMessageType` is a radio wire format shared with every fleet + node; the new discriminant and its CBOR payload shape become readable by deployed peers after the + next OTA, so changing the payload later needs a coordinated fleet upgrade. Kept additive (unused + discriminant, optional payload fields) so old nodes simply ignore it. + core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs + + - `core/archipelago/src/mesh/message_types.rs` lines 28-200 — the `#[repr(u8)] MeshMessageType` + enum (highest current discriminant is `AssistResponse = 25`), and the three places every new + variant must be added: the enum, `from_u8`, `from_label`, and `label`. Also read + `ReactionPayload` (from L533) and `PresencePayload` (from L727) for payload struct conventions, + and the `TypedEnvelope` doc comment about `compact_bytes` (a plain derived `Vec` bloats + every message on the wire — this matters on LoRa). + - `core/archipelago/src/mesh/types.rs` lines 60-118 — the `MeshPeer` struct and its + `#[serde(default)]` optional-field convention (see `lat`/`lon`, `pkc_capable`). + - `core/archipelago/src/mesh/listener/dispatch.rs` around lines 430-490 — the + `Some(MeshMessageType::Reaction)` and `Some(MeshMessageType::Presence)` inbound arms: how a + decoded envelope is matched, its payload deserialized, and peer/message state mutated. + + + Add `LightningInfo = 26` to `MeshMessageType` with a doc comment stating what it advertises and + that it is only ever sent on an explicit operator action. Register it in `from_u8` (26), + `from_label` ("lightning_info"), and `label`. + + Add `LightningInfoPayload` next to the other payload structs: a required `uri: String` (the + `pubkey@host:port` form) and an optional `alias: Option` with `#[serde(default)]`. + Follow the surrounding payload structs' serde conventions. + + Add `#[serde(default)] pub lightning_uri: Option` to `MeshPeer`, with a doc comment + saying it is set only from a received `LightningInfo` advertisement (or federation seeding in a + later plan) and is what the channel-open picker offers as a request target. + + Add an inbound arm in `dispatch.rs` for the new type, mirroring the shape of the `Reaction` and + `Presence` arms: deserialize the payload, validate the URI before storing (a `pubkey@host` form + whose pubkey part is 66 hex characters; the `:port` suffix is optional), and on success write it + onto the resolved `MeshPeer`. On a malformed URI, log at `warn!` and return without touching a + previously stored value. Store the newest advertisement when a peer advertises more than once — + overwrite, do not accumulate. + + Add unit tests in `message_types.rs` covering the round-trip of the new discriminant through + `from_u8`/`from_label`/`label`, and a `dispatch.rs`-level test (or a pure helper test if + `dispatch.rs` has no test harness) asserting that a malformed URI leaves a previously stored good + URI intact. + + + cd core && cargo test -p archipelago mesh::message_types mesh::listener + + + - `cd core && cargo test -p archipelago mesh::message_types mesh::listener` exits 0. + - `grep -c 'LightningInfo' core/archipelago/src/mesh/message_types.rs` is at least 5 (enum, from_u8, from_label, label, payload doc). + - `grep -Eq 'lightning_info' core/archipelago/src/mesh/message_types.rs` succeeds. + - `grep -c 'lightning_uri' core/archipelago/src/mesh/types.rs` is at least 1. + - `grep -c 'LightningInfo' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1. + - `cd core && cargo test -p archipelago` exits 0. + + The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI. + + + + Task 3: Expose the meshed Lightning peers and the send path over RPC + core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs + + - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` around `handle_mesh_contacts_list` + (from L1218) — the canonical read-handler shape: `self.mesh_service.read().await`, the + "Mesh service not running" error, `shared_state()`, then `.read().await` on the relevant map. + - The same file around `handle_mesh_send_reaction` (in the L637-976 family) — the canonical + send-handler shape: build a payload, wrap in `TypedEnvelope::new(...).with_seq(seq)`, send. + - `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line `"mesh." => + self.handle_...(params).await,` registration convention. + - `core/archipelago/src/server.rs` around `is_peer_allowed_path` (from L1270) — confirm whether + the new RPCs need peer reachability. They are operator-local calls over `/rpc/v1`, which is + already in the allow-list; do NOT widen that list. + + + Add `handle_mesh_lightning_peers`: read the mesh peer map, keep only peers whose `lightning_uri` + is set, collapse duplicates by the peer's authenticating key (the `MeshPeer` accessor that + prefers the verified archipelago identity key over the firmware routing key) keeping the most + recently heard entry, and return a stable-sorted array — sort by display name, then by contact + id as the tiebreak, so the picker list does not reshuffle between reads. Each entry carries at + minimum: contact id, display name, `lightning_uri`, `last_heard`, `reachable`, and `hops`. + Returning zero matching peers is an empty array with a success result, never an error. + + Add `handle_mesh_send_lightning_info`: take a target peer identifier in params, read this node's + own URI from the `lnd.getinfo` path built in Task 1, refuse with a clear error when no URI is + available (LND down, or no advertised URI) rather than sending an empty advertisement, then send + a `LightningInfo` envelope to that peer only. It must not broadcast to all contacts: the target + is required, and the handler returns an error when it is absent. + + Register both in the dispatcher as `"mesh.lightning-peers"` and `"mesh.send-lightning-info"`, + following the existing one-line convention. + + Add tests covering: empty peer map yields an empty array; two advertisements from the same peer + yield one entry with the newer URI; ordering is stable across two consecutive calls over the + same peer set; `handle_mesh_send_lightning_info` with no target errors. + + + cd core && cargo test -p archipelago mesh + + + - `cd core && cargo test -p archipelago mesh` exits 0. + - `grep -c '"mesh.lightning-peers"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1. + - `grep -c '"mesh.send-lightning-info"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1. + - `grep -c 'handle_mesh_lightning_peers' core/archipelago/src/api/rpc/mesh/typed_messages.rs` is at least 1. + - `grep -c 'is_peer_allowed_path' core/archipelago/src/server.rs` is unchanged from before this plan (the peer allow-list is not widened). + - `cd core && cargo test -p archipelago` exits 0. + - `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `api::rpc::mesh` or `mesh::message_types`. + + The picker's meshed-Lightning-peer list has a real, deterministic, deduplicated data source, and a node can advertise its own URI to a chosen peer. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| radio peer → typed-envelope decode → `MeshPeer` | Untrusted, unauthenticated-by-default RF input mutates local peer state | +| LND REST (`/v1/getinfo`) → daemon | Local service response parsed into an RPC payload the UI displays and copies | +| operator RPC → outbound mesh send | An operator action that discloses this node's payment endpoint to a chosen peer | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-11 | Spoofing | a radio peer advertising someone else's Lightning URI to redirect a channel open | high | mitigate | The advertisement is stored against the peer's authenticating key (the verified archipelago identity key, never the firmware routing key — see `MeshPeer`'s auth-key accessor doc); the UI in plan 01-06 labels these peers as *request* targets, not trusted opens | +| T-01-12 | Tampering | a malformed or oversized URI corrupting stored peer state | high | mitigate | URI shape validated before store (66-hex pubkey part); invalid input leaves any previously stored value untouched; test asserts this | +| T-01-13 | Information Disclosure | this node's payment endpoint leaking to every radio contact in range | high | mitigate | `mesh.send-lightning-info` requires an explicit target and errors without one; there is no broadcast path, and a received URI is never re-advertised onward | +| T-01-14 | Denial of Service | advertisement flooding growing the peer map unboundedly | medium | accept | The advertisement writes a field on an existing peer record rather than creating records; peer-map growth is governed by the pre-existing contact-discovery limits, unchanged here | +| T-01-15 | Elevation of Privilege | a new RPC becoming peer-reachable and letting a remote peer enumerate Lightning peers | high | mitigate | Both RPCs ride the existing `/rpc/v1` operator surface; the acceptance criteria assert `is_peer_allowed_path` is not widened | +| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates are introduced. If one becomes necessary, stop and run the Package Legitimacy Gate before installing | + + + +- `cd core && cargo test -p archipelago` — green. +- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules. +- Fixture-test failure output captured before the Task 1 implementation. + + + +- `lnd.getinfo` exposes a real identity pubkey and URI list, or an honest absence. +- A `LightningInfo` mesh message exists, is validated on receipt, and populates `MeshPeer.lightning_uri`. +- `mesh.lightning-peers` returns a deduplicated, deterministically ordered list and an empty array when there are none. +- `mesh.send-lightning-info` requires an explicit target and refuses to send an empty advertisement. +- The peer HTTP allow-list is unchanged. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-05-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-05-PLAN.md new file mode 100644 index 00000000..35e98bfe --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-05-PLAN.md @@ -0,0 +1,303 @@ +--- +phase: 01-federation-mesh-hardening +plan: 05 +type: execute +wave: 2 +depends_on: ["01-01"] +files_modified: + - core/archipelago/src/federation/types.rs + - core/archipelago/src/federation/storage.rs + - core/archipelago/src/federation/sync.rs + - core/archipelago/src/api/rpc/federation/handlers.rs + - core/archipelago/src/server.rs + - neode-ui/src/views/federation/types.ts + - neode-ui/src/views/federation/NodeList.vue +autonomous: true +requirements: [FED-02] + +must_haves: + truths: + - "A federation sync failure is recorded on the peer's node record and surfaced through federation.list-nodes, so the operator sees it in the UI instead of it existing only as a debug log line" + - "A successful sync clears a previously recorded sync error for that peer — the badge does not persist after the peer recovers (FED-02 adjacency edge)" + - "A periodic sync pass over zero federated nodes is a clean no-op: no error is recorded, nothing is written, and no error surfaces in the UI (FED-02 empty edge)" + - "A state snapshot older than the one already stored for a peer does not overwrite the newer one — out-of-order sync responses cannot move a peer's status backwards (FED-02 ordering edge)" + - "Exactly one periodic federation sync loop runs in the daemon; the redundant second loop is gone and every behavior unique to it is preserved in the surviving loop" + - "Duplicate node entries do not accumulate across sync cycles — after sync settles the node list has one entry per federated node" + prohibitions: + - statement: "Making sync errors visible MUST NOT expose a peer's onion address, DID, or any transport secret in an error string rendered to a surface wider than the operator's own dashboard — a sync error message names what failed, never credential material" + category: privacy + artifacts: + - path: core/archipelago/src/federation/types.rs + provides: "last_sync_error / last_sync_error_at on FederatedNode" + contains: "last_sync_error" + - path: neode-ui/src/views/federation/NodeList.vue + provides: "Operator-visible sync-error badge on a node row" + contains: "last_sync_error" + key_links: + - from: core/archipelago/src/server.rs + to: core/archipelago/src/federation/storage.rs + via: "the periodic sync loop calls record_sync_result after each peer attempt instead of only debug-logging" + pattern: "record_sync_result" + - from: core/archipelago/src/api/rpc/federation/handlers.rs + to: neode-ui/src/views/federation/NodeList.vue + via: "federation.list-nodes emits last_sync_error, the node row renders it as a badge" + pattern: "last_sync_error" +--- + + +Make federation sync converge and stop failing silently: one sync loop instead of two, a per-peer +sync error persisted and shown to the operator, and out-of-order snapshots unable to move a peer's +state backwards. + +Purpose: FED-02. RESEARCH.md's anti-pattern list is explicit — both periodic sync loops in +`server.rs` log failures at `debug!` only, so a peer that has not synced in days looks identical to +one that synced a minute ago. The same section notes the two loops (90s at ~L497, 1800s at ~L840) +are redundant apart from one tail call, and that the redundancy doubles the write-race exposure that +plan 01-01 just locked down. Open Question 1 asks the reviewer to `git log -p` both loop-insertion +commits before deleting either — that check is a required step here, not an optional one. +Output: `last_sync_error` plumbed store → loop → RPC → UI badge, one surviving loop, and a +monotonicity guard on `update_node_state`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md +@core/archipelago/src/federation/types.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `FederatedNode.last_sync_error: Option` | new optional field | `core/archipelago/src/federation/types.rs` | +| `FederatedNode.last_sync_error_at: Option` | new optional field | same | +| `record_sync_result` | new pub async fn (records or clears a peer's sync error under the store lock) | `core/archipelago/src/federation/storage.rs` | +| `last_sync_error`, `last_sync_error_at` on `federation.list-nodes` | new response fields | `core/archipelago/src/api/rpc/federation/handlers.rs` | +| `FederatedNode.last_sync_error?` / `.last_sync_error_at?` | new TS interface fields | `neode-ui/src/views/federation/types.ts` | +| sync-error badge on a node row | Vue markup + class | `neode-ui/src/views/federation/NodeList.vue` | +| the 1800s periodic federation sync loop | **deleted** (its unique tail call moved into the 90s loop) | `core/archipelago/src/server.rs` | + + + + + Task 1: End-to-end — a failed federation sync becomes visible to the operator + core/archipelago/src/federation/types.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/server.rs, neode-ui/src/views/federation/types.ts, neode-ui/src/views/federation/NodeList.vue + + - `core/archipelago/src/federation/types.rs` lines 50-146 — `FederatedNode`'s existing optional + fields and the doc-comment convention on `last_transport` / `last_transport_at` (a result + field written back after each attempt). The new pair mirrors that shape for the failure side. + - `core/archipelago/src/federation/storage.rs` as left by plan 01-01 — `record_peer_transport` + (the existing "write a result field back after an attempt" function) and the + `FEDERATION_STORE_LOCK` wrapper + `*_inner` split convention the new function must follow. + - `core/archipelago/src/api/rpc/federation/handlers.rs` `handle_federation_list_nodes` + (from L220) — the `serde_json::json!` node object and the `if let Some(...)` conditional-field + pattern the new fields must follow. + - `core/archipelago/src/server.rs` lines 497-600 — the 90s periodic federation sync loop, its + per-peer `sync_with_peer` call and the `debug!(peer = %node.did, error = %e, ...)` arm that + currently swallows failures. + - `neode-ui/src/views/federation/types.ts` lines 19-33 — the `FederatedNode` TS interface. + - `neode-ui/src/views/federation/NodeList.vue` lines 40-140 — the trusted-node and peer rows, + `transportBadge()` (L166) and `trustBadgeClass()` for the badge idiom to mirror, and the + existing loading row. + - `neode-ui/src/views/federation/__tests__/NodeList.test.ts` — the existing suite's mount + conventions. + + + - `record_sync_result(data_dir, did, Err("..."))` sets `last_sync_error` to the message and + `last_sync_error_at` to an RFC 3339 timestamp on that node only. + - `record_sync_result(data_dir, did, Ok(()))` clears both fields on that node. + - `record_sync_result` for a DID that is not in the node list is a no-op returning Ok — a peer + removed mid-pass must not be resurrected by an error write. + - `federation.list-nodes` emits both fields when set and omits them when unset. + - NodeList renders a sync-error badge on a node whose `last_sync_error` is set, and renders no + such badge when it is unset. + + + Write the Rust tests and the NodeList component test first and confirm they fail. + + Add `#[serde(default)] pub last_sync_error: Option` and + `#[serde(default)] pub last_sync_error_at: Option` to `FederatedNode`, with a doc comment + modelled on `last_transport`: these record the outcome of the most recent sync attempt so the + operator can tell a stale peer from a healthy one, replacing a debug-only log line. Update the + `make_node` test helper in `storage.rs`'s test module so the struct literal still compiles. + + Add `record_sync_result(data_dir: &Path, did: &str, outcome: Result<(), String>) -> Result<()>` + to `storage.rs`, acquiring `FEDERATION_STORE_LOCK` and using the `*_inner` load/save functions + established in 01-01. Missing DID is a silent Ok. Never create a node entry. + + In `server.rs`'s 90s loop, replace the debug-only failure arm with a call to `record_sync_result` + carrying the error's display string, and call it with a success outcome on the success arm. + Truncate the recorded message to a bounded length (256 characters) so a pathological error + cannot bloat the node file. Keep the existing `debug!` line as well — persisting is additive, + not a replacement for logs. + + In `handle_federation_list_nodes`, emit the two fields onto the node object using the same + `if let Some(...)` conditional-insert pattern the existing optional fields use. Add the matching + optional fields to the TS `FederatedNode` interface. + + In `NodeList.vue`, add a badge on the node row shown only when `last_sync_error` is set: red + family (`alert-error`-adjacent classes already in the house style), short label, and the full + message plus the timestamp in the element's `title` attribute — the row must stay single-line, so + apply the same `truncate` + `:title` treatment the node-name span already uses. Place it beside + the existing transport badge, not in place of it. Do not add a new nav entry, card, or view — + only this badge inside the existing row. + + + cd core && cargo test -p archipelago federation && cd ../neode-ui && test -f src/views/federation/__tests__/NodeList.test.ts && npx vitest run src/views/federation/__tests__/NodeList.test.ts + + + - `cd core && cargo test -p archipelago federation` exits 0 and includes a test named for the clear-on-success behavior and one for the missing-DID no-op. + - `grep -c 'last_sync_error' core/archipelago/src/federation/types.rs` is at least 2. + - `grep -c 'record_sync_result' core/archipelago/src/federation/storage.rs` is at least 1. + - `grep -c 'record_sync_result' core/archipelago/src/server.rs` is at least 2 (the failure arm and the success arm). + - `grep -c 'last_sync_error' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1. + - `grep -c 'last_sync_error' neode-ui/src/views/federation/types.ts` is at least 1. + - `grep -c 'last_sync_error' neode-ui/src/views/federation/NodeList.vue` is at least 1. + - `cd neode-ui && npx vitest run src/views/federation/__tests__/NodeList.test.ts` exits 0 with a case asserting the badge is absent when the field is unset (the guard against a badge that always renders). + - `cd neode-ui && npm run build` exits 0. + - The SUMMARY records the pre-implementation failing output for both the Rust and the component test. + + A sync failure is persisted per peer, travels through the RPC, and renders as a badge the operator can see — and clears when the peer recovers. + + + + Task 2: Collapse the two periodic sync loops into one + Deleting a background loop changes daemon runtime behavior across + the whole fleet on the next OTA; restoring it means re-deriving code that is gone from the tree + rather than flipping a flag. Mitigated by moving — not discarding — the loop's unique tail call + and by the required git-history check below. + core/archipelago/src/server.rs + + - `core/archipelago/src/server.rs` lines 497-600 (the 90s loop, including its asymmetry + self-heal `notify_join` re-assertion) and lines 840-910 (the 1800s loop, whose unique tail + call is `rpc.refresh_federation_mesh_peers()`). + - The output of `git log -p -L 840,910:core/archipelago/src/server.rs` and + `git log -p -L 497,600:core/archipelago/src/server.rs` — RESEARCH.md Assumption A2 flags that + the 1800s loop may exist for an undocumented reason. Run this BEFORE deleting anything and + record the finding in the SUMMARY. + - `.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md` — "Open Questions" item 1. + + + First run the two `git log -p -L` commands above and write the answer to Open Question 1 into the + SUMMARY: does the 1800s loop do anything the 90s loop does not, beyond + `refresh_federation_mesh_peers()`? If the history shows a documented reason to keep it, STOP, + do not delete it, and record that as a finding for the FED-03 review instead — the phase then + keeps two loops and this task's remaining work is limited to routing both through + `record_sync_result`. + + Otherwise: move the `refresh_federation_mesh_peers()` call to the tail of the 90s loop's + completed pass (after the per-peer iteration, alongside the existing pass-complete log), thread + whatever handle it needs into that task's captured state, and delete the entire 1800s + `tokio::spawn` block. Keep the 90s loop's startup settle delay and its asymmetry self-heal + unchanged. + + Make the surviving loop's zero-node case an explicit clean no-op: when `load_nodes` returns an + empty list the pass continues to the next tick without writing anything and without recording a + sync error against anyone. + + + cd core && cargo build -p archipelago && cargo test -p archipelago federation + + + - `cd core && cargo build -p archipelago` exits 0. + - `grep -v '^ *//' core/archipelago/src/server.rs | grep -c 'federation::sync_with_peer'` equals 1 (comment lines stripped so a doc comment cannot satisfy the gate). + - `grep -v '^ *//' core/archipelago/src/server.rs | grep -c 'refresh_federation_mesh_peers'` equals 1. + - `grep -c 'from_secs(1800)' core/archipelago/src/server.rs` equals 0 + - `cd core && cargo test -p archipelago` exits 0. + - The SUMMARY contains the `git log -p -L` finding answering RESEARCH.md Open Question 1, and states explicitly whether the loop was deleted or kept. + + One periodic federation sync loop remains, its predecessor's unique behavior preserved, with the history check recorded. + + + + Task 3: Stop out-of-order snapshots and duplicates from breaking convergence + core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs + + - `core/archipelago/src/federation/storage.rs` `update_node_state` (from L292 pre-01-01) — it + currently overwrites `last_seen`, `name`, `fips_npub`, and `last_state` unconditionally from + whatever snapshot arrives, with no comparison against what is already stored. + - `core/archipelago/src/federation/types.rs` — `NodeStateSnapshot.timestamp` is an RFC 3339 + string; note that a lexicographic compare is only safe for same-offset RFC 3339, so parse it. + - `core/archipelago/src/federation/storage.rs` — `dedup_nodes_by_onion` and its two existing + tests, for the convergence behavior already present. + - `core/archipelago/src/federation/sync.rs` — `merge_transitive_peers` (from L120) and its + tombstone check, to confirm the guard added here does not conflict with it. + + + Add a monotonicity guard to `update_node_state`: parse the incoming snapshot's timestamp and the + stored `last_state`'s timestamp with `chrono::DateTime::parse_from_rfc3339`; if the incoming one + is strictly older, return Ok without mutating the node — a slow sync response that lands after a + newer one must not move the peer's status backwards. When either timestamp fails to parse, fall + back to the current accept-newest behavior so a peer with a malformed clock is not frozen out, + and log at `debug!`. Learning a peer's `fips_npub` is exempt: a stale snapshot may still carry + the only copy of a FIPS key this node has, so apply that one field even on a rejected snapshot, + and say so in a comment. + + Add tests: a strictly-older snapshot leaves `last_state` and `last_seen` unchanged; an equal + timestamp is accepted (idempotent re-sync); a newer snapshot is accepted; a stale snapshot + carrying a `fips_npub` this node lacks still populates it; an unparseable timestamp is accepted. + + Add a convergence test asserting that repeatedly applying the same peer's snapshot plus a + transitive-peer merge does not grow the node list — one entry per federated node after N cycles. + + + cd core && cargo test -p archipelago federation + + + - `cd core && cargo test -p archipelago federation` exits 0 with the five snapshot-ordering cases and the convergence case present by name. + - `grep -c 'parse_from_rfc3339' core/archipelago/src/federation/storage.rs` is at least 1. + - `cd core && cargo test -p archipelago` exits 0. + - `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`. + + Out-of-order sync responses cannot regress a peer's state, and repeated sync cycles converge to one entry per node. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| federated peer → `sync_with_peer` → node record | A remote peer's snapshot and its timestamp drive local persisted state | +| daemon → operator dashboard | A sync error string crosses from the daemon into rendered UI | +| background loop → federation node store | The surviving periodic loop is now a writer of error state, not only a reader | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-16 | Tampering | a peer replaying an old snapshot to roll a node's status backwards | high | mitigate | The `update_node_state` monotonicity guard rejects strictly-older snapshots (Task 3), with tests | +| T-01-17 | Information Disclosure | a sync error string carrying a peer onion address or transport credential into the UI | medium | mitigate | The recorded message is the error's display string truncated to 256 characters and rendered only on the operator's own dashboard; the prohibition above states the constraint and it is re-checked in the FED-03 review | +| T-01-18 | Denial of Service | an unbounded error message bloating `nodes.json` on every failed pass | medium | mitigate | 256-character truncation before persistence (Task 1) | +| T-01-19 | Repudiation | a silently-failing sync leaving no record of when a peer was last reachable | high | mitigate | `last_sync_error_at` is written on every attempt outcome; the badge makes staleness visible | +| T-01-20 | Denial of Service | deleting the 1800s loop dropping a behavior the fleet depends on | high | mitigate | Mandatory `git log -p -L` history check before deletion, the unique tail call moved rather than dropped, and an explicit STOP path if the history shows a documented reason | + + + +- `cd core && cargo test -p archipelago` — green. +- `cd neode-ui && npx vitest run && npm run build` — green. +- The SUMMARY answers RESEARCH.md Open Question 1 with git evidence. + + + +- A sync failure is persisted, exposed over RPC, and rendered as an operator-visible badge that clears on recovery. +- Exactly one periodic federation sync loop remains, with the deleted loop's unique behavior preserved. +- Out-of-order snapshots cannot regress a peer's state; repeated cycles converge to one entry per node. +- A zero-node sync pass writes nothing and records no error. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md` when done. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-06-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-06-PLAN.md new file mode 100644 index 00000000..4ad07fc3 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-06-PLAN.md @@ -0,0 +1,287 @@ +--- +phase: 01-federation-mesh-hardening +plan: 06 +type: execute +wave: 3 +depends_on: ["01-05"] +files_modified: + - core/archipelago/src/federation/types.rs + - core/archipelago/src/federation/sync.rs + - core/archipelago/src/federation/storage.rs + - core/archipelago/src/api/rpc/federation/handlers.rs +autonomous: false +requirements: [FED-05] + +must_haves: + truths: + - "A trusted federated peer's Lightning URI is known locally after sync and is emitted by federation.list-nodes, so the picker can offer a one-click channel open by hostname" + - "The Lightning field on the federation sync payload is optional with a serde default, so a node running an older build syncs with a newer one in both directions without error" + - "A federated peer that advertises no Lightning URI is emitted without the field rather than with an empty string — the picker can tell 'no Lightning' from 'Lightning at an unknown address'" + - "An inbound Lightning URI that is not well-formed is rejected at sync time and never persisted or rendered" + - "A stale sync snapshot cannot clear a peer's previously known Lightning URI, consistent with the snapshot-ordering guard from plan 01-05" + prohibitions: + - statement: "A node's Lightning URI MUST NOT reach a party the operator has not federated with — it must never be re-exported in this node's own outbound peer hints on behalf of a third-party peer, so a peer-of-a-peer cannot harvest payment endpoints by federating one hop away" + category: privacy + - statement: "Lightning URI sharing MUST NOT be silently enabled in a way the operator cannot see or reverse — whatever default ships, the current sharing state is discoverable from the node's own settings surface and changing it takes effect on the next sync without a data migration" + category: transparency + artifacts: + - path: core/archipelago/src/federation/types.rs + provides: "Lightning identity field(s) on NodeStateSnapshot (and FederationPeerHint only if the decision selects it)" + contains: "lightning" + key_links: + - from: core/archipelago/src/federation/sync.rs + to: core/archipelago/src/federation/types.rs + via: "build_local_state populates the Lightning field from this node's lnd.getinfo identity" + pattern: "lightning" + - from: core/archipelago/src/federation/storage.rs + to: core/archipelago/src/api/rpc/federation/handlers.rs + via: "update_node_state persists the peer's Lightning URI; federation.list-nodes emits it" + pattern: "lightning" +--- + + +Carry a federated peer's Lightning URI over the federation sync payload, so the channel-open picker +can list **trusted nodes by hostname** and open a channel with one click. + +Purpose: FED-05's primary list. RESEARCH.md Pitfall 5 is blunt: building the picker before the +backend can supply a federated peer's Lightning pubkey/host produces a UI that lists names and has +nothing to pass to `lnd.openchannel`. `NodeStateSnapshot` — the payload `federation.get-state` and +sync exchange — carries no Lightning fields at all today. +Output: the sync payload extended, the peer's URI persisted and emitted by `federation.list-nodes`, +and the sharing default explicitly chosen by the operator rather than assumed. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md +@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md +@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md +@core/archipelago/src/federation/types.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| Lightning identity field(s) on `NodeStateSnapshot` | new optional serde-default field(s); exact names fixed by the Task 1 decision | `core/archipelago/src/federation/types.rs` | +| Lightning identity field(s) on `FederationPeerHint` | added **only if** the decision selects transitive sharing | same | +| `FederatedNode.lightning_uri: Option` | new persisted field on the local node record | same | +| `build_local_state` Lightning parameter | changed fn signature in the federation sync builder | `core/archipelago/src/federation/sync.rs` | +| `share_lightning_uri` | server setting + its accessor, **only if** the decision selects opt-in gating | `core/archipelago/src/api/rpc/federation/handlers.rs` (+ server info) | +| `lightning_uri` on `federation.list-nodes` | new response field | `core/archipelago/src/api/rpc/federation/handlers.rs` | + + + + + Task 1: Decide the Lightning field shape and sharing default on the federation sync payload + What shape does the Lightning identity take on the federation sync payload, and is sharing on by default or opt-in? + + This writes a new field into `NodeStateSnapshot`, the payload every federated node exchanges on + every sync. Once a release carrying it reaches the fleet, deployed peers parse that shape — a + later change to the field name, the split, or the sharing scope needs a coordinated fleet + upgrade plus a cleanup of URIs already cached in every peer's `nodes.json`. That is a one-way + door, and the sources disagree about which way to walk through it: + + - `01-CONTEXT.md` records "a federated peer's Lightning URI rides the federation sync payload + **by default** — federation trust is already bilateral and explicit", and marks this + **Claude's discretion, revisable** — not locked. + - `01-RESEARCH.md` Open Question 3 recommends the opposite: follow the `shared_location` + precedent (opt-in, default off) "since exposing a payment channel target more broadly than + intended has real-money implications." + + A second, related question rides along: `NodeStateSnapshot.federated_peers` carries a + `FederationPeerHint` for each of this node's trusted peers, used for transitive discovery. If the + Lightning field goes on the hint too, then Alice syncing with Bob learns Bob's *peers'* Lightning + URIs — a payment endpoint reaching a party that node never federated with. Options B and C below + keep the field off the hint; only choose otherwise deliberately. + + + + + + + Select: option-a, option-b, or option-c. If you pick option-c, also say where the toggle lives (a new row in the existing federation settings surface is the default assumption). + + + + Task 2: End-to-end — a trusted peer's Lightning URI reaches federation.list-nodes + This adds a field to `NodeStateSnapshot`, the wire payload every + fleet node parses on every sync; after the OTA carrying it, changing the field's name, split, or + sharing scope requires a coordinated fleet upgrade and a cleanup of URIs already cached in peers' + node files. + `lnd.getinfo` returns `identity_pubkey` and `uris` (delivered by plan 01-04, Task 1) — confirm by reading `core/archipelago/src/api/rpc/lnd/info.rs` for both field names before starting. + core/archipelago/src/federation/types.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs + + - `core/archipelago/src/federation/types.rs` lines 104-146 — the `shared_location` (`lat`/`lon`) + opt-in field pair with its doc comment explaining absent-vs-null, and the `FederationPeerHint` + struct with its `pubkey`/`onion` split. These are the exact patterns to mirror. + - `core/archipelago/src/api/rpc/federation/handlers.rs` around lines 470-485 — the + `shared_location` gating block (`if data.server_info.share_location { ... } else { None }`) + and how it is threaded into `federation::build_local_state`. + - `core/archipelago/src/federation/sync.rs` around lines 225-265 — `build_local_state`'s + signature and where `shared_location` is mapped into the snapshot at construction time. + - `core/archipelago/src/federation/storage.rs` `update_node_state` as left by plan 01-05, + including the monotonicity guard and the `fips_npub` exemption comment — the new field follows + the same "learn from the peer's snapshot" treatment. + - `core/archipelago/src/api/rpc/lnd/info.rs` as left by plan 01-04 — the `identity_pubkey` / + `uris` field names and the 66-hex validation helper to reuse. + - `core/archipelago/src/api/rpc/lnd/channels.rs` `handle_lnd_openchannel` (from L238) — the + exact URI/pubkey/address parsing the picker will feed, so the persisted format matches what + that handler accepts. + + + - `build_local_state` called with a Lightning URI puts it on the produced snapshot; called + without one produces a snapshot with the field absent (not an empty string). + - A snapshot deserialized from a payload that has no Lightning field succeeds with the field + `None` — an older peer syncs fine. + - `update_node_state` with a snapshot carrying a well-formed URI persists it onto the + `FederatedNode`; with a malformed URI it leaves any previously stored value untouched. + - A snapshot rejected by the plan-01-05 monotonicity guard does not clear an already-known URI. + - `federation.list-nodes` emits `lightning_uri` for a node that has one and omits it otherwise. + + + Implement exactly the option selected in Task 1 — do not substitute a different shape, and do not + add the field to `FederationPeerHint` unless option-a was chosen. Record the chosen option id in + the SUMMARY. + + Write the tests first and confirm they fail. + + Add the Lightning field(s) to `NodeStateSnapshot` with `#[serde(default)]` and a doc comment that + states the sharing rule chosen in Task 1 and explicitly notes where it differs from the + `shared_location` analog directly above it. Add `#[serde(default)] pub lightning_uri: Option` + to `FederatedNode` for the locally-persisted peer value, and update the `make_node` test helper + so the struct literal still compiles. + + Thread the value into `build_local_state` the same way `shared_location` is threaded: an added + parameter, mapped into the snapshot at construction. At the `handlers.rs` call site, source it + from this node's own `lnd.getinfo` identity (prefer the first entry of `uris`; fall back to + composing `identity_pubkey` with the node's reachable host when `uris` is empty), gated per the + Task 1 decision. An LND that is down or has no URI yields `None`, never an empty string and never + a fabricated address. + + In `update_node_state`, learn the peer's URI from the snapshot: validate the shape before storing + (the pubkey part is 66 hexadecimal characters; the `@host[:port]` remainder is optional, matching + what `handle_lnd_openchannel` accepts), and on a malformed value log at `debug!` and leave the + prior value alone. + + In `handle_federation_list_nodes`, emit `lightning_uri` with the same `if let Some(...)` + conditional-insert pattern the other optional fields use. + + + cd core && cargo test -p archipelago federation + + + - `cd core && cargo test -p archipelago federation` exits 0 with the five behaviors above present as named cases. + - `grep -c 'lightning' core/archipelago/src/federation/types.rs` is at least 3. + - `grep -c 'serde(default)' core/archipelago/src/federation/types.rs` increased by at least 2 relative to the pre-change file. + - A round-trip test proves back-compat in both directions: a snapshot JSON with no Lightning key deserializes to `None`, and a snapshot serialized with the field deserializes cleanly after being stripped of unknown keys. + - `grep -c 'lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1. + - If and only if option-a was selected: `grep -c 'lightning' core/archipelago/src/federation/types.rs` includes an occurrence inside the `FederationPeerHint` struct. Otherwise `FederationPeerHint` has none — assert this either way and state which in the SUMMARY. + - `cd core && cargo test -p archipelago` exits 0. + + A trusted federated peer's Lightning URI is synced, validated, persisted, and emitted — the picker's primary list now has real targets. + + + + Task 3: Make the sharing state visible and reversible + core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/federation/sync.rs + + - The Task 1 decision as recorded in the Task 2 SUMMARY notes. + - `core/archipelago/src/api/rpc/federation/handlers.rs` — the `share_location` server-info flag + and the `server.set-location` RPC that toggles it, for the accessor + persistence pattern. + - `core/archipelago/src/federation/sync.rs` — `build_local_state`'s tests (from L335) for the + assertion style. + + + Under option-c: add the `share_lightning_uri` server setting with a default of off, an RPC to + read and set it following the `server.set-location` shape, and make `build_local_state`'s + Lightning parameter `None` whenever the flag is off. Add tests: flag off produces a snapshot with + no Lightning field even when LND has one; flag on produces it; toggling the flag off then + re-syncing produces a snapshot without it. + + Under option-a or option-b: add a read-only surface reporting the current sharing state and the + URI actually being shared, so the operator can see what is going out; and make the outbound value + `None` whenever this node's own Lightning is not installed or not reachable. Add tests: no LND + produces no Lightning field; a present LND produces the URI; the reported state matches what + `build_local_state` actually emits. + + In both cases, add a test asserting a third-party peer's Lightning URI is never re-exported in + this node's own outbound peer hints — build a local state while holding a peer whose URI is + known, serialize it, and assert that URI string does not appear in the outbound payload's peer + hint section. This test is the mechanical form of this plan's privacy prohibition and must exist + regardless of which option was chosen. + + + cd core && cargo test -p archipelago federation::sync + + + - `cd core && cargo test -p archipelago federation::sync` exits 0. + - A test named for the third-party-URI-not-re-exported behavior exists and passes; temporarily injecting the peer URI into the outbound hint makes it fail (fail-first proof recorded in the SUMMARY). + - Under option-c only: `grep -c 'share_lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 2. + - `cd core && cargo test -p archipelago` exits 0. + - `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`. + + The operator can see, and change, what Lightning identity this node shares — and a peer's URI provably never travels one hop further. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| this node → federated peer (outbound snapshot) | This node's payment endpoint crosses to a remote party | +| federated peer → this node (inbound snapshot) | A remote party's claimed payment endpoint is persisted and later fed to `lnd.openchannel` | +| transitive peer hint | A third party's identity data can ride this node's outbound payload to a party it never federated with | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-21 | Information Disclosure | this node's Lightning payment endpoint reaching a non-federated party | high | mitigate | The Task 1 decision fixes the sharing scope explicitly; Task 3 adds the test proving a third-party URI is never re-exported in outbound peer hints, plus a fail-first proof | +| T-01-22 | Spoofing | a peer advertising a Lightning URI it does not control, redirecting a channel open and its funds | high | mitigate | The snapshot arrives over the existing ed25519-signature-verified federation path (unchanged); the URI is bound to that verified peer record and validated for shape before persistence. Not re-implemented here — the existing `identity::NodeIdentity::verify` path is reused, per RESEARCH.md V6 | +| T-01-23 | Tampering | a malformed or oversized URI corrupting the persisted node record | high | mitigate | 66-hex pubkey validation before persist; malformed input leaves the prior value untouched, with a test | +| T-01-24 | Tampering | a replayed older snapshot clearing a known Lightning URI | medium | mitigate | The plan-01-05 monotonicity guard rejects strictly-older snapshots; a test asserts a rejected snapshot does not clear the URI | +| T-01-25 | Repudiation | the operator unable to tell what identity their node is sharing | medium | mitigate | Task 3 adds the visible sharing state (a setting under option-c, a read-only report otherwise) | + + + +- `cd core && cargo test -p archipelago` — green. +- Back-compat round-trip proven in both directions against a payload lacking the new field. +- The chosen option id is recorded in the SUMMARY, together with the fail-first proof for the no-re-export test. + + + +- The Lightning identity field exists on the federation sync payload in exactly the shape the operator chose, additively and back-compatibly. +- A trusted peer's URI is validated, persisted, and emitted by `federation.list-nodes`. +- A third party's URI provably never leaves this node in its own peer hints. +- The current sharing state is visible to the operator. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md` when done. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-07-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-07-PLAN.md new file mode 100644 index 00000000..4e47c47b --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-07-PLAN.md @@ -0,0 +1,251 @@ +--- +phase: 01-federation-mesh-hardening +plan: 07 +type: execute +wave: 2 +depends_on: ["01-04"] +files_modified: + - core/archipelago/src/mesh/message_types.rs + - core/archipelago/src/mesh/listener/dispatch.rs + - core/archipelago/src/api/rpc/mesh/typed_messages.rs + - core/archipelago/src/api/rpc/dispatcher.rs +autonomous: true +requirements: [FED-05] + +must_haves: + truths: + - "A user can send a channel-open request to a meshed Lightning peer, carrying this node's own Lightning URI, an optional amount, and an optional message" + - "A received channel-open request appears in the recipient's mesh conversation as a typed message showing the requester's URI and note, using the existing typed-message rendering path" + - "Sending a channel-open request requires an explicit target peer — there is no broadcast form" + - "A channel-open request whose payload URI is malformed is rejected on receipt and never stored as a message" + - "Two channel-open requests sent to the same peer in quick succession produce two distinct messages with distinct sender sequence numbers, and neither is silently dropped (FED-05 concurrency edge, mesh half)" + - "A request is never rendered or reported as an opened or funded channel — it carries no channel state" + prohibitions: + - statement: "A channel-open request MUST NOT be presented anywhere as an accepted, open, or funded channel — a request that has not been acted on by the recipient must never appear in a channel list, a balance, or a connected-peer count" + category: transparency + - statement: "Receiving a channel-open request MUST NOT cause the node to open a channel, connect to the requester, or move funds on its own — acting on a request is always a separate, explicit human decision" + category: safety + artifacts: + - path: core/archipelago/src/mesh/message_types.rs + provides: "ChannelOpenRequest typed message + payload" + contains: "ChannelOpenRequest" + key_links: + - from: core/archipelago/src/api/rpc/dispatcher.rs + to: core/archipelago/src/api/rpc/mesh/typed_messages.rs + via: "mesh.request-channel match arm" + pattern: "mesh.request-channel" + - from: core/archipelago/src/mesh/listener/dispatch.rs + to: core/archipelago/src/mesh/types.rs + via: "inbound ChannelOpenRequest is stored as a MeshMessage with its typed payload" + pattern: "ChannelOpenRequest" +--- + + +Give a meshed Lightning peer a way to be *asked* for a channel: a typed mesh message carrying the +requester's Lightning URI and an optional note, sent to one chosen peer and rendered in the +recipient's conversation. + +Purpose: FED-05's second list. CONTEXT.md locks the semantics — meshed peers with Lightning +installed are nodes you "request to open a channel with", not nodes you open against directly, +because mesh peers are not bilaterally trusted the way federated nodes are. 01-UI-SPEC.md fixes the +UI verb ("Request Channel", reusing `PeerRequestModal.vue`'s message field and busy states). +PATTERNS.md records that the send/receive shape for this is `typed_messages.rs`'s existing +reaction/reply family — a struct-per-message-type serialized into the standard envelope — and that +no capability/request mechanism exists yet to extend. +Output: `MeshMessageType::ChannelOpenRequest`, its payload, an inbound arm that stores it as a +conversation message, and a `mesh.request-channel` RPC. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md +@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md +@core/archipelago/src/mesh/message_types.rs + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `MeshMessageType::ChannelOpenRequest = 27` (label `channel_open_request`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` | +| `ChannelOpenRequestPayload { uri, amount_sats, message }` | new CBOR payload struct | same | +| inbound `ChannelOpenRequest` arm | listener dispatch arm storing the request as a `MeshMessage` | `core/archipelago/src/mesh/listener/dispatch.rs` | +| `handle_mesh_request_channel` | new RPC handler (`mesh.request-channel`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` | +| `mesh.request-channel` | dispatcher match arm | `core/archipelago/src/api/rpc/dispatcher.rs` | + + + + + Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation + `MeshMessageType` is a radio wire format read by every fleet node + after the next OTA; the discriminant and payload shape become externally visible, so a later change + needs a coordinated fleet upgrade. Kept additive on an unused discriminant with serde-default + optional payload fields, so older nodes ignore it rather than erroring. + `MeshMessageType::LightningInfo = 26` exists (plan 01-04, Task 2) — confirm the highest current discriminant by reading `core/archipelago/src/mesh/message_types.rs` before choosing this type's number. + core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/listener/dispatch.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs + + - `core/archipelago/src/mesh/message_types.rs` — the enum and the four places every variant is + registered (`enum`, `from_u8`, `from_label`, `label`), `InvoicePayload` (from L413) as the + closest payload analog (it also carries a payment-ish string plus an optional amount), and the + `TypedEnvelope` doc comment on `compact_bytes` and LoRa frame size. + - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-760 — `handle_mesh_send_reply` + and `handle_mesh_send_reaction`: param extraction, target-peer resolution, sequence-number + allocation, `TypedEnvelope::new(...).with_seq(seq)`, and the send call. + - `core/archipelago/src/mesh/listener/dispatch.rs` lines 430-500 — the `Reaction` and `Presence` + inbound arms, and how an inbound typed message is turned into a stored `MeshMessage` with + `message_type` and `typed_payload` set. + - `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` fields the stored + request must populate (`plaintext` is the human-readable fallback shown in list views). + - `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line registration convention. + - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract row + for "Primary CTA — meshed Lightning peer", which fixes what the UI in plan 01-08 will send. + + + - `MeshMessageType` round-trips the new variant through `from_u8`, `from_label`, and `label`. + - `handle_mesh_request_channel` with no target peer in params returns an error; with an unknown + target peer it returns an error naming the peer. + - `handle_mesh_request_channel` with a target sends exactly one envelope of the new type, whose + payload carries this node's own Lightning URI and the caller's optional amount and message. + - Two consecutive calls to the same target allocate two different sequence numbers. + - An inbound envelope of the new type with a well-formed URI is stored as a `MeshMessage` whose + `message_type` is the new label and whose `typed_payload` carries the request fields. + - An inbound envelope whose payload URI is malformed stores nothing and logs a warning. + + + Write the tests first and confirm they fail. + + Add `ChannelOpenRequest = 27` to `MeshMessageType` (confirm 27 is unused first) with a doc comment + stating that this is a *request*, that it carries no channel state, and that receiving one never + causes the node to act. Register it in `from_u8`, `from_label` ("channel_open_request"), and + `label`. + + Add `ChannelOpenRequestPayload` beside the other payload structs: a required `uri: String` (the + requester's own `pubkey@host:port`), plus `#[serde(default)] amount_sats: Option` and + `#[serde(default)] message: Option`. Bound the optional message length before send so a + long note cannot blow past the LoRa framing budget the `TypedEnvelope` doc comment warns about; + truncate at the send side rather than rejecting, and say so in a comment. + + Add `handle_mesh_request_channel` following the `handle_mesh_send_reply` shape: require a target + peer identifier in params and error without one (there is no broadcast form); read this node's + own Lightning URI via the identity path plan 01-04 added to `lnd.getinfo`, and error with a clear + message when it is unavailable rather than sending an empty request; build the payload, wrap it in + a `TypedEnvelope` with a freshly allocated sequence number, and send it to that peer only. + Register it in the dispatcher as `"mesh.request-channel"`. + + Add the inbound arm in `dispatch.rs` mirroring the `Reaction` arm: deserialize the payload, + validate the URI shape (66-hex pubkey part, optional `@host[:port]`), and on success store a + `MeshMessage` with the new label as `message_type`, the payload as `typed_payload`, and a + human-readable `plaintext` summary naming the requester and the requested amount when present. + On a malformed URI, log at `warn!` and store nothing. + + Do not add any code path that connects to, opens a channel with, or funds the requester on + receipt. The inbound arm's only effect is storing a message. + + + cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh + + + - `cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh` exits 0 with the six behaviors above present as named cases. + - `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/message_types.rs` is at least 5. + - `grep -c 'channel_open_request' core/archipelago/src/mesh/message_types.rs` is at least 2. + - `grep -c '"mesh.request-channel"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1. + - `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1. + - The inbound arm contains no call to any `openchannel`, `connectpeer`, or send-funds path — verified by reading the arm and recorded in the SUMMARY. + - `cd core && cargo test -p archipelago` exits 0. + - The SUMMARY records the pre-implementation failing test output. + + A channel-open request travels from an RPC call to a chosen peer's conversation, with no side effect beyond a stored message. + + + + Task 2: Harden the request path against duplicates, oversize, and misuse + core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/mesh/listener/dispatch.rs + + - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` as left by Task 1, plus the + `handle_mesh_send_content_inline` size-tier logic for how this codebase bounds payload size + before a send. + - `core/archipelago/src/mesh/outbox.rs` — whether an outbound send is queued and retried, so the + duplicate-suppression window is placed where it will actually see both attempts. + - `core/archipelago/src/mesh/types.rs` — `MeshPeer`'s authenticating-key accessor doc comment + (never use the firmware routing key for authentication). + + + Add a short duplicate-suppression window to `handle_mesh_request_channel`: a second request to the + same target peer within a bounded interval returns a distinct, non-error result reporting that a + request was already sent, rather than emitting a second envelope. The UI in plan 01-08 also + disables its button while a send is in flight, but a backend guard is what actually stops a + double-click or a retried RPC from spamming a peer over a slow radio link. Two requests separated + by more than the window must both go out — the window suppresses accidental duplicates, not + legitimate repeat requests. Add tests for both sides of the window. + + Bound the inbound side too: reject an inbound payload whose message field exceeds the same + length bound the send side truncates at, and reject an `amount_sats` outside the range + `handle_lnd_openchannel` accepts (its existing 20,000..=16,777,215 sat bounds) so a request can + never carry an amount the recipient could not act on. Read `channels.rs` for those exact bounds + rather than restating them from memory. + + Attribute the stored inbound message to the peer's authenticating identity key, not the firmware + routing key, following the `MeshPeer` accessor's documented rule — a request that claims to be + from a trusted peer must be attributable. + + + cd core && cargo test -p archipelago mesh api::rpc::mesh + + + - `cd core && cargo test -p archipelago mesh api::rpc::mesh` exits 0, including a within-window suppression case and an outside-window pass-through case. + - A test asserts an inbound request with an out-of-range `amount_sats` is rejected, using the bounds read from `channels.rs` rather than hardcoded duplicates of them. + - `cd core && cargo test -p archipelago` exits 0. + - `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `mesh` or `api::rpc::mesh`. + + Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| radio peer → typed-envelope decode → stored message | Untrusted RF input becomes a conversation entry naming a payment endpoint | +| operator RPC → outbound request | An operator action discloses this node's payment endpoint to a chosen mesh peer | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-26 | Spoofing | a peer sending a request that appears to come from a trusted node, luring a channel open to an attacker's URI | high | mitigate | The stored message is attributed to the peer's verified archipelago identity key, never the firmware routing key (Task 2); the recipient's action on a request is always explicit and human | +| T-01-27 | Elevation of Privilege | a received request causing an automatic channel open or fund movement | high | mitigate | The inbound arm's only effect is storing a message; the acceptance criteria require reading the arm and recording that it contains no open/connect/send-funds call | +| T-01-28 | Denial of Service | request flooding filling a peer's conversation or saturating a LoRa link | high | mitigate | Send-side duplicate-suppression window plus inbound length and amount bounds (Task 2) | +| T-01-29 | Information Disclosure | broadcasting this node's payment endpoint to every contact in range | high | mitigate | A target peer is required; the handler errors without one and there is no broadcast form | +| T-01-30 | Tampering | an oversize payload fragmenting into unreassemblable LoRa chunks | medium | mitigate | The message field is truncated at the send side against the framing budget the `TypedEnvelope` doc comment describes; inbound oversize is rejected | +| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates. If one becomes necessary, stop and run the Package Legitimacy Gate before installing | + + + +- `cd core && cargo test -p archipelago` — green. +- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules. +- The SUMMARY states, from a direct read, that the inbound arm performs no Lightning action. + + + +- A new typed mesh message carries a channel-open request to one named peer. +- Receiving one stores a conversation message and does nothing else. +- Duplicates within a short window are suppressed; legitimate repeats are not. +- Malformed URIs, oversize notes, and out-of-range amounts are refused. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-08-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-08-PLAN.md new file mode 100644 index 00000000..9d2e9fe9 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-08-PLAN.md @@ -0,0 +1,348 @@ +--- +phase: 01-federation-mesh-hardening +plan: 08 +type: execute +wave: 4 +depends_on: ["01-02", "01-06", "01-07"] +files_modified: + - neode-ui/src/components/LightningChannelModal.vue + - neode-ui/src/components/LightningChannelsPanel.vue + - neode-ui/src/api/rpc-client.ts + - neode-ui/mock-backend.js + - neode-ui/src/components/__tests__/LightningChannelModal.test.ts +autonomous: true +requirements: [FED-05] + +must_haves: + truths: + - "A user can copy their own node's Lightning URI from the channel-open surface; the copy button label flips to Copied! for about two seconds" + - "The displayed own-node URI truncates to its container with the full value in a title tooltip, and the full untruncated value is what reaches the clipboard" + - "Trusted federated nodes that advertise Lightning are listed by hostname with a one-click Open Channel action" + - "Meshed peers that have Lightning installed are listed separately with a Request Channel action, never a direct open — they are not bilaterally trusted" + - "A peer that is both a trusted federated node and a meshed Lightning peer appears exactly once, in the trusted list (FED-05 adjacency edge)" + - "Both picker lists render in a deterministic order that does not reshuffle between refreshes (FED-05 ordering edge)" + - "When both lists are empty a single shared empty state renders once — not one per column" + - "Both lists show the house loading treatment while fetching and the house error row on failure, matching the existing federation node list and Lightning channels panel conventions" + - "Each list row shows the node name with truncation and a title tooltip, its trust badge, and its transport badge, mirroring the existing federation node row" + - "Clicking Open Channel twice, or opening two channels to the same peer at once, results in one open attempt — the action is disabled while a request is in flight (FED-05 concurrency edge)" + - "A manually pasted pubkey with no host still works, falling back to the address-less open path the Lightning channels panel already relies on" + - "The manual-paste field is reached through a de-emphasised Paste URI Manually entry point below both lists, not as a third equal-weight column" + - "The modal renders through the house modal shell so its backdrop covers the full screen and a click outside closes it" + - "The request flow reuses the existing peer-request modal pattern — optional message field, Send Request submit, Sending… busy label" + - statement: "When both lists are empty the shared empty state renders exactly once rather than once per list" + verification: backstop + - statement: "A manually pasted URI that is not in pubkey@host:port form is rejected client-side with a format message before any open call is made" + verification: backstop + prohibitions: + - statement: "A channel-open request sent to a meshed peer MUST NOT be displayed as an open, pending-funding, or connected channel anywhere in the UI — until the recipient acts, it is a sent request and nothing more" + category: transparency + - statement: "The picker MUST NOT present a meshed peer's advertised URI with the same visual authority as a bilaterally-trusted federated node — the two lists stay visually distinct and the meshed action stays a request, so a user cannot mistake an unverified advertisement for a trusted target" + category: safety + artifacts: + - path: neode-ui/src/components/LightningChannelModal.vue + provides: "Own-URI share, trusted-node picker, meshed-peer request picker, manual-paste fallback" + min_lines: 150 + - path: neode-ui/src/components/__tests__/LightningChannelModal.test.ts + provides: "State coverage for empty, loading, error, populated, dedup, ordering, and double-click" + min_lines: 60 + key_links: + - from: neode-ui/src/components/LightningChannelsPanel.vue + to: neode-ui/src/components/LightningChannelModal.vue + via: "the panel's existing Open Channel button opens the new picker modal" + pattern: "LightningChannelModal" + - from: neode-ui/src/components/LightningChannelModal.vue + to: neode-ui/src/api/rpc-client.ts + via: "federation.list-nodes, mesh.lightning-peers, lnd.getinfo, lnd.openchannel, mesh.request-channel" + pattern: "lightning-peers" +--- + + +Make channel opening between nodes first-class UI: share your node's Lightning URI, open a channel +with a trusted federated node in one click, and request a channel from a meshed peer that has +Lightning installed. + +Purpose: FED-05's user-facing half, with scope locked in CONTEXT.md (the second list is *meshed peer +nodes that have Lightning installed* — not `lnd listpeers`, not a curated directory, not a live +LN-graph query) and its visuals fixed by 01-UI-SPEC.md (copy, colours, spacing, the shared empty +state, the de-emphasised manual-paste fallback, and the hard modal rule). +Output: a new picker modal built from the house design system, reached from the Lightning panel's +existing Open Channel button, working against the demo and against archi-dev. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md +@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md +@.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md +@.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md +@.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md +@neode-ui/src/components/BaseModal.vue + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `LightningChannelModal.vue` | new Vue component (picker modal) | `neode-ui/src/components/LightningChannelModal.vue` | +| `meshLightningPeers()`, `requestChannel()`, `sendLightningInfo()` | new rpc-client wrappers | `neode-ui/src/api/rpc-client.ts` | +| `mesh.lightning-peers`, `mesh.send-lightning-info`, `mesh.request-channel` | new mock RPC cases | `neode-ui/mock-backend.js` | +| `identity_pubkey` / `uris` on the mock `lnd.getinfo` result | extended mock response | same | +| `lightning_uri` on the mock `federation.list-nodes` nodes | extended mock response | same | +| `LightningChannelModal.test.ts` | new vitest suite | `neode-ui/src/components/__tests__/LightningChannelModal.test.ts` | + + + + + Task 1: End-to-end — open a channel with a trusted federated node in one click + `federation.list-nodes` emits `lightning_uri` (plan 01-06) and `mesh.lightning-peers` is registered in the dispatcher (plan 01-04) — confirm both by grepping `core/archipelago/src/api/rpc/federation/handlers.rs` and `core/archipelago/src/api/rpc/dispatcher.rs` before starting. + neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/api/rpc-client.ts, neode-ui/mock-backend.js, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/src/components/LightningChannelsPanel.vue + + - `neode-ui/src/components/BaseModal.vue` — the whole file. It already wraps `Teleport to="body"`, + the full-screen `bg-black/60 backdrop-blur-md` backdrop, `@click.self` close, the pinned + `text-xl font-semibold` title, and the scrolling body. The new modal MUST use it; never nest a + modal inside a transform-affected ancestor. + - `neode-ui/src/components/LightningChannelsPanel.vue` lines 246-360 (its current bespoke + open-channel modal, its `openError` ref, `isStartupNotice()` amber-vs-red distinction, and the + fee-preset block) and lines 505-630 (`showOpenModal`, `defaultOpenForm()`, `openForm`, + `openingChannel`, and the validate-before-RPC sequence including the 20,000-sat minimum and the + `pubkey@host:port` split with an optional address). + - `neode-ui/src/views/federation/NodeList.vue` lines 40-140 and 155-190 — the row layout to + mirror (truncated name with `:title`, transport badge, trust badge, action button), the + `trustedNodes` / `peerNodes` computed filters, and the "Loading nodes..." row. + - `neode-ui/src/api/rpc-client.ts` lines 795-850 — the one-line + `this.call({ method: '.', params })` wrapper convention and the existing + `federation.list-nodes` wrapper. + - `neode-ui/mock-backend.js` — the `lnd.getinfo`, `lnd.openchannel`, and `federation.list-nodes` + cases, and the parity harness added by plan 01-02. + - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — Design System, Spacing, + Typography, Color, and the full Copywriting Contract. Every string in this modal comes from + that table verbatim. + + + - Mounted with a node list containing one trusted node that has a Lightning URI and one that does + not, the trusted list renders exactly one row. + - The row shows the node name (truncated, with a `title`), its trust badge, its transport badge, + and an Open Channel button. + - Clicking Open Channel calls the open RPC once with that node's URI; clicking it twice in + immediate succession still results in exactly one call, and the button is disabled while in + flight. + - While the node list is loading, the loading treatment renders and no empty state renders. + - When the node fetch rejects, the error row renders with the contract's error copy. + - The modal root renders through the house modal shell, so the backdrop is a full-screen sibling + of the card rather than a child of a transformed ancestor. + + + Write the test file first and confirm it fails. + + Add rpc-client wrappers for `mesh.lightning-peers`, `mesh.send-lightning-info`, and + `mesh.request-channel` following the existing one-line convention. Extend the mock backend so the + demo answers all three, so `lnd.getinfo` returns an `identity_pubkey` and a `uris` array, and so + `federation.list-nodes` nodes carry `lightning_uri` — mirroring the real handlers per the mock's + established "cite the daemon source" comment convention. The plan-01-02 parity harness must stay + green. + + Create `LightningChannelModal.vue` using `BaseModal` as its shell, title "Open Lightning Channel". + In this task implement the trusted-node section only: fetch the federated node list, keep nodes + whose trust level is trusted AND which have a Lightning URI, sort by display name with a stable + tiebreak so the order does not shuffle between refreshes, and render each as a row mirroring the + federation node row — truncated name with a `title` tooltip, the transport badge reusing + NodeList's existing FIPS/Tor logic, the trust badge, and an Open Channel button on the right. + + Wire Open Channel to the existing open-channel RPC, reusing the panel's proven sequence: validate + before calling, keep the 20,000-sat minimum, split the URI into pubkey and optional address, and + reuse the `openError` ref plus the `isStartupNotice()` amber-vs-red distinction rather than + inventing a new error idiom. Guard against double submission with an in-flight flag keyed to the + target so the button is disabled and a second click is a no-op. + + Point the Lightning panel's **existing** Open Channel button at this modal instead of its bespoke + one. Do not add a new nav entry, route, card, or dashboard tile — the user places new entry + points, this plan only upgrades the one that already exists. Leave the panel's channel list, + close-channel flow, and fee presets untouched. + + Follow the UI-SPEC tables exactly: spacing on the 4px grid, the two-weight typography scale, + accent orange reserved for the primary action buttons, the bolt icon path already used elsewhere + in the app, and the copy strings verbatim. + + + cd neode-ui && test -f src/components/__tests__/LightningChannelModal.test.ts && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs + + + - The test file exists and `npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with all six behaviors present as named cases (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`). + - A test asserts two immediate clicks produce exactly one open call. + - `grep -c 'BaseModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (import + usage). + - `grep -c 'Open Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1 and the copy matches the UI-SPEC Copywriting Contract verbatim. + - `grep -c 'LightningChannelModal' neode-ui/src/components/LightningChannelsPanel.vue` is at least 2. + - `grep -c "'mesh.lightning-peers'" neode-ui/src/api/rpc-client.ts` equals 1. + - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero missing methods. + - `cd neode-ui && npx vitest run` exits 0 and `npm run build` exits 0. + - No new route, nav item, or dashboard card was added — confirmed by `git diff --stat` showing no change to the router or any layout/nav component, recorded in the SUMMARY. + + A trusted federated node can be picked by hostname and a channel opened with one click, through the house modal shell, on the demo and against a real node. + + + + Task 2: The meshed Lightning peers list and the Request Channel flow + neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/mock-backend.js + + - `neode-ui/src/components/federation/PeerRequestModal.vue` — the whole file (66 lines): the + optional message field, the `sending` → "Sending…" busy label, and the + `$emit('send', message)` / `$emit('cancel')` contract. Reuse this component rather than + building a second request modal. + - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-05 Visual Anchor" + section (trusted list is primary, meshed list second) and the empty-state copy rows. + - `neode-ui/src/views/federation/NodeList.vue` — the empty-state block treatment to mirror. + - The plan-01-07 SUMMARY — the exact params `mesh.request-channel` expects. + + + Add the meshed-Lightning-peers section below the trusted list: fetch via `mesh.lightning-peers`, + render rows in the same layout with a Request Channel button in place of Open Channel, and sort + with the same stable ordering rule. + + Deduplicate across the two lists: a peer that is both a trusted federated node and a meshed + Lightning peer appears only in the trusted list. Match on the identity available in both payloads + (the node's Lightning URI is the reliable common key; fall back to the peer's archipelago identity + key when present). Never match on display name. + + Wire Request Channel to `PeerRequestModal` — mount it with the optional message field, and on its + send event call `mesh.request-channel` with the target peer and the message. While a request is + in flight the row's button is disabled and shows the busy label; a second click is a no-op. On + success show a sent-request confirmation on the row. That confirmation must not claim a channel + exists, is pending funding, or is connected; it says a request was sent and nothing more. + + Add the shared empty state: when the trusted list and the meshed list are BOTH empty, render the + UI-SPEC's empty heading and body exactly once for the pair — not once per list. When only one is + empty, that section renders nothing rather than its own empty state. Render the house loading + treatment per section while its fetch is in flight, and the contract's error row on a failed + fetch, using the same `openError` / startup-notice idiom as Task 1. + + Extend the mock backend so `mesh.lightning-peers` returns a small demo peer set and + `mesh.request-channel` records the request in the session store so the demo shows the same sent + state a real node does. + + Extend the test suite: both-empty renders one empty state; one-empty renders none for that + section; a peer present in both lists renders once and in the trusted list; ordering is identical + across two consecutive renders of a shuffled input; a double click on Request Channel produces one + call; the sent confirmation contains no open or connected wording. + + + cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs + + + - `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the six cases above present by name. + - The both-empty case asserts an element count of exactly 1 for the empty-state element, not merely that it is present. + - `grep -c 'PeerRequestModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2. + - `grep -c 'Request Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1. + - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0. + - `cd neode-ui && npx vitest run && npm run build` exits 0. + + Meshed Lightning peers are listed and requestable, deduplicated against the trusted list, with a single shared empty state and no misleading channel wording. + + + + Task 3: Share your own URI, and the manual-paste fallback + neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts + + - `neode-ui/src/components/SendBitcoinModal.vue` — its `copyDetail` / "Copied!" clipboard + feedback pattern (the label flips for about two seconds). Reuse it; do not invent a new + copy-feedback idiom. + - `neode-ui/src/components/LightningChannelsPanel.vue` lines 250-270 and 595-625 — the + `pubkey@host:port` placeholder, the `Format: pubkey@host:port` helper text, and the + validate-before-RPC sequence including the address-optional split. + - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract rows + for "Primary CTA — own URI" and "Manual fallback entry point", and the UI Considerations rows + marked backstop for the manual-paste form. + + + Add the own-node URI block at the top of the modal: read this node's Lightning identity from + `lnd.getinfo`, display the URI truncated to its container with the full value in a `title` + tooltip, and add a copy button whose label flips to the confirmation string for about two seconds. + The clipboard receives the full untruncated value regardless of visual truncation. When the node + has no Lightning URI available, the block explains that instead of showing an empty field or a + fabricated address. + + Add the manual-paste fallback below both lists as a de-emphasised disclosure, not a third + equal-weight column: the entry point reveals a peer URI input with the placeholder and helper text + reused verbatim from the Lightning panel. Validate client-side before calling the open RPC — a + value that is not in `pubkey@host:port` form is rejected with the format message and no RPC is + issued; a bare pubkey with no host is accepted and passes an undefined address through, which is + the behavior the open RPC already supports. Reuse the same error ref and startup-notice treatment. + + Extend the test suite: the copy button places the full untruncated URI on the clipboard and its + label flips then reverts; the URI element carries a `title` with the full value; an invalid + pasted value shows the format message and issues no RPC call; a bare pubkey issues the open call + with an undefined address; the no-URI-available state renders its explanation rather than an + empty field. + + Then verify on the dev preview against archi-dev before this plan is considered complete, per the + user requirement in CONTEXT.md: the preview at the dev port, the copy button, the trusted list, + the meshed list, the request flow, and the manual paste. Record what was exercised in the SUMMARY. + The blocking human sign-off is consolidated into plan 01-09. + + + cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && npm run build + + + - `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the five cases above present by name. + - A test asserts the clipboard receives the full untruncated URI even when the rendered element is truncated. + - A test asserts an invalid pasted value results in zero RPC calls (assert on the call count, not merely on the message being visible). + - `grep -c 'Copy Lightning URI' neode-ui/src/components/LightningChannelModal.vue` is at least 1. + - `grep -c 'Paste URI Manually' neode-ui/src/components/LightningChannelModal.vue` is at least 1. + - `grep -c 'pubkey@host:port' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (placeholder + helper text). + - `cd neode-ui && npx vitest run && npm run build` exits 0. + - The SUMMARY lists the dev-preview steps exercised against archi-dev and what was observed. + + A user can share their own node's Lightning URI and fall back to a pasted URI with real client-side validation, verified on the dev preview against a real node. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| daemon RPC → browser | Peer-advertised Lightning URIs, some of them from unauthenticated radio peers, are rendered and offered as payment targets | +| browser → clipboard | This node's payment endpoint is copied for the user to share out of band | +| user click → `lnd.openchannel` | A UI action commits real funds to a channel | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-31 | Spoofing | a meshed peer's advertised URI presented with the same authority as a bilaterally-trusted federated node, luring funds to an attacker | high | mitigate | The two lists stay visually and semantically distinct; the meshed action is Request Channel, never a direct open; the prohibition above states this and a test asserts the meshed row's action wording | +| T-01-32 | Tampering | a peer-supplied node name or URI containing markup that renders as UI | high | mitigate | Vue's default text interpolation escapes; the plan uses no `v-html` anywhere. A test asserting a name containing angle brackets renders as text is required before this row can be dispositioned | +| T-01-33 | Repudiation | a sent request being read as an open channel, so a user believes they have inbound liquidity they do not | high | mitigate | The sent confirmation is worded as a request only; a test asserts the confirmation contains no open or connected wording | +| T-01-34 | Denial of Service | a double click or a fast repeat committing two channel opens to the same peer | high | mitigate | An in-flight flag keyed to the target disables the action and makes a second click a no-op, backed by the plan-01-07 backend suppression window; a test asserts exactly one call for two immediate clicks | +| T-01-35 | Information Disclosure | this node's Lightning URI being displayed to a shoulder-surfer or copied in a shared session | low | accept | A Lightning URI is a public payment endpoint by design; it is deliberately shareable and carries no spend authority | +| T-01-36 | Elevation of Privilege | the modal bypassing the open RPC's server-side validation by calling with unvalidated input | medium | mitigate | Client-side validation is additive only; the existing server-side pubkey-format and amount-bounds validation in the open handler is reused unchanged and is the authority | + + + +- `cd neode-ui && npx vitest run` — green. +- `cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods. +- `cd neode-ui && npm run build` — green. +- Dev-preview walkthrough against archi-dev recorded in the SUMMARY (own URI copy, trusted open, meshed request, manual paste), per CONTEXT.md's "verified on the dev preview before any deploy" requirement. + + + +- Own-node URI is displayed, truncated with a tooltip, and copied in full. +- Trusted federated nodes with Lightning are listed by hostname with a one-click open. +- Meshed Lightning peers are listed separately and requestable, deduplicated against the trusted list. +- Shared empty state renders once; loading and error states follow the house conventions. +- Manual paste validates client-side and supports a bare pubkey. +- Double-submission is impossible; a request is never shown as a channel. +- No new route, nav entry, or dashboard card was added. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-08-SUMMARY.md` when done. +Stage by explicit path, commit, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-09-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-09-PLAN.md new file mode 100644 index 00000000..287cb0e3 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-09-PLAN.md @@ -0,0 +1,275 @@ +--- +phase: 01-federation-mesh-hardening +plan: 09 +type: execute +wave: 5 +depends_on: ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08"] +files_modified: + - .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md + - core/archipelago/src/federation/storage.rs + - core/archipelago/src/federation/sync.rs + - core/archipelago/src/api/rpc/federation/handlers.rs + - core/archipelago/src/fips/dial.rs + - core/archipelago/src/mesh/mod.rs + - core/archipelago/src/api/rpc/mesh/typed_messages.rs +autonomous: true +requirements: [FED-03, FED-01] + +must_haves: + truths: + - "A findings document exists listing every issue the structured review of the federation/fleet area and the mesh area produced, with file and line citations" + - "Every finding carries exactly one disposition — fixed, or deferred with a written reason — and no finding is left without one (FED-03 ordering edge)" + - "Every reviewed area appears in the document, including areas where the review produced no findings, recorded as reviewed with none rather than omitted (FED-03 empty edge)" + - "Every federation and mesh claim in the codebase concerns document is re-verified against current code and git history before being filed as a finding or dismissed, with the evidence cited (FED-03 adjacency edge)" + - "Every finding marked fixed cites the commit and the test or command that demonstrates the fix" + - "The known-fixed claims are recorded as already-fixed with their commit, not re-fixed" + prohibitions: + - statement: "A finding MUST NOT be closed as fixed without evidence a reader can re-run — a disposition of fixed always cites a commit and a verifying command or test name, never an assertion alone" + category: transparency + - statement: "A finding MUST NOT be dropped silently — an item judged out of scope is recorded as deferred with the reason and the phase or requirement that owns it, never deleted from the list" + category: transparency + artifacts: + - path: .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md + provides: "The FED-03 structured review output with per-finding dispositions" + min_lines: 60 + key_links: + - from: .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md + to: .planning/codebase/CONCERNS.md + via: "each federation/mesh concern is re-verified and cross-referenced by its finding id" + pattern: "CONCERNS" +--- + + +Run the structured code review FED-03 requires over the federation/fleet area and the mesh area, and +close it out: every finding fixed or explicitly deferred with a reason. + +Purpose: FED-03. RESEARCH.md Pitfall 2 is the governing constraint — `.planning/codebase/CONCERNS.md` +is NOT current truth for this phase. At least two of its federation claims were already fixed on main +before this phase started (the tombstone-write-swallowed claim was fixed in `01cbec27`; the +peer-joined DID path does verify an ed25519 signature). Re-fixing an already-fixed bug wastes the +review and risks reverting working code, so every claim gets a fresh code read plus a git-history +check before it is filed or dismissed. +Output: `01-REVIEW-FINDINGS.md` with a disposition on every finding, the small findings fixed inline, +and the phase's code deployed to the dev pair so plan 01-10's verification has something to test. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/codebase/CONCERNS.md +@.planning/codebase/ARCHITECTURE.md +@.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md +@.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md +@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md +@.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md + + +## Artifacts this phase produces + +Created or changed by **this plan**: + +| Symbol | Kind | File | +|---|---|---| +| `01-REVIEW-FINDINGS.md` | new findings document with per-finding dispositions | `.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md` | +| finding-dependent fixes | code changes in the reviewed areas | files listed in `files_modified` | +| dev-pair deployment | the phase build running on archi-dev-box and x250-dev, sha256-verified | (no repo file) | + + + + + Task 1: End-to-end — one finding from discovery to closed disposition + .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md + + - `.planning/codebase/CONCERNS.md` — the federation and mesh entries: the node-removal tombstone + gap (cited at `federation/storage.rs:180-197`), the incomplete federation DID validation, the + unbounded harness curl (cited as a multinode test-harness issue), the node-list dedup scaling + note, and the mesh radio configuration boot race. + - `.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md` — the "Common Pitfalls" section + (especially Pitfall 2's instruction to `git log -p` each cited range before acting) and the + "Assumptions Log" rows A1, A2, and A5. A5 in particular is explicitly NOT independently + re-verified and must be re-checked here. + - The SUMMARYs from plans 01-01, 01-05, and 01-07 — what has already been fixed in this phase, + so those items are recorded as fixed-by-this-phase rather than re-opened. + + + Create `01-REVIEW-FINDINGS.md` with a table whose columns are: finding id (`F-01`, `F-02`, …), + area (federation store / federation sync / federation RPC / FIPS-transport dial / mesh core / + mesh RPC surface), severity, the file and line citation, the evidence (what was read and what + `git log -p` or `git blame` showed), the disposition (`fixed` / `already-fixed` / `deferred`), and + for `fixed` the commit plus the verifying command or test name, or for `deferred` the reason and + the owning phase or requirement. + + Then take exactly one finding all the way through in this task, to prove the pipeline: re-verify + the codebase-concerns claim about incomplete federation DID validation — specifically the part + RESEARCH.md flags as un-re-verified, whether anything checks proof of ownership of a DID on first + contact, as opposed to the peer-joined path which does verify a signature. Read the add-node and + peer-joined paths in the federation RPC handlers and run `git log -p` on them. File the finding + with its evidence, then either fix it (if the fix is contained and does not touch federation trust + or join cryptography beyond what correctness requires — CONTEXT.md's scope fence) or defer it with + a written reason naming what a fix would touch and why that belongs elsewhere. + + Record the two claims RESEARCH.md already verified as fixed with their commits, as `already-fixed` + rows, so a future reader does not re-open them. + + + test -f .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md && grep -Eq '^\| *F-01' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md && cd core && cargo test -p archipelago federation + + + - `01-REVIEW-FINDINGS.md` exists with a header row and at least one `F-NN` row. + - The first finding's row has a non-empty evidence cell naming the command that produced it and a non-empty disposition cell. + - Rows exist recording both already-fixed claims with their commit hashes. + - `cd core && cargo test -p archipelago federation` exits 0 (if the first finding was fixed here, its test is included). + - The SUMMARY quotes the `git log -p` output excerpt that decided the first finding. + + The findings document exists and one finding has travelled the full path from claim to evidence to disposition. + + + + Task 2: Complete the review across both areas and disposition every finding + .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md, core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/fips/dial.rs, core/archipelago/src/mesh/mod.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs + + - `core/archipelago/src/federation/` — `storage.rs`, `sync.rs`, `types.rs`, `invites.rs`, `mod.rs` + as left by plans 01-01, 01-05, and 01-06. + - `core/archipelago/src/api/rpc/federation/handlers.rs` — the full RPC surface, including the + peer-joined, peer-did-changed, and peer-address-changed signature-verification paths. + - `core/archipelago/src/fips/dial.rs` and the transport dial/fallback path — the FIPS-to-Tor + fast-fail behaviour FED-03 names as in scope. + - `core/archipelago/src/mesh/mod.rs` — `purge_federation_peer`, `upsert_federation_peer`, + `seed_federation_peers_into_mesh`; and `core/archipelago/src/api/rpc/mesh/typed_messages.rs` + as left by plans 01-04 and 01-07. + - `.planning/codebase/CONCERNS.md` — every remaining federation and mesh entry. + - `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` — the mock-parity residual + class that plan flagged as a candidate finding for this review. + - `.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md` — the scope fence: do not touch + federation trust or join cryptography beyond what removal and sync correctness require, and no + data-destroying migrations. + + + Review each area in turn and add its findings to the document. For each codebase-concerns claim, + do the fresh read plus `git log -p` on the cited range BEFORE filing or dismissing it, and put + that evidence in the row — a finding that merely restates a concerns bullet without fresh + evidence is not admissible. + + Areas to cover, each of which must appear in the document even when it produced no findings — + record those as reviewed with none rather than omitting them: federation store, federation sync, + federation RPC surface, FIPS and transport dial, mesh core, mesh RPC surface. + + Required specific checks, each of which becomes a row: + - The mock-parity residual class flagged in the plan 01-02 SUMMARY (a mock case that exists but + returns a differently-shaped success object than the daemon). + - Whether the paid-tick grep from plan 01-03 still finds exactly the two surfaces it found at + planning time, or whether a third has appeared. + - The unbounded-curl concern: confirm it belongs to the multinode test harness and defer it to + the phase that owns that requirement, with that phase named in the reason. + - The node-list dedup scaling note: disposition it with the peer counts this fleet actually runs. + - The mesh radio configuration boot race: confirm against current code and defer if it needs real + LoRa hardware, naming that as the reason. + + Fix findings that are contained — a bounded change inside the reviewed area with a test — and + commit each as its own focused commit per CLAUDE.md. Defer anything that would breach the + CONTEXT.md scope fence, require hardware this session lacks, or belong to another phase, and write + the reason and the owner in the row. Every row ends with exactly one disposition. + + Finish with a short summary section stating the counts: findings filed, fixed, already-fixed, and + deferred; and a line stating that the counts sum to the number of rows. + + + cd core && cargo test -p archipelago && cd ../neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs + + + - `cd core && cargo test -p archipelago` exits 0. + - `cd neode-ui && npx vitest run` exits 0 and `node scripts/mock-rpc-parity.mjs` exits 0. + - Every row in `01-REVIEW-FINDINGS.md` has a non-empty disposition cell — verify by counting rows and counting non-empty disposition cells and asserting the two numbers match; record both numbers in the SUMMARY. + - All six named areas appear in the document. + - The summary section's counts sum to the row count. + - Every `fixed` row cites a commit hash and a verifying command or test name. + - Every `deferred` row has a non-empty reason and names an owning phase or requirement. + + Both areas are reviewed, every finding has exactly one evidenced disposition, and the contained fixes are committed. + + + + Task 3: Build and deploy the phase to the dev pair, sha256-verified + archi-dev-box and x250-dev are reachable over the fleet network — confirm with a bounded connectivity probe to each before starting; if either is unreachable, halt rather than deploying to a partial pair. + .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md + + - `scripts/deploy-to-target.sh` — the established deploy path and the environment variable it + takes for the target. Read it fully before running it; do not hand-roll a deploy. + - `CLAUDE.md` — the build instructions (cargo from `core/`; frontend build outputs to + `web/dist/neode-ui/`; grep the built bundle for new strings because the build can silently + no-op) and the deploy discipline (dev pair before any OTA). + - The project memory note on deploying via service restart while containers are running — confirm + what the deploy script does about restarts before running it, and record the answer. + + + Build the backend from `core/` and the frontend from `neode-ui/`, then grep the built frontend + bundle for a string introduced by this phase to prove the build is not stale. + + Deploy to archi-dev-box and then to x250-dev using the established deploy script, one at a time. + After each, verify the deployed binary's sha256 matches the locally built artifact, and record + both hashes. After each deploy, check that the node's app containers are still running and record + the result — a deploy that takes containers down is a finding, not a success. + + Add a short deployment section to `01-REVIEW-FINDINGS.md` recording: the built artifact hashes, + the two target hostnames, the per-target sha256 match, the container-survival result, and the + frontend bundle grep result. + + Do not deploy to any other fleet node, do not cut a release, and do not publish an OTA — this + phase ends at the dev pair plus the verification in plan 01-10. + + + grep -Eq 'sha256' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md + + + - `cd core && cargo build --release -p archipelago` exits 0 and `cd neode-ui && npm run build` exits 0. + - The built frontend bundle contains a string introduced by this phase — assert with a grep over `web/dist/neode-ui/assets/` for the badge ring class name added in plan 01-03. + - The deployment section records two target hostnames, two sha256 pairs that match, and a container-survival result per target. + - No release tag was created and no OTA manifest was published — confirmed by `git tag --points-at HEAD` producing no output, recorded in the SUMMARY. + + The phase's code is running on both dev-pair nodes, provably the artifact that was built, with containers intact. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| developer workstation → fleet node (deploy) | A built binary crosses onto a live node over SSH | +| review process → codebase | A fix applied during review changes federation trust-adjacent code | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-37 | Tampering | a deployed binary differing from the one built and tested | high | mitigate | Per-target sha256 comparison against the local artifact, both hashes recorded (Task 3) | +| T-01-38 | Denial of Service | a deploy restarting the service and killing running app containers | high | mitigate | The established deploy script is read before use and container survival is checked and recorded per target; a container loss is filed as a finding | +| T-01-39 | Elevation of Privilege | a review fix loosening federation trust or join verification | high | mitigate | CONTEXT.md's scope fence is a required read; findings needing trust-code changes are deferred with the reason rather than patched here; the full test suite gates each fix | +| T-01-40 | Repudiation | a finding quietly dropped so a known issue leaves no trace | medium | mitigate | The row-count-equals-disposition-count check and the summing counts section make an omission detectable; the prohibitions above state the rule | +| T-01-41 | Information Disclosure | deploy credentials or node passwords committed while recording deployment evidence | high | mitigate | The deployment section records hostnames and hashes only; per CLAUDE.md, never commit secrets. Stage by explicit path and review the diff before committing | + + + +- `cd core && cargo test -p archipelago` — green. +- `cd neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs && npm run build` — green. +- `01-REVIEW-FINDINGS.md` row count equals its disposition count, and the summary counts sum to it. +- Both dev-pair nodes report a matching sha256 and surviving containers. + + + +- A findings document covers six named areas, including those with no findings. +- Every finding has exactly one evidenced disposition; fixed rows cite commit and test, deferred rows cite reason and owner. +- Every codebase-concerns federation/mesh claim was re-verified against current code and git history before being filed or dismissed. +- The phase is deployed to the dev pair, sha256-verified, with containers intact and no release cut. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md` when done. +Stage by explicit path, commit each fix separately, and `git push gitea-ai main`. + diff --git a/.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md new file mode 100644 index 00000000..71f8724a --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md @@ -0,0 +1,153 @@ +--- +phase: 01-federation-mesh-hardening +plan: 10 +type: execute +wave: 6 +depends_on: ["01-09"] +files_modified: [] +autonomous: false +requirements: [FED-01, FED-02, FED-05, FED-06] + +must_haves: + truths: + - "An operator who removes a federated peer on a live node does not see it reappear after at least two subsequent sync cycles" + - "A peer whose sync is failing shows the operator-visible sync-error badge on the live node, and the badge clears once that peer syncs successfully" + - "The channel-open flow works against a real node on the dev preview: the own-node URI copies, a trusted federated node opens in one click, a meshed Lightning peer can be sent a request, and a manually pasted URI is accepted" + - "The paid tick renders the branded ring on both payment-success surfaces on the dev preview, at a narrow and a desktop viewport, without clipping" + - "The demo and a real node behave the same through the mesh chat surface — aliasing a peer, reacting, editing, deleting, and sending an attachment produce the same modals and the same outcome on both" + prohibitions: + - statement: "The phase MUST NOT be signed off on demo evidence alone — every criterion in this checkpoint that names a real node is exercised against a real node, because a demo-only pass is exactly the divergence class this phase exists to remove" + category: transparency + artifacts: [] + key_links: [] +--- + + +Consolidate every human-gated verification this phase owes into one sign-off, run against the dev +pair rather than the demo. + +Purpose: `01-VALIDATION.md` lists three manual-only verifications (removal sticking across real sync +cycles, the channel-open flow end to end, and the paid-tick visual), and CONTEXT.md adds the user's +own requirement that FED-05 and FED-06 are verified on the dev preview against archi-dev **before any +deploy**. Rather than interrupting each implementation plan with its own checkpoint, they are gathered +here so the operator is asked once, after the code is on the dev pair. +Output: a recorded sign-off, or a list of issues that becomes the input to a gap-closure pass. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/01-federation-mesh-hardening/01-VALIDATION.md +@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md +@.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md + + +## Artifacts this phase produces + +This plan produces no new symbols. It verifies the artifacts produced by plans 01-01 through 01-09: +the serialized federation store, the sync-error badge, the mesh Lightning identity and request +messages, the federation Lightning URI field, the channel-open picker modal, the branded paid tick, +and the demo RPC parity harness. + + + + + Task 1: Phase 1 consolidated verification on the dev pair + + Phase 1 in full, deployed to archi-dev-box and x250-dev and sha256-verified by plan 01-09: + - Federation node-store writes are serialized behind one lock with an atomic node-list write, so + a removal issued during a sync pass can no longer be undone by that sync. + - One periodic federation sync loop instead of two; per-peer sync failures are persisted and + shown as a badge on the node row, clearing when the peer recovers; out-of-order snapshots can + no longer move a peer's state backwards. + - A new channel-open picker modal reached from the Lightning panel's existing Open Channel + button: your own node's Lightning URI with a copy button, trusted federated nodes listed by + hostname with a one-click open, meshed peers running Lightning listed separately with a + request flow, and a manual URI paste fallback. + - The payment-success tick now uses the screensaver ring with its EQ segments on both the send + modal and the scan modal. + - The demo backend answers every mesh and federation RPC the UI calls, and reactions, edits, + deletes, and peer aliasing change demo state instead of returning a bare acknowledgement. + + + Run these against the dev pair, not the demo, except where a step says demo. + + 1. **Removal sticks (FED-01).** On archi-dev-box, open the Federation view and remove a federated + peer. Wait through at least two auto-sync cycles — the loop runs every 90 seconds, so give it + four minutes — then reload. Expected: the peer is gone and stays gone. Then try removing a peer + that no longer exists (repeat the removal): expected an error message, not a silent success. + + 2. **Sync errors are visible (FED-02).** Make one federated peer unreachable — take its node off + the network, or block it — and wait one sync cycle. Expected: that node's row shows a sync-error + badge, and hovering it shows the error text and when it happened. Bring the peer back and wait + one more cycle. Expected: the badge clears on its own. + + 3. **Channel opening (FED-05).** Open the dev preview pointed at archi-dev and go to the Lightning + channels panel, then click Open Channel. Expected: a full-screen modal (the backdrop covers the + whole window and clicking outside closes it), showing your node's Lightning URI at the top. + Click Copy Lightning URI: expected the label flips to Copied! for about two seconds, and pasting + elsewhere gives the complete URI even though the on-screen text is shortened. Check the trusted + list shows your federated nodes by hostname with their FIPS or Tor badge. Check the meshed + Lightning peers list below it. Click Request Channel on a meshed peer, add a short message, and + send: expected a "request sent" style confirmation that does NOT claim a channel is open or + connected. Click Paste URI Manually, enter something malformed such as text with no at-sign: + expected a format message and no attempt to open. Then paste a valid peer URI: expected the + normal open flow. Finally, double-click Open Channel on a trusted node: expected one open + attempt, with the button disabled while it runs. + + 4. **Paid tick (FED-06).** On the dev preview, trigger a payment success in the send modal and in + the scan modal. Expected: the checkmark now sits inside the screensaver-style ring with the + radiating segment lines, at both a narrow phone width and a desktop width, with nothing cut off + by the edge of the card. The amount and the SENT wording are unchanged. + + 5. **Demo and real node match (FED-04).** On the demo, rename a mesh peer, react to a message, + edit one, delete one, and send a small file attachment. Then do the same on archi-dev. + Expected: the same modals appear in the same situations, the changes are visible in both, and + the browser console shows no "Method not found" errors on either. + + If anything fails, describe what you saw and which numbered step it was — that becomes the gap + list for a closure pass rather than a re-run of the whole phase. + + Type "approved" to sign off Phase 1, or describe the issues by step number. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| operator judgement → phase sign-off | A human verdict gates whether this phase is considered complete | +| live fleet node → operator observation | Verification runs against real nodes carrying real federation trust and real Lightning funds | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-01-42 | Repudiation | signing off on demo evidence while a real node still fails | high | mitigate | Each step names where it runs; the prohibition above forbids demo-only sign-off; step 5 explicitly compares the two | +| T-01-43 | Elevation of Privilege | a removed peer regaining federation membership unnoticed because the check was too short | high | mitigate | Step 1 requires waiting at least two sync cycles at the 90-second interval, stated as a wall-clock duration rather than "a while" | +| T-01-44 | Denial of Service | the verification itself taking a live node off the network and leaving it that way | medium | mitigate | Step 2 restores the peer as part of the step and requires observing the badge clear, so the node cannot be left isolated as a side effect | +| T-01-45 | Spoofing | a channel opened against a peer-advertised URI during verification sending funds to the wrong node | high | mitigate | Step 3's request path targets a meshed peer with a request, not an open; the one-click open is exercised only against a bilaterally-trusted federated node the operator already federated with | + + + +The operator's response is the verification. An "approved" response completes the phase; any +described issue is captured verbatim in the SUMMARY as a gap for `/gsd-plan-phase 1 --gaps`. + + + +- All five numbered checks were exercised, each in the place it names. +- The operator either approved or produced a numbered issue list. +- The outcome is recorded in the SUMMARY, including which node each check ran against. + + + +Create `.planning/phases/01-federation-mesh-hardening/01-10-SUMMARY.md` when done, recording the +verdict, the node each check ran against, and any issue text verbatim. +