Archipelago — open-source initial import
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,311 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 11
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/api/rpc/package/config.rs
|
||||
- core/archipelago/src/api/rpc/package/dependencies.rs
|
||||
- scripts/first-boot-containers.sh
|
||||
- scripts/deploy-to-target.sh
|
||||
- scripts/deploy-tailscale.sh
|
||||
- scripts/reconcile-containers.sh
|
||||
- scripts/container-specs.sh
|
||||
autonomous: true
|
||||
requirements: [FED-07]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A fresh Fedimint gateway install derives its admin credential from the per-install secret the manifest declares, so two nodes installed from the same image never share a gateway password (FED-07)"
|
||||
- "No code path configures a gateway container with a credential literal carried in this repository — a missing or unreadable gateway secret makes the install fail loudly instead of quietly starting with a shipped default (FED-07 failure-surfacing)"
|
||||
- "The compromised default hash exists in exactly one place in the tree, as a detection denylist that is never used to configure a container"
|
||||
- "The gateway credential lives under one canonical secret name across the Rust orchestrator, first-boot, reconcile, and both deploy scripts — a node can no longer end up with the daemon reading one file while the scripts wrote another"
|
||||
- "A first boot on a host without htpasswd still produces a unique per-install credential rather than falling back to a shipped one (FED-07 empty edge — the ISO path)"
|
||||
- "Generating the gateway credential twice on the same node is idempotent: the second call leaves the existing value untouched, so a reconcile pass never rotates a working gateway out from under itself (FED-07 adjacency edge)"
|
||||
prohibitions:
|
||||
- statement: "No credential value that grants access to a running service may be committed, printed to a log line, embedded in a container image, or written into an ISO/release artifact — the denylist entry retained for detection is a bcrypt hash of an already-public value and is never passed to a container"
|
||||
category: safety
|
||||
- statement: "Removing the default MUST NOT silently disable the gateway — an install that cannot obtain a per-install credential reports an error naming the missing secret; it never starts an unauthenticated or partially configured gateway instead"
|
||||
category: transparency
|
||||
artifacts:
|
||||
- path: core/archipelago/src/container/secrets.rs
|
||||
provides: "Canonical per-install gateway credential accessor plus the known-default denylist"
|
||||
contains: "KNOWN_DEFAULT_GATEWAY_HASHES"
|
||||
key_links:
|
||||
- from: core/archipelago/src/api/rpc/package/config.rs
|
||||
to: core/archipelago/src/container/secrets.rs
|
||||
via: "the fedimint-gateway spec builder asks container::secrets for the per-install hash and propagates the error instead of substituting a literal"
|
||||
pattern: "gateway_bcrypt_hash"
|
||||
- from: scripts/container-specs.sh
|
||||
to: core/archipelago/src/container/secrets.rs
|
||||
via: "both read the same canonical secret filename, so the shell reconcile path and the daemon agree on one credential"
|
||||
pattern: "fedimint-gateway-hash"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Remove every shipped Fedimint gateway credential from the tree and make each install derive its own,
|
||||
so two nodes flashed from the same ISO never answer to the same gateway password.
|
||||
|
||||
Purpose: FED-07 is a BLOCKER. `apps/fedimint-gateway/manifest.yml` already declares the right thing
|
||||
(`generated_secrets: fedimint-gateway-hash, kind: bcrypt`), and `container::secrets` already
|
||||
materialises it per install at 0600 — but five code paths bypass that and substitute a hash literal
|
||||
committed to this repository when the secret is missing, and one deploy path substitutes a plaintext
|
||||
password literal. Anyone with a copy of this repo holds the admin credential for every gateway that
|
||||
ever took one of those fallbacks. The repo's own standing invariant already forbids this: "Secrets are
|
||||
manifest-declared (`generated_secrets`, materialised by `container::secrets`, 0600/rootless) — never
|
||||
hardcoded, per-app, or logged."
|
||||
Output: one canonical per-install accessor, five fallback sites removed, a detection-only denylist,
|
||||
and tests that fail if a credential literal is ever reintroduced.
|
||||
</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
|
||||
@apps/fedimint-gateway/manifest.yml
|
||||
@core/archipelago/src/container/secrets.rs
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `KNOWN_DEFAULT_GATEWAY_HASHES` | detection-only denylist constant | `core/archipelago/src/container/secrets.rs` |
|
||||
| `gateway_bcrypt_hash(secrets_dir) -> Result<String>` | canonical per-install accessor | same |
|
||||
| `ensure_gateway_credential(secrets_dir) -> Result<()>` | idempotent generator (bcrypt hash + `.pw` sibling) | same |
|
||||
| fallback-free `fedimint-gateway` spec arm | changed match arm | `core/archipelago/src/api/rpc/package/config.rs` |
|
||||
| fallback-free `configure_fedimint_lnd` | changed function | `core/archipelago/src/api/rpc/package/dependencies.rs` |
|
||||
| credential generation without a shipped fallback | changed shell blocks | `scripts/first-boot-containers.sh`, `scripts/reconcile-containers.sh`, `scripts/deploy-to-target.sh`, `scripts/deploy-tailscale.sh` |
|
||||
| canonical secret-name read with an empty guard | changed shell block | `scripts/container-specs.sh` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a gateway spec that cannot be built without a per-install credential</name>
|
||||
<files>core/archipelago/src/container/secrets.rs, core/archipelago/src/api/rpc/package/config.rs, core/archipelago/src/api/rpc/package/dependencies.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/container/secrets.rs` — the whole file (about 225 lines). Note
|
||||
`ensure_one`'s `SecretGenKind::Bcrypt` arm: it already generates a 24-byte random hex password,
|
||||
bcrypt-hashes it, writes the hash to `<name>` and the plaintext to `<name>.pw`, both 0600 via
|
||||
the atomic `write_secret` helper. Note the idempotent fast path and the self-heal branch. This
|
||||
is the behaviour the new accessor must reuse, not reimplement.
|
||||
- `core/archipelago/src/api/rpc/package/config.rs` lines 596-620 (`read_secret`, which takes a
|
||||
`default: &str` — the mechanism that makes a fallback literal possible) and lines 1051-1084
|
||||
(the `"fedimint-gateway"` match arm inside the app-config table, where the hash is read with a
|
||||
literal default and then passed to `--bcrypt-password-hash`).
|
||||
- `core/archipelago/src/api/rpc/package/dependencies.rs` lines 718-769 (`configure_fedimint_lnd`)
|
||||
— the second site, reading the same secret path directly with `unwrap_or_else` onto the same
|
||||
literal, then rebuilding the whole argv in LND mode.
|
||||
- `core/archipelago/src/api/rpc/package/install.rs` lines 583-606 — how `get_app_config` and
|
||||
`configure_fedimint_lnd` are called during install, so you can see what an error from either
|
||||
has to propagate through.
|
||||
- `apps/fedimint-gateway/manifest.yml` — the `generated_secrets` block already declaring
|
||||
`fedimint-gateway-hash` with `kind: bcrypt`, and the `secret_env` mapping `FEDI_HASH` to it.
|
||||
The manifest is already correct; this task makes the non-manifest paths agree with it.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `ensure_gateway_credential` on an empty secrets dir writes both the hash file and its `.pw`
|
||||
sibling, each 0600, and the plaintext verifies against the hash.
|
||||
- Called a second time on the same dir it changes nothing — the hash read back is byte-identical.
|
||||
- `gateway_bcrypt_hash` on a dir with no gateway secret returns `Err`, and the error message names
|
||||
the missing secret file so an operator can act on it.
|
||||
- `gateway_bcrypt_hash` on a dir whose stored hash is a known-default denylist entry returns `Err`
|
||||
rather than handing the compromised value back to a caller.
|
||||
- Two successive fresh generations in two different temp dirs produce two different hashes — the
|
||||
value is per install, not per build.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests in `secrets.rs`'s existing `mod tests` first and confirm they fail.
|
||||
|
||||
In `core/archipelago/src/container/secrets.rs` add three items.
|
||||
|
||||
First, a private denylist constant `KNOWN_DEFAULT_GATEWAY_HASHES: &[&str]` holding the single
|
||||
bcrypt hash currently used as a fallback at `config.rs:1054` (copy it from there verbatim). Give
|
||||
it a doc comment saying it exists only so an install carrying it can be detected and rotated, that
|
||||
it must never be handed to a container, and that plan 01-16 consumes it for the migration. This is
|
||||
the one and only place that value may appear in the tree after this plan.
|
||||
|
||||
Second, `pub fn ensure_gateway_credential(secrets_dir: &Path) -> Result<()>` — a thin wrapper that
|
||||
reuses the existing bcrypt generation path for the `fedimint-gateway-hash` name rather than
|
||||
duplicating it. Factor the `SecretGenKind::Bcrypt` arm of `ensure_one` into a small helper both
|
||||
call so there is exactly one bcrypt-generation implementation; keep `ensure_one`'s existing
|
||||
idempotent fast path and self-heal semantics intact so callers on a reconcile tick never rotate a
|
||||
working credential.
|
||||
|
||||
Third, `pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result<String>` — reads the canonical
|
||||
hash file, trims it, and returns `Err` with a message naming the file path when it is missing,
|
||||
empty, or unreadable. Before returning Ok, compare the trimmed value against the denylist and
|
||||
return `Err` if it matches, with a message saying the install is carrying a publicly known default
|
||||
and pointing at the rotation path.
|
||||
|
||||
In `config.rs`: change the `"fedimint-gateway"` arm to obtain its hash from
|
||||
`container::secrets::gateway_bcrypt_hash`, calling `ensure_gateway_credential` first so a fresh
|
||||
node self-provisions. Because `get_app_config` returns a tuple rather than a `Result`, do not
|
||||
silently swallow the error — surface it the way the surrounding code surfaces other hard install
|
||||
failures (an `Err` return threaded to the caller if the signature already allows it, otherwise a
|
||||
logged error plus an argv the install path rejects; whichever you choose, an install with no
|
||||
credential must not reach `podman run`). Record the choice and its reason in the SUMMARY. Delete
|
||||
the `default` parameter from `read_secret` if no other caller needs it; if other callers do, leave
|
||||
the helper alone and simply stop routing the gateway through it.
|
||||
|
||||
In `dependencies.rs`: `configure_fedimint_lnd` must take the already-resolved hash as a parameter
|
||||
from its caller rather than re-reading the file with its own fallback, so there is one read site
|
||||
and one failure point. Update the `install.rs` call accordingly.
|
||||
|
||||
Do not change the gateway's ports, volumes, data directory, network, capabilities, health check,
|
||||
or any non-credential argv element. This task changes where the credential comes from, nothing
|
||||
else about how the gateway runs.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago secrets 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && cargo test -p archipelago secrets` exits 0 and its output names at least five test cases covering: fresh generation, idempotence, missing-secret error, denylisted-value error, and two dirs producing two different values.
|
||||
- `grep -rl 't9YjjxkiktrlYvjajB' --include='*.rs' core/ | wc -l` equals 1, and that one file is `core/archipelago/src/container/secrets.rs`.
|
||||
- `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/config.rs` equals 0.
|
||||
- `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/dependencies.rs` equals 0.
|
||||
- `grep -v '^\s*//' core/archipelago/src/api/rpc/package/config.rs | grep -c 'gateway_bcrypt_hash'` is at least 1.
|
||||
- `grep -v '^\s*//' core/archipelago/src/container/secrets.rs | grep -c 'KNOWN_DEFAULT_GATEWAY_HASHES'` is at least 2 (the definition and its use in the accessor).
|
||||
- `cd core && cargo build -p archipelago` exits 0.
|
||||
- `cd core && cargo test -p archipelago` exits 0 — no existing suite regressed.
|
||||
- The SUMMARY records how a credential-less install is made to fail and why that mechanism was chosen.
|
||||
</acceptance_criteria>
|
||||
<done>The Rust orchestrator can only configure a gateway with a per-install credential; the compromised literal survives in exactly one detection-only location.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The shell install paths generate their own credential instead of shipping one</name>
|
||||
<files>scripts/first-boot-containers.sh, scripts/reconcile-containers.sh, scripts/deploy-to-target.sh, scripts/deploy-tailscale.sh, scripts/container-specs.sh</files>
|
||||
<precondition>`openssl` is on PATH on this machine (the scripts already rely on it for the other per-install database passwords, so the replacement generator introduces no new host dependency)</precondition>
|
||||
<read_first>
|
||||
- `scripts/first-boot-containers.sh` lines 390-426 — the per-install password loop for
|
||||
mempool/btcpay/mysql-root (the correct pattern: `openssl rand`, write, chmod 600), then the
|
||||
gateway block immediately below it that writes `fedimint-gateway-password`, tries `htpasswd` for
|
||||
the hash, and on a host without `htpasswd` logs a warning and assigns the shipped literal. This
|
||||
is the ISO first-boot path, so this is the site that put the default on real nodes.
|
||||
- `scripts/reconcile-containers.sh` lines 690-710 — the same generate-or-skip block, with the same
|
||||
`htpasswd` dependency and the same two-file naming.
|
||||
- `scripts/deploy-to-target.sh` lines 1224-1262 — the remote generation block, the
|
||||
`FEDI_HASH=` export read back over SSH, and the literal fallback when the read comes back empty.
|
||||
- `scripts/deploy-tailscale.sh` lines 494-513 (generation plus the same literal fallback) and lines
|
||||
770-793 (the container-creation block, where a plaintext password fallback is substituted when
|
||||
the password file cannot be read, and where the argv uses a plaintext password flag rather than
|
||||
the hash flag every other path uses).
|
||||
- `scripts/container-specs.sh` lines 60-72 — the shared spec loader, which reads
|
||||
`fedimint-gateway-hash` (correct name) and escapes `$` so the bcrypt hash survives the
|
||||
`eval` in `reconcile-containers.sh`'s `build_run_cmd`. Preserve that escaping.
|
||||
</read_first>
|
||||
<action>
|
||||
Replace the htpasswd-or-fallback pattern everywhere with generation that has no fallback.
|
||||
|
||||
In `first-boot-containers.sh` and `reconcile-containers.sh`: keep generating the plaintext with
|
||||
`openssl rand`, but when `htpasswd` is unavailable do NOT assign a shipped value. Either compute
|
||||
the bcrypt hash without `htpasswd` (openssl's `passwd` applet does not emit bcrypt, so if you go
|
||||
this route use a hasher the host actually has — verify what is present on a node before choosing)
|
||||
or, if no local hasher exists, leave the hash file absent and let the daemon's
|
||||
`ensure_gateway_credential` from Task 1 materialise it on the next reconcile tick. The second
|
||||
option is preferred: it removes the host dependency entirely and puts generation on the one
|
||||
canonical path. In that case the script must log that the gateway credential will be generated by
|
||||
the daemon, and must not create a half-provisioned pair of files.
|
||||
|
||||
Unify the naming. The manifest and the daemon use `fedimint-gateway-hash` for the hash and
|
||||
`fedimint-gateway-hash.pw` for the plaintext; the scripts use `fedimint-gateway-password` for the
|
||||
plaintext. Converge on the manifest's names. Where a script currently writes
|
||||
`fedimint-gateway-password`, have it write the `.pw` sibling name instead, and — because
|
||||
migrations never destroy data — if the legacy file exists and the new one does not, copy the value
|
||||
across (preserving 0600) rather than regenerating, so a node that already has a working unique
|
||||
credential keeps it. Never delete the legacy file in this plan; plan 01-16 owns retirement.
|
||||
|
||||
In `deploy-to-target.sh` and `deploy-tailscale.sh`: when the hash read back from the target comes
|
||||
back empty, abort that step with a clear message instead of substituting the literal. A deploy that
|
||||
cannot read the target's credential must not create a gateway container. In
|
||||
`deploy-tailscale.sh`'s container-creation block, remove the plaintext-password fallback on line
|
||||
777 entirely and switch that argv to the same hash flag every other path uses, sourced from the
|
||||
same secret; if the hash is unavailable, skip creating the gateway container and print why.
|
||||
|
||||
In `container-specs.sh`: leave the secret name as-is (it is already canonical) and leave the `$`
|
||||
escaping intact; only add the empty-value guard so a missing hash produces a skipped spec with a
|
||||
message rather than an empty hash argument.
|
||||
|
||||
Every changed script must stay `sh`-compatible where it already is and must pass `bash -n`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>for f in scripts/first-boot-containers.sh scripts/reconcile-containers.sh scripts/deploy-to-target.sh scripts/deploy-tailscale.sh scripts/container-specs.sh; do bash -n "$f" || exit 1; done; test "$(grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l)" -eq 0</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n` exits 0 for all five scripts.
|
||||
- `grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l` equals 0.
|
||||
- `grep -c "|| echo 'archipelago'" scripts/deploy-tailscale.sh` equals 0.
|
||||
- `grep -c -- '--password ' scripts/deploy-tailscale.sh` equals 0 — the gateway argv uses the hash flag, like every other path.
|
||||
- `grep -rl 't9YjjxkiktrlYvjajB' . --include='*.rs' --include='*.sh' --include='*.yml' --include='*.json' --include='*.md' | wc -l` equals 1 (only the Task 1 denylist).
|
||||
- `grep -v '^\s*#' scripts/first-boot-containers.sh | grep -c 'htpasswd'` is 0, or the SUMMARY records which hasher replaced it and that it is present on a node.
|
||||
- `cd core && cargo test -p archipelago` exits 0.
|
||||
- The SUMMARY records, for each of the five scripts, what the no-credential path now does, and confirms the legacy plaintext filename is copied forward rather than regenerated when present.
|
||||
</acceptance_criteria>
|
||||
<done>No script in the tree can configure a gateway with a credential that shipped with the repo; a node with no credential gets one generated for it or is told why the gateway was skipped.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **Whether the compromised hash's plaintext is publicly recoverable:** the planner did not run
|
||||
`bcrypt::verify` against candidate plaintexts. The severity of FED-07 does not depend on it (a
|
||||
shipped hash is a shipped credential regardless), but the migration in plan 01-16 phrases its
|
||||
operator message differently if the plaintext is a guessable word. Task 1's tests are the natural
|
||||
place to settle it; record the finding in the SUMMARY either way.
|
||||
- **Whether `get_app_config`'s signature can return `Result` without a wide refactor:** the planner
|
||||
read the call site but not every arm of the table. Task 1 explicitly allows either mechanism and
|
||||
requires the choice to be recorded, so this is a bounded implementation decision, not a scope gap.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| repository → running node | Anything committed here reaches every node and every reader of the mirror |
|
||||
| gateway admin API (`0.0.0.0:8176`) → network | The credential this plan governs is the only thing gating Lightning gateway administration |
|
||||
| deploy host → target node over SSH | Credentials are read back across this boundary by two deploy scripts |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-50 | Elevation of Privilege | shipped default credential granting gateway admin on any node that took a fallback | critical | mitigate | Both tasks delete every configure-time fallback; the repo-wide grep acceptance criterion fails the task if any credential literal survives outside the detection denylist |
|
||||
| T-01-51 | Spoofing | an attacker authenticating to a node's gateway with the publicly known default | critical | mitigate | `gateway_bcrypt_hash` refuses to return a denylisted value, so a node carrying it cannot be reconfigured with it even by this codebase |
|
||||
| T-01-52 | Information Disclosure | the generated plaintext leaking through a log line or a deploy transcript | high | mitigate | Generation reuses `write_secret` (0600, atomic, never logged); the scripts are changed to log only that generation happened, never the value; the acceptance criteria forbid printing it |
|
||||
| T-01-53 | Denial of Service | removing the fallback bricking installs on hosts without a bcrypt hasher | medium | mitigate | Task 2's preferred branch removes the host-tool dependency entirely by deferring to the daemon's own generator, and requires the skip path to print a reason rather than fail silently |
|
||||
| T-01-54 | Tampering | a half-written credential pair leaving a gateway configured against a hash whose plaintext nobody holds | medium | mitigate | Generation reuses the existing atomic temp-file-plus-rename `write_secret` and its self-heal branch; Task 2 forbids creating a half-provisioned pair |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs no packages — it edits existing Rust and shell only. If an implementation choice would add a crate, stop and raise it: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd core && cargo test -p archipelago` — green.
|
||||
- `cd core && cargo build -p archipelago` — green.
|
||||
- `bash -n` clean on all five changed scripts.
|
||||
- Repo-wide: exactly one occurrence of the compromised hash, in the detection denylist.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The gateway credential comes from `container::secrets` on every path — daemon, first boot, reconcile, and both deploys.
|
||||
- No credential literal in the tree configures anything; the one retained copy exists solely to detect and reject.
|
||||
- A node with no credential gets one generated, or is told clearly why the gateway was not created.
|
||||
- One canonical secret filename, with the legacy plaintext value carried forward rather than regenerated.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-11-SUMMARY.md` when done, recording the
|
||||
credential-less failure mechanism chosen, the per-script no-credential behaviour, and whether the
|
||||
compromised hash's plaintext turned out to be recoverable.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 12
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
- neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-02]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On a wide viewport the connected-nodes card's height is set by its row sibling, not by how many nodes are in the list — adding nodes makes the inner list scroll instead of making the row taller (UIFIX-02)"
|
||||
- "The inner list scrolls within the matched height: with more rows than fit, a scrollbar appears inside the card and the card stays put"
|
||||
- "With a short sibling the card still has a usable list height rather than collapsing to its header and tabs (UIFIX-02 empty edge, sibling half)"
|
||||
- "With zero connected nodes the card renders its existing empty/loading row and does not collapse (UIFIX-02 empty edge, list half)"
|
||||
- "All three tabs — trusted, observers, requests — share the same scroll behaviour, so switching tabs never changes the card's height (UIFIX-02 adjacency edge)"
|
||||
- "The stacked single-column layout below the row breakpoint is unchanged: the list keeps its existing capped height and its existing scroll"
|
||||
prohibitions:
|
||||
- statement: "Nothing outside the connected-nodes card's own height and overflow behaviour may change — the card's glass styling, padding, header, tab strip, row markup, counts, and every animation stay byte-identical, and no sibling card in any Web5 row is restyled to make the fix work"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
|
||||
provides: "Structural pin on the scroll contract for all three tab panes"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: neode-ui/src/views/web5/Web5ConnectedNodes.vue
|
||||
to: neode-ui/src/views/web5/Web5.vue
|
||||
via: "the card is a min-height-zero flex column whose scroll pane contributes no intrinsic height at the row breakpoint, so the grid row is sized by the sibling and stretch gives the card that height"
|
||||
pattern: "overflow-y-auto"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the connected-nodes list obey the row: its height tracks the taller sibling beside it and the
|
||||
list scrolls inside that height, instead of growing until every node fits.
|
||||
|
||||
Purpose: UIFIX-02 is a BLOCKER, and it is a regression of an earlier request ("was still meant to
|
||||
scroll"). Quick task 260729-je5 made the list fill the card's height; what is missing is the other
|
||||
half — the list must not *drive* the card's height. Today all three tab panes carry
|
||||
`max-h-72 xl:max-h-none`, so at the `xl` breakpoint where the row becomes two columns the cap is
|
||||
lifted and nothing bounds the list: it grows to fit every row, stretches the grid row, and the
|
||||
scrollbar the user expects never appears.
|
||||
Output: a bounded, sibling-matched card with an internal scroll at the row breakpoint, an unchanged
|
||||
stacked layout below it, and a test that pins the contract so a future cleanup cannot undo it again.
|
||||
</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
|
||||
@neode-ui/src/views/web5/Web5.vue
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| scroll-contract classes on the three tab panes | changed template classes | `neode-ui/src/views/web5/Web5ConnectedNodes.vue` |
|
||||
| row-breakpoint height floor on the card root | changed template classes | same |
|
||||
| `neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` | new vitest suite | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height</name>
|
||||
<files>neode-ui/src/views/web5/Web5ConnectedNodes.vue, neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/web5/Web5ConnectedNodes.vue` lines 1-135: the card root
|
||||
(`glass-card p-6 scroll-mt-24 flex flex-col`), the desktop and mobile header blocks, the four-tab
|
||||
strip, and the three `v-show` tab panes at lines 57, 90 and 120 — all three currently carrying
|
||||
`space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`. Also read the loading and
|
||||
empty rows inside the trusted pane so you know what renders when the list is empty.
|
||||
- `neode-ui/src/views/web5/Web5.vue` lines 57-74: the three `grid grid-cols-1 xl:grid-cols-2 gap-6`
|
||||
rows. The connected-nodes card is the left item of the first row and `Web5NodeVisibility` is its
|
||||
right sibling. Confirm no `items-start`/`self-start` is applied anywhere on that row — grid's
|
||||
default `align-items: stretch` is what makes the sibling-matched height work, and this plan must
|
||||
not add or remove alignment utilities on the row.
|
||||
- `neode-ui/src/views/web5/Web5NodeVisibility.vue` — read only far enough to see roughly how tall
|
||||
it renders (it is the sibling whose height the card must adopt). Do not modify it.
|
||||
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — the house convention for a
|
||||
structural DOM/class pin test in this repo (this is the file the standing rule names as
|
||||
must-stay-green; read it for its mounting and assertion style, do not change it).
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Mounting the component and reading the trusted pane's class list: it has `overflow-y-auto`, has
|
||||
`min-h-0`, and has no class that removes its height bound at the row breakpoint.
|
||||
- The same three assertions hold for the observers pane and the requests pane.
|
||||
- The pane keeps a capped height below the row breakpoint (the stacked layout is unchanged).
|
||||
- The card root is a flex column with a height floor at the row breakpoint, so a short sibling
|
||||
cannot collapse the list area.
|
||||
- With an empty node list the pane still renders (the existing empty/loading row is present) and
|
||||
the pane element is still in the tree.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails.
|
||||
|
||||
In `Web5ConnectedNodes.vue`, change only the height/overflow contract:
|
||||
|
||||
On each of the three tab panes, replace the current sizing classes so that below the row
|
||||
breakpoint nothing changes (keep the existing capped height and `overflow-y-auto`, keep basis
|
||||
`auto` so the auto-height stacked column still sizes to content), and at the row breakpoint the
|
||||
pane becomes a zero-basis growing flex child with no height cap — `xl:flex-1 xl:basis-0
|
||||
xl:max-h-none` alongside the existing `min-h-0 overflow-y-auto`. Zero basis is the whole trick:
|
||||
it makes the pane contribute nothing to the card's intrinsic height, so the grid row is sized by
|
||||
the sibling alone, `align-items: stretch` gives the card that row height, and `flex-1` then hands
|
||||
the leftover height to the pane, which scrolls inside it.
|
||||
|
||||
On the card root, keep `glass-card p-6 scroll-mt-24 flex flex-col` exactly as it is and add
|
||||
`min-h-0` plus a row-breakpoint height floor (`xl:min-h-[20rem]`) so a sibling shorter than the
|
||||
header-plus-tabs block still leaves a usable, scrolling list area rather than a collapsed strip.
|
||||
Choose the floor to sit close to today's stacked cap so the visual weight of the card is familiar.
|
||||
|
||||
Change nothing else. Do not touch the header blocks, the tab strip, the per-row markup, the count
|
||||
badges, the pulse dot on the requests tab, any `v-show`/`v-if` condition, any script logic, or any
|
||||
class on `Web5.vue`'s grid rows. Do not add a scrollbar style — the list already scrolls with the
|
||||
house default below the breakpoint and must look identical above it.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would pass vacuously).
|
||||
- `grep -c 'xl:max-h-none' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 and each of those three lines also matches `xl:basis-0`.
|
||||
- `grep -c 'flex-auto' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 0.
|
||||
- `grep -c 'max-h-72' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 — the stacked cap is untouched.
|
||||
- `grep -c 'xl:min-h-' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 1.
|
||||
- `git diff --stat -- neode-ui/src/views/web5/Web5.vue` reports no change.
|
||||
- `git diff -- neode-ui/src/views/web5/Web5ConnectedNodes.vue | grep -c '^[-+].*<script'` equals 0 — no script-block change.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite, including `src/views/dashboard/__tests__/keepAliveTabs.test.ts`, stays green.
|
||||
</acceptance_criteria>
|
||||
<done>All three panes carry the bounded scroll contract, the stacked layout is untouched, and a test pins it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Prove it against the real preview and settle the second "connected nodes" surface</name>
|
||||
<files>neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) — jsdom cannot compute layout, so the height claim has to be observed in a real browser engine</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/DEV-SCRIPTS.md` lines 1-40 — how the dev preview and mock backend are started and on
|
||||
which ports, and how to stop them cleanly.
|
||||
- `neode-ui/src/views/settings/AccountInfoSection.vue` (grep it for "connected" / "nodes" first) —
|
||||
the todo flags a second "connected nodes" block living in settings. Determine whether it is the
|
||||
same list in a different place or unrelated copy, and record the verdict.
|
||||
</read_first>
|
||||
<action>
|
||||
Start the dev preview, open the Web5 tab at a wide viewport (at or above the row breakpoint), and
|
||||
observe the first row directly. Confirm three things and record each in the SUMMARY with the
|
||||
viewport width you used:
|
||||
|
||||
1. The connected-nodes card and its right-hand sibling are the same height.
|
||||
2. With more connected nodes than fit, the list scrolls inside the card and the card does not grow
|
||||
— if the mock backend does not supply enough nodes to overflow, temporarily add rows in the
|
||||
browser's element inspector to force the condition rather than editing the mock backend, and say
|
||||
so in the SUMMARY.
|
||||
3. Narrowing below the row breakpoint restores exactly the previous stacked appearance.
|
||||
|
||||
Then settle the second surface: grep the settings section named above for a connected-nodes list.
|
||||
If it is a genuinely separate list with the same grow-to-fit behaviour, fix it the same way in this
|
||||
plan and add its file to the plan's `files_modified` in the SUMMARY. If it is unrelated (for
|
||||
example a count or a link rather than a scrolling list), record that finding and leave it alone.
|
||||
Do not silently skip this step — the todo explicitly flagged the ambiguity.
|
||||
|
||||
Extend the test file with a case for whichever surface the investigation confirmed, so the pin
|
||||
covers what actually shipped.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0.
|
||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'xl:basis-0' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op, so grep the built bundle for a string this plan introduced).
|
||||
- The SUMMARY records all three dev-preview observations with the viewport width used for each.
|
||||
- The SUMMARY records an explicit verdict on the settings "connected nodes" block: same defect and fixed here, or unrelated and why.
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The behaviour is confirmed in a real browser at both sides of the breakpoint, and the second candidate surface has a recorded verdict rather than an assumption.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The 20rem floor is a judgement call, not a measured value.** The planner did not render
|
||||
`Web5NodeVisibility.vue` to learn its height. If the sibling is reliably taller than the floor the
|
||||
floor never binds and the exact value is invisible; if it is shorter, the floor is what the user
|
||||
sees. Task 2's dev-preview observation is where that gets confirmed — if the floor looks wrong on
|
||||
screen, adjust it there and record the final value.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| federated peer data → rendered node row | The list renders peer-supplied names and identifiers |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-55 | Information Disclosure | a bounded, scrolling list hiding a connected node the operator needs to notice | medium | mitigate | The tab strip's existing count badges stay untouched and remain visible above the scroll area, so the total is always readable without scrolling; the acceptance criteria forbid changing them |
|
||||
| T-01-56 | Spoofing | a long peer-supplied node name overflowing the newly bounded pane and overlapping adjacent chrome | low | accept | Row markup is unchanged by this plan; the panes already truncate as they do today, and this plan alters only the container's height and overflow |
|
||||
| T-01-57 | Denial of Service | a very large peer list making the card expensive to render | low | accept | The list is already fully rendered today; bounding the container reduces painted area rather than increasing it, and virtualisation is out of scope for a layout fix |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — template class changes and one vitest file only. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the new class.
|
||||
- Dev-preview observation recorded at both sides of the row breakpoint.
|
||||
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The card's height comes from its row sibling; the list scrolls inside it and never grows to fit.
|
||||
- A short sibling still leaves a usable list height.
|
||||
- The stacked layout below the breakpoint is byte-identical to before.
|
||||
- The settings "connected nodes" block has a recorded verdict.
|
||||
- A test pins the contract so the behaviour cannot silently regress a third time.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md` when done, recording the
|
||||
dev-preview observations, the final floor value, and the settings-surface verdict.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 13
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
- neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-03]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "On a viewport too short to show the whole seed step, an on-brand cue at the bottom of the scrolling area tells the user there is more below — the confirmation tickbox is no longer silently out of sight (UIFIX-03)"
|
||||
- "Activating the cue brings the confirmation tickbox into view, so discovering it takes one action rather than a guess"
|
||||
- "The cue disappears once the tickbox is visible, and never reappears while it stays visible (UIFIX-03 adjacency edge)"
|
||||
- "On a viewport tall enough to show everything the cue never renders at all — no element, no reserved space, no layout shift, so tall screens look exactly as they did (UIFIX-03 empty edge)"
|
||||
- "The cue is absent while the seed is still generating and while an error is showing, because there is no tickbox to point at yet"
|
||||
- "The cue's motion is disabled under prefers-reduced-motion, matching the site-wide convention"
|
||||
prohibitions:
|
||||
- statement: "Nothing about the existing onboarding step may change other than the addition of this cue — the header, the seed word grid, the words/QR tabs, the warning box, the tickbox itself, the fixed footer and its Continue button, and every existing animation stay exactly as they are, and the shared onboarding container styles in style.css are not touched"
|
||||
category: safety
|
||||
- statement: "The cue MUST NOT let a user proceed without ticking the box — it is a wayfinding affordance only; it never sets the confirmation state, never enables the Continue button, and never auto-ticks on scroll"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts
|
||||
provides: "Overflow-driven show/hide behaviour of the cue, including the no-overflow no-render case"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
to: neode-ui/src/views/OnboardingSeedGenerate.vue
|
||||
via: "the cue's visibility is derived from the scroll container's own overflow measurement and the tickbox's position within it, so it is impossible for the cue to show when there is nothing below"
|
||||
pattern: "scrollHeight"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make the seed-confirmation tickbox obviously findable on short screens, in a way that looks like it
|
||||
was always part of the design.
|
||||
|
||||
Purpose: UIFIX-03 is a BLOCKER — on a short viewport the tickbox sits below the fold inside the
|
||||
step's scrolling area while the Continue button stays pinned and disabled in the fixed footer, so
|
||||
onboarding reads as broken rather than incomplete. The user asked for this to be solved "in a
|
||||
beautiful way": the fix has to feel intentional and native to the house glass/dark style, not a
|
||||
bolted-on arrow, and it must be invisible on screens tall enough not to need it.
|
||||
Output: a bottom scroll cue on the seed step that appears only when it is needed, scrolls the tickbox
|
||||
into view when activated, and vanishes once the tickbox is on screen.
|
||||
</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
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| bottom scroll-cue overlay | new template block (conditional) | `neode-ui/src/views/OnboardingSeedGenerate.vue` |
|
||||
| `showScrollCue` + `updateScrollCue()` + `revealConfirm()` | new script state and handlers | same |
|
||||
| `.onb-cue-*` scoped styles incl. reduced-motion guard | new scoped CSS | same |
|
||||
| `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` | new vitest suite | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a short viewport shows a cue that reveals the tickbox</name>
|
||||
<files>neode-ui/src/views/OnboardingSeedGenerate.vue, neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/OnboardingSeedGenerate.vue` — the whole file (262 lines). The structure that
|
||||
matters: a `h-[100dvh]` outer centring wrapper; a `path-glass-container onb-scroll-container
|
||||
flex flex-col` card; a `flex-shrink-0` header; the scrolling middle region
|
||||
(`flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0`) that contains the loading
|
||||
state, the error state, the words/QR tabs, the word grid, the orange warning box and — last —
|
||||
the confirmation `<label>` with the checkbox bound to `confirmed`; and the `flex-shrink-0`
|
||||
fixed footer holding the Continue button gated on `confirmed`. Note the existing
|
||||
`watch(confirmed, …)` that focuses the Continue button, and the `onb-lock-spin` scoped keyframes
|
||||
block at the bottom (the house pattern for a small scoped animation in this file).
|
||||
- `neode-ui/src/style.css` — find the `.onb-scroll-container` rules (around line 1146 and a
|
||||
breakpoint block around line 1162) to see what the shared onboarding container already does.
|
||||
Read only; this plan must not modify the shared stylesheet, because these classes are used by
|
||||
every other onboarding step.
|
||||
- `neode-ui/src/components/RefreshIndicator.vue` — the house convention for a small, purely
|
||||
presentational overlay component with a scoped keyframes animation, for style reference.
|
||||
- `neode-ui/src/components/SendBitcoinModal.vue` — grep it for `prefers-reduced-motion` and copy
|
||||
that media-query syntax verbatim for the cue's guard, so all reduced-motion guards in this repo
|
||||
read identically.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- With the scroll region reporting more content than fits and the tickbox below the visible area,
|
||||
the cue element is in the DOM.
|
||||
- With the scroll region reporting no overflow, the cue element is absent from the DOM entirely —
|
||||
not merely hidden, so it can occupy no space and cause no shift.
|
||||
- Scrolling to the bottom (tickbox now inside the visible area) removes the cue.
|
||||
- Activating the cue calls the scroll-into-view path for the tickbox and does not change
|
||||
`confirmed`.
|
||||
- While `loading` is true, or while `errorMessage` is set and no words have arrived, the cue is
|
||||
absent regardless of overflow.
|
||||
- Ticking the box removes the cue.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails. In jsdom there is no layout engine, so drive the
|
||||
measurements by defining `scrollHeight`, `clientHeight` and `scrollTop` on the scroll element with
|
||||
`Object.defineProperty` and dispatching a `scroll` event — assert on what the component renders in
|
||||
response, not on computed geometry.
|
||||
|
||||
In `OnboardingSeedGenerate.vue`:
|
||||
|
||||
Add a template ref to the existing scrolling middle region and one to the confirmation label. Add
|
||||
a `showScrollCue` ref and an `updateScrollCue()` function that sets it true only when all of these
|
||||
hold: words are present, not loading, the scroll element reports more scrollable content below the
|
||||
current position, the confirmation label's bottom lies below the scroll element's visible bottom,
|
||||
and `confirmed` is still false. Call it from a `scroll` listener on the scroll element, from a
|
||||
`resize` listener on the window, from a `ResizeObserver` on the inner content wrapper (the word
|
||||
grid changes height when the user switches between the words and QR tabs), from a watcher on
|
||||
`words`, and from a watcher on `confirmed`. Remove every listener and disconnect the observer in
|
||||
`onUnmounted` alongside the existing `stopTimers()` call.
|
||||
|
||||
Render the cue as a `v-if="showScrollCue"` overlay positioned against the scrolling region's
|
||||
bottom edge, inside a `Transition` so it fades rather than pops. Compose it from two layers, both
|
||||
`pointer-events-none` except the button itself:
|
||||
a soft gradient fade from transparent to the card's own dark backdrop across roughly 64px, so the
|
||||
content appears to slide under the edge rather than being cut off; and, centred on it, a small
|
||||
glass pill — the house `bg-black/60` + `backdrop-blur` treatment, `rounded-full`, `text-white/75`
|
||||
at `text-xs`, with the orange accent (`#fb923c` / `text-orange-400`) used only for a downward
|
||||
chevron drawn as inline 24×24 `stroke-width="2"` SVG per the icon convention in
|
||||
`01-UI-SPEC.md`. Copy for the pill: **"One more step below"**. Give the chevron a gentle 2s
|
||||
ease-in-out vertical bob of no more than 3px, defined in the file's existing scoped style block
|
||||
next to `onb-lock-spin`, and guard it with the `prefers-reduced-motion` media query copied from
|
||||
`SendBitcoinModal.vue`.
|
||||
|
||||
Make the pill a real `<button type="button">` whose click smooth-scrolls the confirmation label
|
||||
into view (`scrollIntoView({ behavior: 'smooth', block: 'center' })`) and nothing else — it must
|
||||
never touch `confirmed`, never focus or enable the Continue button, and never call `proceed()`.
|
||||
Give it an `aria-label` naming what it reveals so it is reachable and understandable without
|
||||
sight, and make sure it is keyboard-focusable in the natural order.
|
||||
|
||||
Do not alter the header, the words/QR tab strip, the word grid, the QR block, the warning box, the
|
||||
tickbox markup, the footer, the Continue button, or any existing class on the card or the scroll
|
||||
region. Do not edit `style.css`. Add nothing that renders when `showScrollCue` is false.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/views/__tests__/OnboardingScrollCue.test.ts && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite contains a case asserting the cue element is absent when the scroll element reports no overflow, and a case asserting it is present when it reports overflow with the tickbox below the fold.
|
||||
- The suite contains a case asserting activating the cue leaves `confirmed` false.
|
||||
- `grep -c 'prefers-reduced-motion' neode-ui/src/views/OnboardingSeedGenerate.vue` equals 1.
|
||||
- `grep -c 'scrollIntoView' neode-ui/src/views/OnboardingSeedGenerate.vue` equals 1.
|
||||
- `git diff --stat -- neode-ui/src/style.css` reports no change.
|
||||
- `git diff -- neode-ui/src/views/OnboardingSeedGenerate.vue | grep -c '^-.*type="checkbox"'` equals 0 — the tickbox markup is untouched.
|
||||
- `git diff -- neode-ui/src/views/OnboardingSeedGenerate.vue | grep -c '^-.*path-action-button'` equals 0 — the footer button markup is untouched.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite stays green.
|
||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'One more step below' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op).
|
||||
</acceptance_criteria>
|
||||
<done>The cue appears only when the tickbox is out of reach, reveals it on activation, and leaves everything else about the step untouched.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Confirm it on a real short viewport and settle whether any other step needs it</name>
|
||||
<files>neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) and the onboarding route is reachable there — jsdom proves the logic but only a browser proves it looks right</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/ONBOARDING_FLOW.md` — the step order and which routes make up the flow, so you know
|
||||
which steps to check in the next paragraph.
|
||||
- `neode-ui/DEV-SCRIPTS.md` lines 1-40 — starting and stopping the preview.
|
||||
</read_first>
|
||||
<action>
|
||||
Start the dev preview and open the seed-generate step. Check it at three heights and record each
|
||||
observation in the SUMMARY with the exact viewport used:
|
||||
|
||||
1. A short viewport (for example 1280×620, a small laptop or mobile landscape). Expected: the cue
|
||||
is visible, reads as part of the card rather than an overlay bolted on top of it, and clicking
|
||||
it brings the tickbox into view; the cue then disappears.
|
||||
2. A tall viewport (for example 1440×1000). Expected: no cue at all, and the step is
|
||||
pixel-identical to before this change — compare against the current build if you are unsure.
|
||||
3. A narrow phone viewport (for example 390×740). Expected: the cue reads correctly at that width
|
||||
and does not overlap the word grid or the warning box.
|
||||
|
||||
If the cue does not look like it belongs at any of the three, adjust the gradient depth, the pill
|
||||
size, or the copy until it does, then re-run the test suite. This is the "beautiful way" the user
|
||||
asked for — treat a cue that looks bolted on as a failure of this task, not a matter of taste.
|
||||
|
||||
Then settle the scope question the requirement leaves open. Run
|
||||
`grep -l 'type="checkbox"' neode-ui/src/views/Onboarding*.vue` and, for every step that has a
|
||||
confirmation tickbox inside a scrolling region, check it at the short viewport. If another step has
|
||||
the same defect, apply the same cue there in this plan, add the file to the plan's
|
||||
`files_modified` in the SUMMARY, and extend the test. If no other step does, record the grep
|
||||
output and the verdict. Do not assume the seed step is the only one.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run src/views/__tests__/OnboardingScrollCue.test.ts` exits 0.
|
||||
- `cd neode-ui && npm run build` exits 0.
|
||||
- The SUMMARY records all three viewport observations with exact dimensions, and states explicitly that the tall-viewport rendering was unchanged.
|
||||
- The SUMMARY includes the `grep -l 'type="checkbox"' neode-ui/src/views/Onboarding*.vue` output and a per-file verdict.
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>The cue is confirmed to look right at three real viewports, and every onboarding step with a confirmation tickbox has a recorded verdict.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The seed-generate step is assumed to be the one the user hit.** The todo says "verify it's this
|
||||
step". The planner confirmed this step has a confirmation tickbox at the bottom of a scrolling
|
||||
region with a pinned, disabled Continue button below it — the exact reported symptom — but did not
|
||||
enumerate every onboarding view. Task 2 closes this with a grep and a per-file verdict rather than
|
||||
leaving it as an assumption.
|
||||
- **The chosen affordance is the scroll cue, not the sticky-footer alternative.** The todo listed
|
||||
three candidate approaches. The cue was chosen because the other two change the tall-screen
|
||||
appearance (a sticky footer alters the card at every height; an auto-scroll moves content the user
|
||||
did not ask to move), and the standing rule forbids changing existing visuals. If the cue proves
|
||||
unsatisfying at Task 2, raise it rather than silently switching approach.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| displayed recovery seed → screen | This step renders 24 words that grant full control of the node, identities and wallet |
|
||||
| user consent → onboarding progression | The tickbox is the recorded acknowledgement that the seed was written down |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-58 | Repudiation | an affordance that advances onboarding without a genuine acknowledgement | high | mitigate | The prohibition and an explicit test case forbid the cue from touching `confirmed`; the cue only scrolls |
|
||||
| T-01-59 | Information Disclosure | a new overlay covering seed words so a user transcribes them wrongly and loses recovery | high | mitigate | The cue renders only at the bottom edge of the scroll region and only while content remains below; Task 2 requires checking at a narrow width that it does not overlap the word grid |
|
||||
| T-01-60 | Denial of Service | scroll and resize handlers firing continuously on a low-power onboarding device | low | mitigate | The handler is a few property reads and one boolean assignment with no allocation or RPC; listeners and the observer are removed in `onUnmounted` |
|
||||
| T-01-61 | Tampering | the shared onboarding stylesheet being edited and silently restyling every other step | medium | mitigate | All new CSS is scoped to this component, and an acceptance criterion fails the task if `style.css` shows any diff |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — one component and one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the cue copy.
|
||||
- Short, tall and narrow viewport observations recorded, with the tall case confirmed unchanged.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- A short viewport shows an on-brand cue that leads to the tickbox in one action.
|
||||
- A tall viewport renders no cue and is unchanged.
|
||||
- The cue never affects consent state or the Continue button.
|
||||
- Motion is reduced-motion guarded and the shared stylesheet is untouched.
|
||||
- Every onboarding step with a confirmation tickbox has a recorded verdict.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md` when done, recording the three
|
||||
viewport observations, any design adjustments made to reach "belongs here", and the per-step grep
|
||||
verdict.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 14
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/composables/usePaidItemViewer.ts
|
||||
- neode-ui/src/views/Cloud.vue
|
||||
- neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-04, UIFIX-06]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Clicking a purchased picture in Paid Files opens it in the app's own lightbox — no browser tab, matching how My Files already behaves (UIFIX-04)"
|
||||
- "A purchased video opens in the same lightbox with its player controls, consistent with every other video in the app"
|
||||
- "A purchased audio track still goes to the global bottom-bar player and never to the lightbox, exactly as today (UIFIX-04 adjacency edge)"
|
||||
- "A purchased file with no in-app viewer (a document) still opens the way it does today rather than failing silently — the change adds a viewer path, it does not remove one"
|
||||
- "The row shows a house loading state for the whole time the purchased file is being fetched, so a slow open never looks like a dead click (UIFIX-06)"
|
||||
- "A fetch that fails or times out surfaces the existing error treatment instead of being swallowed, and the row's loading state clears (UIFIX-06 failure-surfacing)"
|
||||
- "Clicking the same purchased item twice in quick succession produces one fetch, not two (UIFIX-04 concurrency edge)"
|
||||
- "Every surface named by phase 2's findings as slow-opening has a recorded verdict — an existing loader confirmed, or a missing one added (UIFIX-06)"
|
||||
- "A cached revisit still shows no spinner: loaders are driven by a first load, never by a background refresh, preserving PERF-02"
|
||||
prohibitions:
|
||||
- statement: "The viewer path MUST NOT re-charge, re-purchase, or re-request payment for content the buyer already owns — opening a purchased item reads the local owned cache and nothing else"
|
||||
category: safety
|
||||
- statement: "Purchased bytes MUST NOT outlive the viewing session as a reachable object URL — every URL this path creates is revoked by whichever component owns it, with exactly one owner per URL"
|
||||
category: privacy
|
||||
- statement: "No loading affordance may be added to a path that is already instant or already cached — a spinner on a cached revisit is a PERF-02 regression, not a UIFIX-06 fix"
|
||||
category: transparency
|
||||
artifacts:
|
||||
- path: neode-ui/src/composables/usePaidItemViewer.ts
|
||||
provides: "Fetch, decode, route-to-viewer and loading/error state for a purchased item"
|
||||
contains: "opening"
|
||||
- path: neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts
|
||||
provides: "Per-mime routing, loading state, error surfacing and double-click dedup"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: neode-ui/src/views/Cloud.vue
|
||||
to: neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
via: "a paid-items lightbox instance fed synthetic FileBrowserItem entries and a resolver that returns the already-fetched object URL"
|
||||
pattern: "MediaLightbox"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make a purchased picture open where every other picture in the app opens — the lightbox — and make
|
||||
the wait visible while it loads.
|
||||
|
||||
Purpose: two user-reported issues that phase 2 classified as pre-existing and captured rather than
|
||||
fixed. `Cloud.vue`'s `viewPaidItem()` calls `window.open(url, '_blank', 'noopener')` (introduced
|
||||
f3393581, 2026-07-22), so Paid Files is the one media surface that leaves the app. The same function
|
||||
issues `content.owned-get` with a 60-second timeout and renders no loading affordance at all, and its
|
||||
`catch` swallows every failure — so a slow or failed open is indistinguishable from a click that did
|
||||
nothing. Both live in the same twelve lines, so they are fixed together.
|
||||
Output: a small viewer composable with tested per-mime routing, a paid-items lightbox in Cloud, an
|
||||
inline house loading state on the row, real error surfacing, and a recorded verdict for every other
|
||||
surface phase 2 flagged as slow.
|
||||
</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
|
||||
@neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `usePaidItemViewer()` | new composable — fetch, decode, route, state | `neode-ui/src/composables/usePaidItemViewer.ts` |
|
||||
| paid-items `MediaLightbox` instance + row loading state | changed template | `neode-ui/src/views/Cloud.vue` |
|
||||
| `viewPaidItem` delegating to the composable | changed script | same |
|
||||
| `neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts` | new vitest suite | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a purchased picture opens in the lightbox, with the wait visible</name>
|
||||
<files>neode-ui/src/composables/usePaidItemViewer.ts, neode-ui/src/views/Cloud.vue, neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/Cloud.vue` lines 150-178 (the Paid Files tab rows: each row is a
|
||||
`glass-card p-3 flex items-center gap-3 cursor-pointer` div with `@click="viewPaidItem(it)"`, an
|
||||
emoji type glyph, filename, size/sats/date line, and a "Paid" pill), lines 456-495 (the
|
||||
`PaidItem` interface, the `paidResource` cached resource with `persist: false`, and
|
||||
`viewPaidItem` itself — the `content.owned-get` call with `timeout: 60000`, the base64→Uint8Array
|
||||
→Blob→`URL.createObjectURL` chain, the audio branch that hands off to `useAudioPlayer`, the
|
||||
`window.open` call, the 60-second revoke timer and the empty `catch`), lines 390-400 (the
|
||||
existing `MediaLightbox` instance for own files, showing exactly which props it takes:
|
||||
`items`, `start-index`, `show`, `fetch-blob-url`, `stream-url`, and `@close`), and lines 696-731
|
||||
(`lightboxIndex`/`lightboxItems` refs and `handlePreview`, the working example of driving that
|
||||
component). Also note `loadError` and the `alert-error` block at line 369 — the error surface
|
||||
this plan reuses rather than inventing one.
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` — the whole file. What matters: it takes
|
||||
`items: FileBrowserItem[]` and filters them by extension through `getFileCategory`, so a
|
||||
synthetic item's `name` must carry a real extension; it calls `props.fetchBlobUrl(item.path)`
|
||||
for images and `props.streamUrl(item.path)` for video/audio when supplied; it caches returned
|
||||
URLs in its own `urlCache` and revokes every one of them in `onUnmounted`. That last point
|
||||
decides URL ownership: whatever this plan hands to the lightbox must not also be revoked by
|
||||
Cloud.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 172-190 — the existing house treatment for exactly this
|
||||
interaction: a per-item `playing === item.id` guard rendering a 3×3 border spinner with an
|
||||
"Opening..." label inside the button. Reuse this treatment; do not invent a new one.
|
||||
- `neode-ui/src/api/filebrowser-client.ts` — the `FileBrowserItem` shape, so the synthetic item is
|
||||
structurally valid rather than cast.
|
||||
- `neode-ui/src/composables/useAudioPlayer.ts` — the `play(url, name)` contract the audio branch
|
||||
already uses.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Given a purchased item with an `image/*` mime, `open()` fetches it once, then exposes it as a
|
||||
lightbox item with a resolvable object URL; it does not call `window.open`.
|
||||
- Given a `video/*` mime, same — routed to the lightbox.
|
||||
- Given an `audio/*` mime, `open()` routes to the audio player and never to the lightbox.
|
||||
- Given a mime with no in-app viewer, `open()` falls back to the existing browser-tab behaviour.
|
||||
- `opening` is set to the item's key for the whole duration of the fetch and cleared in every exit
|
||||
path, including the failure path.
|
||||
- A rejected or timed-out fetch sets an error message and clears `opening`; it does not throw past
|
||||
the caller.
|
||||
- Calling `open()` twice for the same item while the first call is in flight issues one RPC.
|
||||
- The synthetic lightbox item's `name` ends in the real file extension so the lightbox's own
|
||||
category filter accepts it.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails. Stub the RPC client and `URL.createObjectURL`/
|
||||
`atob` at the module boundary; jsdom has no real blob decoding, so assert on what was requested and
|
||||
what was routed where, not on byte content.
|
||||
|
||||
Create `neode-ui/src/composables/usePaidItemViewer.ts` exporting `usePaidItemViewer()` returning at
|
||||
least: `opening` (a ref holding the key of the item currently being fetched, or null), `error` (a
|
||||
ref holding a user-facing message or null), `lightboxItems`, `lightboxIndex`, `resolveBlobUrl(path)`
|
||||
and `open(item)`. Move the existing fetch-and-decode chain out of `Cloud.vue` verbatim — same RPC
|
||||
method, same params, same 60-second timeout, same base64 decode, same blob construction. Then
|
||||
branch on the resolved mime: audio hands off to `useAudioPlayer` exactly as today; image and video
|
||||
build a synthetic `FileBrowserItem` (a stable synthetic `path` key, a `name` that is the item's
|
||||
basename so its extension survives, `isDir: false`, and the size from the item), register the
|
||||
created object URL against that path in an internal map that `resolveBlobUrl` reads, and set
|
||||
`lightboxItems`/`lightboxIndex` to show it; anything else keeps today's browser-tab behaviour
|
||||
including its existing revoke timer.
|
||||
|
||||
URL ownership, stated once so there is exactly one owner: URLs handed to the lightbox are revoked
|
||||
by the lightbox on unmount — the composable must not schedule a revoke for those. URLs handed to
|
||||
the audio player keep today's behaviour. URLs opened in a browser tab keep today's revoke timer.
|
||||
|
||||
Guard concurrency by keying on the item and returning early when that key is already in `opening`.
|
||||
Replace the empty `catch` with one that sets `error` to a short user-facing message (reuse the tone
|
||||
of the existing copy in this view) and clears `opening` in a `finally`.
|
||||
|
||||
In `Cloud.vue`: import the composable, delete the old `viewPaidItem` body and delegate to
|
||||
`open(it)`, and wire two things into the template. First, the Paid Files row gets the PeerFiles
|
||||
loading treatment — while `opening` matches that row's key, render the 3×3 border spinner and an
|
||||
"Opening…" label in place of the "Paid" pill, and make the row non-interactive for the duration so
|
||||
a second click cannot queue. Second, add a second `MediaLightbox` instance below the existing one,
|
||||
bound to the composable's `lightboxItems`/`lightboxIndex`, with `fetch-blob-url` and `stream-url`
|
||||
both pointing at `resolveBlobUrl`, and `@close` clearing the composable's index. Surface `error`
|
||||
through the view's existing `loadError` alert rather than adding a new error element.
|
||||
|
||||
Change nothing else in `Cloud.vue` — not the tab strip, not the category pills, not the Folders,
|
||||
My Files or Peer Files sections, not the peer cards, not the existing own-files lightbox instance,
|
||||
not any cached-resource key, TTL or `persist` flag.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/composables/__tests__/usePaidItemViewer.test.ts && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite contains a case per mime family — image, video, audio, and no-in-app-viewer — plus a loading-state case, an error case and a double-click dedup case.
|
||||
- `grep -c 'window.open' neode-ui/src/views/Cloud.vue` equals 0.
|
||||
- `grep -c 'MediaLightbox' neode-ui/src/views/Cloud.vue` is at least 3 (import plus two instances).
|
||||
- `grep -c 'usePaidItemViewer' neode-ui/src/views/Cloud.vue` is at least 2.
|
||||
- `git diff -- neode-ui/src/views/Cloud.vue | grep -c "^-.*key: 'cloud\."` equals 0 — no cached-resource key was moved or renamed.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite stays green.
|
||||
- `cd neode-ui && npm run build` exits 0 and `grep -rq 'usePaidItemViewer\|Opening…' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md the frontend build can silently no-op).
|
||||
</acceptance_criteria>
|
||||
<done>Purchased pictures and videos open in the app lightbox with a visible wait and a real error path; audio and documents behave exactly as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Settle the slow-open inventory — verdict per surface, loader only where genuinely missing</name>
|
||||
<files>neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts</files>
|
||||
<read_first>
|
||||
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the `## Outstanding` section and the
|
||||
`### Per-surface verdict` block under `## Re-measurement (gap closure)`. These name the surfaces
|
||||
to audit: Discover, Server, Web5, Fleet, AppDetails, OpenWrtGateway, MarketplaceAppDetails and
|
||||
Wallet-send. Read them for what each surface's cost actually is — several are *revisit*
|
||||
regressions on already-cached views, which is the one case where a loader would be a PERF-02
|
||||
regression rather than a fix.
|
||||
- `neode-ui/src/components/RefreshIndicator.vue` — the whole file, including its doc comment: it
|
||||
renders only in the `refreshing` state and deliberately renders nothing for `loading`, because a
|
||||
first load is the view's own skeleton's job. This is the rule that decides which affordance a
|
||||
surface needs.
|
||||
- `neode-ui/src/components/SkeletonCard.vue` — the house first-load skeleton, and
|
||||
`neode-ui/src/components/cloud/FileGrid.vue`'s skeleton block for the grid variant.
|
||||
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — the D-05 rules on what a background-refresh
|
||||
indicator may and may not show, so anything added here matches decisions already locked.
|
||||
</read_first>
|
||||
<action>
|
||||
Audit each surface named above. For each one, determine two things from the code: does a first
|
||||
open (no cache) render a loading affordance today, and is the open genuinely slow and uncached
|
||||
rather than a cached revisit. Record a one-line verdict per surface in the SUMMARY as a table with
|
||||
columns: surface, file, existing affordance, genuinely slow first open, action taken.
|
||||
|
||||
Add a house loading affordance only where the audit proves both a genuinely slow uncached first
|
||||
open and no existing affordance — a `SkeletonCard`/`FileGrid`-style skeleton for a list or grid, a
|
||||
`RefreshIndicator` only for background revalidation. Never gate a new affordance on a
|
||||
`refreshing` state for a first load, and never add one to a cached revisit path; PERF-02's
|
||||
no-spinner-on-revisit guarantee outranks this requirement wherever they meet, and phase 2's
|
||||
verdict is that the named revisit regressions are client-side render cost, not a missing loader.
|
||||
|
||||
If a surface needs a fix, implement it in this plan and add its file to `files_modified` in the
|
||||
SUMMARY. If every surface already has one — which the planner's own read of these files suggests
|
||||
is likely, with `Cloud.vue`'s paid-open being the single genuine gap — say so explicitly with the
|
||||
evidence, and do not add a loader for its own sake. A verdict of "already covered" is a valid
|
||||
outcome; an unrecorded surface is not.
|
||||
|
||||
Extend the test file with a case pinning that the paid-open loading state is driven by the fetch
|
||||
being in flight and not by any cached-resource `refreshing` state, so a later refactor cannot turn
|
||||
it into a revisit spinner.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/composables/__tests__/usePaidItemViewer.test.ts && npx vitest run && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
|
||||
- The SUMMARY contains the per-surface verdict table with a row for every surface named in `02-FINDINGS.md`'s outstanding list, each with a file path and an explicit action.
|
||||
- Every surface where the action is "loader added" names the file, and that file appears in the SUMMARY's `files_modified` addendum.
|
||||
- The SUMMARY states explicitly that no affordance was added to a cached-revisit path, naming PERF-02.
|
||||
- The test suite contains the case pinning that the paid-open loading state is not derived from a `refreshing` state.
|
||||
</acceptance_criteria>
|
||||
<done>Every flagged surface has an evidence-backed verdict, and the only loaders added are on genuinely slow uncached opens.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **Documents keep the browser-tab path.** UIFIX-04's text names pictures, and the app has no in-app
|
||||
document viewer; routing a PDF into a media lightbox would be a downgrade, not a fix. This is a
|
||||
deliberate scope boundary, not an omission — recorded here so it is visible rather than silent. If
|
||||
the user wants documents in-app too, that is a new requirement, not a gap in this one.
|
||||
- **The planner's read suggests every other named surface already has a loading affordance** (grep
|
||||
showed loading/skeleton markup in Server, Web5, Fleet, AppDetails, MarketplaceAppDetails,
|
||||
Marketplace, Apps, OpenWrtGateway and the Discover app grid). Task 2 re-verifies rather than
|
||||
assuming, because a grep hit is not proof that the affordance covers the *first uncached open*.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| peer-supplied purchased bytes → app-origin viewer | Content bought from another node is now rendered inside the app origin instead of a separate tab |
|
||||
| purchase records → rendered row | Paid amounts and purchase history are financial data already marked `persist: false` |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-62 | Elevation of Privilege | peer-supplied bytes rendered in-origin instead of an isolated tab | high | mitigate | The content is delivered as a blob object URL with the mime the backend reports and rendered only through `<img>`/`<video>` elements the lightbox already uses for local files; no `srcdoc`, no `innerHTML`, no iframe, and the no-in-app-viewer branch keeps today's separate-tab behaviour for anything that is not an image or a video |
|
||||
| T-01-63 | Information Disclosure | a purchased-content object URL outliving the view and remaining fetchable | medium | mitigate | The prohibition fixes exactly one owner per URL; the lightbox revokes what it is given on unmount, and the composable is forbidden from scheduling a competing revoke for those |
|
||||
| T-01-64 | Repudiation | a failed open being indistinguishable from a click that did nothing | medium | mitigate | The empty `catch` is replaced with one that sets a user-facing error through the view's existing alert, and a test case asserts the failure path both surfaces and clears state |
|
||||
| T-01-65 | Denial of Service | repeated clicks queuing multiple 60-second fetches of large purchased files | medium | mitigate | The `opening` key guard returns early for an in-flight item, the row is made non-interactive while loading, and a test case pins single-fetch behaviour |
|
||||
| T-01-66 | Spoofing | a purchased item's declared mime steering it to the wrong viewer | low | accept | Mime comes from the same backend response the current code already trusts for its blob type; this plan changes routing, not provenance, and tightening mime provenance belongs to the content pipeline, not a viewer fix |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — one new composable, one view edit, one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the new strings.
|
||||
- Per-surface slow-open verdict table recorded in the SUMMARY.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Paid Files pictures and videos open in the app lightbox; audio and documents are unchanged.
|
||||
- The fetch is visibly in progress while it runs and its failures are surfaced, not swallowed.
|
||||
- One fetch per click, one owner per object URL.
|
||||
- Every phase-2-flagged slow surface has a recorded verdict, and no cached revisit gained a spinner.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md` when done, recording the
|
||||
per-surface verdict table, any files added to scope by Task 2, and the URL-ownership decision.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 15
|
||||
type: execute
|
||||
wave: 7
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- neode-ui/src/composables/usePipSession.ts
|
||||
- neode-ui/src/utils/pip.ts
|
||||
- neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
- neode-ui/src/composables/__tests__/usePipSession.test.ts
|
||||
- neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-05]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Entering picture-in-picture closes the lightbox, and it closes with a deliberate handoff animation rather than blinking out (UIFIX-05)"
|
||||
- "The video keeps playing in the picture-in-picture window after the lightbox has closed — closing the lightbox no longer takes the session with it"
|
||||
- "An active picture-in-picture session survives a main-tab change: the playing element is no longer a descendant of any view that a tab switch can detach"
|
||||
- "Buffering does not end the session — a waiting or stalled event pauses nothing, tears nothing down, and leaves the session active (UIFIX-05 adjacency edge)"
|
||||
- "Only an explicit stop ends the session: leaving picture-in-picture is the single path that releases the element and cleans up"
|
||||
- "A normal close, with no picture-in-picture involved, looks and animates exactly as it does today (UIFIX-05 empty edge — the no-session case)"
|
||||
- "The lightbox's props and emitted events are unchanged, so every existing call site keeps working without edits"
|
||||
- "The handoff animation is disabled under prefers-reduced-motion, matching the site-wide convention"
|
||||
prohibitions:
|
||||
- statement: "A picture-in-picture session MUST NOT keep media playing after the user has ended it, and MUST NOT leave an orphaned video element or a live object URL in the document once released — release always tears down what it adopted"
|
||||
category: privacy
|
||||
- statement: "The persistent host MUST NOT be visible, focusable, interactive, or able to affect layout in any state — it is an off-screen custodial element, never a second player UI"
|
||||
category: safety
|
||||
- statement: "This plan MUST NOT change MediaLightbox's prop names, prop types, or emitted events — plan 01-14 adds a second instance of this component in parallel, and a contract change would break it"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/composables/usePipSession.ts
|
||||
provides: "Singleton picture-in-picture session with a body-level custodial host for the playing element"
|
||||
contains: "adopt"
|
||||
- path: neode-ui/src/components/__tests__/MediaLightboxPip.test.ts
|
||||
provides: "Handoff-closes-lightbox, buffering-survives, release-on-leave assertions"
|
||||
min_lines: 50
|
||||
key_links:
|
||||
- from: neode-ui/src/components/cloud/MediaLightbox.vue
|
||||
to: neode-ui/src/composables/usePipSession.ts
|
||||
via: "on enterpictureinpicture the lightbox hands its video to the session host before emitting close, so the element outlives its own unmount"
|
||||
pattern: "usePipSession"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make picture-in-picture behave like a handoff: the lightbox gets out of the way with a fluid
|
||||
animation, and the session then survives everything that used to kill it.
|
||||
|
||||
Purpose: two user reports, both classified by phase 2 as pre-existing. `src/utils/pip.ts`'s
|
||||
`togglePip()` (f72d4b92, 2026-07-23) only toggles the browser API and never touches
|
||||
`MediaLightbox.vue`'s visibility, so entering PiP leaves a full-screen backdrop sitting over the app.
|
||||
And the session dies on a tab change because the `<video>` lives inside a view that used to unmount
|
||||
outright — phase 2's KeepAlive work removed the unmount, which is what makes survival achievable now,
|
||||
but the element is still a descendant of the view tree and of a `Teleport`, both of which a
|
||||
deactivation can move. The fix is to stop relying on where the element happens to live: hand it to a
|
||||
body-level custodial host at the moment PiP begins.
|
||||
Output: a session composable owning the custodial host, a handoff animation on the lightbox, explicit
|
||||
buffering tolerance, and tests that pin all three.
|
||||
</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
|
||||
@neode-ui/src/utils/pip.ts
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `usePipSession()` — `active`, `adopt`, `release`, `element` | new singleton composable | `neode-ui/src/composables/usePipSession.ts` |
|
||||
| body-level custodial host element | new runtime DOM node (off-screen) | same |
|
||||
| `isPipSupported()` | new lazy support probe | `neode-ui/src/utils/pip.ts` |
|
||||
| PiP handoff close + buffering tolerance | changed component behaviour | `neode-ui/src/components/cloud/MediaLightbox.vue` |
|
||||
| `.lightbox-pip-handoff` + reduced-motion guard | new scoped CSS | same |
|
||||
| `usePipSession.test.ts`, `MediaLightboxPip.test.ts` | new vitest suites | new files |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a video handed to the session outlives its owner's unmount</name>
|
||||
<files>neode-ui/src/composables/usePipSession.ts, neode-ui/src/utils/pip.ts, neode-ui/src/composables/__tests__/usePipSession.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/utils/pip.ts` — the whole file (19 lines). Note that `pipSupported` is a
|
||||
module-level `const` evaluated at import time: that is why a test cannot stub support after
|
||||
import, and why this task adds a lazy probe alongside it rather than replacing it outright.
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` lines 20-31 (the PiP button, `v-if` gated on
|
||||
`pipSupported`, calling `togglePip(videoEl)`) and lines 76-87 (the `<video>` element: `ref`,
|
||||
`:src="currentUrl"`, `:key="currentUrl"`, `controls`, `autoplay`). The `:key` binding matters —
|
||||
any change to `currentUrl` destroys and recreates the element, which is one of the ways a
|
||||
session can die.
|
||||
- `neode-ui/src/App.vue` lines 1-60 — how app-level persistent UI is mounted (`GlobalAudioPlayer`
|
||||
is the precedent for something that must outlive route changes). Read for the precedent only;
|
||||
this plan does not modify `App.vue`, because a composable-owned body-level node needs no
|
||||
template anchor and therefore cannot disturb the dashboard DOM shape that
|
||||
`src/views/dashboard/__tests__/keepAliveTabs.test.ts` pins.
|
||||
- `neode-ui/src/composables/useAudioPlayer.ts` — the house convention for a module-singleton
|
||||
composable holding cross-view media state.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `adopt(video)` moves the element into the session host, and the host is a child of
|
||||
`document.body`.
|
||||
- After `adopt`, unmounting the component that originally rendered the video leaves the element
|
||||
still connected to the document.
|
||||
- `release()` removes the element from the host and leaves nothing behind under `document.body`.
|
||||
- The host is created at most once no matter how many times the composable is called, and its
|
||||
computed presentation is non-interactive and off-screen.
|
||||
- `active` is true between adopt and release and false outside that window.
|
||||
- `isPipSupported()` reads the document at call time, so a test can stub support before or after
|
||||
the module is imported.
|
||||
- `togglePip`'s existing behaviour and signature are unchanged.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test file first and confirm it fails. jsdom has no picture-in-picture API, so stub
|
||||
`document.pictureInPictureEnabled`, `document.pictureInPictureElement`,
|
||||
`HTMLVideoElement.prototype.requestPictureInPicture` and `document.exitPictureInPicture` in the
|
||||
test setup, and drive state by dispatching `enterpictureinpicture` / `leavepictureinpicture`
|
||||
events on the element.
|
||||
|
||||
In `neode-ui/src/utils/pip.ts`: add `export function isPipSupported(): boolean` that performs the
|
||||
same three checks at call time instead of at import time. Leave the existing `pipSupported` const
|
||||
and `togglePip` exactly as they are so nothing that imports them today changes behaviour.
|
||||
|
||||
Create `neode-ui/src/composables/usePipSession.ts` as a module singleton exporting
|
||||
`usePipSession()` returning at least `active` (readonly ref), `element` (readonly ref) and the
|
||||
functions `adopt(video: HTMLVideoElement)` and `release()`.
|
||||
|
||||
The host: create it lazily on first `adopt`, once per module, as a plain `div` appended to
|
||||
`document.body` with an identifying `data-` attribute. Style it so it can never be seen or
|
||||
interacted with and can never affect layout — fixed position, off-screen, one pixel, zero opacity,
|
||||
no pointer events, `aria-hidden`, and not focusable. Do not give it a visible size or a z-index
|
||||
that could ever place it over the app.
|
||||
|
||||
`adopt(video)`: append the element into the host (this both keeps it in the document and detaches
|
||||
it from whatever view owned it), record it as `element`, set `active`, and attach a
|
||||
`leavepictureinpicture` listener that calls `release()`. Adopting while a session is already
|
||||
active must release the previous one first rather than leaking it.
|
||||
|
||||
`release()`: remove the adopted element from the host, pause it, clear its `src` and call `load()`
|
||||
so no media keeps buffering, drop the listener, clear `element`, and clear `active`. Leave the
|
||||
host itself in place for reuse — an empty off-screen div costs nothing and re-creating it on every
|
||||
session is churn.
|
||||
|
||||
Do not import this composable anywhere yet; Task 2 wires it. Keep it free of Vue lifecycle hooks —
|
||||
it is a module singleton, and a lifecycle hook in a bare composable is exactly the silent-no-op
|
||||
class phase 2 hit twice.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/composables/__tests__/usePipSession.test.ts && npx vitest run src/composables/__tests__/usePipSession.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/composables/__tests__/usePipSession.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite contains a case asserting the adopted element is still `document.body.contains(...)` after the owning component unmounts.
|
||||
- The suite contains a case asserting `release()` leaves no adopted element under the host.
|
||||
- The suite contains a case asserting repeated `usePipSession()` calls create exactly one host node.
|
||||
- `grep -v '^\s*//' neode-ui/src/utils/pip.ts | grep -c 'isPipSupported'` equals 1.
|
||||
- `git diff -- neode-ui/src/utils/pip.ts | grep -c '^-'` is at most 1 (only the trailing-context line changes; `togglePip` and `pipSupported` are additions-only edits).
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A video handed to the session stays in the document no matter what happens to the component that rendered it, and release tears it down completely.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: The lightbox hands off — enter PiP, animate closed, keep playing</name>
|
||||
<files>neode-ui/src/components/cloud/MediaLightbox.vue, neode-ui/src/components/__tests__/MediaLightboxPip.test.ts</files>
|
||||
<read_first>
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` — the whole file (449 lines). Specifically:
|
||||
the `Teleport`/`Transition name="lightbox-fade"` shell and the `v-if="show"` backdrop; the PiP
|
||||
button; the `<video>` with its `ref` and `:key`; `close()`, which emits and nothing else;
|
||||
`onUnmounted`, which revokes every URL in `urlCache`; and the `.lightbox-backdrop` /
|
||||
`lightbox-fade` CSS at the bottom, which is what a normal close animates with today and must
|
||||
keep animating with.
|
||||
- `neode-ui/src/composables/usePipSession.ts` as left by Task 1.
|
||||
- `neode-ui/src/components/SendBitcoinModal.vue` — grep it for `prefers-reduced-motion` and copy
|
||||
that media-query syntax verbatim for the handoff guard.
|
||||
- `neode-ui/src/components/__tests__/` — any existing suite in this directory, for the house
|
||||
mounting and assertion conventions.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Dispatching `enterpictureinpicture` on the lightbox's video causes the component to emit `close`
|
||||
exactly once.
|
||||
- Before that emit, the video has been adopted by the session, so it is no longer a descendant of
|
||||
the lightbox's own subtree.
|
||||
- The handoff class is applied to the backdrop for the duration of the animation and only on the
|
||||
PiP path — closing with the close button or Escape applies no handoff class.
|
||||
- After the component unmounts following a handoff, the video is still connected to the document.
|
||||
- Dispatching `leavepictureinpicture` releases the session.
|
||||
- The component's declared props and emits are unchanged.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the test cases first and confirm they fail.
|
||||
|
||||
In `MediaLightbox.vue`, wire the session. On the video element, add `enterpictureinpicture` and
|
||||
`leavepictureinpicture` handlers — listen for the events rather than inferring from the button
|
||||
click, so a PiP entered by any route (the browser's own control, a keyboard shortcut) behaves the
|
||||
same.
|
||||
|
||||
On enter: adopt the video into the session, add a `lightbox-pip-handoff` class to the backdrop, and
|
||||
emit `close` when the handoff animation finishes — drive that off `transitionend` with a bounded
|
||||
fallback timer so a browser that skips the transition still closes. Order matters and must be
|
||||
exactly this: adopt first, animate second, emit last. Adopting first is what makes the element
|
||||
survive the unmount that the emit triggers.
|
||||
|
||||
Design the handoff so it reads as the video moving into the picture-in-picture window rather than a
|
||||
dismissal: the backdrop's blur and opacity fall away while the content scales down slightly and
|
||||
drifts toward the corner the PiP window occupies, over roughly 300ms on the house easing. Keep it
|
||||
scoped, keep it on the existing `.lightbox-backdrop`/content elements rather than restructuring the
|
||||
markup, and guard the motion with the `prefers-reduced-motion` media query copied from
|
||||
`SendBitcoinModal.vue` — under reduced motion the handoff becomes an immediate close, never a
|
||||
lingering one.
|
||||
|
||||
On leave: call the session's release. Because the lightbox has already unmounted by then, the
|
||||
session's own listener from Task 1 is the primary path; the component-level handler exists for the
|
||||
case where PiP is exited while the lightbox is somehow still mounted, and must be idempotent with
|
||||
it.
|
||||
|
||||
Switch the PiP button's `v-if` from the import-time `pipSupported` const to `isPipSupported()` so
|
||||
the button's presence is testable.
|
||||
|
||||
Do not change `props`, `defineEmits`, `close()`'s emitted event, the normal-close transition, the
|
||||
navigation arrows, the keyboard handler, the media-loading logic, `urlCache`, or the `onUnmounted`
|
||||
revoke. Plan 01-14 adds a second instance of this component with the same prop set; a contract
|
||||
change would break it.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/components/__tests__/MediaLightboxPip.test.ts && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts` exits 0 (the `test -f` guard is required).
|
||||
- The suite contains a case asserting exactly one `close` emit on `enterpictureinpicture`, and a case asserting no handoff class is applied on a button-driven close.
|
||||
- The suite contains a case asserting the video is still document-connected after the post-handoff unmount.
|
||||
- `grep -c 'usePipSession' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 2.
|
||||
- `grep -c 'enterpictureinpicture' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1.
|
||||
- `grep -c 'prefers-reduced-motion' neode-ui/src/components/cloud/MediaLightbox.vue` equals 1.
|
||||
- `git diff -- neode-ui/src/components/cloud/MediaLightbox.vue | grep -cE '^-.*(defineProps|defineEmits|fetchBlobUrl|streamUrl|startIndex)'` equals 0 — the public contract is untouched.
|
||||
- `cd neode-ui && npx vitest run` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>Entering picture-in-picture animates the lightbox away and leaves the video playing; a normal close is unchanged.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Buffering and navigation cannot end a session — then prove it in a browser</name>
|
||||
<files>neode-ui/src/components/cloud/MediaLightbox.vue, neode-ui/src/components/__tests__/MediaLightboxPip.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) and it serves at least one video — jsdom has no picture-in-picture implementation, so only a Chromium-based browser can prove the session actually survives</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/src/components/cloud/MediaLightbox.vue` as left by Task 2 — specifically `prev()`,
|
||||
`next()`, the `watch(currentItem, …)` that calls `loadMedia` and sets `currentUrl` to null, and
|
||||
the `:key="currentUrl"` binding on the video. Each of these can destroy the playing element.
|
||||
- `neode-ui/src/views/dashboard/keepAlive.ts` (or wherever `KEEP_ALIVE_PATHS` is defined — grep for
|
||||
it) — enough to understand that a main-tab switch now deactivates rather than unmounts the view,
|
||||
which is the change that makes tab survival reachable at all.
|
||||
</read_first>
|
||||
<action>
|
||||
Close the remaining ways a session can die.
|
||||
|
||||
Add explicit `waiting` and `stalled` handlers on the video that do nothing but record that
|
||||
buffering is happening — no pause, no reload, no src change, no release. Their existence is the
|
||||
point: they document that buffering is a tolerated state and give a test something to assert
|
||||
against, so a later change cannot quietly add teardown there. Do not add a `pause` handler that
|
||||
releases the session; a pause during buffering and a pause by the user are indistinguishable from
|
||||
the element, and only an explicit exit from picture-in-picture may end a session.
|
||||
|
||||
Guard the destroy-the-element paths: while the session is active, `prev()` and `next()` return
|
||||
early, and the `currentItem` watcher does not reset `currentUrl`. In the normal flow the lightbox
|
||||
has already closed by then and these are unreachable, but they are cheap insurance against the
|
||||
exact class of bug this requirement is about.
|
||||
|
||||
Add test cases: a `waiting` event leaves the session active; a `stalled` event leaves the session
|
||||
active; `next()` during an active session does not change the rendered item.
|
||||
|
||||
Then prove it in a browser, because jsdom cannot. Start the dev preview in Chromium, open a video
|
||||
in the lightbox, and record each of these in the SUMMARY:
|
||||
|
||||
1. Click the picture-in-picture button. Expected: the lightbox animates away as a handoff — it
|
||||
should read as the video moving, not as a dismissal — and the video keeps playing in the PiP
|
||||
window.
|
||||
2. With PiP playing, switch between main tabs several times. Expected: playback continues
|
||||
uninterrupted.
|
||||
3. With PiP playing, force a buffering pause (throttle the network in devtools, or seek far ahead).
|
||||
Expected: it resumes and the PiP window stays.
|
||||
4. Close the PiP window explicitly. Expected: playback stops and nothing is left behind — check the
|
||||
element inspector for a stray video under `document.body`.
|
||||
5. Open the lightbox again and close it with the close button and with Escape. Expected: exactly
|
||||
the animation it had before this plan.
|
||||
|
||||
If the handoff does not read as a handoff, adjust the animation and re-record; the requirement
|
||||
asks for a fluid on-brand transition, so a jarring one is a failed task.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts && npx vitest run && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
|
||||
- `grep -c 'waiting' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1 and `grep -c 'stalled' neode-ui/src/components/cloud/MediaLightbox.vue` is at least 1.
|
||||
- The suite contains buffering-tolerance cases for both `waiting` and `stalled`, and a navigation-guard case.
|
||||
- `grep -rq 'lightbox-pip-handoff' ../web/dist/neode-ui/assets/` succeeds from `neode-ui` after the build (per CLAUDE.md the frontend build can silently no-op).
|
||||
- The SUMMARY records all five browser observations, naming the browser and version, and states whether the handoff needed adjustment to read correctly.
|
||||
- The SUMMARY explicitly confirms observation 4 found no orphaned element left under the document.
|
||||
</acceptance_criteria>
|
||||
<done>Buffering and navigation cannot end a session, and all five behaviours are confirmed in a real browser.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The custodial-host approach was chosen over "keep the lightbox mounted but invisible".** The todo
|
||||
offered both. Keeping the component mounted would leave the video inside a `Teleport` inside a
|
||||
`KeepAlive`d view, and both of those move their subtrees on deactivation — a moved element is a
|
||||
removed element as far as the picture-in-picture spec is concerned. The planner did not verify Vue
|
||||
3.5's exact teleport-under-deactivation behaviour, and deliberately chose the design that does not
|
||||
depend on the answer. If the executor establishes that the simpler approach is safe, raise it rather
|
||||
than switching silently.
|
||||
- **The PiP window's corner is browser- and user-controlled**, so the handoff's drift direction is a
|
||||
best-effort convention, not a guaranteed match. Task 3's browser observation is where it is judged.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| in-app media playback → an OS-level window outside the app's own chrome | Picture-in-picture puts content in a surface the app no longer draws |
|
||||
| adopted element → document lifetime | An element deliberately kept alive past its owner's unmount is state that outlives its normal cleanup |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-67 | Information Disclosure | private media continuing to play in a floating window after the user believes they closed it | high | mitigate | The lightbox closing is now an explicit consequence of the user starting PiP, not a side effect; release pauses, clears `src` and calls `load()`, and Task 3's fourth browser observation requires confirming nothing is left behind |
|
||||
| T-01-68 | Denial of Service | an orphaned adopted element buffering a large stream forever after its owner is gone | high | mitigate | Release is bound to `leavepictureinpicture` inside the session itself, so it fires even when the component that adopted the element no longer exists; a test asserts the host is empty after release |
|
||||
| T-01-69 | Tampering | the custodial host being reachable or clickable and intercepting input | medium | mitigate | The host is off-screen, one pixel, zero opacity, pointer-events none, `aria-hidden` and non-focusable, and the prohibition forbids any state in which it can affect layout |
|
||||
| T-01-70 | Elevation of Privilege | a second component adopting into an already-active session and leaking the first element | medium | mitigate | `adopt` releases any existing session first; a test covers the repeated-adopt path |
|
||||
| T-01-71 | Repudiation | a contract change to the lightbox silently breaking the parallel plan 01-14 | medium | mitigate | An explicit prohibition plus a diff-based acceptance criterion fail the task if props or emits change |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — two new source files, two edits, two vitest files. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
|
||||
- `cd neode-ui && npm run build` — green, and the built bundle carries the handoff class.
|
||||
- Five browser observations recorded, including the no-orphan check.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Entering picture-in-picture closes the lightbox with a handoff animation and the video keeps playing.
|
||||
- The session survives main-tab changes and buffering; only an explicit stop ends it.
|
||||
- Release leaves nothing playing and nothing orphaned.
|
||||
- A normal close is visually unchanged, and the component's public contract is untouched.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md` when done, recording the five
|
||||
browser observations, the browser and version used, and any animation adjustment made.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 16
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["01-11"]
|
||||
files_modified:
|
||||
- core/archipelago/src/container/secrets.rs
|
||||
- core/archipelago/src/container/prod_orchestrator.rs
|
||||
autonomous: false
|
||||
requirements: [FED-07]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A node already running a gateway on the shipped default credential rotates itself onto a unique one without an operator having to know it was affected (FED-07 migration)"
|
||||
- "Rotation preserves the gateway's data: /var/lib/archipelago/fedimint-gateway survives, and so do the container name, its ports, its volumes and its adoption identity (CLAUDE.md — migrations never destroy data)"
|
||||
- "A node already carrying a unique credential is left completely alone — detection matches the known defaults only, never 'anything I did not generate this run' (FED-07 adjacency edge)"
|
||||
- "Rotation runs at most once per affected node: after it completes, later reconcile ticks detect nothing and change nothing (FED-07 idempotence)"
|
||||
- "A rotation the operator can see: it is announced in the node's logs naming the app and that credentials changed, and it never prints the credential itself"
|
||||
- "After rotation the operator has a supported way to obtain the new gateway credential, so rotating does not lock them out of their own gateway"
|
||||
- "A rotation that cannot complete leaves the previous working state intact and reports an error rather than leaving a gateway configured against a credential nobody holds (FED-07 failure-surfacing)"
|
||||
prohibitions:
|
||||
- statement: "Rotation MUST NOT delete, move, reinitialise or chown the gateway's data directory, its Lightning backend credentials, or any other app's secrets — it replaces one credential file and lets the existing recreate path rebuild the container around unchanged data"
|
||||
category: safety
|
||||
- statement: "The rotated credential MUST NOT be written to a log line, a status RPC response, a deploy transcript, or any file outside the 0600 rootless secrets directory"
|
||||
category: privacy
|
||||
- statement: "Detection MUST NOT rotate a credential merely because it is unrecognised — only an exact match against the known-default denylist triggers rotation, so an operator who set their own credential deliberately keeps it"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: core/archipelago/src/container/secrets.rs
|
||||
provides: "Denylist-driven detection and rotation of a compromised gateway credential"
|
||||
contains: "rotate_compromised_gateway_credential"
|
||||
key_links:
|
||||
- from: core/archipelago/src/container/prod_orchestrator.rs
|
||||
to: core/archipelago/src/container/secrets.rs
|
||||
via: "the reconcile path that already materialises generated secrets also asks for compromised-credential rotation, so an existing node heals on its next tick"
|
||||
pattern: "rotate_compromised_gateway_credential"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Get the nodes that are already running on the shipped gateway credential off it, without touching
|
||||
their data.
|
||||
|
||||
Purpose: plan 01-11 stops new installs from ever taking a shipped credential, but it does nothing for
|
||||
the nodes that already did. Those gateways answer to a credential published in this repository, so
|
||||
until they rotate, FED-07 is only half closed — and the requirement is explicit that existing installs
|
||||
carrying the default get a migration path. The repo's standing rule bounds how: migrations never
|
||||
destroy data — preserve `/var/lib/archipelago/<app>`, secrets, credentials, ports and adoption
|
||||
container names, and keep a rollback path.
|
||||
Output: detection against the denylist plan 01-11 established, rotation through the recreate machinery
|
||||
that already preserves data, an operator-visible announcement, and a sign-off on a real node.
|
||||
</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-11-SUMMARY.md
|
||||
@apps/fedimint-gateway/manifest.yml
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| `rotate_compromised_gateway_credential(secrets_dir) -> Result<bool>` | new detection + rotation entry point | `core/archipelago/src/container/secrets.rs` |
|
||||
| rotation call on the reconcile path | changed reconcile step | `core/archipelago/src/container/prod_orchestrator.rs` |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: End-to-end — a node carrying the default rotates itself and keeps its data</name>
|
||||
<files>core/archipelago/src/container/secrets.rs, core/archipelago/src/container/prod_orchestrator.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/container/secrets.rs` as left by plan 01-11 — the
|
||||
`KNOWN_DEFAULT_GATEWAY_HASHES` denylist, `ensure_gateway_credential`, `gateway_bcrypt_hash`, the
|
||||
shared bcrypt generation helper, and the atomic 0600 `write_secret`. Rotation reuses all of it;
|
||||
write no new generation or file-writing code.
|
||||
- `core/archipelago/src/container/prod_orchestrator.rs` around line 3227 and line 3245 — the
|
||||
comment naming the per-app generated secrets (`fmcd-password`, `fedimint-gateway-hash`, …) and
|
||||
the `crate::container::secrets::ensure_generated_secrets(&self.secrets_dir, manifest)?` call.
|
||||
This is the tick that runs on every reconcile and the natural place to hang detection.
|
||||
- `core/container/src/manifest.rs` — grep for `secret_env_hash` and read its definition and every
|
||||
use. This is the existing mechanism by which a changed secret drives a container recreate, and
|
||||
it is what makes rotation preserve data: the recreate path it feeds already keeps the data
|
||||
directory, ports, volumes and container name. Reuse it rather than stopping and removing the
|
||||
container by hand.
|
||||
- `apps/bitcoin-ui/manifest.yml` lines 8-45 — the in-repo precedent for "the password rotated, so
|
||||
the rendered bytes changed, so the container is recreated". Read it for how a rotation is
|
||||
expected to propagate on this platform.
|
||||
- `core/archipelago/src/container/boot_reconciler.rs` — enough to determine whether boot has its
|
||||
own separate path that also needs the call, or whether it funnels through the same reconcile
|
||||
step. Record the finding; if it needs the call too, add that file to `files_modified` in the
|
||||
SUMMARY.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Given a secrets dir whose gateway hash file contains a denylist entry, rotation replaces it with
|
||||
a freshly generated pair and reports that it rotated.
|
||||
- Given a secrets dir whose gateway hash is not on the denylist, rotation changes nothing and
|
||||
reports that it did not rotate — including when the value is one nobody recognises.
|
||||
- Given a secrets dir with no gateway hash at all, rotation changes nothing and reports that it did
|
||||
not rotate; generation is `ensure_gateway_credential`'s job, not rotation's.
|
||||
- Running rotation twice on the same affected dir rotates once; the second run is a no-op.
|
||||
- After rotation the new hash is not on the denylist and its `.pw` sibling verifies against it.
|
||||
- Rotation touches no file other than the gateway credential pair — every other file in the
|
||||
secrets dir is byte-identical afterwards.
|
||||
</behavior>
|
||||
<action>
|
||||
Write the tests in `secrets.rs`'s `mod tests` first and confirm they fail. Use `tempfile::tempdir`
|
||||
the way the existing tests in that module do, and seed the affected case by writing a denylist
|
||||
entry into the hash file. Include a case that seeds several unrelated secret files alongside it and
|
||||
asserts they are untouched.
|
||||
|
||||
Add `pub fn rotate_compromised_gateway_credential(secrets_dir: &Path) -> Result<bool>` to
|
||||
`secrets.rs`. It reads the gateway hash file; if it is absent or unreadable it returns `Ok(false)`
|
||||
without writing; if its trimmed value is not an exact match for a denylist entry it returns
|
||||
`Ok(false)`; only on an exact match does it generate a replacement pair through the same helper
|
||||
`ensure_gateway_credential` uses and return `Ok(true)`. Because the underlying write is the
|
||||
existing atomic temp-file-plus-rename, a failure mid-rotation leaves the previous file in place —
|
||||
that is the rollback path, and it should be stated in the function's doc comment so nobody later
|
||||
"improves" it into a truncate-in-place.
|
||||
|
||||
Wire it into `prod_orchestrator.rs` immediately alongside the existing `ensure_generated_secrets`
|
||||
call. When it returns `true`, log at info level that the Fedimint gateway credential was rotated
|
||||
because the node was carrying a publicly known default, that the gateway will be recreated, and
|
||||
where the operator can obtain the new one — and never log the value. Then make the recreate happen
|
||||
through the existing `secret_env_hash` change-detection path rather than by stopping or removing
|
||||
the container directly: the hash file changed, so the resolved secret env changes, so the platform's
|
||||
own recreate machinery fires with the data directory, ports, volumes and container name all
|
||||
preserved. If that path does not fire for this app for some reason you discover, do not hand-roll a
|
||||
remove-and-run; stop and record what you found, because a hand-rolled recreate is the exact
|
||||
anti-pattern CLAUDE.md names.
|
||||
|
||||
Settle the operator-recovery question and record the answer. The plaintext already lands at
|
||||
`fedimint-gateway-hash.pw`, 0600, rootless. Determine whether the app-credentials surface in the UI
|
||||
(`neode-ui/src/views/Credentials.vue` and whatever RPC feeds it) already exposes per-app generated
|
||||
credentials. If it does, confirm the rotated value appears there and say so. If it does not, the
|
||||
log line must name the exact path an operator reads, and the SUMMARY must record that a UI surface
|
||||
is a gap with the file that would own it. Do not leave "how does the operator get the new password"
|
||||
unanswered — rotating a credential the user cannot retrieve is a lockout, not a fix.
|
||||
|
||||
Do not change the gateway's ports, volumes, data directory, network, capabilities, health check or
|
||||
any other manifest-driven property.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo test -p archipelago secrets 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd core && cargo test -p archipelago secrets` exits 0 and its output names cases for: rotates-on-denylisted, no-op-on-unique, no-op-on-absent, idempotent-second-run, and other-secrets-untouched.
|
||||
- `grep -v '^\s*//' core/archipelago/src/container/secrets.rs | grep -c 'rotate_compromised_gateway_credential'` is at least 2 (definition plus test use).
|
||||
- `grep -v '^\s*//' core/archipelago/src/container/prod_orchestrator.rs | grep -c 'rotate_compromised_gateway_credential'` equals 1.
|
||||
- `git diff -- core/archipelago/src/container/prod_orchestrator.rs | grep -ciE '^\+.*(rm -f|remove_dir_all|podman rm|chown)'` equals 0 — no hand-rolled teardown was introduced.
|
||||
- `cd core && cargo build -p archipelago` exits 0 and `cd core && cargo test -p archipelago` exits 0.
|
||||
- The SUMMARY records the boot-reconciler finding, whether the recreate fired through `secret_env_hash`, and the operator-recovery answer with its evidence.
|
||||
</acceptance_criteria>
|
||||
<done>An affected node heals itself on its next reconcile tick, once, without losing data, and the operator can still get into their gateway.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Confirm the rotation on a real node</name>
|
||||
<what-built>
|
||||
FED-07 in full, ready to run on a node:
|
||||
- No code path in the tree can configure a Fedimint gateway with a credential that shipped with
|
||||
the repository. The five fallback sites (two in the Rust orchestrator, three in the install and
|
||||
deploy scripts) are gone, along with the plaintext password fallback in the Tailscale deploy
|
||||
path. The one surviving copy of the old hash is a denylist used only to detect it.
|
||||
- Every install now takes its credential from the per-install secret the manifest already
|
||||
declared, generated at 0600 by `container::secrets`.
|
||||
- A node that is already carrying the old default rotates itself on its next reconcile tick and
|
||||
is recreated around its existing data directory, ports and container name.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run this on archi-dev-box. Note that archy-x250-dev has been offline since phase 2 — do not wait
|
||||
for it; single-node verification with the second-node gap recorded honestly is the expected
|
||||
pattern here.
|
||||
|
||||
1. **Before you change anything, record the current state.** On the node, check whether the
|
||||
gateway credential file currently holds the old shipped value, and whether a gateway container
|
||||
is running. Note both. This is what tells you whether you are testing the rotation path or the
|
||||
already-clean path — say which one you got.
|
||||
|
||||
2. **Deploy this phase's build to archi-dev-box only.** Use the dev-pair deploy path, not a
|
||||
release, not an OTA, and not the Tailscale alpha-tester path. Record the exact command.
|
||||
|
||||
3. **Watch the rotation.** Follow the node's logs across a reconcile tick. Expected if the node was
|
||||
affected: one info line saying the gateway credential was rotated because a publicly known
|
||||
default was in use, naming where to get the new one — and no credential value anywhere in the
|
||||
log. Expected if the node was already clean: no rotation line at all.
|
||||
|
||||
4. **Confirm the credential is now unique.** Read the gateway hash file on the node and confirm it
|
||||
is not the old shipped value, and that its file mode is 0600 and it is owned by the rootless
|
||||
service user, not root.
|
||||
|
||||
5. **Confirm the data survived.** List `/var/lib/archipelago/fedimint-gateway` and confirm its
|
||||
contents are the same ones that were there in step 1 — the gateway's own state must not have been
|
||||
reinitialised. Confirm the container came back with the same name and the same published ports.
|
||||
|
||||
6. **Confirm the gateway actually works.** Check the container is running and healthy, and that its
|
||||
admin endpoint answers. Then authenticate to it with the new credential from the path the log
|
||||
line named. Expected: the new credential works. Then try the old shipped one. Expected: rejected.
|
||||
|
||||
7. **Confirm a fresh install is unique too.** If practical, uninstall and reinstall the gateway on
|
||||
the node and confirm the credential it comes up with differs from the one from step 4 — that is
|
||||
the per-install property, and it is the whole point of the requirement.
|
||||
|
||||
8. **Confirm nothing else moved.** Run `tests/lifecycle/run-gate.sh` on the node (the gate runs
|
||||
on-node, never over RPC) and confirm it is still green. This plan changed orchestrator reconcile
|
||||
behaviour, which is exactly the case CLAUDE.md says to re-run the gate for. A single clean pass
|
||||
is enough here; the 5× run is Phase 3's criterion.
|
||||
|
||||
If any step fails, say which numbered step and what you saw — that becomes the gap list rather than
|
||||
a re-run of the whole plan.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to sign off FED-07, or describe the issues by step number.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **Whether archi-dev-box is actually affected is unknown to the planner.** Its gateway may have been
|
||||
provisioned by a path that generated a unique credential. Step 1 makes the executor establish which
|
||||
case they are in and say so, rather than reporting a green run that never exercised the rotation. If
|
||||
the node is clean, the rotation path still needs proving — seed the old value into the credential
|
||||
file on the node deliberately, then re-run steps 3 to 6, and record that you did.
|
||||
- **Whether the UI already exposes per-app generated credentials** was not verified by the planner.
|
||||
Task 1 makes it an explicit finding with a named owning file if it turns out to be a gap.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| gateway admin API → network | The credential being rotated is the only gate on Lightning gateway administration |
|
||||
| reconcile tick → running container | An automated rotation recreates a live, funded service without asking |
|
||||
| node logs → operator and anyone who can read them | The rotation announcement crosses this boundary |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-72 | Elevation of Privilege | a node continuing to answer to the published default after the code fix ships | critical | mitigate | Detection and rotation run on the same reconcile tick that already materialises secrets, so an affected node heals without operator action; step 6 proves the old credential is rejected afterwards |
|
||||
| T-01-73 | Denial of Service | rotation recreating the gateway repeatedly, or in a loop, on every tick | high | mitigate | Rotation is denylist-exact and therefore self-terminating — the rotated value is not on the denylist, so the next tick is a no-op; an idempotence test and step 3's log observation both cover it |
|
||||
| T-01-74 | Information Disclosure | the new credential appearing in a log line, status output or deploy transcript | high | mitigate | An explicit prohibition, the log line is specified to name a path rather than a value, and step 3 requires confirming no value appears in the log |
|
||||
| T-01-75 | Tampering | a hand-rolled remove-and-recreate losing the gateway's data directory | critical | mitigate | The action forbids hand-rolled teardown, routes the recreate through the existing `secret_env_hash` path, and an acceptance criterion greps the diff for teardown primitives; step 5 verifies the data on the node |
|
||||
| T-01-76 | Repudiation | signing off without ever exercising the rotation because the node happened to be clean | high | mitigate | Step 1 forces the executor to declare which case they are in, and the planner assumption requires deliberately seeding the affected state if the node is clean |
|
||||
| T-01-77 | Denial of Service | an operator locked out of their own gateway by a rotation they cannot recover from | high | mitigate | Task 1 requires the recovery path to be settled and named in the log line before this plan is done; step 6 proves the new credential actually authenticates |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — two Rust edits. If an implementation choice would add a crate, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd core && cargo test -p archipelago` — green.
|
||||
- The blocking checkpoint's eight steps, run on archi-dev-box, with the affected-or-clean case declared.
|
||||
- `tests/lifecycle/run-gate.sh` green on-node after the change.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- An affected node rotates itself once, keeps its data, ports and container name, and comes back healthy.
|
||||
- The old shipped credential no longer authenticates; the new one does.
|
||||
- A fresh install produces a different credential again.
|
||||
- The rotation is announced without ever printing the value, and the operator has a named way to retrieve it.
|
||||
- The second dev-pair node's absence is recorded as a gap rather than glossed over.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-16-SUMMARY.md` when done, recording the
|
||||
affected-or-clean verdict for archi-dev-box, the deploy command used, the gate result, the operator
|
||||
recovery path, and any issue text verbatim.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 17
|
||||
type: execute
|
||||
wave: 8
|
||||
depends_on: ["01-14"]
|
||||
files_modified:
|
||||
- neode-ui/src/views/Cloud.vue
|
||||
- neode-ui/src/views/PeerFiles.vue
|
||||
- neode-ui/src/views/__tests__/TransportPills.test.ts
|
||||
autonomous: true
|
||||
requirements: [UIFIX-01]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every place the cloud surfaces show a file's transport state shows it at mobile widths too — a phone user can see whether a file came over FIPS or over Tor (UIFIX-01)"
|
||||
- "The pills are pinned by a test, so a future cleanup or refactor that removes one fails the suite instead of shipping (UIFIX-01 — 'kept, never removed')"
|
||||
- "A peer whose transport is not yet known renders the existing not-known treatment rather than a fabricated pill (UIFIX-01 empty edge)"
|
||||
- "A pill never truncates into meaninglessness or overlaps its neighbour at the narrowest supported width — it wraps or compacts instead"
|
||||
- "Desktop rendering of every pill is unchanged: same text, same colours, same position, same spacing"
|
||||
- "Every render site of the transport pill in the cloud surfaces has a recorded mobile verdict — no site is left unchecked"
|
||||
prohibitions:
|
||||
- statement: "The transport pill MUST NOT claim a transport the app has not actually observed — it renders from the recorded result of the last real browse, and a missing or stale reading shows the not-known treatment rather than defaulting to the more reassuring value"
|
||||
category: transparency
|
||||
- statement: "Nothing on these views may change except the transport pills' responsive rendering — file rows, peer cards, buttons, badges, counts, tabs and every animation stay exactly as they are, and desktop is untouched"
|
||||
category: safety
|
||||
artifacts:
|
||||
- path: neode-ui/src/views/__tests__/TransportPills.test.ts
|
||||
provides: "A render-site pin for every FIPS/Tor pill, so removal breaks the build"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: neode-ui/src/views/PeerFiles.vue
|
||||
to: neode-ui/src/views/Cloud.vue
|
||||
via: "both read the same recorded browse transport for a peer, so the pill means the same thing wherever it renders"
|
||||
pattern: "transport"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Keep the FIPS/Tor pills forever, and make sure a phone shows them.
|
||||
|
||||
Purpose: UIFIX-01 is a BLOCKER with two halves. The user explicitly values these pills ("really
|
||||
helpful") and asked that no future cleanup remove them — that half is solved by pinning them with a
|
||||
test, which nothing in the repo does today. The other half is that at mobile widths they are hidden or
|
||||
cramped, so exactly the users least able to judge their connection cannot see whether a file arrived
|
||||
over the fast encrypted mesh or over Tor. The planner could not determine which specific render site
|
||||
fails on a phone, so this plan audits every site rather than guessing at one.
|
||||
Output: a complete, recorded per-site mobile verdict; a fix at every failing site; and a test that
|
||||
makes their removal a build failure.
|
||||
</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-14-SUMMARY.md
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
Created or changed by **this plan**:
|
||||
|
||||
| Symbol | Kind | File |
|
||||
|---|---|---|
|
||||
| responsive transport-pill rendering | changed template classes at the failing sites | `neode-ui/src/views/Cloud.vue`, `neode-ui/src/views/PeerFiles.vue` |
|
||||
| `neode-ui/src/views/__tests__/TransportPills.test.ts` | new vitest suite — the "never remove these" pin | new file |
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Task 1: Audit every transport-pill site and fix the ones a phone cannot read</name>
|
||||
<files>neode-ui/src/views/Cloud.vue, neode-ui/src/views/PeerFiles.vue, neode-ui/src/views/__tests__/TransportPills.test.ts</files>
|
||||
<precondition>The local dev preview can be started (`cd neode-ui && npm run dev:mock` serves the UI on :8100 against the mock backend) with peer data present — jsdom cannot tell you whether a pill is cramped, only whether it exists</precondition>
|
||||
<read_first>
|
||||
- `neode-ui/src/views/Cloud.vue` lines 280-324 — the peer cards in the Folders tab. The badge row
|
||||
is `flex items-center gap-2 text-xs` holding the trust pill and, when
|
||||
`peerTransport(peer.onion)` is known, the transport pill rendering
|
||||
`FIPS`/`TOR` plus a latency figure, with a `Peer Node` text fallback when it is not known. Note
|
||||
the row has no wrapping and no responsive treatment at all.
|
||||
- `neode-ui/src/views/Cloud.vue` lines 199-218 — the Peer Files aggregated list rows. Each row
|
||||
shows a category icon, filename, size and price, and a peer-name pill — and no transport pill,
|
||||
even though these rows are files from peers. Decide, and record, whether this is a site that
|
||||
should carry one: the requirement is about a user seeing a file's transport state.
|
||||
- `neode-ui/src/views/Cloud.vue` lines 150-178 — the Paid Files rows, for the same decision.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 8-38 — the header. There is a desktop title block
|
||||
(`hidden md:block`) carrying the pill, and a separate `md:hidden` copy of the pill added
|
||||
specifically so mobile still sees it. Read the comment above it: someone already fixed one half
|
||||
of this. Confirm whether that copy actually renders and is legible on a phone today.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 640-676 — `transportPill`, the single source of the
|
||||
label, colour classes and tooltip for `fips` / `mesh` / `lan` / `tor` / unknown. This is the
|
||||
canonical mapping; anything this plan adds must use it rather than re-deriving colours.
|
||||
- `neode-ui/src/views/PeerFiles.vue` lines 152-232 — the per-file card body, which shows an access
|
||||
badge and action buttons, for the same site decision.
|
||||
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — the house convention for a
|
||||
structural pin test, and the file the standing rule requires stay green.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Mounting each surface with a known transport renders the transport pill with its label text.
|
||||
- Mounting with an unknown transport renders the existing not-known treatment and no pill.
|
||||
- Removing the pill from any audited site makes the suite fail — that is the whole point of the
|
||||
test, so write each assertion so it is specific to a site, not satisfied by any pill anywhere.
|
||||
- The pill's label and colour come from the canonical mapping, not from a duplicated table.
|
||||
</behavior>
|
||||
<action>
|
||||
Start with the audit, because the fix depends on it. Start the dev preview and open each of the
|
||||
sites listed above at a phone viewport (390×740 is the reference; also check 320×640, the narrowest
|
||||
the app supports). For each site record: does the pill render at all, is its text fully readable,
|
||||
does it overlap or push anything, and does it survive a long peer name or a long filename. Put the
|
||||
result in the SUMMARY as a table with columns: site, file and line, renders on mobile, legible,
|
||||
action.
|
||||
|
||||
Then fix every site the audit marked as failing, and only those. The likely shapes, depending on
|
||||
what you find: let the badge row wrap (`flex-wrap`) so a pill drops to a second line instead of
|
||||
overflowing; drop the latency figure from the pill at small widths while keeping the transport word,
|
||||
since the word is the security-relevant part and the milliseconds are not; or render a compact pill
|
||||
variant on mobile the way `PeerFiles.vue`'s header already renders a mobile-specific copy. Choose
|
||||
per site based on what you actually saw, and record why. Do not apply a responsive change to a site
|
||||
the audit passed — an unnecessary change to a working desktop layout is exactly what the standing
|
||||
rule forbids.
|
||||
|
||||
Settle the two open site questions rather than leaving them: whether the Peer Files aggregated
|
||||
rows and the Paid Files rows should carry a transport pill. Both list files that came from peers,
|
||||
and the requirement is about a user seeing a file's transport state — but the aggregated rows show
|
||||
files from many peers at once, and a per-row pill may be the honest answer or may be noise. Make a
|
||||
decision, state the reasoning, and if the answer is yes, implement it using the canonical mapping
|
||||
and add it to the pin test. If the answer is no, record why the existing peer-level pill is
|
||||
sufficient for those rows.
|
||||
|
||||
Write the pin test as you go: one assertion per confirmed render site, each keyed to something that
|
||||
identifies that site specifically, plus a comment at the top of the file saying in plain words that
|
||||
these pills are a user-requested permanent feature and that a failure here means someone removed
|
||||
one, not that the test is stale.
|
||||
|
||||
Change nothing else on either view. Desktop rendering must be untouched at every site, including
|
||||
the ones you fix.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && test -f src/views/__tests__/TransportPills.test.ts && npx vitest run src/views/__tests__/TransportPills.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The test file exists and `cd neode-ui && npx vitest run src/views/__tests__/TransportPills.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
|
||||
- The suite has at least one site-specific assertion per render site the audit confirmed, and at least one unknown-transport case asserting no pill is fabricated.
|
||||
- The SUMMARY contains the per-site audit table, with a row for each of the five sites named in `read_first` and an explicit action for each.
|
||||
- The SUMMARY records the decision and reasoning for the Peer Files aggregated rows and the Paid Files rows.
|
||||
- `grep -c 'transportPill' neode-ui/src/views/PeerFiles.vue` is unchanged or higher — the canonical mapping was reused, never replaced.
|
||||
- `cd neode-ui && npx vitest run` exits 0 — every existing suite, including `keepAliveTabs.test.ts`, stays green.
|
||||
</acceptance_criteria>
|
||||
<done>Every transport-pill site has a recorded mobile verdict, the failing ones are fixed, and a test makes their removal a build failure.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Re-check the fixed sites on a phone viewport and confirm desktop is untouched</name>
|
||||
<files>neode-ui/src/views/__tests__/TransportPills.test.ts</files>
|
||||
<precondition>Task 1's changes are in the working tree and the dev preview can be restarted against them</precondition>
|
||||
<read_first>
|
||||
- The audit table Task 1 wrote into the SUMMARY — it is the checklist for this task.
|
||||
- `neode-ui/DEV-SCRIPTS.md` lines 1-40 — starting and stopping the preview.
|
||||
</read_first>
|
||||
<action>
|
||||
Re-open every site the audit marked as fixed at both 390×740 and 320×640 and confirm the pill now
|
||||
renders fully and legibly, with a long peer name and a long filename present so the overflow case
|
||||
is actually exercised — if the mock data has no long names, edit the rendered text in the element
|
||||
inspector to force it rather than changing the mock backend, and say so.
|
||||
|
||||
Then confirm desktop is untouched. Open each changed site at 1440×900 and compare against the
|
||||
pre-change build. State in the SUMMARY that each changed site renders identically on desktop, or
|
||||
name what moved and fix it — the standing rule is that the only visual change this phase ships is
|
||||
the one the user asked for.
|
||||
|
||||
Finally, confirm the pin does its job: temporarily delete one pill from one site, run the suite,
|
||||
and confirm it fails. Restore the pill and confirm the suite passes again. Record both results.
|
||||
A pin that does not fail when the thing it pins is removed is not a pin.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd neode-ui && npx vitest run && npm run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd neode-ui && npx vitest run` exits 0 and `cd neode-ui && npm run build` exits 0.
|
||||
- The SUMMARY records the 390×740 and 320×640 re-check for every fixed site, including the long-name case and how it was forced.
|
||||
- The SUMMARY states explicitly, per changed site, that desktop rendering at 1440×900 is unchanged.
|
||||
- The SUMMARY records the deliberate-removal check: which pill was removed, that the suite failed, and that it passed again after restoring.
|
||||
- `git status --short -- neode-ui/src/views/Cloud.vue neode-ui/src/views/PeerFiles.vue` shows no leftover deliberate-removal edit.
|
||||
</acceptance_criteria>
|
||||
<done>The pills are readable on the narrowest supported phone, desktop is unchanged, and the pin is proven to actually fail on removal.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **The planner could not identify which specific site fails on mobile.** Reading the source showed
|
||||
`PeerFiles.vue`'s header already carries a mobile-specific pill copy added for exactly this reason,
|
||||
and `Cloud.vue`'s peer-card badge row has no responsive treatment at all — the latter is the most
|
||||
likely culprit, but "most likely" is not evidence. Task 1 is therefore an audit that fixes what it
|
||||
finds, rather than a fix aimed at a guessed target. If the audit finds every site already renders
|
||||
correctly, that is a legitimate outcome for the mobile half — record it with the evidence, and the
|
||||
"kept, never removed" half of the requirement is still fully delivered by the pin test.
|
||||
- **Whether the aggregated Peer Files rows and the Paid Files rows should carry their own pill is a
|
||||
genuine product question**, not something the planner should decide from a file read. Task 1
|
||||
requires a stated decision with reasoning either way, so the answer is recorded rather than assumed.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| observed browse transport → security claim shown to the user | The pill is a security signal: it tells the user whether their file moved over the encrypted mesh or over Tor |
|
||||
| peer-supplied names → rendered alongside the pill | Long or hostile peer names share the row the pill lives in |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-78 | Spoofing | a pill claiming a transport that was not actually used, so a user trusts a channel they should not | high | mitigate | The prohibition requires the pill to render only from the recorded browse result; an unknown-transport test case asserts no pill is fabricated, and the canonical mapping is reused rather than duplicated |
|
||||
| T-01-79 | Information Disclosure | a mobile user unable to see that a file arrived over Tor and acting as though it were the trusted mesh path | high | mitigate | This is the requirement itself; Task 1's audit covers every render site and Task 2 re-checks each fix at the two narrowest supported widths |
|
||||
| T-01-80 | Tampering | a later cleanup silently deleting the pills again | high | mitigate | The pin test asserts per site, and Task 2 proves the pin actually fails when a pill is removed |
|
||||
| T-01-81 | Spoofing | a long peer-supplied name pushing the pill off screen so it is effectively absent on mobile | medium | mitigate | Task 2 requires the long-name case to be exercised deliberately at both narrow widths, not just whatever the mock data happens to contain |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing — template class changes and one vitest file. If an implementation choice would add a dependency, stop: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd neode-ui && npx vitest run` — green, including `keepAliveTabs.test.ts`.
|
||||
- `cd neode-ui && npm run build` — green.
|
||||
- Per-site audit table plus the 390×740 / 320×640 re-check and the 1440×900 desktop comparison, all recorded.
|
||||
- The deliberate-removal check confirming the pin fails on removal.
|
||||
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Every transport-pill render site has an evidence-backed mobile verdict.
|
||||
- Every failing site is fixed, and no passing site was touched.
|
||||
- Desktop rendering is unchanged everywhere.
|
||||
- A test pins the pills so removing one breaks the build, and that pin is proven to work.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md` when done, recording the audit
|
||||
table, the two site decisions with reasoning, the re-check observations, and the deliberate-removal
|
||||
result.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 18
|
||||
type: execute
|
||||
wave: 9
|
||||
depends_on: ["01-12", "01-13", "01-14", "01-15", "01-17"]
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements: [UIFIX-01, UIFIX-02, UIFIX-03, UIFIX-04, UIFIX-05, UIFIX-06]
|
||||
gap_closure: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All six UI fixes are exercised on real node hardware, not only on the local preview, because two of them are about how the app behaves on a device rather than in a viewport"
|
||||
- "The connected-nodes list scrolls at a sibling-matched height on the node's own screen and at a phone width"
|
||||
- "The onboarding tickbox is discoverable on a genuinely short viewport on the node"
|
||||
- "A purchased picture opens in the app's lightbox on the node, with the wait visible"
|
||||
- "Picture-in-picture closes the lightbox with a handoff and survives a real tab change and a real buffering pause on the node"
|
||||
- "The FIPS/Tor pills are readable at phone width on the node"
|
||||
- "Every surface that was not supposed to change is confirmed unchanged on the node — the standing visual-invisibility rule is verified, not assumed"
|
||||
prohibitions:
|
||||
- statement: "This phase's frontend MUST NOT be deployed beyond the dev pair — no OTA, no release, no fleet node, no alpha-tester path; a verification step is never a reason to widen a deploy"
|
||||
category: safety
|
||||
- statement: "Sign-off MUST NOT be given on local-preview evidence alone for any check that names the node — the local preview and a real device disagree exactly where these fixes matter, which is why phase 2's on-device pass found four issues the preview did not"
|
||||
category: transparency
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Put all six UI fixes in front of a human, on the node, once.
|
||||
|
||||
Purpose: each of plans 01-12 through 01-17 verifies itself with tests and a local-preview observation,
|
||||
which is the right granularity for an autonomous plan but is not sufficient evidence for a
|
||||
user-reported blocker. Two of these fixes — picture-in-picture surviving a tab change, and the pills
|
||||
at phone width — are about device behaviour that a desktop preview cannot reproduce. Rather than
|
||||
interrupting five plans with five checkpoints, they are gathered here so the operator is asked once,
|
||||
after the code is on archi-dev-box. This mirrors how plan 01-10 consolidates the federation and
|
||||
Lightning sign-offs.
|
||||
Output: a recorded sign-off, or a numbered issue list 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-12-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-15-SUMMARY.md
|
||||
@.planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Put the six fixes on archi-dev-box, frontend only, dev pair only</name>
|
||||
<files>none — this task builds, deploys and verifies delivery; it modifies no file in the repository</files>
|
||||
<precondition>archi-dev-box resolves and answers over HTTP from this machine, and `scripts/deploy-config.sh` exists (it is gitignored; `scripts/deploy-config.example` documents it) so the deploy script can authenticate</precondition>
|
||||
<read_first>
|
||||
- `scripts/deploy-to-target.sh` lines 1-30 — the usage block. `--frontend-only` skips the Rust
|
||||
build and container rebuilds; `--live` targets the default host; `--both` fans out to additional
|
||||
hosts; `--tailscale` reaches the alpha-tester nodes and must not be used here.
|
||||
- `.planning/phases/02-ui-performance/02-08-SUMMARY.md` — the exact command phase 2 used for the
|
||||
same kind of dev-pair frontend deploy, and its record of archy-x250-dev being offline. Reuse the
|
||||
command shape rather than inventing one.
|
||||
- The five plan SUMMARYs listed in `<context>` — specifically each one's recorded local-preview
|
||||
observations, so you know what the node is expected to reproduce.
|
||||
</read_first>
|
||||
<action>
|
||||
Build the frontend and deploy it to archi-dev-box with the dev-pair frontend-only path. Record the
|
||||
exact command in the SUMMARY.
|
||||
|
||||
Do not use the Tailscale or alpha-tester paths, do not cut a release, do not touch the OTA manifest,
|
||||
and do not deploy to any fleet node. Note that this plan set's FED-07 work is backend and is
|
||||
verified separately by plan 01-16 — this deploy is frontend only.
|
||||
|
||||
Check whether archy-x250-dev is reachable. It has been offline since phase 2. If it is still
|
||||
offline, record that plainly as a gap rather than waiting for it or pretending the pair was
|
||||
covered; single-node verification on archi-dev-box with the second-node gap recorded honestly is
|
||||
the expected pattern for this phase.
|
||||
|
||||
Then confirm the node is actually serving this build before handing over to the checkpoint — fetch
|
||||
the served bundle from archi-dev-box and grep it for strings this plan set introduced (the
|
||||
onboarding cue copy, the picture-in-picture handoff class, and the paid-item viewer). Grep the
|
||||
served asset, not the local `web/dist` copy: per CLAUDE.md the frontend build can silently no-op,
|
||||
and a checkpoint run against a stale bundle is worse than no checkpoint.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>curl -fsS --max-time 20 "${ARCHY_DEV_URL:?set ARCHY_DEV_URL to archi-dev-box's UI base URL}/" -o /dev/null && echo served</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the exact deploy command and the host list it targeted, and that list contains no fleet, alpha-tester or Tailscale host.
|
||||
- The SUMMARY records the served-bundle grep result for all three introduced strings, naming the URL fetched.
|
||||
- The SUMMARY records archy-x250-dev's reachability, and if unreachable records it as an explicit gap.
|
||||
- No release artifact, OTA manifest or catalog was modified — `git status --short -- release-manifest.json releases/ app-catalog/` is empty.
|
||||
</acceptance_criteria>
|
||||
<done>archi-dev-box is serving a bundle that provably contains all six fixes, and the second-node gap is recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Six-fix sign-off on archi-dev-box</name>
|
||||
<what-built>
|
||||
Six user-reported UI issues, now on archi-dev-box:
|
||||
- **Connected nodes (UIFIX-02).** On the Web5 tab, the connected-nodes card no longer grows to fit
|
||||
every node. Its height now comes from the card beside it in the row and the list scrolls inside
|
||||
that height, with a floor so a short neighbour cannot squash it. Below the two-column breakpoint
|
||||
nothing changed.
|
||||
- **Onboarding tickbox (UIFIX-03).** On a screen too short to show the whole seed step, a soft
|
||||
gradient and a small glass pill reading "One more step below" now appear at the bottom edge of
|
||||
the scrolling area. Clicking it scrolls the confirmation tickbox into view, and it disappears
|
||||
once the tickbox is visible. On a tall screen it never appears at all.
|
||||
- **Paid Files pictures (UIFIX-04).** Purchased pictures and videos now open in the app's own
|
||||
lightbox instead of a browser tab. Purchased music still goes to the bottom-bar player, and
|
||||
purchased documents still open the way they did.
|
||||
- **Loader states (UIFIX-06).** Opening a purchased file now shows a spinner and an "Opening…"
|
||||
label on its row for as long as the fetch takes, and a failure now shows an error instead of
|
||||
appearing to do nothing. Every other surface that was flagged as slow was audited and its verdict
|
||||
recorded.
|
||||
- **Picture-in-picture (UIFIX-05).** Entering picture-in-picture now closes the lightbox with a
|
||||
handoff animation, and the video keeps playing. The session survives switching main tabs and
|
||||
survives buffering pauses; only explicitly stopping it ends it.
|
||||
- **FIPS/Tor pills (UIFIX-01).** The pills are now pinned by a test so no future cleanup can remove
|
||||
them, and every site that could not be read at phone width was fixed.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run all of this against archi-dev-box's own UI, not the local preview. Use a real phone or the
|
||||
browser's device emulation for the narrow checks, and say which you used.
|
||||
|
||||
1. **Connected nodes scroll (UIFIX-02).** Open the Web5 tab on a wide window. Expected: the
|
||||
connected-nodes card and the card to its right are the same height, and if there are more nodes
|
||||
than fit, the list scrolls inside the card — the row does not get taller. Switch between the
|
||||
trusted, observers and requests tabs: expected the card's height does not change. Then narrow the
|
||||
window to a single column: expected exactly the layout you had before this change.
|
||||
|
||||
2. **Onboarding cue (UIFIX-03).** Open the onboarding seed step at a short viewport (a small laptop
|
||||
height, or device emulation at roughly 1280×620). Expected: a soft fade and a small pill reading
|
||||
"One more step below" at the bottom of the scrolling area; clicking it brings the tickbox into
|
||||
view and the cue disappears. Confirm the cue does not tick the box for you and the Continue
|
||||
button stays disabled until you tick it yourself. Then open the same step at full height:
|
||||
expected no cue at all and a step that looks exactly as it did before.
|
||||
|
||||
3. **Paid Files in the lightbox (UIFIX-04) and the loader (UIFIX-06).** Go to Cloud → Paid Files
|
||||
and click a purchased picture. Expected: the row shows a spinner and "Opening…" while it loads,
|
||||
then the picture opens in the app's lightbox — no new browser tab. Click a purchased video:
|
||||
expected the same, in the lightbox with player controls. Click a purchased music track: expected
|
||||
the bottom-bar player, not the lightbox. If you can, click one twice quickly: expected one load,
|
||||
not two.
|
||||
|
||||
4. **Picture-in-picture (UIFIX-05).** Open a video in the lightbox and click the
|
||||
picture-in-picture button. Expected: the lightbox animates away in a way that reads as the video
|
||||
moving into the small window rather than the lightbox being dismissed, and the video keeps
|
||||
playing. Now switch between main tabs a few times: expected playback continues. Now cause a
|
||||
buffering pause — throttle the network in devtools, or seek far ahead: expected it recovers and
|
||||
the small window stays. Now close the small window explicitly: expected playback stops and
|
||||
nothing is left behind. Finally open the lightbox again and close it with the close button and
|
||||
with Escape: expected exactly the close animation it had before.
|
||||
|
||||
5. **FIPS/Tor pills at phone width (UIFIX-01).** At a phone width, go to Cloud and look at the peer
|
||||
cards, then open a peer's files. Expected: wherever a FIPS or Tor pill appears on desktop it
|
||||
appears here too, fully readable, not clipped and not overlapping anything, including when a peer
|
||||
name or filename is long. Compare the same screens at desktop width: expected unchanged.
|
||||
|
||||
6. **Nothing else moved.** Move through the main tabs and the Cloud sub-tabs. Expected: the page
|
||||
margins, the slide transitions between tabs, and every existing animation look exactly as they
|
||||
did before this plan set. Phase 2 broke margins and slide transitions this way once, so this is a
|
||||
real check, not a formality.
|
||||
|
||||
If anything fails, say which numbered step and what you saw — that becomes the gap list rather than
|
||||
a re-run of the whole plan set.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to sign off UIFIX-01 through UIFIX-06, or describe the issues by step number.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Planner Assumptions (flagged, unresolved)
|
||||
|
||||
- **archy-x250-dev is assumed to still be offline.** Phase 2 checked three times and found it gone.
|
||||
Task 1 re-checks rather than assuming, and records the gap either way; nothing in this plan blocks on
|
||||
it.
|
||||
- **Whether archi-dev-box has purchased content to test step 3 with** is unknown to the planner. If it
|
||||
has none, say so in the sign-off rather than marking step 3 passed on the demo — a demo-only pass for
|
||||
a paid-content path is exactly the divergence class this phase exists to remove.
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| operator judgement → sign-off | A human verdict gates whether these six blockers are considered closed |
|
||||
| deploy host → node | A frontend bundle crosses this boundary onto a live node |
|
||||
| live node → operator observation | Verification runs against a real node holding real federation trust, real purchases and real funds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-01-82 | Elevation of Privilege | a verification deploy reaching fleet or alpha-tester nodes | high | mitigate | Task 1 requires the frontend-only dev-pair path, forbids the Tailscale and alpha-tester flags and any release or OTA path, and requires the exact command and host list to be recorded for audit |
|
||||
| T-01-83 | Repudiation | signing off against a stale bundle the node never actually received | high | mitigate | Task 1 requires grepping the bundle served by the node — not the local build output — for three strings this plan set introduced, before the checkpoint runs |
|
||||
| T-01-84 | Information Disclosure | a screenshot or recording of the verification exposing a recovery seed from step 2 | high | mitigate | Step 2 exercises the onboarding step's layout only; the operator is not asked to capture or transcribe the words, and nothing in this plan asks for an image of that screen |
|
||||
| T-01-85 | Repudiation | a demo-only pass on the paid-content path being recorded as a node pass | medium | mitigate | The prohibition forbids local-preview evidence for node-named checks, and the planner assumption requires saying so explicitly if the node has no purchased content |
|
||||
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs nothing and modifies no source file — it builds, deploys and asks. If a fix arising from the checkpoint needs a dependency, it belongs in a gap-closure plan whose research covers the Package Legitimacy Gate first |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
The operator's response is the verification. An "approved" response closes UIFIX-01 through UIFIX-06;
|
||||
any described issue is captured verbatim in the SUMMARY as a gap for `/gsd-plan-phase 1 --gaps`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All six numbered checks were exercised on archi-dev-box, at the widths each one names.
|
||||
- The operator either approved or produced a numbered issue list.
|
||||
- The outcome is recorded in the SUMMARY, including which device or emulation was used for the narrow checks.
|
||||
- The archy-x250-dev gap is recorded rather than glossed over.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-18-SUMMARY.md` when done, recording the
|
||||
verdict, the deploy command, the served-bundle grep evidence, the device used for narrow checks, and
|
||||
any issue text verbatim.
|
||||
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||||
</output>
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 19
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- core/archipelago/src/api/rpc/lnd/wallet.rs
|
||||
autonomous: false
|
||||
requirements: [FED-08]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "An invoice created through the wallet UI's Receive flow embeds a route hint for every private/unannounced channel the node holds"
|
||||
- "`lncli decodepayreq <invoice>` on an invoice created via the wallet UI shows a non-empty `route_hints` array containing the private channel's chan_id"
|
||||
- "A real external wallet can pay an invoice created by the wallet UI on a node whose only channel is private — the HTLC arrives and the invoice reaches SETTLED"
|
||||
- "Nodes with public channels are unaffected — payments still route directly over the public channel"
|
||||
- "Every other invoice-creation call site in the codebase is audited for the same omission, and each is either fixed or documented as deliberately not needing route hints"
|
||||
prohibitions:
|
||||
- "MUST NOT change the amount, memo, expiry, or any other invoice field's existing behavior"
|
||||
- "MUST NOT log, echo, or commit any macaroon, invoice preimage, or node credential"
|
||||
artifacts:
|
||||
- path: "core/archipelago/src/api/rpc/lnd/wallet.rs"
|
||||
provides: "Invoice creation that sets LND's `private` flag so route hints are embedded"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix Lightning receive on nodes whose channels are private/unannounced.
|
||||
|
||||
`handle_lnd_createinvoice` posts to LND's REST `/v1/invoices` with only `value`
|
||||
and `memo`. LND defaults `private` to `false`, so the returned invoice carries
|
||||
`route_hints: []`. Private channels are not propagated through public gossip, so
|
||||
a sender has no way to find a route — the invoice is unpayable by anyone.
|
||||
|
||||
Diagnosed on `archy-x250-mad2` (2026-07-31), whose single channel to "Olympus by
|
||||
ZEUS" is `private: true` with ~40.8k sats of usable inbound. Three wallet-UI
|
||||
invoices (10,000 / 5,000 / 500 sats) all had empty route hints and never received
|
||||
an HTLC. The one invoice that DID settle carries a memo ("Paid to Archipelago
|
||||
(Order ID: ...)") that appears nowhere in this Rust source — it came from a
|
||||
separate system (likely BTCPay) that builds invoices correctly. That is why
|
||||
"some payments have worked" while the wallet's own Receive flow never has.
|
||||
|
||||
**This is not node-specific.** The bug is unconditional; it only *manifests*
|
||||
where a node lacks a public channel. Any user relying on a private channel has a
|
||||
broken Receive flow today.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Embed route hints in wallet-created invoices</name>
|
||||
<reversibility rating="reversible">One field in one JSON body; revert is a single-line change.</reversibility>
|
||||
<files>core/archipelago/src/api/rpc/lnd/wallet.rs</files>
|
||||
<read_first>
|
||||
- `core/archipelago/src/api/rpc/lnd/wallet.rs` around lines 554-557 — `handle_lnd_createinvoice`'s `invoice_body` construction
|
||||
- `core/archipelago/src/api/rpc/lnd/channels.rs:271,368` — the only current uses of a `private` field (channel OPEN, not invoice creation); confirms the omission is specific to invoices
|
||||
</read_first>
|
||||
<action>
|
||||
Add `"private": true` to `invoice_body` in `handle_lnd_createinvoice` so LND
|
||||
embeds hop hints for unannounced channels:
|
||||
|
||||
```rust
|
||||
let invoice_body = serde_json::json!({
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
"private": true,
|
||||
});
|
||||
```
|
||||
|
||||
Setting it unconditionally is correct and safe: a route hint is harmless when
|
||||
the node also has public channels — LND still routes directly over a public
|
||||
channel when it can, and the hint merely offers an alternate path. Add a
|
||||
short comment stating why it is unconditional, so a future reader doesn't
|
||||
"optimize" it back to conditional and silently reintroduce the bug.
|
||||
|
||||
Then audit every OTHER invoice-creation call site for the same omission —
|
||||
grep the tree for `/v1/invoices`, `addinvoice`, hold-invoice, LNURL and any
|
||||
keysend-adjacent flow. Fix each that should carry route hints; for any that
|
||||
deliberately should not, record the reason in the SUMMARY. Report the full
|
||||
list either way.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd core && cargo build --release 2>&1 | tail -5 && cargo test -p archipelago 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -A6 'let invoice_body' core/archipelago/src/api/rpc/lnd/wallet.rs` shows `"private": true`
|
||||
- `cargo build --release` succeeds
|
||||
- Existing tests pass
|
||||
- The SUMMARY lists every invoice-creation call site found, with fixed/not-needed and the reason
|
||||
</acceptance_criteria>
|
||||
<done>Wallet-created invoices ask LND for route hints, and every other invoice path has been audited.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Confirm the fix, then hand off to post-OTA verification</name>
|
||||
<what-built>
|
||||
Invoice creation now sets LND's `private` flag at both call sites, so
|
||||
invoices embed a hop hint for the node's unannounced channel and become
|
||||
routable from outside.
|
||||
</what-built>
|
||||
<constraint priority="highest">
|
||||
**`archy-x250-mad2` is a USER'S device holding real funds. Never deploy to
|
||||
it, SSH to it, or run any command against it.** This fix reaches it only via
|
||||
the normal OTA release, after the release is confirmed. Any verification
|
||||
involving that node is performed by its owner after the release lands —
|
||||
never by us, and never as a direct deploy.
|
||||
</constraint>
|
||||
<how-to-verify>
|
||||
Verifiable now, without touching any user device:
|
||||
1. Tests assert the request body sent to LND's `/v1/invoices` carries
|
||||
`"private": true` for BOTH `handle_lnd_createinvoice` (wallet Receive)
|
||||
and `create_invoice` (paid-content/peer-files seller flow).
|
||||
2. On a node under our control with only PUBLIC channels, creating and paying
|
||||
an invoice still behaves exactly as before — no regression. Note the
|
||||
limitation honestly: a public-channel node shows empty route hints even
|
||||
when the fix is correct, so this checks non-regression only.
|
||||
|
||||
Post-OTA-release, performed by the device owner:
|
||||
3. Create an invoice through the **wallet UI** (not raw `lncli`) — e.g. 100 sats.
|
||||
4. `lncli decodepayreq <invoice>` → `route_hints` populated with the Olympus
|
||||
channel's `chan_id` (was `[]` before this fix).
|
||||
5. Pay it from a real external wallet; `lncli listinvoices` shows a non-empty
|
||||
`htlcs` array and `state: SETTLED`.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved", or describe what you saw — which step, what happened instead.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cargo build --release` and the existing test suite pass
|
||||
- On archy-x250-mad2, a wallet-UI invoice decodes with populated `route_hints` and settles when paid externally
|
||||
- A public-channel node is unaffected
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Receiving Lightning payments works through the wallet UI on a node whose only channel is private
|
||||
- No regression for nodes with public channels
|
||||
- Any other invoice-creation path sharing this omission is fixed or explicitly cleared
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-19-SUMMARY.md`. It MUST record: the full list of invoice-creation call sites audited with each one's disposition, and the decoded `route_hints` before/after evidence from the node.
|
||||
</output>
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 20
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- scripts/container-doctor.sh
|
||||
autonomous: false
|
||||
requirements: [FED-09]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The container doctor no longer restarts Tor on every run — a node left alone shows Tor uptime growing past the doctor's 5-minute timer interval"
|
||||
- "A hidden-service directory at mode 2700 (Tor's own setting) is recognised as correct and triggers no chmod and no restart"
|
||||
- "A genuinely insecure hidden-service directory (group- or other-readable, e.g. 750 or 707) is still corrected"
|
||||
- "Even when a real permission fix IS applied, Tor cannot be restarted more than once per backoff window, so no future defect can reproduce a restart storm"
|
||||
- "Tor retains its consensus/HSDir cache long enough to resolve .onion addresses, so the mesh Tor fallback works"
|
||||
prohibitions:
|
||||
- "MUST NOT loosen hidden-service directory permissions — group and other access must remain denied"
|
||||
- "MUST NOT disable the doctor's other fixes or the timer itself"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Mesh sends fail entirely on affected nodes because both transports are down, and
|
||||
the second failure is self-inflicted.
|
||||
|
||||
Diagnosed 2026-07-31 on a live node:
|
||||
1. The FIPS direct transport (Yggdrasil-style `fd..` IPv6) times out with
|
||||
`connect_fail` for peers other than the currently-connected tree peers, so
|
||||
every send falls back to Tor.
|
||||
2. The Tor fallback then fails with `No more HSDir available to query` — Tor
|
||||
cannot resolve any peer `.onion` address.
|
||||
|
||||
Root cause of (2): **the container doctor restarts Tor every ~5 minutes,
|
||||
forever.** `fix_tor_permissions()` in `scripts/container-doctor.sh` treats any
|
||||
mode other than the literal string `700` as broken:
|
||||
|
||||
```sh
|
||||
perms=$(stat -c '%a' "$dir")
|
||||
if [ "$perms" != "700" ]; then
|
||||
chmod 700 "$dir"; fixed=true
|
||||
fi
|
||||
...
|
||||
if $fixed; then systemctl restart tor@default; fi
|
||||
```
|
||||
|
||||
Tor sets its own `HiddenServiceDir` to **2700** (setgid). So every run the doctor
|
||||
sees `2700 != 700`, "fixes" it, and restarts Tor; Tor comes back up and sets 2700
|
||||
again; the timer fires 5 minutes later and the cycle repeats. Observed restarts
|
||||
on the node: 13:07:56 → 13:13:14 → 13:18:39 → 13:23:57, each within a second of
|
||||
an `archipelago-doctor.timer` firing. Tor never survives long enough to build a
|
||||
usable consensus/HSDir cache, so onion lookups fail and the mesh's only remaining
|
||||
transport dies with it.
|
||||
|
||||
`2700` is not a defect — the setgid bit is harmless here and group/other access
|
||||
is still fully denied, which is the property that actually matters.
|
||||
|
||||
**Scope note:** this plan fixes the restart loop only. The FIPS direct-transport
|
||||
`connect_fail` (problem 1) is a separate concern and belongs with FED-03's
|
||||
structured review of the transport/dial layer — record it there, do not attempt
|
||||
both here.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Stop the doctor from fighting Tor over the setgid bit</name>
|
||||
<reversibility rating="reversible">Contained to one shell function; revert is a single-file change.</reversibility>
|
||||
<files>scripts/container-doctor.sh</files>
|
||||
<read_first>
|
||||
- `scripts/container-doctor.sh` — `fix_tor_permissions()` (~lines 139-164) and how other `fix_*` functions signal "changed" vs "no drift"
|
||||
- `image-recipe/configs/archipelago-doctor.timer` — `OnUnitActiveSec=5min`, `RandomizedDelaySec=60`: this is the loop's clock
|
||||
- `core/archipelago/src/bootstrap.rs:24-31` — the script is embedded via `include_str!` and written to `/home/archipelago/archy/scripts/container-doctor.sh` on every boot, so nodes pick the fix up through the normal binary release
|
||||
</read_first>
|
||||
<action>
|
||||
Correct the permission predicate so it tests the property that matters —
|
||||
group and other have no access — instead of exact-matching one octal string.
|
||||
Compare the low three digits of `stat -c '%a'` (which omits leading zeros, so
|
||||
handle both `700` and `2700` forms), and treat the directory as correct when
|
||||
those are `700`. Only a genuinely permissive mode (any group or other bit
|
||||
set, e.g. `750`, `707`, `2755`) is a real defect worth fixing.
|
||||
|
||||
Keep correcting real defects, and keep restarting Tor when a real fix is
|
||||
applied — but add a **restart backoff** so a restart storm is impossible even
|
||||
if some future condition makes the fix fire repeatedly: record the last
|
||||
restart time (e.g. a timestamp file under `/var/lib/archipelago/`) and skip
|
||||
the restart if one happened within the last 30 minutes, logging that it was
|
||||
skipped. The current defect is being fixed at the predicate, but the backoff
|
||||
is what makes the class of failure non-recurring.
|
||||
|
||||
Log clearly in both directions — when a directory is accepted as already
|
||||
correct (at debug level, so a healthy node stays quiet) and when a real fix
|
||||
is applied. The original bug was invisible precisely because "Fixed
|
||||
permissions on ... (2700 -> 700)" looked like the doctor working correctly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/container-doctor.sh && sudo bash -c 'set -e; d=$(mktemp -d); mkdir -p "$d/hidden_service_test"; chmod 2700 "$d/hidden_service_test"; stat -c "%a" "$d/hidden_service_test"' </automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bash -n scripts/container-doctor.sh` passes
|
||||
- A directory at mode `2700` is accepted: no chmod, no restart, `fixed` stays false
|
||||
- A directory at mode `750` or `707` is still corrected to deny group/other
|
||||
- A second real fix within the backoff window logs a skip instead of restarting Tor
|
||||
- The doctor's other fixes and the timer are untouched
|
||||
</acceptance_criteria>
|
||||
<done>The doctor recognises Tor's own 2700 as correct, so it stops restarting Tor every five minutes, and a backoff prevents any future restart storm.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Confirm Tor stays up and mesh sends recover</name>
|
||||
<what-built>
|
||||
The doctor no longer mistakes Tor's setgid `2700` hidden-service directory for
|
||||
a permission defect, so it stops chmod-ing it and restarting Tor every ~5
|
||||
minutes. A restart backoff makes a restart storm impossible even if some other
|
||||
condition triggers the fix repeatedly.
|
||||
</what-built>
|
||||
<constraint priority="highest">
|
||||
This ships to affected nodes via the **normal OTA release only**. Never deploy
|
||||
directly to a user's device (`archy-x250-mad2` or any node that is not ours).
|
||||
</constraint>
|
||||
<how-to-verify>
|
||||
On an affected node, after the release lands:
|
||||
1. `systemctl status tor@default` — note the uptime. Wait 15 minutes (three
|
||||
doctor intervals) and check again: uptime should keep growing, with no
|
||||
restart. Before the fix it reset roughly every 5 minutes.
|
||||
2. `journalctl -u archipelago-doctor -n 50` — no recurring
|
||||
"Fixed permissions on ... hidden_service_* (2700 -> 700)" lines.
|
||||
3. Once Tor has been up ~20-30 minutes, confirm onion resolution works: a
|
||||
mesh send to a peer reachable only via Tor should succeed, and the logs
|
||||
should no longer show `No more HSDir available to query`.
|
||||
4. Confirm the doctor still does its job: temporarily `chmod 750` a
|
||||
hidden-service directory, wait for the next doctor run, and check it is
|
||||
corrected back to deny group/other access.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved", or describe what you saw — which step, what happened instead.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `bash -n` passes; the 2700 case is accepted and the 750/707 cases are still fixed
|
||||
- On an affected node, Tor uptime exceeds the doctor's interval and onion resolution recovers
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Tor is no longer restarted every ~5 minutes by the doctor
|
||||
- Tor keeps its consensus/HSDir cache, so `.onion` peers resolve and the mesh Tor fallback works again
|
||||
- Genuinely insecure hidden-service directory permissions are still corrected
|
||||
- A restart backoff makes this class of failure non-recurring
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/01-federation-mesh-hardening/01-20-SUMMARY.md`. It MUST record the corrected predicate, the backoff mechanism and window, and a note handing the FIPS direct-transport `connect_fail` (problem 1 of the original diagnosis) to FED-03's transport/dial-layer review.
|
||||
</output>
|
||||
@@ -0,0 +1,53 @@
|
||||
# Phase 1 Context — Federation & Mesh Hardening
|
||||
|
||||
**Source:** User decisions captured in conversation 2026-07-29 (no full discuss-phase run)
|
||||
|
||||
<domain>
|
||||
Federation/fleet + mesh hardening on a live OTA fleet, plus two lightning-adjacent UI features
|
||||
(channel-open UX, on-brand paid-tick animation). Backend: Rust workspace at core/. Frontend:
|
||||
neode-ui (Vue), dev preview :8100 against archi-dev.
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
- **FED-05 "public nodes" scope (LOCKED, user 2026-07-29, corrected same day):** the
|
||||
public/other list in the channel-open picker = MESHED PEER NODES THAT HAVE LIGHTNING
|
||||
INSTALLED — nodes known over the mesh (mesh peers/contacts beyond bilateral federation
|
||||
trust) that advertise lightning capability. NOT lnd listpeers, NOT a curated list, NOT
|
||||
a live LN-graph query. Implies peers need to advertise a "lightning installed/available"
|
||||
capability (plus their URI/pubkey) over mesh/federation state so the picker can list
|
||||
them. Manual URI paste can remain as a fallback entry path. Primary lists: (1) trusted
|
||||
federated nodes by hostname, (2) meshed peers with lightning installed — "request to
|
||||
open a channel with" these.
|
||||
- **FED-05 URI sharing default (Claude's discretion, revisable):** a federated peer's
|
||||
Lightning URI/pubkey rides the federation sync payload by default — federation trust is
|
||||
already bilateral and explicit. Follow the existing shared-field pattern in
|
||||
NodeStateSnapshot; if an opt-in toggle already exists for similar fields (e.g.
|
||||
shared_location), mirror that pattern with default ON for lightning URI.
|
||||
- **FED-06 (LOCKED, user):** paid-tick circle = ScreensaverRing.vue style (EQ segments),
|
||||
applied consistently to every paid/success tick surface (SendBitcoinModal success pane,
|
||||
WalletScanModal success-ring).
|
||||
- **FED-04:** demo attachment parity core already shipped on main (c2ce71c6) — remaining
|
||||
scope is the leftover mock gaps found in research (contacts-list/save, reaction/reply/
|
||||
edit/delete/forward stubs that never mutate demo state).
|
||||
- **Priority framing (user):** federation removal/sync correctness is the reason this phase
|
||||
exists — "we should just be working on making that as tight as possible".
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
- UI work verified on the :8100 dev preview against archi-dev BEFORE any deploy (user
|
||||
requirement, applies to FED-05/FED-06).
|
||||
- Deploy discipline per CLAUDE.md: dev pair before OTA; commit+push every unit of work.
|
||||
- Modals must Teleport to body (repeated user complaint — see project feedback memory).
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
- Live Lightning-graph search of arbitrary public nodes (not connected peers) — out of
|
||||
scope for FED-05 v1.
|
||||
- Curated/shipped public-node directory — not wanted.
|
||||
</deferred>
|
||||
|
||||
<scope_fence>
|
||||
Do not touch federation trust/join cryptography beyond what removal/sync correctness
|
||||
requires (STATE.md blocker: tombstone fix touches trust code — re-verify with
|
||||
tests/multinode/smoke.sh, don't patch blind). No data-destroying migrations.
|
||||
</scope_fence>
|
||||
@@ -0,0 +1,398 @@
|
||||
# Phase 1: Federation & Mesh Hardening - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-29
|
||||
**Files analyzed:** 11 (backend touch points) + 5 (frontend/mock touch points)
|
||||
**Analogs found:** 15 / 16
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `core/archipelago/src/federation/storage.rs` (add lock) | model/store | CRUD (file-backed) | `core/archipelago/src/update.rs` `UPDATE_OP_LOCK` (`try_lock` guard on a mutating op) | role-match (same "serialize file-mutating ops" problem, different domain) |
|
||||
| `core/archipelago/src/server.rs` (~L497, ~L840 loops) | service (periodic loop) | event-driven/batch | itself (Tor-refresh loop at ~L480) + `mesh/listener/session.rs:386` `PORT_OPEN_LOCK` for the coordination primitive | exact (loop shape) / role-match (lock) |
|
||||
| `core/archipelago/src/federation/types.rs` (NodeStateSnapshot + FederationPeerHint additions) | model | transform (serde) | itself — `shared_location` (`lat`/`lon`) opt-in field, same struct | exact |
|
||||
| `core/archipelago/src/api/rpc/federation/handlers.rs` (build_local_state call site) | controller/RPC handler | request-response | itself — `shared_location` gating block (L476-479) | exact |
|
||||
| `core/archipelago/src/api/rpc/lnd/info.rs` (`handle_lnd_getinfo` extension) | controller/RPC handler | request-response | `core/archipelago/src/api/rpc/lnd/channels.rs::handle_lnd_openchannel` (adjacent LND REST handler, validation + response shaping style) | role-match |
|
||||
| `core/archipelago/src/api/rpc/dispatcher.rs` (new method registration) | route/dispatcher | request-response | itself — `"lnd.getinfo"`/`"lnd.openchannel"`/`"federation.list-nodes"` match arms | exact |
|
||||
| `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (new mesh capability / channel-open-request message type) | controller/RPC handler | event-driven | itself — `handle_mesh_contacts_list` (L1215) and reaction/reply handlers (L637-976) | exact |
|
||||
| `neode-ui/mock-backend.js` (`mesh.contacts-list`/`contacts-save`, stateful reaction/reply/edit/delete/forward) | mock RPC handler | CRUD (in-memory per-session store) | itself — `mesh.send-content-inline` (L4355) for stateful `meshStore.dynamic` mutation; `mesh.transport-advice` (L4318) for "mirror the daemon comment" convention | exact |
|
||||
| `neode-ui/src/components/LightningChannelModal.vue` (NEW, FED-05) | component/modal | request-response | `neode-ui/src/components/LightningChannelsPanel.vue` (open-channel form + error handling) + `neode-ui/src/components/BaseModal.vue` (Teleport shell) + `neode-ui/src/components/federation/PeerRequestModal.vue` (request flow) | exact (composite of 3 analogs) |
|
||||
| `neode-ui/src/views/federation/NodeList.vue` (picker-row pattern reused inside new modal) | component | request-response | itself | exact |
|
||||
| `neode-ui/src/components/ScreensaverRing.vue` (new `badge` size variant) | component | transform (pure CSS/SVG) | itself — existing `compact`/`default` size-class pattern | exact |
|
||||
| `neode-ui/src/components/SendBitcoinModal.vue` / `WalletScanModal.vue` (swap ring) | component | transform | `neode-ui/src/components/Screensaver.vue` (existing `ScreensaverRing` + centered-content layering pattern) | exact |
|
||||
| `neode-ui/src/api/rpc-client.ts` (new method wrappers: own LN URI, channel-open-request) | service (API client) | request-response | itself — `mesh.contacts-list`/`contacts-save` wrappers (L804, L813) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `core/archipelago/src/federation/storage.rs` (locking fix)
|
||||
|
||||
**Analog:** `core/archipelago/src/update.rs:35` (`UPDATE_OP_LOCK`)
|
||||
|
||||
**Core pattern** (lines 25-35, `update.rs`):
|
||||
```rust
|
||||
/// Serializes the mutating update operations (download, apply, and the
|
||||
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
|
||||
/// exactly this race: two concurrent `update.download` RPCs shared one
|
||||
/// staging file, a cancel wiped staging mid-flight, a third download began
|
||||
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
|
||||
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
|
||||
/// via `try_lock` so a concurrent caller gets an explicit "already running"
|
||||
/// error instead of silently interleaving.
|
||||
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
```
|
||||
This is the closest documented precedent in the codebase for "two async
|
||||
call sites race on the same on-disk resource, fix with a static
|
||||
`tokio::sync::Mutex::const_new(())`" — same shape of bug as Pitfall 1 in
|
||||
RESEARCH.md, already root-caused and fixed once before. Copy the doc-comment
|
||||
style (explain *why*, cite the historical incident) and the `try_lock`
|
||||
vs. `.lock().await` decision: prefer plain `.lock().await` (blocking wait,
|
||||
not `try_lock`-reject) for the federation case, since federation writes are
|
||||
infrequent and a caller silently failing "already syncing" would reintroduce
|
||||
the original bug's symptom (lost writes) rather than fix it — unlike
|
||||
`update.rs`'s deliberate reject-on-contention UX.
|
||||
|
||||
**Secondary reference:** `core/archipelago/src/container/app_ops.rs:17-24` — a
|
||||
`HashMap<String, Arc<tokio::sync::Mutex<()>>>` keyed per-app-id, for when a
|
||||
single global lock is too coarse. Not needed here (one `data_dir` = one
|
||||
federation store = one lock is fine), but note this pattern exists if the
|
||||
planner decides per-node-id granularity is warranted.
|
||||
|
||||
**Also apply:** `federation::storage::save_nodes` is a direct `fs::write()`,
|
||||
not atomic temp+rename. No existing atomic-write helper was found elsewhere
|
||||
in `core/archipelago/src/` (grepped, none present) — this will be genuinely
|
||||
new code; keep it minimal (`fs::write` to `nodes.json.tmp` then `fs::rename`).
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/server.rs` (two federation sync loops, ~L497 / ~L840)
|
||||
|
||||
**Analog:** itself (the Tor-refresh loop pattern immediately above, ~L479) and `mesh/listener/session.rs:386`'s `PORT_OPEN_LOCK` for how a shared static lock is threaded through an async loop body.
|
||||
|
||||
**Loop skeleton pattern** (both existing loops share this shape — `server.rs:497-515` and `server.rs:840-853`):
|
||||
```rust
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(20)).await; // startup settle delay
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(90));
|
||||
// 1800s loop additionally sets:
|
||||
// interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let nodes = match crate::federation::load_nodes(&data_dir).await {
|
||||
Ok(n) if !n.is_empty() => n,
|
||||
_ => continue,
|
||||
};
|
||||
// ... snapshot local identity, iterate `nodes`, call sync_with_peer ...
|
||||
}
|
||||
});
|
||||
```
|
||||
**Error handling pattern:** both loops only `debug!()` on failure (RESEARCH.md
|
||||
Anti-Pattern flagged this — FED-02 requires operator-visible errors). Do not
|
||||
copy this part; extend to persist `last_sync_error` on the node record
|
||||
(mirrors how `record_peer_transport` already persists `last_transport`/
|
||||
`last_transport_at` in `federation/storage.rs:120-147` — same "write a
|
||||
result field back to the node struct after each attempt" shape, just for the
|
||||
error side instead of the success side).
|
||||
|
||||
**If collapsing the two loops:** the 1800s loop's unique tail call is
|
||||
`refresh_federation_mesh_peers()` (per RESEARCH.md Open Question 1) — move
|
||||
that single call to the end of the 90s loop's per-pass completion rather
|
||||
than deleting the loop body wholesale, preserving whatever roster-propagation
|
||||
behavior it provides.
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/federation/types.rs` (NodeStateSnapshot Lightning fields)
|
||||
|
||||
**Analog:** itself — the existing `shared_location` (`lat`/`lon`) opt-in field on the same struct (lines 124-131).
|
||||
|
||||
**Exact pattern to mirror** (`federation/types.rs:124-131`):
|
||||
```rust
|
||||
/// This node's own location, for the Mesh Map — only present when the
|
||||
/// sender has opted in via `server.set-location`'s `share` flag. Absent
|
||||
/// (not just null) for nodes that haven't opted in, so older receivers
|
||||
/// and the map's "no location shared" state both fall out naturally.
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
```
|
||||
Add `lightning_uri: Option<String>` (or `lightning_pubkey` + `lightning_host`
|
||||
split, matching `FederationPeerHint`'s `pubkey`/`onion` split style at
|
||||
line 137-145) with the same `#[serde(default)]` back-compat annotation and a
|
||||
doc comment explaining the opt-in gating (per CONTEXT.md's locked decision:
|
||||
default ON for federation, unlike `shared_location`'s default-off — call
|
||||
this out explicitly in the doc comment since it deviates from the analog).
|
||||
|
||||
**Gating call site analog** (`api/rpc/federation/handlers.rs:476-479`):
|
||||
```rust
|
||||
let shared_location = if data.server_info.share_location {
|
||||
data.server_info.lat.zip(data.server_info.lon)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
```
|
||||
Mirror this shape for the Lightning URI gate, then thread it through
|
||||
`federation::build_local_state(...)` the same way `shared_location` is
|
||||
threaded (`sync.rs:230,258-259` — accepted as a parameter, mapped into the
|
||||
snapshot fields at construction time). If FED-05 lands the "default ON"
|
||||
decision, this becomes a simpler unconditional read (no `if`), but keep the
|
||||
struct-level `Option` + `#[serde(default)]` regardless so a future opt-out
|
||||
setting is a pure additive change.
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/api/rpc/lnd/info.rs` (own-node Lightning URI RPC)
|
||||
|
||||
**Analog:** `core/archipelago/src/api/rpc/lnd/channels.rs::handle_lnd_openchannel` (`channels.rs:238-336`) for response/validation style in the same file family — reuse verbatim, do not modify; new code only needs to *read* `identity_pubkey`/`uris` out of the same LND REST response `handle_lnd_getinfo` already fetches but doesn't forward. Read `info.rs`'s current struct/response shape directly before editing (not excerpted here — small, single-file change, one Read call is enough at implementation time).
|
||||
|
||||
**Dispatcher registration analog** (`api/rpc/dispatcher.rs:125,128`):
|
||||
```rust
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
...
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
```
|
||||
Any new RPC (e.g. a dedicated `lnd.own-uri` if the planner decides not to
|
||||
extend `getinfo`) follows this exact one-line match-arm registration
|
||||
convention — no separate route table, no middleware wiring beyond this.
|
||||
|
||||
---
|
||||
|
||||
### `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (mesh capability advertisement / channel-open-request message type)
|
||||
|
||||
**No direct analog exists** — RESEARCH.md confirms this is greenfield (no
|
||||
capability-advertisement field on mesh peers/contacts today). Closest
|
||||
structural analogs for *how to add a new field to a broadcast peer struct*
|
||||
and *how to add a new mesh message type*:
|
||||
|
||||
**Analog A — read/return handler shape** (`typed_messages.rs:1215-1234`,
|
||||
`handle_mesh_contacts_list`):
|
||||
```rust
|
||||
pub(in crate::api::rpc) async fn handle_mesh_contacts_list(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let contacts = state.contacts.read().await;
|
||||
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||
// ... merge/collapse logic ...
|
||||
}
|
||||
```
|
||||
Use this shape (`mesh_service.read().await` → `shared_state()` →
|
||||
`.read().await` on the relevant map) for any new "list peers with lightning
|
||||
capability" RPC.
|
||||
|
||||
**Analog B — event-driven send handlers** (`typed_messages.rs:637-976`,
|
||||
reaction/reply/receipt/forward family) — mirror for a new
|
||||
"channel-open-request" mesh message type: same struct-per-message-type,
|
||||
serialize-and-broadcast pattern already used for reactions/replies.
|
||||
|
||||
**Security note (carries from RESEARCH.md V4):** any new RPC meant to be
|
||||
peer-reachable (not just locally-authenticated) must be added to
|
||||
`is_peer_allowed_path()` (`server.rs:1270`) explicitly — don't assume it
|
||||
inherits reachability.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/mock-backend.js` (contacts-list/save + stateful reaction/reply/edit/delete/forward)
|
||||
|
||||
**Analog — stateful mutation pattern** (`mock-backend.js:4355-4370`,
|
||||
`mesh.send-content-inline`):
|
||||
```javascript
|
||||
case 'mesh.send-content-inline': {
|
||||
// ... validate params ...
|
||||
const meshStore = currentStore().mesh
|
||||
const id = 100 + meshStore.dynamic.length
|
||||
meshStore.dynamic.push({
|
||||
// ... message shape matching real daemon's mesh message schema ...
|
||||
})
|
||||
return res.json({ result: { ... } })
|
||||
}
|
||||
```
|
||||
**Analog — "mirror the daemon, cite the source" comment convention**
|
||||
(`mock-backend.js:4312-4317`, immediately above `mesh.transport-advice`):
|
||||
```javascript
|
||||
// Mirrors the real daemon's size-based tier logic
|
||||
// (typed_messages.rs handle_mesh_transport_advice) so the demo shows the
|
||||
// SAME modals a real node would — the chooser only appears in the narrow
|
||||
// fits-both band, never unconditionally.
|
||||
```
|
||||
Apply both patterns verbatim to the FED-04 gaps:
|
||||
- `mesh.contacts-list`/`mesh.contacts-save` — currently **absent** (404s),
|
||||
add cases that read/write a per-session contacts bucket the same way
|
||||
`meshStore.dynamic` is a per-session bucket (`currentStore().mesh`), citing
|
||||
`typed_messages.rs:1180-1371` as source of truth per the comment convention.
|
||||
- `mesh.send-reaction`/`send-reply`/`edit-message`/`delete-message`/
|
||||
`forward-message` — currently bare `{ok:true}` acks
|
||||
(`mock-backend.js:4479-4490`, cited verbatim in RESEARCH.md) — replace each
|
||||
with a `meshStore.dynamic` mutation (find message by id, mutate reactions
|
||||
array / set edited text / mark deleted / push a forwarded copy), citing
|
||||
`typed_messages.rs:637-976` (reply/reaction) and `:1065-1180` (edit/delete)
|
||||
as source of truth.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/components/LightningChannelModal.vue` (NEW, FED-05)
|
||||
|
||||
**Analog 1 — modal shell:** `neode-ui/src/components/BaseModal.vue:1-40`
|
||||
```vue
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="fixed inset-0 flex items-center justify-center p-4" @click.self="close">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="glass-card p-6 w-full relative z-10 flex flex-col" role="dialog" aria-modal="true" @click.stop>
|
||||
<div class="flex items-start justify-between gap-4 mb-4 shrink-0">
|
||||
<h3 class="text-xl font-semibold text-white">{{ title }}</h3>
|
||||
<button @click="close" aria-label="Close">...</button>
|
||||
</div>
|
||||
<div v-if="$slots.header" class="shrink-0"><slot name="header" /></div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">...</div>
|
||||
```
|
||||
Hard rule per CONTEXT.md/UI-SPEC.md: every new modal MUST use `BaseModal.vue`
|
||||
or replicate this exact `Teleport` + `fixed inset-0` + `@click.self="close"`
|
||||
structure — never nest inside a `transform`-affected ancestor.
|
||||
|
||||
**Analog 2 — manual URI form + error/startup-notice treatment:**
|
||||
`neode-ui/src/components/LightningChannelsPanel.vue`
|
||||
```vue
|
||||
<!-- line 258-261 -->
|
||||
placeholder="pubkey@host:port"
|
||||
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
|
||||
```
|
||||
```vue
|
||||
<!-- line 329-336 -->
|
||||
<div v-if="openError" :class="isStartupNotice(openError) ? amberClasses : 'alert-error'">
|
||||
<span v-if="isStartupNotice(openError)" class="mr-1">⏳</span>{{ openError }}
|
||||
</div>
|
||||
```
|
||||
```js
|
||||
// line 599-624 (validation-before-RPC pattern)
|
||||
if (!uri) { openError.value = 'Peer URI is required'; return }
|
||||
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
|
||||
```
|
||||
Copy this validate-before-RPC-call, `openError` ref, `isStartupNotice()`
|
||||
amber-vs-red distinction pattern verbatim into the new modal's "Paste URI
|
||||
Manually" fallback path.
|
||||
|
||||
**Analog 3 — request flow (meshed peer "Request Channel"):**
|
||||
`neode-ui/src/components/federation/PeerRequestModal.vue`
|
||||
```vue
|
||||
<!-- line 34-37 -->
|
||||
<button :disabled="sending" @click="$emit('send', message.trim() || undefined)">
|
||||
{{ sending ? 'Sending…' : 'Send Request' }}
|
||||
</button>
|
||||
```
|
||||
Per UI-SPEC.md's Copywriting Contract, reuse this component's pattern
|
||||
directly (optional message field, `sending`/`Sending…` busy state,
|
||||
`$emit('send', ...)` / `$emit('cancel')` contract) rather than building a new
|
||||
request-modal component.
|
||||
|
||||
**Analog 4 — picker row layout (trusted-nodes / meshed-LN-peers lists):**
|
||||
`neode-ui/src/views/federation/NodeList.vue`
|
||||
```vue
|
||||
<!-- line 56-65 -->
|
||||
<span v-if="transportBadge(node)" :class="transportBadge(node)!.cls" :title="transportBadge(node)!.title">
|
||||
{{ transportBadge(node)!.label }}
|
||||
</span>
|
||||
<span :class="trustBadgeClass(node.trust_level)">{{ node.trust_level }}</span>
|
||||
```
|
||||
```js
|
||||
// line 158-159
|
||||
const trustedNodes = computed(() => props.nodes.filter(n => n.trust_level === 'trusted'))
|
||||
const peerNodes = computed(() => props.nodes.filter(n => n.trust_level !== 'trusted'))
|
||||
```
|
||||
Mirror this row layout (name + transport badge + trust/status badge +
|
||||
action button) for both the trusted-nodes and meshed-LN-peers picker
|
||||
columns; reuse `transportBadge()`'s FIPS/Tor logic as-is per
|
||||
`Don't Hand-Roll` in RESEARCH.md (no new transport-tracking needed).
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/components/ScreensaverRing.vue` (new `badge` size variant)
|
||||
|
||||
**Analog:** itself — existing `compact`/`default` size-class pattern (lines 15-66).
|
||||
```vue
|
||||
const props = withDefaults(defineProps<{
|
||||
size?: 'default' | 'compact'
|
||||
...
|
||||
}>(), { size: 'default', segmentCount: 48 })
|
||||
const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default')
|
||||
```
|
||||
```css
|
||||
.viz-ring-compact { /* diameter/--viz-radius rules, lines 60-66 incl. breakpoint */ }
|
||||
```
|
||||
Add a third `'badge'` union member + `viz-ring-badge` CSS class following the
|
||||
exact same shape (mobile diameter, `≥768px` breakpoint diameter,
|
||||
`--viz-radius` custom property), sized per UI-SPEC.md's table (160px/192px,
|
||||
`--viz-radius` 80px/96px). Also add the missing
|
||||
`@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } }`
|
||||
guard inside this component (UI-SPEC.md flagged this as a real, currently
|
||||
absent gap — contrast with `SendBitcoinModal.vue`'s existing `.burst-ring`
|
||||
reduced-motion guard, which should be the copy source for the exact media
|
||||
query syntax).
|
||||
|
||||
**Composition analog (how to layer center content over the ring):**
|
||||
`neode-ui/src/components/Screensaver.vue`'s existing
|
||||
`ScreensaverRing` + `ScreensaverLogo` centered-absolute layering — reuse this
|
||||
`position: relative` wrapper + `position: absolute; inset: 0` inner-content
|
||||
pattern for both `SendBitcoinModal.vue`'s `.burst-core` and
|
||||
`WalletScanModal.vue`'s success-ring inner content.
|
||||
|
||||
---
|
||||
|
||||
### `neode-ui/src/api/rpc-client.ts` (new method wrappers)
|
||||
|
||||
**Analog:** existing `mesh.contacts-list`/`contacts-save` wrappers (lines 804, 813):
|
||||
```typescript
|
||||
return this.call({ method: 'mesh.contacts-list', params: {} })
|
||||
...
|
||||
return this.call({ method: 'mesh.contacts-save', params })
|
||||
```
|
||||
New wrappers (own Lightning URI fetch, channel-open-request send) follow this
|
||||
exact `this.call({ method: '<namespace>.<verb>', params })` one-liner
|
||||
convention — no custom fetch/axios logic, no new client class.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Async static lock for a racy on-disk resource
|
||||
**Source:** `core/archipelago/src/update.rs:35` (`UPDATE_OP_LOCK`)
|
||||
**Apply to:** `federation/storage.rs`'s `load_nodes`/`save_nodes`/`remove_node`/`update_node_state` call sites (FED-01/FED-02 core fix)
|
||||
```rust
|
||||
static <NAME>_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
// acquire with .lock().await (not try_lock — federation writes should queue, not reject)
|
||||
```
|
||||
|
||||
### Optional opt-in shared field on `NodeStateSnapshot`
|
||||
**Source:** `core/archipelago/src/federation/types.rs:124-131` (`shared_location`)
|
||||
**Apply to:** New `lightning_uri`/`lightning_pubkey` field (FED-05)
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub lat: Option<f64>,
|
||||
```
|
||||
|
||||
### Teleport-to-body modal shell
|
||||
**Source:** `neode-ui/src/components/BaseModal.vue:1-40`
|
||||
**Apply to:** All new FED-05 UI (hard rule per CONTEXT.md/UI-SPEC.md)
|
||||
|
||||
### Mock backend must mirror real daemon logic, with a comment citing the source file/lines
|
||||
**Source:** `neode-ui/mock-backend.js:4312-4317` (comment above `mesh.transport-advice`)
|
||||
**Apply to:** All FED-04 mock-backend.js gap fills (contacts-list/save, reaction/reply/edit/delete/forward)
|
||||
|
||||
### One-line RPC dispatcher registration
|
||||
**Source:** `core/archipelago/src/api/rpc/dispatcher.rs:125,128,349`
|
||||
**Apply to:** Any new backend RPC method added for FED-05 (own LN URI, channel-open-request)
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| Mesh peer "has lightning" capability advertisement (new field on mesh peer/contact struct + propagation) | model + event-driven | No existing capability-advertisement mechanism for mesh (as opposed to federation) peers exists in the codebase — RESEARCH.md confirms this is genuinely greenfield. Nearest structural precedent is the read/broadcast handler shapes in `typed_messages.rs` (see Pattern Assignments above), not a field-level analog. Planner should design this as a new optional field on whatever struct already carries mesh peer capability info (check `mesh/mod.rs` peer struct at implementation time), following the same `#[serde(default)] Option<T>` back-compat convention used everywhere else in this codebase. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `core/archipelago/src/{federation,mesh,api/rpc,server.rs,update.rs,container,content_invoice.rs}`, `neode-ui/src/{components,views/federation,api}`, `neode-ui/mock-backend.js`
|
||||
**Files scanned:** ~25 (targeted reads/greps; RESEARCH.md's existing file:line citations reused where already verified)
|
||||
**Pattern extraction date:** 2026-07-29
|
||||
@@ -0,0 +1,371 @@
|
||||
# Phase 1: Federation & Mesh Hardening - Research
|
||||
|
||||
**Researched:** 2026-07-29
|
||||
**Domain:** Federation node sync/removal (Rust/Tokio async daemon), mesh RPC parity (Rust + Node.js mock backend), Lightning channel-open UX (Vue 3), on-brand success animation (Vue 3/CSS)
|
||||
**Confidence:** HIGH (backend federation/mesh code — read directly, git-blamed); MEDIUM (FED-05 Lightning-URI UX — net-new surface, no prior art in repo); HIGH (FED-06 — both source components read directly)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
No CONTEXT.md exists for this phase (not yet run through `/gsd-discuss-phase`). No locked decisions or discretion areas to honor beyond `REQUIREMENTS.md` and the phase description supplied by the orchestrator. Treat all implementation choices below as recommendations for the planner, not locked decisions — the planner should flag any of these that warrant a user check-in (see `## Assumptions Log`).
|
||||
</user_constraints>
|
||||
|
||||
## Summary
|
||||
|
||||
The federation and mesh code is more mature than `CONCERNS.md` suggests — several concerns it lists (tombstone-write-swallowed, DID-join without signature verification) were already fixed in commit `01cbec27` (2026-07-02) and the `handle_federation_peer_joined` signature-verification path respectively. **Do not treat `CONCERNS.md` as current truth for this phase; the structured review (FED-03) must re-verify each claim against the code read in this research before acting on it.**
|
||||
|
||||
The real, currently-live bug class behind the user's "nodes reappear / sync issues" reports is almost certainly a **concurrency race on `federation/nodes.json`**: `federation/storage.rs` has zero locking (no `Mutex`, no atomic temp-file+rename) around `load_nodes()` → mutate → `save_nodes()`, yet the daemon runs **two independent, overlapping periodic federation-sync loops** (`server.rs` ~line 497, every 90s; `server.rs` ~line 840, every 1800s) plus the manual `federation.sync-state` RPC and `federation.remove-node` RPC — all of which do their own read-modify-write cycle against the same file with no coordination. A `remove_node()` call racing against an in-flight `sync_with_peer()`'s `update_node_state()` (which read the node list *before* the removal landed) will have the sync's stale read clobber the just-written removal when it saves — the removed node reappears with no error, exactly matching the reported symptom, and it is invisible to logs because both loops only `debug!()` on failure. This is the primary hypothesis to design a fix and a regression test around for FED-01/FED-02.
|
||||
|
||||
Mesh attachment-send parity (FED-04) was fixed just before this phase started (commit `c2ce71c6`, uncommitted → committed by another concurrent agent during this research session): `mock-backend.js` now implements `mesh.send-content-inline` / `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` mirroring the daemon's real tier logic. What remains for full "rest of the mesh chat surface" parity: `mesh.contacts-list` / `mesh.contacts-save` (peer aliasing, called live from `Mesh.vue` on mount and on rename) are **not implemented in mock-backend.js at all** and will 404 with "Method not found" on the demo; and `mesh.send-reaction` / `send-reply` / `edit-message` / `delete-message` / `forward-message` are stubbed as bare `{ok:true}` acks that never mutate `meshStore.dynamic`, so reactions/edits/deletes silently don't render on the demo even though the RPC call "succeeds."
|
||||
|
||||
FED-05 (Lightning channel-open UX) is greenfield: no RPC anywhere in the codebase currently returns this node's own Lightning `identity_pubkey`/`uris` (LND's own `/v1/getinfo` provides both, but `handle_lnd_getinfo` in `core/archipelago/src/api/rpc/lnd/info.rs` doesn't parse or forward them), and `NodeStateSnapshot` (the federation sync payload) carries no Lightning fields for a peer's pubkey/host, so there is no way today to look up a *federated* peer's channel-open target. `handle_lnd_openchannel` (channels.rs) already accepts `pubkey` + optional `address` + `amount`/fee params and does the connect-then-open sequence correctly — reuse it as-is. "Public nodes" browse/request has no existing data source in this codebase (no LN graph query, no curated list) and needs a scope decision from the user before planning task breakdown.
|
||||
|
||||
FED-06 is a straightforward swap: `ScreensaverRing.vue` (`compact` size = 240px/320px) renders only the radiating EQ segments (no circle of its own — the "circle" is the separately-layered content in the center, exactly as `Screensaver.vue` does with `ScreensaverLogo`). `SendBitcoinModal.vue`'s `.send-success-burst` is 112px (7rem) with 3 CSS-ripple `.burst-ring` elements plus a `.burst-core` circle+checkmark — swap the `.burst-ring` elements for `<ScreensaverRing size="compact" />`, keep `.burst-core`+checkmark centered on top, and reconcile the size mismatch (ring is 2-3x larger than the current burst container; either scale it down via CSS `transform: scale()` or accept the larger footprint since the modal is `max-w-2xl`). `WalletScanModal.vue` has a second, simpler "paid tick" (`.success-ring`, no ripple animation at all) that the phase's "wherever else the paid tick appears" clause covers — plan to update both.
|
||||
|
||||
**Primary recommendation:** Start FED-03's structured review by (1) auditing every `federation::storage` read-modify-write call site for the missing-lock race described above and design a fix (a `tokio::sync::Mutex` per data_dir, or collapsing the two periodic sync loops into one), (2) re-verifying every `CONCERNS.md` federation/mesh claim against current code before acting on it, (3) filling the two demo-parity gaps in `mock-backend.js` (contacts-list/save + stateful reaction/edit/delete), (4) treating FED-05 as new RPC surface (own-node Lightning URI, peer Lightning info propagation, and a scoped "public nodes" answer) before any UI work, and (5) the FED-06 CSS/component swap.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Federation node list, tombstones, sync loops | API / Backend (`core/archipelago/src/federation/`) | — | Disk-persisted state; must be race-free at the storage layer, not patched in the RPC or UI layer |
|
||||
| Federation removal propagation to mesh chat | API / Backend (`core/archipelago/src/mesh/mod.rs::purge_federation_peer`) | Frontend (Pinia `stores/mesh.ts`) | Backend already purges peer/messages/contacts server-side; frontend must not cache a stale contact after the WebSocket state bump |
|
||||
| Mesh RPC surface (attachments, reactions, contacts) | API / Backend (`core/archipelago/src/api/rpc/mesh/`) | Dev tooling (`neode-ui/mock-backend.js`) | The demo backend is a parity shim over the same RPC surface — it must mirror backend behavior, never invent its own contract |
|
||||
| FIPS/Tor transport dial + fallback | API / Backend (`core/archipelago/src/fips/dial.rs`, `transport/`) | — | Transport selection is a backend concern; UI only displays the resulting badge (`last_transport`) |
|
||||
| Lightning node URI (own + peer) | API / Backend (new: `lnd.getinfo` extension, federation sync payload extension) | Frontend (new modal) | LND is the source of truth for `identity_pubkey`/`uris`; federation sync is the transport for sharing a peer's LN info |
|
||||
| Channel-open UX (initiate) | Frontend (new modal, `Teleport`-to-body, house style) | API / Backend (`lnd.openchannel` — already exists) | Backend channel-open RPC is complete; only the UI (URI share, trusted-peer picker, public-node browse) is missing |
|
||||
| Paid-tick success animation | Frontend (`SendBitcoinModal.vue`, `WalletScanModal.vue`, `ScreensaverRing.vue`) | — | Pure presentation; no backend involvement |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
This phase does not introduce new external dependencies. It is a hardening + UI-surface pass over an existing Rust (Tokio/Hyper/reqwest/serde) backend and Vue 3 (Pinia, Vue Router, Teleport) frontend, plus a Node.js/Express demo backend (`neode-ui/mock-backend.js`). No new libraries are needed for any of FED-01 through FED-06 — `ScreensaverRing.vue` and `handle_lnd_openchannel` already exist and should be reused, not reimplemented.
|
||||
|
||||
### Core (existing, reused)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| tokio | (workspace pin) | Async runtime, `interval()`/`spawn()` for the periodic sync loops | Already the project's async foundation |
|
||||
| reqwest | (workspace pin) | HTTP client for FIPS/Tor peer dial and LND REST calls | Already used throughout `fips/dial.rs` and `api/rpc/lnd/` |
|
||||
| serde/serde_json | (workspace pin) | Wire format for `NodeStateSnapshot`, RPC params | Project-wide convention |
|
||||
| Vue 3 + Pinia | (package.json pin) | Frontend reactivity/state | Existing frontend stack |
|
||||
|
||||
**Version verification:** No new packages are being added; skip registry verification per protocol (nothing to verify). If the planner introduces any new crate/npm package during execution, verify it then.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
No external packages are being introduced by this phase — this section is not applicable. If a later plan step decides to add a dependency (e.g., a curated public-LSP list requires a small crate), run the Package Legitimacy Gate at that time.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Federation node list (disk) │
|
||||
│ federation/{nodes,removed-nodes}.json │
|
||||
│ NO LOCK — read-modify-write per call │
|
||||
└───────────────┬─────────────────────────┘
|
||||
│ load_nodes() / save_nodes()
|
||||
┌───────────────────────────┼───────────────────────────┬─────────────────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌───────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ 90s auto-sync │ │ 1800s auto-sync │ │ RPC: sync-state, │ │ RPC: remove-node, │
|
||||
│ loop (server.rs│ │ loop (server.rs │ │ (manual "Sync") │ │ set-trust, join, │
|
||||
│ ~L497) │ │ ~L840) │ │ │ │ peer-joined │
|
||||
└───────┬───────┘ └────────┬──────────┘ └────────┬──────────┘ └───────────┬─────────┘
|
||||
│ sync_with_peer() │ sync_with_peer() │ │
|
||||
│ → update_node_state() │ → update_node_state() │ │
|
||||
└──────────────┬─────────────┴──────────────┬─────────────────┘ │
|
||||
▼ ▼ ▼
|
||||
(race: stale in-memory list from an in-flight sync's earlier load_nodes()
|
||||
overwrites a concurrent remove_node()'s just-saved tombstoned list)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ mesh/mod.rs: purge_federation_peer │
|
||||
│ (peers, messages, contacts, presence)│
|
||||
└───────────────┬───────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ StateManager broadcast → WebSocket │
|
||||
│ → Pinia stores → Federation.vue / │
|
||||
│ Mesh.vue re-render │
|
||||
└─────────────────────────────────────┘
|
||||
|
||||
Mesh attachment/parity path (FED-04):
|
||||
Frontend attach flow → mesh.transport-advice → {auto-mesh|choose|tor-only}
|
||||
→ mesh.send-content-inline (small) | mesh.send-content (large, via /api/blob)
|
||||
real daemon: api/rpc/mesh/typed_messages.rs demo: mock-backend.js (mirrors tier logic — DONE)
|
||||
Frontend contacts/reactions/edit/delete
|
||||
real daemon: api/rpc/mesh/typed_messages.rs (contacts-list/save, send-reaction, edit-message, ...)
|
||||
demo: mock-backend.js — contacts-list/save MISSING (404); reaction/edit/delete are no-op acks (GAP)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
No new directories needed. Touch points:
|
||||
```
|
||||
core/archipelago/src/federation/storage.rs # add locking around load/save
|
||||
core/archipelago/src/server.rs # collapse or coordinate the two sync loops
|
||||
core/archipelago/src/api/rpc/lnd/info.rs # extend handle_lnd_getinfo with identity_pubkey/uris
|
||||
core/archipelago/src/federation/types.rs # (maybe) add lightning fields to NodeStateSnapshot
|
||||
core/archipelago/src/api/rpc/federation/handlers.rs # (maybe) new RPC to fetch a peer's LN info
|
||||
neode-ui/mock-backend.js # add mesh.contacts-list/save, stateful reaction/edit/delete
|
||||
neode-ui/src/components/LightningChannelModal.vue # NEW — FED-05 (name TBD by planner)
|
||||
neode-ui/src/components/SendBitcoinModal.vue # FED-06 swap
|
||||
neode-ui/src/components/WalletScanModal.vue # FED-06 swap (secondary paid-tick site)
|
||||
```
|
||||
|
||||
### Pattern 1: Federation removal is already "belt and suspenders" — reuse, don't rewrite
|
||||
**What:** `handle_federation_remove_node` (handlers.rs:273) captures the peer's pubkey *before* calling `federation::remove_node`, then after removal calls `mesh::purge_federation_peer` to drop the synthetic mesh contact, its messages, presence, and persisted mesh-contacts entry. `federation::remove_node` (storage.rs:180) already tombstones the DID **before** saving the filtered node list and propagates a tombstone-write failure as an error (fixed in `01cbec27`).
|
||||
**When to use:** This is the correct pattern for FED-01 already. Don't redesign it — the actual gap is the concurrency race in the storage layer underneath it (see Pitfall 1), not the removal logic itself.
|
||||
**Example:**
|
||||
```rust
|
||||
// Source: core/archipelago/src/federation/storage.rs:180-198 (already fixed, 01cbec27)
|
||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| n.did != did);
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
// Tombstone FIRST and propagate failure — a remove whose tombstone
|
||||
// never landed isn't a remove.
|
||||
tombstone_did(data_dir, did).await.context("persist removal tombstone")?;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Transitive sync already respects tombstones — verify, don't re-add protection
|
||||
**What:** `merge_transitive_peers` (sync.rs:120) loads `load_removed_dids()` and skips any hint whose DID is tombstoned, and `handle_federation_peer_joined` (handlers.rs:641) independently rejects a `peer-joined` callback for a tombstoned DID. Both paths that could resurrect a removed node already check the tombstone list.
|
||||
**When to use:** The FED-03 review should write a test that concurrently exercises remove + an in-flight sync (see Pitfall 1) rather than re-deriving the (already-correct) tombstone-check logic.
|
||||
|
||||
### Pattern 3: Demo backend must be a byte-for-byte RPC mirror, not a "close enough" mock
|
||||
**What:** `mock-backend.js`'s `mesh.transport-advice` case (line 4318) explicitly duplicates the daemon's size thresholds (`MESH_AUTO_MAX = 1024`, `MESH_HARD_MAX = 2300`) with a comment pointing at `typed_messages.rs handle_mesh_transport_advice` as the source of truth.
|
||||
**When to use:** Apply the same pattern to `mesh.contacts-list`/`mesh.contacts-save` and to the reaction/edit/delete stubs — read the real handler in `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (lines 1180-1371 for contacts/presence, 637-976 for reply/reaction/receipt/forward, 1065-1180 for edit/delete) and mirror its actual state transitions in `meshStore.dynamic`, not just an `{ok:true}` ack.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Unlocked read-modify-write on shared JSON files:** `federation/storage.rs` has none of `load_nodes()`/`save_nodes()` behind a mutex, and writes are a direct `fs::write()` (not atomic temp+rename). Any new federation code must NOT add a third code path that does its own read-modify-write without going through a shared lock — that widens the race window instead of closing it.
|
||||
- **Silent `debug!()` on periodic-loop errors:** Both sync loops in `server.rs` log sync failures at `debug!` level only (not surfaced to the state broadcast, not visible in the UI). FED-02 explicitly requires operator-visible sync errors — don't add a third silent loop; extend the existing ones to persist a `last_sync_error` alongside `last_seen`.
|
||||
- **Reinventing `handle_lnd_openchannel`'s connect-then-open sequence:** It already does `perm=false` synchronous peer connect before opening (with a documented reason: `perm=true` races and fails with "peer is not online"). Reuse it; do not write a second Lightning-channel RPC.
|
||||
- **Assuming `ScreensaverRing` is pre-sized for a 112px success badge:** its `compact` class is 240px (mobile) / 320px (≥768px) — 2-3x the current `.send-success-burst`. Naive drop-in will overflow the modal card; must be explicitly scaled or the surrounding layout redesigned.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Connecting to + opening a channel with an LN peer | A new RPC | `lnd.openchannel` (`api/rpc/lnd/channels.rs:238`) | Already validates pubkey format, amount bounds, fee params, and does the connect-before-open sequence correctly with the documented `perm=false` fix |
|
||||
| Paid/success radial visual | A new ring/particle component | `ScreensaverRing.vue` (`compact` size) | Explicitly required by FED-06; also already ships a reduced-motion-friendly animation pattern to copy for consistency |
|
||||
| Peer transport badge (FIPS/Tor) | New transport-tracking logic | `FederatedNode.last_transport`/`last_transport_at` (already written by `record_peer_transport`) | Already ground-truth (records what was actually used, not predicted) — FED-05's peer picker can reuse this field to show reachability |
|
||||
| Federated-peer list for the "trusted nodes" picker | A new RPC | `federation.list-nodes` (already returns `did`, `name`, `onion`, `trust_level`, `last_seen`) | FED-05 only needs to ADD Lightning fields to this payload/peer lookup, not build a parallel peer list |
|
||||
|
||||
**Key insight:** Almost every piece of infrastructure FED-01/02/04/05 need already exists in some form — the gaps are narrow (a missing lock, a missing mock method, a missing struct field) rather than missing subsystems. Resist the urge to redesign the federation sync architecture; patch the specific race and the specific parity/field gaps identified here.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Unlocked concurrent read-modify-write on `federation/nodes.json` (PRIMARY SUSPECT for FED-01/FED-02)
|
||||
**What goes wrong:** A federated node the operator removes reappears after "some sync cycles," with no error anywhere — exactly the symptom reported. `federation::remove_node()` and `federation::sync::update_node_state()` (called from `sync_with_peer()`) both do `load_nodes()` → mutate in memory → `save_nodes()` with zero mutex and a non-atomic `fs::write()`. Two async tasks (e.g., the 90s auto-sync loop mid-flight for peer X, and a `federation.remove-node` RPC for the same peer X arriving concurrently) can interleave: the sync task's `load_nodes()` snapshot (taken before the removal) still contains X; the removal completes and saves a list without X; the sync task then finishes and calls `save_nodes()` with its stale in-memory list, silently restoring X.
|
||||
**Why it happens:** No `Mutex`/`RwLock` guards the federation JSON files, and there are TWO independent periodic sync loops (`server.rs` ~line 497, every 90s; ~line 840, every 1800s — the second loop's comment even says "every 30 min" while the interval literal is `Duration::from_secs(1800)`, i.e. that arithmetic is correct but the redundancy with the 90s loop is not otherwise explained or justified anywhere in the code) plus manual "Sync All" and remove/join/set-trust RPCs, all writing the same file.
|
||||
**How to avoid:** Add a per-data-dir `tokio::sync::Mutex<()>` (or an `Arc<Mutex<Vec<FederatedNode>>>` cache) that every `federation::storage` read-modify-write function acquires for the duration of its load+mutate+save; consider switching `save_nodes` to atomic temp-file+rename to avoid partial-write corruption on crash. Separately, evaluate collapsing the two periodic sync loops into one (the 90s loop already does everything the 1800s loop does, plus asymmetry self-heal) — the 1800s loop appears vestigial/redundant and doubles the race exposure for no described benefit.
|
||||
**Warning signs:** `tests/multinode/smoke.sh`'s "removed-node tombstone" section (already covers the transitive-reappear case) intermittently fails only under load/timing variance, or a removed node's `last_seen` timestamp updates *after* a `federation.remove-node` call succeeded — that's the race manifesting as reappearance without any logged error.
|
||||
|
||||
### Pitfall 2: Trusting `CONCERNS.md` as current state for this phase
|
||||
**What goes wrong:** Re-fixing an already-fixed bug (tombstone-write-swallowed was fixed in `01cbec27`, 2026-07-02) wastes the FED-03 review's time and risks reintroducing a regression if the "fix" reverts working code.
|
||||
**Why it happens:** `CONCERNS.md` was generated 2026-07-29 from a static codebase snapshot/analysis pass that in at least two documented cases (tombstone swallow, DID-join-without-verification) predates fixes already on `main`.
|
||||
**How to avoid:** For every `CONCERNS.md` federation/mesh item, `git log -p` the referenced file/line range before deciding it's still open. Two items already verified fixed in this research: "Federation node removal tombstone gap" (fixed `01cbec27`) and part of "Federation DID validation incomplete" (the `peer-joined` RPC does require and verify an ed25519 signature — `handlers.rs:588-607`). The remaining un-verified part of that concern — no proof-of-ownership check on the *original* DID mint, i.e. can anyone claim any DID string on first contact — may still be valid; verify it during the review rather than assuming either way.
|
||||
**Warning signs:** A "finding" in the FED-03 review that exactly matches a `CONCERNS.md` bullet without a fresh code read is a signal to re-verify before filing it.
|
||||
|
||||
### Pitfall 3: Demo mock silently no-ops instead of erroring on unmirrored RPCs
|
||||
**What goes wrong:** `mesh.contacts-list`/`mesh.contacts-save` are called live from `Mesh.vue` (lines 113, 896) but have no case in `mock-backend.js`'s switch — they fall through to the `default` case which returns a proper JSON-RPC error (`Method not found`), but the frontend call sites wrap them in `try {} catch { /* non-fatal */ }`, so the failure is invisible during manual demo testing unless you watch the browser console or server log (`console.log('[RPC] Unknown method: ...')`).
|
||||
**Why it happens:** New frontend RPC call sites get added over time; `mock-backend.js` parity is manual and easy to miss for methods that aren't on the "main" flow (aliasing a peer is a secondary action, not part of onboarding/attach-file).
|
||||
**How to avoid:** Grep `neode-ui/src/api/rpc-client.ts` for every `mesh.*`/`federation.*` method string and cross-reference against `mock-backend.js`'s switch cases as an explicit FED-03/FED-04 checklist item, not just the attachment-send path already fixed.
|
||||
**Warning signs:** Browser console shows `[RPC] Unknown method: mesh.contacts-list` while testing the demo at `:8100`.
|
||||
|
||||
### Pitfall 4: `ScreensaverRing`'s size classes don't have a "success-badge" variant
|
||||
**What goes wrong:** Dropping `<ScreensaverRing size="compact" />` directly into `.send-success-burst` (currently 112px) either overflows the card or looks disproportionate at 240-320px without adjusting the surrounding layout.
|
||||
**Why it happens:** `ScreensaverRing.vue`'s two size classes (`viz-ring-default`, `viz-ring-compact`) were designed for full-screen screensaver and settings-panel contexts (`SystemDangerZone.vue`), not for an inline modal success pane.
|
||||
**How to avoid:** Either (a) wrap the component in a container with `transform: scale(0.5)` (112/240 ≈ 0.47) and compensate for the transform not affecting layout box size (use negative margins or a fixed wrapping box), or (b) add a third `compact-sm`/`badge` size variant to `ScreensaverRing.vue` sized for this use case (cleaner, and reusable for `WalletScanModal.vue`'s `.success-ring` too). Confirm the choice with a UI-spec/sketch before implementation given this affects two components.
|
||||
**Warning signs:** Visual QA on `:8100` shows the ring clipped by the modal's `max-h-[90vh] overflow-y-auto` container or the checkmark badge floating disconnected from the ring's visual center.
|
||||
|
||||
### Pitfall 5: FED-05 has no backend field for a peer's Lightning identity
|
||||
**What goes wrong:** Building the "trusted nodes by hostname, one-click channel open" UI before the backend can supply a federated peer's LN `pubkey`/`host:port` results in a UI that can list *names* but has nothing to pass to `lnd.openchannel`.
|
||||
**Why it happens:** `NodeStateSnapshot` (the payload `federation.get-state`/sync exchanges) has no Lightning fields at all — it was designed for app/CPU/mem/tor status, not payment-channel metadata.
|
||||
**How to avoid:** Plan FED-05 backend-first: (1) extend `handle_lnd_getinfo` to parse and return `identity_pubkey` + `uris` from LND's real `/v1/getinfo` response (both fields already exist in LND's REST API — the daemon's `LndGetInfoResponse` struct just doesn't deserialize them yet), (2) add optional `lightning_pubkey`/`lightning_uri` fields to `NodeStateSnapshot` so a synced peer's info includes it (defaulted via `#[serde(default)]` for backward compat, matching every other optional field in that struct), (3) decide and scope the "public nodes" browse/request feature — no existing data source; recommend a small curated static list (documented, versioned) rather than a live LN graph query (`DescribeGraph` is heavy and not currently proxied anywhere in this codebase) unless the user specifically wants live graph browsing.
|
||||
**Warning signs:** A plan step that starts building `LightningChannelModal.vue` before a corresponding backend RPC/field change is scoped — check the plan's task ordering.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Reuse: opening a channel (backend already correct)
|
||||
```rust
|
||||
// Source: core/archipelago/src/api/rpc/lnd/channels.rs:238-336 (excerpted)
|
||||
// Params: { pubkey: <66-hex>, amount: <sats>, address?: <host:port>, private?, target_conf?, sat_per_vbyte? }
|
||||
// Validates pubkey format + amount bounds (20,000..=16,777,215 sats) before touching LND.
|
||||
// Connects to the peer synchronously (perm=false) before opening so "peer not online" is
|
||||
// surfaced deterministically instead of racing the open.
|
||||
```
|
||||
|
||||
### Reuse: transport badge already ground-truth per peer
|
||||
```rust
|
||||
// Source: core/archipelago/src/federation/storage.rs:120-147
|
||||
// record_peer_transport() writes last_transport/last_transport_at after every
|
||||
// successful PeerRequest — the FED-05 peer picker can show "reachable via FIPS"
|
||||
// / "reachable via Tor" per trusted node without any new plumbing.
|
||||
```
|
||||
|
||||
### Gap: demo mesh chat action stubs don't mutate state
|
||||
```javascript
|
||||
// Source: neode-ui/mock-backend.js:4479-4490 (current — needs to become stateful)
|
||||
case 'mesh.send-reaction':
|
||||
case 'mesh.send-reply':
|
||||
case 'mesh.send-read-receipt':
|
||||
case 'mesh.edit-message':
|
||||
case 'mesh.delete-message':
|
||||
case 'mesh.forward-message':
|
||||
case 'mesh.send-channel':
|
||||
case 'mesh.refresh':
|
||||
case 'mesh.reboot-radio': {
|
||||
return res.json({ result: { ok: true, sent: true } }) // no meshStore.dynamic mutation
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|---------------|--------|
|
||||
| Tombstone write silently dropped (`let _ = tombstone_did(...)`) | Tombstone write propagated as a hard error, written before the node-list save | 2026-07-02, `01cbec27` | A failed tombstone write now fails the whole remove — matches FED-01's "a failed removal surfaces an error" requirement already, at the single-call level (the remaining gap is the cross-call race in Pitfall 1) |
|
||||
| Mesh attachment send: demo threw "Method not found" and force-opened a demo-only chooser modal | `mock-backend.js` implements the same RPC surface + mirrors the real size-tier logic | 2026-07-29, `c2ce71c6` | FED-04's core attachment-parity requirement is met; remaining gaps are contacts and reaction/edit/delete (see Pitfall 3) |
|
||||
|
||||
**Deprecated/outdated:** None specific to this phase's tech; no framework/library version churn involved.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The unlocked read-modify-write race on `federation/nodes.json` (Pitfall 1) is the primary cause of the user-reported "nodes reappear / sync issues" — this is a code-derived hypothesis, not confirmed via a reproduced failure in this research session | Summary, Pitfall 1 | If wrong, the planner should still fix the race (it's a real bug regardless) but should budget review time for other causes too — start FED-03 with a broader look, not a narrow patch-and-close on this one hypothesis |
|
||||
| A2 | The 1800s periodic federation sync loop (`server.rs` ~line 840) is redundant given the 90s loop and safe to remove/collapse | Pitfall 1, Anti-Patterns | If a maintainer added it for a specific reason not documented in the surrounding comments (e.g. covering a case the 90s loop misses), removing it could regress that unstated behavior — confirm via `git log -p` / git blame on that block before deleting |
|
||||
| A3 | "Public nodes" for channel-open browse/request (FED-05) should be a small curated static list rather than a live LN network graph query | Pitfall 5 | If the user actually wants live graph discovery, the curated-list approach under-delivers; this needs explicit user confirmation before FED-05 backend work starts |
|
||||
| A4 | `ScreensaverRing`'s size mismatch with `.send-success-burst` should be solved with a CSS scale-down rather than a new component size variant | Pitfall 4 | Either approach works technically; scale-down is faster but may look slightly different under `prefers-reduced-motion`; a new size variant is cleaner but touches the shared component. Low risk either way — a UI sketch/spec pass can decide before implementation |
|
||||
| A5 | The remaining "Federation DID validation incomplete" concern (no proof-of-ownership check on first DID mint) is still an open gap, not yet fixed like its sibling claims | Pitfall 2 | Not independently re-verified in this session (only the peer-joined signature check was confirmed); FED-03 should explicitly re-check this specific sub-claim before filing or dismissing it |
|
||||
|
||||
**If this table is empty:** N/A — see rows above.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Is the two-loop federation sync redundancy intentional?**
|
||||
- What we know: Both loops call `sync_with_peer` over all `Trusted`/`Observer` nodes; only the 90s loop does the "asymmetry self-heal" `notify_join` re-assertion; the 1800s loop additionally calls `refresh_federation_mesh_peers()` after its full pass (the 90s loop does not).
|
||||
- What's unclear: Whether the 1800s loop's `refresh_federation_mesh_peers()` call covers a gap the 90s loop leaves (e.g. name/roster propagation to mesh chat), which would mean simply deleting it regresses something.
|
||||
- Recommendation: `git log -p` / blame both loop-insertion commits during FED-03; if the mesh-peer-refresh behavior is the only unique value of the 1800s loop, move that single call into the 90s loop's completion and delete the 1800s loop entirely, closing half the race window.
|
||||
|
||||
2. **What UI/UX should "browse/request channels with public nodes" (FED-05) actually look like?**
|
||||
- What we know: No existing data source; `lnd.openchannel` supports a manual pubkey+address entry today (a user could theoretically paste a public node's URI already, just with no picker/browse UI).
|
||||
- What's unclear: Whether "public nodes" means (a) a curated list Archipelago ships/updates, (b) a live query against some LSP directory API, or (c) simply a well-labeled manual-paste field with format help (lowest-effort, matches what the backend already supports).
|
||||
- Recommendation: Flag for `/gsd-discuss-phase` or a direct user check-in before FED-05 planning — this is a scope decision, not a technical one.
|
||||
|
||||
3. **Does `federation.get-state`'s `federated_peers` hint list need a Lightning field, or should peer LN info be a separate on-demand RPC?**
|
||||
- What we know: `NodeStateSnapshot.federated_peers` already carries a lightweight `FederationPeerHint` (did/pubkey/onion/name/fips_npub) shared during sync; adding `lightning_uri` there means every synced peer's LN info is cached locally without an extra round-trip.
|
||||
- What's unclear: Whether peers want to opt out of advertising their LN URI transitively (privacy consideration, similar to the existing `shared_location` opt-in pattern for lat/lon).
|
||||
- Recommendation: Follow the `shared_location` precedent (`Option<(f64,f64)>` only sent when the node opts in via `server.set-location`) — add an explicit opt-in setting for Lightning URI sharing rather than defaulting it on, since exposing a payment channel target more broadly than intended has real-money implications.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Rust toolchain / cargo (from `core/`) | FED-01/02/03/04 backend work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
|
||||
| Node.js / npm (`neode-ui/`) | FED-04/05/06 frontend + mock-backend.js work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
|
||||
| `:8100` dev preview proxying to archi-dev | FED-05/06 required verification step per phase description | Not probed this session — verify at execution time per `docs/../reference_neode_ui_dev_testing.md` (mock=5959, pw password123) | — | — |
|
||||
| LND REST API (`LND_REST_BASE_URL`, local macaroon) | FED-05 `lnd.getinfo` extension + `lnd.openchannel` reuse | Assumed present on real nodes per existing `channels.rs`/`info.rs` code; demo backend has no real LND — FED-05 UI must be exercised against archi-dev (real LND) per phase description, not the pure-mock demo | — | Demo-only mock stub for `lnd.getinfo` identity fields if archi-dev is unavailable during a work session |
|
||||
| `tests/multinode/smoke.sh` | Regression coverage for FED-01/02 fix | Present, already covers removed-node tombstone + transitive-reappear scenarios; does NOT currently exercise the concurrent-race scenario (Pitfall 1) | — | Extend smoke.sh with a concurrent remove+sync test, or add a Rust-level `#[tokio::test]` in `federation/storage.rs` that spawns concurrent remove/save calls |
|
||||
|
||||
**Missing dependencies with no fallback:** None identified — this phase is code-only, no new external services.
|
||||
|
||||
**Missing dependencies with fallback:** LND-backed FED-05 verification (see row above) — use archi-dev per phase instructions; demo-only stubbing is a fallback if archi-dev is temporarily unavailable, but the phase's own success criteria require verification against archi-dev before deploy, so this fallback should not be treated as sufficient sign-off.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework (backend) | `cargo test` (Rust, workspace root `core/`) — federation/storage.rs and federation/sync.rs already have `#[tokio::test]` unit coverage |
|
||||
| Framework (frontend) | Vitest (`neode-ui/vitest.config.ts`, `vitest run`) |
|
||||
| Config file | `core/Cargo.toml` (workspace); `neode-ui/vitest.config.ts` |
|
||||
| Quick run command | `cd core && cargo test -p archipelago federation:: --lib` (backend); `cd neode-ui && npx vitest run src/components/__tests__/` (frontend, scope to touched files) |
|
||||
| Full suite command | `cd core && CARGO_INCREMENTAL=0 cargo test` (backend, full); `cd neode-ui && npm run test` (frontend, full); `tests/multinode/smoke.sh` (cross-node, requires 2+ live nodes, run on-node per CLAUDE.md gate policy) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| FED-01 | Removed node never reappears, incl. under concurrent sync | unit + integration | `cargo test -p archipelago federation::storage::tests` (existing) + NEW concurrent-race test | ✅ existing tests / ❌ Wave 0 for the new race test |
|
||||
| FED-01 | Failed removal surfaces an error, not a silent no-op | unit | `cargo test -p archipelago federation::storage::tests::test_remove_nonexistent_node_errors` (existing, covers the "not found" case; add one for a simulated tombstone-write I/O failure) | ✅ existing / ❌ Wave 0 for I/O-failure case |
|
||||
| FED-02 | Sync converges; fleet nodes agree on node list | integration (multinode) | `tests/multinode/smoke.sh` section "federation pairing" + "removed-node tombstone" (existing) | ✅ |
|
||||
| FED-02 | Sync errors are operator-visible | manual / UI | No automated test yet — requires a UI element (e.g. per-node "last sync error" badge) that doesn't exist yet | ❌ Wave 0 (needs the field to exist first) |
|
||||
| FED-03 | Structured review findings fixed or deferred with reason | N/A (process requirement) | N/A — tracked via the plan's findings list, not a single automated test | — |
|
||||
| FED-04 | Attachment send parity demo vs real | manual (visual) + existing `mock-backend.js` logic mirrors daemon tier thresholds | Manual walk-through on `:8100` per phase description; consider a Vitest test asserting `mesh.transport-advice` tier boundaries match `MESH_AUTO_MAX`/`MESH_HARD_MAX` constants | ❌ Wave 0 (no existing frontend test pins these thresholds) |
|
||||
| FED-04 | Contacts list/save + reaction/edit/delete parity | manual + NEW mock-backend.js stateful behavior | Manual on `:8100`; no existing automated coverage of `mock-backend.js` behavior (it's a dev tool, not covered by `npm run test`) | ❌ Wave 0 if automated coverage is wanted; otherwise manual-only is acceptable for a demo shim |
|
||||
| FED-05 | Own node Lightning URI is shareable | unit (backend) + manual (UI) | NEW `cargo test` for `handle_lnd_getinfo`'s identity_pubkey/uris parsing (mock LND response fixture); manual UI check on archi-dev | ❌ Wave 0 |
|
||||
| FED-05 | Trusted-node picker + channel open flow | manual (UI, requires archi-dev + a live peer) | Manual per phase description ("tested live on the :8100 dev preview against archi-dev") | ❌ Wave 0 — inherently a live/manual check per the phase's own success criteria |
|
||||
| FED-06 | Paid-tick animation matches screensaver ring everywhere it appears | manual (visual) | Manual visual check of `SendBitcoinModal.vue` + `WalletScanModal.vue` on `:8100` | ❌ Wave 0 — visual-only requirement, no meaningful automated assertion beyond "component renders" |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** Backend: `cargo test -p archipelago federation:: mesh::` (scoped). Frontend: `npx vitest run` scoped to touched component test files, or a full quick run if none exist yet for touched files.
|
||||
- **Per wave merge:** Full `cargo test` (backend) + `npm run test` (frontend).
|
||||
- **Phase gate:** Full backend + frontend suites green, plus `tests/multinode/smoke.sh` federation sections green on a real 2-node pair, plus a manual FED-05/FED-06 walkthrough on `:8100` against archi-dev before any deploy (per phase description, "fixed there before any deploy").
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] New `#[tokio::test]` in `core/archipelago/src/federation/storage.rs` (or a new integration test) that spawns concurrent `remove_node()` + `update_node_state()`/`sync_with_peer`-equivalent calls against the same `data_dir` and asserts the removed node stays removed — this is the regression test for Pitfall 1 and does not exist today.
|
||||
- [ ] Test/fixture for `handle_lnd_getinfo` parsing `identity_pubkey`/`uris` from a mocked LND `/v1/getinfo` JSON response (FED-05) — no existing test touches this handler's response shape.
|
||||
- [ ] Decide whether `mock-backend.js` behavior warrants automated (Vitest/Playwright-against-mock) coverage, or manual-only is acceptable given it's a dev-preview tool, not shipped code — recommend manual-only unless the team already has a pattern for testing the mock backend elsewhere (none found in this research).
|
||||
- [ ] Framework install: none — all frameworks (`cargo test`, Vitest) are already configured and running.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | Partial | Federation peer identity is DID/ed25519-key-based, not password auth; `peer-joined`/`peer-did-changed`/`peer-address-changed` all require and verify an ed25519 signature over a canonical message before mutating state — already correct, verify no new RPC bypasses this |
|
||||
| V3 Session Management | No | Not applicable — federation/mesh RPCs are peer-signed, not session-cookie based |
|
||||
| V4 Access Control | Yes | `is_peer_allowed_path()` (`server.rs:1270`, tested at `server.rs:2075+`) restricts which HTTP paths a peer-only listener will serve — any new FED-05 RPC (e.g. "fetch peer's Lightning URI") that's meant to be peer-reachable must be added to this allow-list explicitly, not left to fall through |
|
||||
| V5 Input Validation | Yes | `lnd.openchannel` already validates pubkey format (66-hex) and amount bounds server-side (`channels.rs:252-268`) — reuse, and apply the same rigor to any new Lightning-URI-sharing field (validate the URI format before persisting/displaying it) |
|
||||
| V6 Cryptography | Yes | ed25519 signature verification via `identity::NodeIdentity::verify` — never hand-roll signature checks; reuse this existing verification path if FED-05 needs to authenticate a peer's advertised Lightning info |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Federation peer spoofing a DID they don't control | Spoofing | ed25519 signature verification (already implemented for join/address-change/did-rotation — confirm any new FED-05 peer-info exchange follows the same pattern) |
|
||||
| Concurrent-write race corrupting/reverting federation state (Pitfall 1) | Tampering (unintentional, but security-relevant since it undermines the "removal sticks" guarantee — a removed/untrusted peer regaining federation membership is a real access-control regression) | Add locking around the storage layer (see Pitfall 1's fix) |
|
||||
| Unbounded transitive federation exposure (a Trusted peer's peer list auto-added as Observer) | Elevation of Privilege (bounded) | Already mitigated — `merge_transitive_peers` only runs for `Trusted`-level sources and only adds new peers as `Observer` (never auto-escalates to `Trusted`); this is intentional and correct, don't loosen it |
|
||||
| Advertising this node's Lightning payment-channel target more broadly than intended (FED-05 new surface) | Information Disclosure | Follow the existing `shared_location` opt-in pattern — do not default Lightning URI sharing to "on" for all federated peers (see Open Question 3) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `core/archipelago/src/federation/storage.rs`, `sync.rs`, `types.rs`, `invites.rs` — read directly, current `main`
|
||||
- `core/archipelago/src/api/rpc/federation/handlers.rs` — read directly, current `main`
|
||||
- `core/archipelago/src/server.rs` (periodic sync loop sections, `is_peer_allowed_path`) — read directly
|
||||
- `core/archipelago/src/mesh/mod.rs` (`purge_federation_peer`, `upsert_federation_peer`, `seed_federation_peers_into_mesh`) — read directly
|
||||
- `core/archipelago/src/api/rpc/lnd/channels.rs`, `info.rs` — read directly
|
||||
- `core/archipelago/src/fips/dial.rs` — read directly
|
||||
- `neode-ui/mock-backend.js` (mesh RPC switch cases) — read directly, current `main` (post commit `c2ce71c6`)
|
||||
- `neode-ui/src/views/Federation.vue`, `neode-ui/src/api/rpc-client.ts`, `neode-ui/src/components/ScreensaverRing.vue`, `neode-ui/src/components/Screensaver.vue`, `neode-ui/src/components/SendBitcoinModal.vue`, `neode-ui/src/components/WalletScanModal.vue`, `neode-ui/src/views/Mesh.vue` — read directly
|
||||
- `git log -p` on `core/archipelago/src/federation/storage.rs` (commit `01cbec27`) and `git show c2ce71c6` — verified fix history directly, not from documentation
|
||||
- `tests/multinode/smoke.sh` — read directly for existing federation test coverage
|
||||
- `.planning/REQUIREMENTS.md`, `.planning/STATE.md`, `.planning/codebase/ARCHITECTURE.md`, `.planning/codebase/CONCERNS.md` — project-provided context (CONCERNS.md's federation claims were then verified/refuted against live code per Pitfall 2)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- None — this research relied entirely on direct codebase reads and git history, not external web sources, since the phase is about hardening this specific project's existing code rather than adopting new external technology.
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — no new dependencies; existing stack confirmed by direct file reads
|
||||
- Architecture (FED-01/02/03/04): HIGH — read the actual implementation, confirmed fix history via git log, identified a concrete unverified race condition with file:line citations
|
||||
- Architecture (FED-05): MEDIUM — greenfield UI/RPC surface; confirmed what's missing (no existing Lightning-URI field/RPC) but the design (opt-in sharing, public-nodes scope) needs a user decision, not just engineering judgment
|
||||
- Pitfalls: HIGH for Pitfalls 1-3 (backend/demo, code-verified); MEDIUM for Pitfalls 4-5 (frontend sizing and FED-05 scope, judgment calls flagged in Assumptions Log)
|
||||
|
||||
**Research date:** 2026-07-29
|
||||
**Valid until:** 2026-08-12 (14 days — this is a fast-moving area of an actively-developed codebase; other agents were committing federation/mesh-adjacent changes during this very research session, per the mock-backend.js commit observed mid-session)
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
phase: 1
|
||||
slug: federation-mesh-hardening
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 2026-07-29
|
||||
---
|
||||
|
||||
# Phase 1 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for the two UI-facing requirements in this phase:
|
||||
> **FED-05** (inter-node Lightning channel-opening UX) and **FED-06** (on-brand paid-tick
|
||||
> animation). The rest of Phase 1 (FED-01–04) is backend/parity work with no new UI surface.
|
||||
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none — no `components.json` found; project is **Vue 3**, and shadcn/ui does not support Vue (React-only), so the shadcn init gate does not apply here. Registry safety gate: not applicable. |
|
||||
| Preset | not applicable |
|
||||
| Component library | none — hand-authored Tailwind utilities + a custom "glass" CSS system (`glass-card`, `glass-button`, `glass-button-warning/danger/success`, `input-glass`, `alert-error/warning/info`, `BaseModal.vue`) defined in `neode-ui/src/style.css` and reused project-wide |
|
||||
| Icon library | none — inline hand-authored SVG, 24×24 viewBox, `stroke-width="2"` outline style (heroicons-esque but not the package). The bolt path `M13 10V3L4 14h7v7l9-11h-7z` is already the house Lightning icon (used in `Server.vue`, `HomeWalletCard.vue`, `Web5Wallet.vue`) — reuse it verbatim for any new Lightning iconography in FED-05, do not source a new icon. |
|
||||
| Font | Avenir Next (`font-sans`, body/UI text), Montserrat 700/800 (`font-archipelago`, headers only — not used in modals) |
|
||||
|
||||
**Modal contract (hard rule, repeated user complaint):** Every new modal in this phase MUST use `BaseModal.vue` (already wraps `Teleport to="body"` + full-screen `bg-black/60 backdrop-blur-md` backdrop + column layout with pinned header/footer and scrolling body) or, if a bespoke modal is unavoidable, MUST replicate that exact `<Teleport to="body">` + `fixed inset-0` + `@click.self="close"` pattern. Never nest a modal inside a `transform`-affected ancestor (glass-panel `translateZ` layers trap `position:fixed`).
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values (must be multiples of 4) — matches `tailwind.config.js`'s existing 4px-grid `spacing` tokens (`1`=4px … `8`=32px) plus standard Tailwind rem multiples used throughout the codebase for larger gaps:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| xs | 4px | Icon-to-label gaps, badge padding |
|
||||
| sm | 8px | Compact row spacing, `gap-2` |
|
||||
| md | 16px | Default element spacing, `p-4` card padding |
|
||||
| lg | 24px | Section padding, `mb-6` between panel sections |
|
||||
| xl | 32px | Layout gaps between major picker columns |
|
||||
| 2xl | 48px | `py-12` empty-state vertical padding |
|
||||
| 3xl | 64px | Not used by this phase's new elements |
|
||||
|
||||
Exceptions: 44px minimum touch target on all new interactive buttons (global rule already enforced in `style.css` for mobile — the "Copy URI" / "Open Channel" / "Request Channel" buttons inherit `min-height: 44px` from `.glass-button` automatically, no override needed).
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
Scoped to this phase's new elements only (existing typography elsewhere is unchanged). Exactly two weights govern this phase's new elements — 400 and 600; Label and Body are differentiated from each other by size and color (not weight), matching how `NodeList.vue` already distinguishes node-name text from badge/hint text:
|
||||
|
||||
| Role | Size | Weight | Line Height |
|
||||
|------|------|--------|-------------|
|
||||
| Label | 12px (`text-xs`) | 400 (regular), `text-white/60` | 1.4 |
|
||||
| Body | 14px (`text-sm`) | 400 (regular), `text-white` | 1.5 |
|
||||
| Heading | 20px (`text-xl`) | 600 (semibold) | 1.3 |
|
||||
|
||||
Heading is pinned to `text-xl` (20px), not a range — this matches `BaseModal.vue`'s own `<h3 class="text-xl font-semibold">` title (the component every new modal in this phase must use per the Modal contract above) and `WalletScanModal.vue`'s pane title, i.e. the size the existing house modals actually use most for their titles. Modal titles ("Open Lightning Channel", "Request Channel") use Heading; node names use Body (`text-white`); URI strings, badges, and helper/meta text use Label (`text-white/60`) per the existing `LightningChannelsPanel.vue`/`NodeList.vue` convention.
|
||||
|
||||
**Inherited — not governed by this contract:** The `SendBitcoinModal.vue`/`WalletScanModal.vue` success-amount numerals (e.g. `12,345 sats`, `text-5xl font-black` — 48px / weight 800) are pre-existing, unchanged display text. FED-06 only replaces the ring graphic behind/around that text, never the text itself, so this weight/size falls outside the phase's new-elements typography contract above and is not counted toward its weight budget.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
| Role | Value | Usage |
|
||||
|------|-------|-------|
|
||||
| Dominant (60%) | `#000000` + `rgba(0,0,0,.35–.65)` | Page background, `.glass`/`.glass-card` surfaces |
|
||||
| Secondary (30%) | `rgba(0,0,0,.65)` blur(18px) card, `rgba(255,255,255,.05–.08)` nested rows | Modal cards, picker list rows (`bg-black/20` per-node rows, `bg-white/5` nested detail blocks) |
|
||||
| Accent (10%) | Archipelago orange `#fb923c` / `rgba(251,146,60,*)` | **Reserved for:** the "Open Channel" / "Request Channel" / "Copy Lightning URI" primary CTA buttons (`.glass-button-warning`), the Lightning bolt icon fill, focus-visible glow rings, the active picker-tab underline (mirrors existing `.mode-switcher-btn-active` treatment) |
|
||||
| Destructive | `#ef4444` family (`.glass-button-danger`) | Not used by FED-05 v1 (no destructive action ships this phase — channel *close* is existing, out-of-scope UI in `LightningChannelsPanel.vue`); declared for consistency if a future "revoke URI sharing" action is added |
|
||||
|
||||
**Inherited semantic colors (pre-existing house convention, unchanged by this phase, NOT part of the 10% accent budget):**
|
||||
- Success/paid emerald `#4ade80` text / `rgba(16,185,129,*)` fills — the paid-tick's center badge and "SENT"/amount numerals (FED-06 keeps this palette; only the surrounding ring geometry changes).
|
||||
- Info blue `#60a5fa` — FIPS/Tor transport badges already shown next to trusted-node rows (`NodeList.vue`'s `transportBadge`); reused as-is in the FED-05 trusted-node picker rows, not introduced by this phase.
|
||||
|
||||
Accent reserved for: **primary Lightning-channel action buttons, the Lightning bolt icon, focus rings, and the active picker-tab indicator only** — never for body text, card backgrounds, or informational badges.
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Primary CTA — own URI | **"Copy Lightning URI"** (copy-to-clipboard button; on success the label flips to **"Copied!"** for ~2s, mirroring `SendBitcoinModal.vue`'s existing `copyDetail`/`Copied!` pattern — do not invent a new copy-feedback idiom) |
|
||||
| Primary CTA — trusted federated node | **"Open Channel"** (one-click; matches the verb already used in `LightningChannelsPanel.vue`'s existing Open Channel button/modal) |
|
||||
| Primary CTA — meshed Lightning peer | **"Request Channel"** (opens the request flow reusing `PeerRequestModal.vue`'s pattern — optional message field, "Send Request" submit button, `sending` → **"Sending…"** busy label — do not build a new request-modal component from scratch) |
|
||||
| Manual fallback entry point | **"Paste URI Manually"** (reveals a `Peer URI` input, placeholder `pubkey@host:port`, helper text `Format: pubkey@host:port` — verbatim reuse of `LightningChannelsPanel.vue`'s existing field copy) |
|
||||
| Empty state heading | **"No Lightning peers yet"** |
|
||||
| Empty state body | **"Add a federated node or connect with a meshed peer running Lightning to open a channel directly — or paste a peer's URI manually below."** |
|
||||
| Error state | **"Couldn't reach that peer — check they're online and try again."** (tone/placement mirrors the existing `openError`/`alert-error` treatment in `LightningChannelsPanel.vue`; LND "still starting up" transient errors reuse that same component's amber `isStartupNotice` treatment rather than the red error style) |
|
||||
| Destructive confirmation | Not applicable — FED-05 v1 ships open/request flows only, no destructive action |
|
||||
| FED-06 copy | Not applicable — pure visual swap. Existing "SENT" / success-amount / "Done" button copy in `SendBitcoinModal.vue` and `WalletScanModal.vue` is unchanged; only the ring graphic behind the checkmark changes. |
|
||||
|
||||
---
|
||||
|
||||
## UI Considerations
|
||||
|
||||
Applicable state considerations resolved: 13 covered, 3 backstop, 0 unresolved.
|
||||
|
||||
| Category | Element(s) | Status | Resolution / Reason |
|
||||
|----------|------------|--------|---------------------|
|
||||
| long-text | own-node URI display | ✅ covered | The displayed `pubkey@host:port` string truncates (CSS `truncate` + `title` tooltip, the existing house pattern) to fit its container; the full untruncated value is what gets copied to clipboard regardless of visual truncation |
|
||||
| empty | trusted-nodes picker list | ✅ covered | Empty state copy row above renders once when both the trusted and meshed-peer lists are empty (shared empty state, not duplicated per column) |
|
||||
| empty | meshed-LN-peers picker list | 🧪 backstop | Same shared empty-state copy as above; no wired test yet asserting the "shared, not duplicated" rendering rule — flag for planner/executor to add a component test |
|
||||
| loading | trusted-nodes picker list | ✅ covered | Mirrors `NodeList.vue`'s existing "Loading nodes..." spinner row treatment |
|
||||
| loading | meshed-LN-peers picker list | ✅ covered | Same spinner treatment as trusted-nodes list |
|
||||
| error | trusted-nodes / meshed-peer picker lists | ✅ covered | Ties to the Copywriting Contract error row; styled with `.alert-error`/`openError` convention already in `LightningChannelsPanel.vue` |
|
||||
| populated | trusted-nodes picker list | ✅ covered | Row layout mirrors `NodeList.vue`'s trusted-node row: name, trust badge, transport badge (FIPS/Tor), one-click "Open Channel" button |
|
||||
| populated | meshed-LN-peers picker list | ✅ covered | Same row layout, "Request Channel" button in place of "Open Channel" (peers are not bilaterally trusted, so the action is a request, never a direct open) |
|
||||
| zero-one-many | trusted-nodes / meshed-peer lists | ✅ covered (dismissed) | No item-count copy is planned for either list (unlike e.g. the channel-status tabs' count badges) — singular/plural phrasing is not applicable |
|
||||
| overflow | picker list rows (long node names) | ✅ covered | `truncate` class + `:title` tooltip on the node-name span, identical to the existing `NodeList.vue` convention |
|
||||
| partial | manual-URI-paste form | ✅ covered | A pasted pubkey without a host falls back to `lnd.openchannel`'s existing address-less-pubkey handling (`address = parts[1] \|\| undefined`), already proven in `LightningChannelsPanel.vue` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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. |
|
||||
|
||||
<!-- Status vocabulary (locked by probe-core projectTruths):
|
||||
✅ covered → a plain truth string lifted into must_haves.truths
|
||||
🧪 backstop → a flat scalar { statement, verification: backstop }; at verify time, no explicit
|
||||
evidence → insufficient_spec → human_needed (never a silent pass, #1154)
|
||||
⚠ unresolved → an explicit planner assumption (surfaced, never silently dropped)
|
||||
Rows are REPLACED (not appended) on a probe re-run — idempotent. -->
|
||||
|
||||
---
|
||||
|
||||
## FED-05 Visual Anchor
|
||||
|
||||
Primary visual anchor: the trusted-nodes list (federation trust is the primary path); meshed-lightning-peers list second; manual-paste fallback visually de-emphasized below both (collapsed behind the "Paste URI Manually" entry point per the Copywriting Contract above, not rendered as a third equal-weight column).
|
||||
|
||||
---
|
||||
|
||||
## FED-06 Sizing Decision (resolves RESEARCH.md Pitfall 4 / Assumption A4)
|
||||
|
||||
RESEARCH.md flagged the `ScreensaverRing` size mismatch (`compact` = 240–320px vs. the current 96–112px paid-tick badges) as needing a UI-spec decision before implementation. **Decision: add a new `badge` size variant to `ScreensaverRing.vue`**, not a CSS `transform: scale()` wrapper — cleaner, reusable across both call sites, and avoids reduced-motion/layout-box mismatches that a transform hack would introduce.
|
||||
|
||||
| Variant | Diameter (mobile) | Diameter (≥768px) | `--viz-radius` | Used by |
|
||||
|---------|-------------------|--------------------|-----------------|---------|
|
||||
| `badge` (NEW) | 160px | 192px | 80px / 96px | `SendBitcoinModal.vue` `.send-success-burst` (replaces the 112px burst), `WalletScanModal.vue` `.success-ring` (replaces the 96px/`w-24` ring) |
|
||||
| `compact` (existing, unchanged) | 240px | 320px | 120px / 160px | `SystemDangerZone.vue` and other existing overlay contexts — do not touch |
|
||||
| `default` (existing, unchanged) | 280–400px (responsive) | — | 140–200px | Full-screen `Screensaver.vue` |
|
||||
|
||||
Composition at both call sites: `<ScreensaverRing size="badge" />` renders the radiating EQ segments; the existing `.burst-core` (green circle + checkmark, `SendBitcoinModal.vue`) or `.success-ring` inner content (`WalletScanModal.vue`) is layered centered on top via `position: absolute; inset: 0` within a shared `position: relative` wrapper sized to the `badge` diameter — same layering pattern `Screensaver.vue` already uses for `ScreensaverLogo` inside `ScreensaverRing`. Do not resize or restyle the checkmark/core itself; only its container changes from a bespoke 96–112px circle to the `badge`-sized wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|--------------|
|
||||
| shadcn official | none | not applicable — shadcn/ui is React-only; this is a Vue 3 project with an established hand-rolled design system (see Design System table) |
|
||||
| third-party | none | not applicable |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 1
|
||||
slug: federation-mesh-hardening
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-07-29
|
||||
---
|
||||
|
||||
# Phase 1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | cargo test (Rust, workspace at core/) + bash harnesses (tests/multinode/, tests/lifecycle/) + node --check / manual curl for mock-backend |
|
||||
| **Config file** | core/Cargo.toml (workspace); tests/multinode/smoke.sh |
|
||||
| **Quick run command** | `cd core && cargo test -p archipelago federation` |
|
||||
| **Full suite command** | `cd core && cargo test` (plus on-node `tests/multinode/smoke.sh` for cross-node behavior) |
|
||||
| **Estimated runtime** | ~120 seconds (cargo test); multinode smoke is node-gated |
|
||||
|
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `cd core && cargo test -p archipelago federation` (or the targeted module's tests)
|
||||
- **After every plan wave:** Run `cd core && cargo test`; frontend waves: `cd neode-ui && npm run build` + grep dist for new strings
|
||||
- **Before `/gsd-verify-work`:** Full suite green + multinode smoke considerations noted (cross-node checks are hardware/node-gated)
|
||||
- **Max feedback latency:** 180 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| (filled by planner) | — | — | FED-01..06 | — | — | — | — | — | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] Storage-race unit tests for `federation/storage.rs` (concurrent load/save + remove-during-sync) — stubs for FED-01/FED-02
|
||||
- [ ] Mock-backend RPC parity checks (mesh contacts + message-mutation methods) — FED-04 remainder
|
||||
|
||||
*Existing infrastructure covers cargo test; multinode smoke.sh covers cross-node sync but runs on-node only.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Removed peer never reappears across real fleet sync cycles | FED-01 | Needs two live nodes + wall-clock sync cycles | Remove a peer on archi-dev, watch peer list through ≥2 sync cycles (90s loop), confirm absent + error surfaced on induced failure |
|
||||
| Channel-open UX end-to-end | FED-05 | Visual/UX judgment + live LND | Drive :8100 preview against archi-dev; share URI, open channel to trusted node, request public-node channel |
|
||||
| Paid-tick animation on-brand | FED-06 | Visual judgment | Trigger payment success in preview; compare ring/EQ segments to screensaver |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 180s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user