Files
archy/.planning/phases/01-federation-mesh-hardening/01-08-PLAN.md
T

25 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-federation-mesh-hardening 08 execute 4
01-02
01-06
01-07
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
true
FED-05
truths prohibitions artifacts key_links
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 verification
When both lists are empty the shared empty state renders exactly once rather than once per list backstop
statement verification
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 backstop
statement category
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 transparency
statement category
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 safety
path provides min_lines
neode-ui/src/components/LightningChannelModal.vue Own-URI share, trusted-node picker, meshed-peer request picker, manual-paste fallback 150
path provides min_lines
neode-ui/src/components/__tests__/LightningChannelModal.test.ts State coverage for empty, loading, error, populated, dedup, ordering, and double-click 60
from to via pattern
neode-ui/src/components/LightningChannelsPanel.vue neode-ui/src/components/LightningChannelModal.vue the panel's existing Open Channel button opens the new picker modal LightningChannelModal
from to via pattern
neode-ui/src/components/LightningChannelModal.vue neode-ui/src/api/rpc-client.ts federation.list-nodes, mesh.lightning-peers, lnd.getinfo, lnd.openchannel, mesh.request-channel lightning-peers
Make channel opening between nodes first-class UI: share your node's Lightning URI, open a channel with a trusted federated node in one click, and request a channel from a meshed peer that has Lightning installed.

Purpose: FED-05's user-facing half, with scope locked in CONTEXT.md (the second list is meshed peer nodes that have Lightning installed — not lnd listpeers, not a curated directory, not a live LN-graph query) and its visuals fixed by 01-UI-SPEC.md (copy, colours, spacing, the shared empty state, the de-emphasised manual-paste fallback, and the hard modal rule). Output: a new picker modal built from the house design system, reached from the Lightning panel's existing Open Channel button, working against the demo and against archi-dev.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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

Artifacts this phase produces

Created or changed by this plan:

Symbol Kind File
LightningChannelModal.vue new Vue component (picker modal) neode-ui/src/components/LightningChannelModal.vue
meshLightningPeers(), requestChannel(), sendLightningInfo() new rpc-client wrappers neode-ui/src/api/rpc-client.ts
mesh.lightning-peers, mesh.send-lightning-info, mesh.request-channel new mock RPC cases neode-ui/mock-backend.js
identity_pubkey / uris on the mock lnd.getinfo result extended mock response same
lightning_uri on the mock federation.list-nodes nodes extended mock response same
LightningChannelModal.test.ts new vitest suite neode-ui/src/components/__tests__/LightningChannelModal.test.ts
Task 1: End-to-end — open a channel with a trusted federated node in one click `federation.list-nodes` emits `lightning_uri` (plan 01-06) and `mesh.lightning-peers` is registered in the dispatcher (plan 01-04) — confirm both by grepping `core/archipelago/src/api/rpc/federation/handlers.rs` and `core/archipelago/src/api/rpc/dispatcher.rs` before starting. neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/api/rpc-client.ts, neode-ui/mock-backend.js, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/src/components/LightningChannelsPanel.vue - `neode-ui/src/components/BaseModal.vue` — the whole file. It already wraps `Teleport to="body"`, the full-screen `bg-black/60 backdrop-blur-md` backdrop, `@click.self` close, the pinned `text-xl font-semibold` title, and the scrolling body. The new modal MUST use it; never nest a modal inside a transform-affected ancestor. - `neode-ui/src/components/LightningChannelsPanel.vue` lines 246-360 (its current bespoke open-channel modal, its `openError` ref, `isStartupNotice()` amber-vs-red distinction, and the fee-preset block) and lines 505-630 (`showOpenModal`, `defaultOpenForm()`, `openForm`, `openingChannel`, and the validate-before-RPC sequence including the 20,000-sat minimum and the `pubkey@host:port` split with an optional address). - `neode-ui/src/views/federation/NodeList.vue` lines 40-140 and 155-190 — the row layout to mirror (truncated name with `:title`, transport badge, trust badge, action button), the `trustedNodes` / `peerNodes` computed filters, and the "Loading nodes..." row. - `neode-ui/src/api/rpc-client.ts` lines 795-850 — the one-line `this.call({ method: '.', params })` wrapper convention and the existing `federation.list-nodes` wrapper. - `neode-ui/mock-backend.js` — the `lnd.getinfo`, `lnd.openchannel`, and `federation.list-nodes` cases, and the parity harness added by plan 01-02. - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — Design System, Spacing, Typography, Color, and the full Copywriting Contract. Every string in this modal comes from that table verbatim. - Mounted with a node list containing one trusted node that has a Lightning URI and one that does not, the trusted list renders exactly one row. - The row shows the node name (truncated, with a `title`), its trust badge, its transport badge, and an Open Channel button. - Clicking Open Channel calls the open RPC once with that node's URI; clicking it twice in immediate succession still results in exactly one call, and the button is disabled while in flight. - While the node list is loading, the loading treatment renders and no empty state renders. - When the node fetch rejects, the error row renders with the contract's error copy. - The modal root renders through the house modal shell, so the backdrop is a full-screen sibling of the card rather than a child of a transformed ancestor. Write the test file first and confirm it fails.
Add rpc-client wrappers for `mesh.lightning-peers`, `mesh.send-lightning-info`, and
`mesh.request-channel` following the existing one-line convention. Extend the mock backend so the
demo answers all three, so `lnd.getinfo` returns an `identity_pubkey` and a `uris` array, and so
`federation.list-nodes` nodes carry `lightning_uri` — mirroring the real handlers per the mock's
established "cite the daemon source" comment convention. The plan-01-02 parity harness must stay
green.

