docs(01): create Phase 1 federation & mesh hardening plans (10 plans, 6 waves)
This commit is contained in:
parent
db25545a9a
commit
bf96378d67
@ -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 | - |
|
||||
|
||||
253
.planning/phases/01-federation-mesh-hardening/01-01-PLAN.md
Normal file
253
.planning/phases/01-federation-mesh-hardening/01-01-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a removal survives an in-flight sync (lock + atomic write)</name>
|
||||
<reversibility rating="reversible">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.</reversibility>
|
||||
<files>core/archipelago/src/federation/storage.rs</files>
|
||||
<read_first>
|
||||
- `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)".
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `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.
|
||||
</behavior>
|
||||
<action>
|
||||
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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation::storage</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Bring every remaining federation mutator under the lock + surface tombstone-write failure</name>
|
||||
<files>core/archipelago/src/federation/storage.rs</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>Every federation node-store mutator is serialized; a tombstone-write failure is proven to surface as an error with no half-applied removal.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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`.
|
||||
</output>
|
||||
301
.planning/phases/01-federation-mesh-hardening/01-02-PLAN.md
Normal file
301
.planning/phases/01-federation-mesh-hardening/01-02-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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 |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end — alias a mesh peer on the demo and it sticks, proven by a parity harness</name>
|
||||
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs, neode-ui/package.json</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.<verb>'` / `'federation.<verb>'` string
|
||||
literal, scan `mock-backend.js` for every `case '<method>':`, 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>Peer aliasing works end-to-end on the demo and a single command proves it, with the remaining method gaps enumerated by name.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Close the remaining ten UI-called methods with no mock handler</name>
|
||||
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs</files>
|
||||
<read_first>
|
||||
- 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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 '<method>':` 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.
|
||||
</acceptance_criteria>
|
||||
<done>The demo answers every mesh and federation RPC the UI calls, and the parity harness is proven to fail when it does not.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Make the message-mutation stubs mutate demo state</name>
|
||||
<files>neode-ui/mock-backend.js, neode-ui/scripts/mock-rpc-parity.mjs</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && node --check mock-backend.js && node scripts/mock-rpc-parity.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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).
|
||||
</acceptance_criteria>
|
||||
<done>Reactions, replies, edits, deletes, and forwards render on the demo exactly as on a real node, proven by the live harness.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## 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.
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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`.
|
||||
</output>
|
||||
246
.planning/phases/01-federation-mesh-hardening/01-03-PLAN.md
Normal file
246
.planning/phases/01-federation-mesh-hardening/01-03-PLAN.md
Normal file
@ -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 <ScreensaverRing size=\"badge\" /> 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 <ScreensaverRing size=\"badge\" /> in place of the plain circle"
|
||||
pattern: "ScreensaverRing"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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 |
|
||||
|
||||
<!-- planner-discipline-allow: burst-ring -->
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — the badge ring variant renders as the payment-success tick</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `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.
|
||||
</behavior>
|
||||
<action>
|
||||
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 `<ScreensaverRing size="badge" />`, 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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).
|
||||
</acceptance_criteria>
|
||||
<done>The badge variant exists, the send-payment success tick renders it, and both are pinned by tests that failed before the change.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Bring the scan-modal paid tick to the same ring</name>
|
||||
<files>neode-ui/src/components/WalletScanModal.vue, neode-ui/src/components/__tests__/PaidTick.test.ts</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
Replace WalletScanModal's fixed success circle with the same composition Task 1 established:
|
||||
a `position: relative` container sized to the badge diameter holding `<ScreensaverRing size="badge" />`
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>Both paid-tick surfaces render the identical branded ring, with no clipping at either breakpoint.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## 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.
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md` when done.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
295
.planning/phases/01-federation-mesh-hardening/01-04-PLAN.md
Normal file
295
.planning/phases/01-federation-mesh-hardening/01-04-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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`).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `LndInfo.identity_pubkey: Option<String>` | new response field on `lnd.getinfo` | `core/archipelago/src/api/rpc/lnd/info.rs` |
|
||||
| `LndInfo.uris: Vec<String>` | 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<String>` | 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` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary</name>
|
||||
<reversibility rating="reversible">Two additive optional fields on an internal RPC response; no
|
||||
consumer breaks if they are removed again.</reversibility>
|
||||
<files>core/archipelago/src/api/rpc/lnd/info.rs</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a
|
||||
`serde_json::from_str::<LndGetInfoResponse>(...)` 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<String>` and `uris: Vec<String>` to `LndGetInfoResponse` with
|
||||
`#[serde(default)]`, and the corresponding `identity_pubkey: Option<String>` and
|
||||
`uris: Vec<String>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago lnd::info</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>`lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored</name>
|
||||
<reversibility rating="costly">`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.</reversibility>
|
||||
<files>core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
|
||||
<read_first>
|
||||
- `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<u8>` 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.
|
||||
</read_first>
|
||||
<action>
|
||||
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<String>` with `#[serde(default)]`.
|
||||
Follow the surrounding payload structs' serde conventions.
|
||||
|
||||
Add `#[serde(default)] pub lightning_uri: Option<String>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago mesh::message_types mesh::listener</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Expose the meshed Lightning peers and the send path over RPC</name>
|
||||
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs</files>
|
||||
<read_first>
|
||||
- `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.<verb>" =>
|
||||
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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago mesh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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`.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
303
.planning/phases/01-federation-mesh-hardening/01-05-PLAN.md
Normal file
303
.planning/phases/01-federation-mesh-hardening/01-05-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `FederatedNode.last_sync_error: Option<String>` | new optional field | `core/archipelago/src/federation/types.rs` |
|
||||
| `FederatedNode.last_sync_error_at: Option<String>` | 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` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a failed federation sync becomes visible to the operator</name>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `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.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the Rust tests and the NodeList component test first and confirm they fail.
|
||||
|
||||
Add `#[serde(default)] pub last_sync_error: Option<String>` and
|
||||
`#[serde(default)] pub last_sync_error_at: Option<String>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Collapse the two periodic sync loops into one</name>
|
||||
<reversibility rating="costly">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.</reversibility>
|
||||
<files>core/archipelago/src/server.rs</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo build -p archipelago && cargo test -p archipelago federation</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 <!-- planner-discipline-allow: from_secs(1800) -->
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>One periodic federation sync loop remains, its predecessor's unique behavior preserved, with the history check recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Stop out-of-order snapshots and duplicates from breaking convergence</name>
|
||||
<files>core/archipelago/src/federation/storage.rs, core/archipelago/src/federation/sync.rs</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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`.
|
||||
</acceptance_criteria>
|
||||
<done>Out-of-order sync responses cannot regress a peer's state, and repeated sync cycles converge to one entry per node.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md` when done.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
287
.planning/phases/01-federation-mesh-hardening/01-06-PLAN.md
Normal file
287
.planning/phases/01-federation-mesh-hardening/01-06-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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<String>` | 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` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Task 1: Decide the Lightning field shape and sharing default on the federation sync payload</name>
|
||||
<decision>What shape does the Lightning identity take on the federation sync payload, and is sharing on by default or opt-in?</decision>
|
||||
<context>
|
||||
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.
|
||||
</context>
|
||||
<options>
|
||||
<option id="option-a">
|
||||
<name>Single `lightning_uri` on the snapshot AND on the peer hint, shared by default</name>
|
||||
<pros>Widest picker coverage — a peer-of-a-peer's URI is available without an extra sync hop; simplest single field; matches CONTEXT.md's default-on stance</pros>
|
||||
<cons>Sends a payment endpoint to nodes the operator never federated with, which the plan's own privacy prohibition forbids; hardest to walk back once cached across the fleet</cons>
|
||||
</option>
|
||||
<option id="option-b">
|
||||
<name>Single `lightning_uri` on the snapshot only, shared by default with direct federated peers (CONTEXT.md's stated default, narrowed)</name>
|
||||
<pros>Implements CONTEXT.md's recorded discretion default; bilateral federation trust is already explicit, so no new consent surface is needed; one field, one hop, no transitive leak; ships the picker with real data on day one</pros>
|
||||
<cons>Every existing federated pair starts sharing a payment endpoint on the OTA that carries it, with no per-operator prompt; reversing later means shipping an opt-out and waiting for peers to re-sync</cons>
|
||||
</option>
|
||||
<option id="option-c">
|
||||
<name>Single `lightning_uri` on the snapshot only, gated behind an explicit opt-in setting defaulting off (RESEARCH.md Open Question 3)</name>
|
||||
<pros>Mirrors the proven `shared_location` pattern exactly; no operator starts sharing a payment endpoint without acting; safest given real-money implications; the field itself stays additive so flipping the default later is a one-line change</pros>
|
||||
<cons>The trusted-node picker is empty until both sides opt in, so the FED-05 flow needs a discoverable "turn on Lightning sharing" path or it looks broken; more surface to build in this plan</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>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).</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 2: End-to-end — a trusted peer's Lightning URI reaches federation.list-nodes</name>
|
||||
<reversibility rating="one-way">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.</reversibility>
|
||||
<precondition>`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.</precondition>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `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.
|
||||
</behavior>
|
||||
<action>
|
||||
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<String>`
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>A trusted federated peer's Lightning URI is synced, validated, persisted, and emitted — the picker's primary list now has real targets.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Make the sharing state visible and reversible</name>
|
||||
<files>core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/federation/sync.rs</files>
|
||||
<read_first>
|
||||
- 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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago federation::sync</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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`.
|
||||
</acceptance_criteria>
|
||||
<done>The operator can see, and change, what Lightning identity this node shares — and a peer's URI provably never travels one hop further.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md` when done.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
251
.planning/phases/01-federation-mesh-hardening/01-07-PLAN.md
Normal file
251
.planning/phases/01-federation-mesh-hardening/01-07-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation</name>
|
||||
<reversibility rating="costly">`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.</reversibility>
|
||||
<precondition>`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.</precondition>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `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.
|
||||
</behavior>
|
||||
<action>
|
||||
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<u64>` and
|
||||
`#[serde(default)] message: Option<String>`. 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>A channel-open request travels from an RPC call to a chosen peer's conversation, with no side effect beyond a stored message.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Harden the request path against duplicates, oversize, and misuse</name>
|
||||
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
|
||||
<read_first>
|
||||
- `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).
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago mesh api::rpc::mesh</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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`.
|
||||
</acceptance_criteria>
|
||||
<done>Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
348
.planning/phases/01-federation-mesh-hardening/01-08-PLAN.md
Normal file
348
.planning/phases/01-federation-mesh-hardening/01-08-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — open a channel with a trusted federated node in one click</name>
|
||||
<precondition>`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.</precondition>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
- `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: '<ns>.<verb>', 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.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The meshed Lightning peers list and the Request Channel flow</name>
|
||||
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/mock-backend.js</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>Meshed Lightning peers are listed and requestable, deduplicated against the trusted list, with a single shared empty state and no misleading channel wording.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Share your own URI, and the manual-paste fallback</name>
|
||||
<files>neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-08-SUMMARY.md` when done.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
275
.planning/phases/01-federation-mesh-hardening/01-09-PLAN.md
Normal file
275
.planning/phases/01-federation-mesh-hardening/01-09-PLAN.md
Normal file
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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) |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end — one finding from discovery to closed disposition</name>
|
||||
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</files>
|
||||
<read_first>
|
||||
- `.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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>The findings document exists and one finding has travelled the full path from claim to evidence to disposition.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Complete the review across both areas and disposition every finding</name>
|
||||
<files>.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</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago && cd ../neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>Both areas are reviewed, every finding has exactly one evidenced disposition, and the contained fixes are committed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Build and deploy the phase to the dev pair, sha256-verified</name>
|
||||
<precondition>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.</precondition>
|
||||
<files>.planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</files>
|
||||
<read_first>
|
||||
- `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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -Eq 'sha256' .planning/phases/01-federation-mesh-hardening/01-REVIEW-FINDINGS.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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.
|
||||
</acceptance_criteria>
|
||||
<done>The phase's code is running on both dev-pair nodes, provably the artifact that was built, with containers intact.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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`.
|
||||
</output>
|
||||
153
.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md
Normal file
153
.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md
Normal file
@ -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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
## 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.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 1: Phase 1 consolidated verification on the dev pair</name>
|
||||
<what-built>
|
||||
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.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
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.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to sign off Phase 1, or describe the issues by step number.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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.
|
||||
</output>
|
||||
Loading…
x
Reference in New Issue
Block a user