Compare commits
5 Commits
demo-build
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2278ad518 | ||
|
|
e52f458c12 | ||
|
|
bf96378d67 | ||
|
|
db25545a9a | ||
|
|
2e54c3e40c |
@ -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)
|
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
|
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
|
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
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 2: UI Performance
|
### 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 |
|
| 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 | - |
|
| 2. UI Performance | 0/TBD | Not started | - |
|
||||||
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
|
| 3. Multinode Verification Pass | 0/TBD | Not started | - |
|
||||||
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
|
| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - |
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: '1.0'
|
gsd_state_version: 1.0
|
||||||
|
milestone: v1.8.0
|
||||||
|
milestone_name: milestone
|
||||||
|
current_phase: 1
|
||||||
|
current_phase_name: Federation & Mesh Hardening
|
||||||
status: planning
|
status: planning
|
||||||
|
stopped_at: Phase 1 planned (10 plans, checker-approved)
|
||||||
|
last_updated: "2026-07-29T16:20:37.730Z"
|
||||||
|
last_activity: 2026-07-29
|
||||||
|
last_activity_desc: "Completed quick task 260729-fw7: mesh hop graphic redesign (branded HopVizModal, vertical mobile layout)"
|
||||||
progress:
|
progress:
|
||||||
total_phases: 8
|
total_phases: 8
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
total_plans: 0
|
total_plans: 10
|
||||||
completed_plans: 0
|
completed_plans: 0
|
||||||
percent: 0
|
percent: 0
|
||||||
---
|
---
|
||||||
@ -30,6 +38,7 @@ Progress: [░░░░░░░░░░] 0%
|
|||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 0
|
- Total plans completed: 0
|
||||||
- Average duration: —
|
- Average duration: —
|
||||||
- Total execution time: —
|
- Total execution time: —
|
||||||
@ -87,6 +96,6 @@ None yet.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-07-29
|
Last session: 2026-07-29T16:20:37.711Z
|
||||||
Stopped at: Roadmap + state initialized from ingest; next step is `/gsd-plan-phase 1`
|
Stopped at: Phase 1 planned (10 plans, checker-approved)
|
||||||
Resume file: None
|
Resume file: .planning/phases/01-federation-mesh-hardening/01-01-PLAN.md
|
||||||
|
|||||||
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-04", "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>
|
||||||
160
.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md
Normal file
160
.planning/phases/01-federation-mesh-hardening/01-10-PLAN.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
6. **Single-node gate stays green (CLAUDE.md mandate).** Phase 1 modified daemon internals
|
||||||
|
(`federation/storage.rs`, `server.rs` — a periodic loop was removed), which falls under the
|
||||||
|
"re-run the gate after orchestrator/lifecycle changes" rule. Run `tests/lifecycle/run-gate.sh`
|
||||||
|
ON a dev-pair node (gate runs on-node, never via RPC). Expected: green, 0 not-ok. A full 5×
|
||||||
|
run on .228 is NOT required here (that is Phase 3's multinode criterion) — one clean pass on
|
||||||
|
the dev pair is the insurance this checkpoint needs.
|
||||||
|
|
||||||
|
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>
|
||||||
@ -117,6 +117,7 @@ Applicable state considerations resolved: 13 covered, 3 backstop, 0 unresolved.
|
|||||||
| error | manual-URI-paste form | 🧪 backstop | Invalid-format message ("Peer URI must be `pubkey@host:port`") is specified but no explicit format-validation test is scoped yet — planner should add one, do not silently skip client-side validation before calling `lnd.openchannel` |
|
| error | manual-URI-paste form | 🧪 backstop | Invalid-format message ("Peer URI must be `pubkey@host:port`") is specified but no explicit format-validation test is scoped yet — planner should add one, do not silently skip client-side validation before calling `lnd.openchannel` |
|
||||||
| long-text | manual-URI-paste form | ✅ covered | Same truncation/tooltip treatment as the own-node URI display |
|
| long-text | manual-URI-paste form | ✅ covered | Same truncation/tooltip treatment as the own-node URI display |
|
||||||
| unclassified | request-to-open-channel flow | ✅ covered (dismissed) | Reuses `PeerRequestModal.vue` verbatim (message field, Send Request/Sending states) — its own state coverage predates this phase and is not re-specified here |
|
| unclassified | request-to-open-channel flow | ✅ covered (dismissed) | Reuses `PeerRequestModal.vue` verbatim (message field, Send Request/Sending states) — its own state coverage predates this phase and is not re-specified here |
|
||||||
|
| long-text | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered (dismissed) | The ring itself renders no text content (pure SVG/CSS segments); the 48px sats amount inside it is inherited text explicitly out of this contract (see Typography inherited note) |
|
||||||
| overflow | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered | New `badge` `ScreensaverRing` size variant (see below) is explicitly sized to fit inside the modal's `max-h-[90vh] overflow-y-auto` card without clipping — do not drop in the existing `compact` (240–320px) variant unscaled |
|
| overflow | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered | New `badge` `ScreensaverRing` size variant (see below) is explicitly sized to fit inside the modal's `max-h-[90vh] overflow-y-auto` card without clipping — do not drop in the existing `compact` (240–320px) variant unscaled |
|
||||||
| static-content (motion) | paid-tick ring, all `ScreensaverRing` size variants | 🧪 backstop | `ScreensaverRing.vue`'s `segment-pulse` animation currently has **no** `prefers-reduced-motion` guard anywhere (a real gap — confirmed by reading the component; contrast with `SendBitcoinModal.vue`'s existing `.burst-ring`, which already has one). This phase must add `@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } }` inside `ScreensaverRing.vue` itself so the guard applies to every size variant (including the new `badge` one), matching the site-wide reduced-motion convention. No existing automated test covers this — flag for planner as a Wave 0 test gap. |
|
| static-content (motion) | paid-tick ring, all `ScreensaverRing` size variants | 🧪 backstop | `ScreensaverRing.vue`'s `segment-pulse` animation currently has **no** `prefers-reduced-motion` guard anywhere (a real gap — confirmed by reading the component; contrast with `SendBitcoinModal.vue`'s existing `.burst-ring`, which already has one). This phase must add `@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } }` inside `ScreensaverRing.vue` itself so the guard applies to every size variant (including the new `badge` one), matching the site-wide reduced-motion convention. No existing automated test covers this — flag for planner as a Wave 0 test gap. |
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,348 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260729-gjd
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- neode-ui/docker/nginx-demo.conf
|
||||||
|
- neode-ui/docker/indee-demo-signin.js
|
||||||
|
- neode-ui/Dockerfile.web
|
||||||
|
- docker-compose.demo.yml
|
||||||
|
- demo-deploy/docker-compose.yml
|
||||||
|
- neode-ui/src/composables/useDemoIntro.ts
|
||||||
|
- neode-ui/src/views/appSession/useAppIdentity.ts
|
||||||
|
- neode-ui/mock-backend.js
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260729-GJD]
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A fresh demo session (clean browser, no localStorage) shows IndeeHub as an installed, running app in My Apps"
|
||||||
|
- "Launching IndeeHub in the demo renders the real indee.tx1138.com site inside the in-app iframe session (no new tab, no external interstitial)"
|
||||||
|
- "The embedded IndeeHub boots signed-in (active demo account visible, no login wall) and no identity-picker modal blocks the demo visitor"
|
||||||
|
- "The non-demo (real node) build is byte-for-byte unaffected in behavior: indeedhub launch, identity picker, and NIP-07 bridge all work as before"
|
||||||
|
- "The served demo content contains no occurrence of the private release-server IP (existing Docker build guards still pass)"
|
||||||
|
artifacts:
|
||||||
|
- "neode-ui/docker/nginx-demo.conf — new whole-origin reverse-proxy server block (port 2101) for indee.tx1138.com with framing headers stripped and sign-in script injected"
|
||||||
|
- "neode-ui/docker/indee-demo-signin.js — demo-only localStorage seeding script with a labelled throwaway demo nsec"
|
||||||
|
- "docker-compose.demo.yml and demo-deploy/docker-compose.yml — publish the new 2101 port"
|
||||||
|
- "neode-ui/src/composables/useDemoIntro.ts — indeedhub moved from external-tab to iframe launch via the :2101 proxy origin"
|
||||||
|
- "neode-ui/mock-backend.js — indeedhub present in staticDevApps as installed/running"
|
||||||
|
key_links:
|
||||||
|
- "demoAppUrl('indeedhub') → http://<demo-host>:2101/ → nginx :2101 server block → https://indee.tx1138.com upstream"
|
||||||
|
- "nginx sub_filter → /__demo/indee-demo-signin.js → seeds indeedhub-accounts + indeedhub-active-account localStorage keys → IndeeHub boot-restore logs the visitor in"
|
||||||
|
- "staticDevApps['indeedhub'] → structuredClone into per-session package-data → My Apps grid on fresh session"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Make IndeeHub a first-class app in the PUBLIC DEMO only: (1) the real site
|
||||||
|
https://indee.tx1138.com/ renders inside the in-app iframe session (today it is
|
||||||
|
frame-busted by `X-Frame-Options: SAMEORIGIN` and opens externally), (2) a demo
|
||||||
|
visitor sees IndeeHub already signed in with a throwaway demo Nostr identity
|
||||||
|
(no login wall, no identity-picker modal), and (3) IndeeHub appears as an
|
||||||
|
already-installed, running app on a completely fresh demo session.
|
||||||
|
|
||||||
|
Purpose: the demo currently punts IndeeHub to a new tab with a login wall —
|
||||||
|
the flagship media app looks broken/hostile to demo visitors.
|
||||||
|
Output: demo-scoped changes across nginx-demo.conf, a new sign-in seed script,
|
||||||
|
the two demo compose files, useDemoIntro.ts, useAppIdentity.ts, mock-backend.js.
|
||||||
|
|
||||||
|
All behavior changes are gated behind IS_DEMO / demo-image build paths. The
|
||||||
|
real-node build must be completely unaffected.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@CLAUDE.md
|
||||||
|
@neode-ui/src/composables/useDemoIntro.ts
|
||||||
|
@neode-ui/docker/nginx-demo.conf
|
||||||
|
@neode-ui/Dockerfile.web
|
||||||
|
@neode-ui/Dockerfile.backend
|
||||||
|
@docker-compose.demo.yml
|
||||||
|
@demo-deploy/docker-compose.yml
|
||||||
|
@neode-ui/src/views/appSession/appSessionConfig.ts
|
||||||
|
@neode-ui/src/views/appSession/useAppIdentity.ts
|
||||||
|
@neode-ui/src/views/appSession/useNostrBridge.ts
|
||||||
|
@neode-ui/src/stores/appLauncher.ts
|
||||||
|
@neode-ui/mock-backend.js
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<verified_findings>
|
||||||
|
Facts confirmed by inspection on 2026-07-29 (do not re-derive, but re-verify
|
||||||
|
the live-bundle details marked "verify at exec time"):
|
||||||
|
|
||||||
|
- `curl -sI https://indee.tx1138.com/` → `X-Frame-Options: SAMEORIGIN`, no
|
||||||
|
Content-Security-Policy header today (strip both defensively).
|
||||||
|
- The live index.html loads a hashed module bundle from absolute-root paths
|
||||||
|
(`/assets/index-*.js`, `/icons/...`, `/manifest.json`). This is why the old
|
||||||
|
`/app/indeedhub/` path-prefix + sub_filter proxy broke (asset/router paths
|
||||||
|
escape the prefix). A WHOLE-ORIGIN proxy on a dedicated port has no such
|
||||||
|
problem — the SPA sees itself at `/` and every relative call just works.
|
||||||
|
- The live bundle (assets/index-BMWtjRCn.js) uses applesauce-accounts:
|
||||||
|
- localStorage key `"indeedhub-accounts"` = JSON array of serialized
|
||||||
|
accounts (`Fe.toJSON`/`Fe.fromJSON`), restored on boot before UI renders.
|
||||||
|
- localStorage key `"indeedhub-active-account"` = active account id.
|
||||||
|
- A private-key account class exists whose `fromJSON` does
|
||||||
|
`const t=Vn(e.signer.key); new mr(e.pubkey, new ai(t))` — i.e. shape is
|
||||||
|
`{ id, type: "<verify at exec time>", pubkey, signer: { key: "<hex sk>" } }`
|
||||||
|
plus common fields from `loadCommonFields` (verify exact `type` string and
|
||||||
|
common fields by grepping the live bundle for `static type` / the class's
|
||||||
|
`toJSON`). An `"extension"` account type also exists (fallback path).
|
||||||
|
- The bundle expects NIP-07 as a real `window.nostr` object ("Signer
|
||||||
|
extension missing" guard) — it does NOT contain the archipelago
|
||||||
|
`nostr-request` postMessage client.
|
||||||
|
- Parent-side NIP-07 plumbing already exists: `AppSession.vue` line ~423
|
||||||
|
routes `nostr-request` messages to `useNostrBridge`, which calls
|
||||||
|
`node.nostr-pubkey` (mocked in mock-backend.js) and `node.nostr-sign`
|
||||||
|
(NOT implemented in mock-backend.js). Only needed for the fallback approach.
|
||||||
|
- `useAppIdentity.ts`: `isIdentityAwareApp('indeedhub')` is true → on iframe
|
||||||
|
load with no stored identity it opens the identity-picker modal. In the demo
|
||||||
|
this is a blocking modal the visitor shouldn't see.
|
||||||
|
- `appLauncher.ts openSession`: `IS_DEMO && isDemoExternal(appId)` is the only
|
||||||
|
thing forcing indeedhub external; `NEW_TAB_APP_IDS` is already bypassed when
|
||||||
|
`IS_DEMO && isDemoApp(appId)`. `AppSession.vue mustOpenNewTab` has the same
|
||||||
|
two-clause shape. Removing indeedhub from `DEMO_EXTERNAL_URLS` while keeping
|
||||||
|
`isDemoApp('indeedhub')` true flips it to the iframe path everywhere.
|
||||||
|
- `mock-backend.js`: per-visitor session state is initialized via
|
||||||
|
`md['package-data'] = structuredClone(staticDevApps)` (~line 5493), so
|
||||||
|
adding an entry to `staticDevApps` (~line 828) makes it installed on every
|
||||||
|
fresh session. `APP_PORTS`-style map at ~line 323 already has
|
||||||
|
`'indeedhub': 8190`; an icon exists at `/assets/img/app-icons/indeedhub.png`.
|
||||||
|
- `Dockerfile.web` copies `nginx-demo.conf` to `/etc/nginx/nginx.conf.template`
|
||||||
|
and runs `docker-entrypoint-custom.sh` (env substitution) — read the
|
||||||
|
entrypoint before editing so the new server block's nginx `$vars` survive
|
||||||
|
templating the same way the existing blocks' do.
|
||||||
|
- Both Docker builds already scrub + fail on any occurrence of the private
|
||||||
|
release-server IP; nothing in this change may hardcode host IPs — build the
|
||||||
|
iframe URL from `window.location.hostname`.
|
||||||
|
- `indeedhub/` at repo root is a git submodule (not checked out) — NEVER stage
|
||||||
|
any path under it. `indeedhub-demo/` is a prior standalone-build attempt
|
||||||
|
(clones the GitHub fork, builds with VITE env); this plan supersedes it by
|
||||||
|
proxying the LIVE site instead — leave that directory untouched.
|
||||||
|
</verified_findings>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: nginx whole-origin proxy on :2101 + sign-in seed script + compose ports</name>
|
||||||
|
<files>neode-ui/docker/nginx-demo.conf, neode-ui/docker/indee-demo-signin.js, neode-ui/Dockerfile.web, docker-compose.demo.yml, demo-deploy/docker-compose.yml, neode-ui/docker/docker-entrypoint.sh</files>
|
||||||
|
<action>
|
||||||
|
Add a second `server` block to nginx-demo.conf: `listen 2101;` that is a
|
||||||
|
pure whole-origin reverse proxy of `https://indee.tx1138.com` — no path
|
||||||
|
prefix, no URL rewriting (this is the fix for the documented sub_filter
|
||||||
|
breakage: the SPA keeps its own absolute-root paths). In that block:
|
||||||
|
`location / { proxy_pass https://indee.tx1138.com; }` with
|
||||||
|
`proxy_ssl_server_name on;`, `proxy_ssl_name indee.tx1138.com;`,
|
||||||
|
`proxy_set_header Host indee.tx1138.com;`,
|
||||||
|
`proxy_http_version 1.1;` + WebSocket upgrade headers (reuse the existing
|
||||||
|
`$connection_upgrade` map), `proxy_hide_header X-Frame-Options;` and
|
||||||
|
`proxy_hide_header Content-Security-Policy;`. For HTML injection:
|
||||||
|
`proxy_set_header Accept-Encoding "";` (upstream must not gzip or
|
||||||
|
sub_filter no-ops), `sub_filter_types text/html;`, `sub_filter_once on;`,
|
||||||
|
`sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';`
|
||||||
|
— a classic (non-module) script injected at end of head still executes
|
||||||
|
BEFORE the SPA's deferred module bundle, which is what the seeding needs.
|
||||||
|
Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }`
|
||||||
|
(or alias) inside the 2101 server so the seed script is served same-origin
|
||||||
|
to the iframe. Update the comment block that currently explains why
|
||||||
|
IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design.
|
||||||
|
|
||||||
|
Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic
|
||||||
|
script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and
|
||||||
|
that the embedded key is a freshly generated THROWAWAY demo identity, not
|
||||||
|
a real secret. Generate ONE fresh secp256k1 keypair at implementation time
|
||||||
|
(e.g. `node -e` with a tiny script using any available schnorr/secp lib, or
|
||||||
|
a one-off `npx` of nostr-tools in the scratchpad — the generator itself is
|
||||||
|
not committed) and embed hex sk + hex pk as constants. The script: if
|
||||||
|
`localStorage.getItem('indeedhub-accounts')` is empty/absent, write the
|
||||||
|
two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array
|
||||||
|
with ONE serialized private-key account: verify the exact `type` string
|
||||||
|
and common-field shape against the live bundle per verified_findings, shape
|
||||||
|
`{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields`
|
||||||
|
round-trips, give it a friendly name/metadata like "Archy Demo" if the
|
||||||
|
shape supports it) and `indeedhub-active-account` (that account's id).
|
||||||
|
Because the script runs on the :2101 origin inside the iframe, this
|
||||||
|
touches only the proxied app's isolated storage. IndeeHub then restores
|
||||||
|
the account on boot and self-signs with its own bundled signer — no
|
||||||
|
window.nostr and no parent bridge required. Do NOT define a partial
|
||||||
|
`window.nostr` in this approach (a pubkey-only shim with a broken
|
||||||
|
signEvent causes worse failures than no shim).
|
||||||
|
|
||||||
|
FALLBACK (only if live testing in Task-3 verification shows the seeded
|
||||||
|
account shape is not accepted): seed an `"extension"`-type account
|
||||||
|
instead, define a `window.nostr` postMessage client in this same script
|
||||||
|
(request/response protocol matching useNostrBridge: post
|
||||||
|
`{type:'nostr-request', id, method, params}` to `window.parent`, resolve on
|
||||||
|
`{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` /
|
||||||
|
`identity.nostr-sign` in mock-backend.js with real schnorr signatures over
|
||||||
|
the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is
|
||||||
|
pure JS and Dockerfile.backend runs `npm install` over package.json).
|
||||||
|
Prefer the primary approach; only fall back with evidence.
|
||||||
|
|
||||||
|
Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is
|
||||||
|
already inside `neode-ui/` so the existing `COPY neode-ui/ ./` +
|
||||||
|
dist copy do NOT ship it — add an explicit
|
||||||
|
`COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js`
|
||||||
|
in the nginx stage of Dockerfile.web; it lands only in the demo web image,
|
||||||
|
never in real-node artifacts). Publish the port in docker-compose.demo.yml
|
||||||
|
(`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an
|
||||||
|
env-overridable mapping consistent with its existing `DEMO_WEB_PORT`
|
||||||
|
style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that
|
||||||
|
file's header comment). Read docker-entrypoint.sh first and make sure the
|
||||||
|
new server block survives its template substitution exactly like the
|
||||||
|
existing blocks (same escaping convention for nginx `$` variables); touch
|
||||||
|
the entrypoint only if its substitution list needs it.
|
||||||
|
|
||||||
|
Do not put any host IP in any of these files; upstream hostname
|
||||||
|
indee.tx1138.com is fine.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js</automated>
|
||||||
|
</verify>
|
||||||
|
<done>nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall</name>
|
||||||
|
<files>neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts</files>
|
||||||
|
<action>
|
||||||
|
In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete
|
||||||
|
the map entirely if it becomes empty, simplifying `isDemoExternal` to
|
||||||
|
return false — keep the exported function so call sites in appLauncher.ts
|
||||||
|
and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')`
|
||||||
|
return the proxied origin built at runtime:
|
||||||
|
`${window.location.protocol}//${window.location.hostname}:2101/`
|
||||||
|
(hostname, never a hardcoded host/IP — works on any deploy host). Keep
|
||||||
|
`isDemoApp('indeedhub')` true (it must stay in the demoable set so the
|
||||||
|
NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab
|
||||||
|
keeps routing it into the in-app iframe session, and so the install
|
||||||
|
button stays enabled). Update the file-header comment block that
|
||||||
|
currently documents the external-tab workaround to describe the :2101
|
||||||
|
whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA)
|
||||||
|
but guard `typeof window !== 'undefined'` if other tests import the module
|
||||||
|
in node context — check the existing unit tests under
|
||||||
|
src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions
|
||||||
|
about indeedhub being demo-external and update them to the new behavior.
|
||||||
|
|
||||||
|
In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from
|
||||||
|
useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`,
|
||||||
|
when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor
|
||||||
|
must not be interrupted by an identity modal (the embedded IndeeHub is
|
||||||
|
already signed in via the seeded account from Task 1, and `sendIdentity`'s
|
||||||
|
`identity.sign` RPC is not what logs it in). Real-node behavior
|
||||||
|
(picker on first launch) is untouched because IS_DEMO is compile-time
|
||||||
|
false there.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions</name>
|
||||||
|
<files>neode-ui/mock-backend.js</files>
|
||||||
|
<action>
|
||||||
|
Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the
|
||||||
|
existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub`
|
||||||
|
(match the existing title map at ~line 537 and APP_TITLES), a short/long
|
||||||
|
description consistent with the marketplace copy ("Bitcoin documentary
|
||||||
|
streaming platform" per the existing entry), `state: 'running'`,
|
||||||
|
`lanPort: 8190` (matches the existing port map), icon
|
||||||
|
`/assets/img/app-icons/indeedhub.png`. Because per-session demo state is
|
||||||
|
`structuredClone(staticDevApps)`, this alone makes it installed+running on
|
||||||
|
every fresh session. Then reconcile the rest of the mock so nothing
|
||||||
|
contradicts installed status: check the marketplace/available-apps mock
|
||||||
|
responses and any install/uninstall handlers (~lines 540-740, 1900-1960,
|
||||||
|
4900+) for `indeedhub` entries that would render it as not-installed or
|
||||||
|
double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub
|
||||||
|
placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the
|
||||||
|
iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey`
|
||||||
|
mock as-is unless Task 1's fallback path was taken (in which case align
|
||||||
|
its pubkey with the throwaway demo key and add the sign handlers described
|
||||||
|
there).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine)</automated>
|
||||||
|
</verify>
|
||||||
|
<done>A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host |
|
||||||
|
| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path |
|
||||||
|
| public visitors → demo host | anyone can drive the proxy |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" |
|
||||||
|
| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop |
|
||||||
|
| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy |
|
||||||
|
| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config |
|
||||||
|
| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Local (executor, before commit):
|
||||||
|
1. nginx config parses (Task 1 verify).
|
||||||
|
2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build
|
||||||
|
unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of
|
||||||
|
the plain build — that is EXPECTED; the bundle-grep for demo strings must be
|
||||||
|
done on the VITE_DEMO=1 build, which is exactly what the demo Docker image
|
||||||
|
builds (Dockerfile.web defaults ARG VITE_DEMO=1).
|
||||||
|
3. Fresh-session mock package-data includes indeedhub (Task 3 verify).
|
||||||
|
4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up
|
||||||
|
--build` locally, browse http://localhost:2100 in a private window →
|
||||||
|
login `entertoexit` → IndeeHub installed → launch → iframe renders the
|
||||||
|
proxied site from http://localhost:2101 with a signed-in account.
|
||||||
|
5. `git status` — confirm nothing under indeedhub/ is staged, ever.
|
||||||
|
|
||||||
|
Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100):
|
||||||
|
1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options
|
||||||
|
header and the injected `indee-demo-signin.js` tag in the HTML body
|
||||||
|
(`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the
|
||||||
|
port is unreachable, the vps2 firewall needs 2101 opened — flag to
|
||||||
|
orchestrator.
|
||||||
|
2. Fresh private browser window → :2100 → login → IndeeHub shows installed/
|
||||||
|
running on the dashboard/My Apps without any install action.
|
||||||
|
3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a
|
||||||
|
new tab; content browsable; no identity-picker modal.
|
||||||
|
4. Signed-in check: IndeeHub header shows an active account (avatar/profile
|
||||||
|
instead of a sign-in button). If the seeded account shape was rejected
|
||||||
|
(login wall still visible), execute the documented fallback (extension
|
||||||
|
account + window.nostr shim + mock signer) and redeploy.
|
||||||
|
5. View-source/network spot-check: no occurrence of the private
|
||||||
|
release-server IP in any served response.
|
||||||
|
6. Repeat-visit check: reload the iframe once — a service worker registered by
|
||||||
|
IndeeHub may serve cached HTML without the injected tag on later loads;
|
||||||
|
that is acceptable because localStorage is already seeded on first load,
|
||||||
|
but confirm sign-in persists.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Demo visitor on a fresh browser sees IndeeHub installed, launches it into
|
||||||
|
the in-app iframe, and browses indee.tx1138.com content signed in — zero
|
||||||
|
clicks spent on install/login/identity modals.
|
||||||
|
- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated).
|
||||||
|
- No secrets committed beyond the labelled throwaway demo key; nothing staged
|
||||||
|
under indeedhub/; demo serves no private release-server IP.
|
||||||
|
- Work committed in focused commits (infra / frontend / mock) with the
|
||||||
|
Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to
|
||||||
|
the orchestrator.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
Loading…
x
Reference in New Issue
Block a user