Create `LightningChannelModal.vue` using `BaseModal` as its shell, title "Open Lightning Channel".
In this task implement the trusted-node section only: fetch the federated node list, keep nodes
whose trust level is trusted AND which have a Lightning URI, sort by display name with a stable
tiebreak so the order does not shuffle between refreshes, and render each as a row mirroring the
federation node row — truncated name with a `title` tooltip, the transport badge reusing
NodeList's existing FIPS/Tor logic, the trust badge, and an Open Channel button on the right.

Wire Open Channel to the existing open-channel RPC, reusing the panel's proven sequence: validate
before calling, keep the 20,000-sat minimum, split the URI into pubkey and optional address, and
reuse the `openError` ref plus the `isStartupNotice()` amber-vs-red distinction rather than
inventing a new error idiom. Guard against double submission with an in-flight flag keyed to the
target so the button is disabled and a second click is a no-op.

Point the Lightning panel's **existing** Open Channel button at this modal instead of its bespoke
one. Do not add a new nav entry, route, card, or dashboard tile — the user places new entry
points, this plan only upgrades the one that already exists. Leave the panel's channel list,
close-channel flow, and fee presets untouched.

Follow the UI-SPEC tables exactly: spacing on the 4px grid, the two-weight typography scale,
accent orange reserved for the primary action buttons, the bolt icon path already used elsewhere
in the app, and the copy strings verbatim.
cd neode-ui && test -f src/components/__tests__/LightningChannelModal.test.ts && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs - The test file exists and `npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with all six behaviors present as named cases (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`). - A test asserts two immediate clicks produce exactly one open call. - `grep -c 'BaseModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (import + usage). - `grep -c 'Open Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1 and the copy matches the UI-SPEC Copywriting Contract verbatim. - `grep -c 'LightningChannelModal' neode-ui/src/components/LightningChannelsPanel.vue` is at least 2. - `grep -c "'mesh.lightning-peers'" neode-ui/src/api/rpc-client.ts` equals 1. - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero missing methods. - `cd neode-ui && npx vitest run` exits 0 and `npm run build` exits 0. - No new route, nav item, or dashboard card was added — confirmed by `git diff --stat` showing no change to the router or any layout/nav component, recorded in the SUMMARY. A trusted federated node can be picked by hostname and a channel opened with one click, through the house modal shell, on the demo and against a real node. Task 2: The meshed Lightning peers list and the Request Channel flow neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts, neode-ui/mock-backend.js - `neode-ui/src/components/federation/PeerRequestModal.vue` — the whole file (66 lines): the optional message field, the `sending` → "Sending…" busy label, and the `$emit('send', message)` / `$emit('cancel')` contract. Reuse this component rather than building a second request modal. - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-05 Visual Anchor" section (trusted list is primary, meshed list second) and the empty-state copy rows. - `neode-ui/src/views/federation/NodeList.vue` — the empty-state block treatment to mirror. - The plan-01-07 SUMMARY — the exact params `mesh.request-channel` expects. Add the meshed-Lightning-peers section below the trusted list: fetch via `mesh.lightning-peers`, render rows in the same layout with a Request Channel button in place of Open Channel, and sort with the same stable ordering rule.
Deduplicate across the two lists: a peer that is both a trusted federated node and a meshed
Lightning peer appears only in the trusted list. Match on the identity available in both payloads
(the node's Lightning URI is the reliable common key; fall back to the peer's archipelago identity
key when present). Never match on display name.

Wire Request Channel to `PeerRequestModal` — mount it with the optional message field, and on its
send event call `mesh.request-channel` with the target peer and the message. While a request is
in flight the row's button is disabled and shows the busy label; a second click is a no-op. On
success show a sent-request confirmation on the row. That confirmation must not claim a channel
exists, is pending funding, or is connected; it says a request was sent and nothing more.

Add the shared empty state: when the trusted list and the meshed list are BOTH empty, render the
UI-SPEC's empty heading and body exactly once for the pair — not once per list. When only one is
empty, that section renders nothing rather than its own empty state. Render the house loading
treatment per section while its fetch is in flight, and the contract's error row on a failed
fetch, using the same `openError` / startup-notice idiom as Task 1.

Extend the mock backend so `mesh.lightning-peers` returns a small demo peer set and
`mesh.request-channel` records the request in the session store so the demo shows the same sent
state a real node does.

Extend the test suite: both-empty renders one empty state; one-empty renders none for that
section; a peer present in both lists renders once and in the trusted list; ordering is identical
across two consecutive renders of a shuffled input; a double click on Request Channel produces one
call; the sent confirmation contains no open or connected wording.
cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs - `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the six cases above present by name. - The both-empty case asserts an element count of exactly 1 for the empty-state element, not merely that it is present. - `grep -c 'PeerRequestModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2. - `grep -c 'Request Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1. - `cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0. - `cd neode-ui && npx vitest run && npm run build` exits 0. Meshed Lightning peers are listed and requestable, deduplicated against the trusted list, with a single shared empty state and no misleading channel wording. Task 3: Share your own URI, and the manual-paste fallback neode-ui/src/components/LightningChannelModal.vue, neode-ui/src/components/__tests__/LightningChannelModal.test.ts - `neode-ui/src/components/SendBitcoinModal.vue` — its `copyDetail` / "Copied!" clipboard feedback pattern (the label flips for about two seconds). Reuse it; do not invent a new copy-feedback idiom. - `neode-ui/src/components/LightningChannelsPanel.vue` lines 250-270 and 595-625 — the `pubkey@host:port` placeholder, the `Format: pubkey@host:port` helper text, and the validate-before-RPC sequence including the address-optional split. - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract rows for "Primary CTA — own URI" and "Manual fallback entry point", and the UI Considerations rows marked backstop for the manual-paste form. Add the own-node URI block at the top of the modal: read this node's Lightning identity from `lnd.getinfo`, display the URI truncated to its container with the full value in a `title` tooltip, and add a copy button whose label flips to the confirmation string for about two seconds. The clipboard receives the full untruncated value regardless of visual truncation. When the node has no Lightning URI available, the block explains that instead of showing an empty field or a fabricated address.
Add the manual-paste fallback below both lists as a de-emphasised disclosure, not a third
equal-weight column: the entry point reveals a peer URI input with the placeholder and helper text
reused verbatim from the Lightning panel. Validate client-side before calling the open RPC — a
value that is not in `pubkey@host:port` form is rejected with the format message and no RPC is
issued; a bare pubkey with no host is accepted and passes an undefined address through, which is
the behavior the open RPC already supports. Reuse the same error ref and startup-notice treatment.

Extend the test suite: the copy button places the full untruncated URI on the clipboard and its
label flips then reverts; the URI element carries a `title` with the full value; an invalid
pasted value shows the format message and issues no RPC call; a bare pubkey issues the open call
with an undefined address; the no-URI-available state renders its explanation rather than an
empty field.

Then verify on the dev preview against archi-dev before this plan is considered complete, per the
user requirement in CONTEXT.md: the preview at the dev port, the copy button, the trusted list,
the meshed list, the request flow, and the manual paste. Record what was exercised in the SUMMARY.
The blocking human sign-off is consolidated into plan 01-09.
cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && npm run build - `cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the five cases above present by name. - A test asserts the clipboard receives the full untruncated URI even when the rendered element is truncated. - A test asserts an invalid pasted value results in zero RPC calls (assert on the call count, not merely on the message being visible). - `grep -c 'Copy Lightning URI' neode-ui/src/components/LightningChannelModal.vue` is at least 1. - `grep -c 'Paste URI Manually' neode-ui/src/components/LightningChannelModal.vue` is at least 1. - `grep -c 'pubkey@host:port' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (placeholder + helper text). - `cd neode-ui && npx vitest run && npm run build` exits 0. - The SUMMARY lists the dev-preview steps exercised against archi-dev and what was observed. A user can share their own node's Lightning URI and fall back to a pasted URI with real client-side validation, verified on the dev preview against a real node.

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

<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>
Create `.planning/phases/01-federation-mesh-hardening/01-08-SUMMARY.md` when done. Stage by explicit path, commit, and `git push gitea-ai main`.