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
statement
category
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
privacy
path
provides
contains
core/archipelago/src/api/rpc/lnd/info.rs
identity_pubkey + uris on the lnd.getinfo response
mesh.lightning-peers and mesh.send-lightning-info match arms
mesh.lightning-peers
Give the platform the two Lightning facts the channel-open UI needs from the mesh side: **this node's
own shareable URI**, and **which meshed peers have Lightning installed and what their URI is**.
Purpose: FED-05, whose scope is LOCKED in CONTEXT.md — the "public/other" list in the channel-open
picker is meshed peer nodes that have Lightning installed, not lnd listpeers, not a curated
directory, not a live LN-graph query. That requires peers to advertise a Lightning capability plus
their URI over the mesh. RESEARCH.md Pitfall 5 confirms neither datum exists today: handle_lnd_getinfo
fetches LND's /v1/getinfo but its response struct does not deserialize identity_pubkey or uris,
and PATTERNS.md records that mesh peer capability advertisement has no analog in the codebase —
it is genuinely new surface, to be built on the existing typed-envelope pattern.
Output: an extended lnd.getinfo, a new LightningInfo typed mesh message, a lightning_uri field
on MeshPeer, and two new RPCs (mesh.lightning-peers, mesh.send-lightning-info).
Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary
Two additive optional fields on an internal RPC response; no
consumer breaks if they are removed again.
core/archipelago/src/api/rpc/lnd/info.rs
- `core/archipelago/src/api/rpc/lnd/info.rs` lines 1-110 — the `LndInfo` serialize struct, the
`LndGetInfoResponse` deserialize struct (which currently declares only `alias`,
`num_active_channels`, `num_peers`, `synced_to_chain`, `block_height`), and how
`handle_lnd_getinfo` maps one into the other with `unwrap_or_default()`.
- `core/archipelago/src/api/rpc/lnd/channels.rs` around `handle_lnd_openchannel` (from L238) —
the sibling handler's pubkey validation (66 hex chars) and error-shaping style to mirror.
- Deserializing an LND `/v1/getinfo` body that contains `identity_pubkey` and a non-empty `uris`
array yields both on the mapped response.
- Deserializing a body with neither field present succeeds and yields `identity_pubkey: None`
and an empty `uris` vector — never a fabricated or placeholder identity.
- A body whose `identity_pubkey` is not 66 hex characters yields `identity_pubkey: None` rather
than propagating a malformed key that `lnd.openchannel` would later reject.
Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a
`serde_json::from_str::(...)` over three fixture bodies (full, empty,
malformed pubkey) plus the mapping function. Extract the `LndGetInfoResponse` → `LndInfo`
identity mapping into a small pure function so it is testable without an HTTP call; keep the
existing HTTP flow otherwise untouched.
Add `identity_pubkey: Option<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.
cd core && cargo test -p archipelago lnd::info
- `cd core && cargo test -p archipelago lnd::info` exits 0 with at least 3 test cases.
- `grep -c 'identity_pubkey' core/archipelago/src/api/rpc/lnd/info.rs` is at least 4.
- `grep -c 'uris' core/archipelago/src/api/rpc/lnd/info.rs` is at least 3.
- `cd core && cargo build -p archipelago` exits 0.
- The SUMMARY records the pre-implementation failing output of the fixture tests.
`lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests.
Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored
`MeshMessageType` is a radio wire format shared with every fleet
node; the new discriminant and its CBOR payload shape become readable by deployed peers after the
next OTA, so changing the payload later needs a coordinated fleet upgrade. Kept additive (unused
discriminant, optional payload fields) so old nodes simply ignore it.
core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs
- `core/archipelago/src/mesh/message_types.rs` lines 28-200 — the `#[repr(u8)] MeshMessageType`
enum (highest current discriminant is `AssistResponse = 25`), and the three places every new
variant must be added: the enum, `from_u8`, `from_label`, and `label`. Also read
`ReactionPayload` (from L533) and `PresencePayload` (from L727) for payload struct conventions,
and the `TypedEnvelope` doc comment about `compact_bytes` (a plain derived `Vec` bloats
every message on the wire — this matters on LoRa).
- `core/archipelago/src/mesh/types.rs` lines 60-118 — the `MeshPeer` struct and its
`#[serde(default)]` optional-field convention (see `lat`/`lon`, `pkc_capable`).
- `core/archipelago/src/mesh/listener/dispatch.rs` around lines 430-490 — the
`Some(MeshMessageType::Reaction)` and `Some(MeshMessageType::Presence)` inbound arms: how a
decoded envelope is matched, its payload deserialized, and peer/message state mutated.
Add `LightningInfo = 26` to `MeshMessageType` with a doc comment stating what it advertises and
that it is only ever sent on an explicit operator action. Register it in `from_u8` (26),
`from_label` ("lightning_info"), and `label`.
Add `LightningInfoPayload` next to the other payload structs: a required `uri: String` (the
`pubkey@host:port` form) and an optional `alias: Option<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.
cd core && cargo test -p archipelago mesh::message_types mesh::listener
- `cd core && cargo test -p archipelago mesh::message_types mesh::listener` exits 0.
- `grep -c 'LightningInfo' core/archipelago/src/mesh/message_types.rs` is at least 5 (enum, from_u8, from_label, label, payload doc).
- `grep -Eq 'lightning_info' core/archipelago/src/mesh/message_types.rs` succeeds.
- `grep -c 'lightning_uri' core/archipelago/src/mesh/types.rs` is at least 1.
- `grep -c 'LightningInfo' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1.
- `cd core && cargo test -p archipelago` exits 0.
The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI.
Task 3: Expose the meshed Lightning peers and the send path over RPC
core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` around `handle_mesh_contacts_list`
(from L1218) — the canonical read-handler shape: `self.mesh_service.read().await`, the
"Mesh service not running" error, `shared_state()`, then `.read().await` on the relevant map.
- The same file around `handle_mesh_send_reaction` (in the L637-976 family) — the canonical
send-handler shape: build a payload, wrap in `TypedEnvelope::new(...).with_seq(seq)`, send.
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line `"mesh." =>
self.handle_...(params).await,` registration convention.
- `core/archipelago/src/server.rs` around `is_peer_allowed_path` (from L1270) — confirm whether
the new RPCs need peer reachability. They are operator-local calls over `/rpc/v1`, which is
already in the allow-list; do NOT widen that list.
Add `handle_mesh_lightning_peers`: read the mesh peer map, keep only peers whose `lightning_uri`
is set, collapse duplicates by the peer's authenticating key (the `MeshPeer` accessor that
prefers the verified archipelago identity key over the firmware routing key) keeping the most
recently heard entry, and return a stable-sorted array — sort by display name, then by contact
id as the tiebreak, so the picker list does not reshuffle between reads. Each entry carries at
minimum: contact id, display name, `lightning_uri`, `last_heard`, `reachable`, and `hops`.
Returning zero matching peers is an empty array with a success result, never an error.
Add `handle_mesh_send_lightning_info`: take a target peer identifier in params, read this node's
own URI from the `lnd.getinfo` path built in Task 1, refuse with a clear error when no URI is
available (LND down, or no advertised URI) rather than sending an empty advertisement, then send
a `LightningInfo` envelope to that peer only. It must not broadcast to all contacts: the target
is required, and the handler returns an error when it is absent.
Register both in the dispatcher as `"mesh.lightning-peers"` and `"mesh.send-lightning-info"`,
following the existing one-line convention.
Add tests covering: empty peer map yields an empty array; two advertisements from the same peer
yield one entry with the newer URI; ordering is stable across two consecutive calls over the
same peer set; `handle_mesh_send_lightning_info` with no target errors.
cd core && cargo test -p archipelago mesh
- `cd core && cargo test -p archipelago mesh` exits 0.
- `grep -c '"mesh.lightning-peers"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
- `grep -c '"mesh.send-lightning-info"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
- `grep -c 'handle_mesh_lightning_peers' core/archipelago/src/api/rpc/mesh/typed_messages.rs` is at least 1.
- `grep -c 'is_peer_allowed_path' core/archipelago/src/server.rs` is unchanged from before this plan (the peer allow-list is not widened).
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `api::rpc::mesh` or `mesh::message_types`.
The picker's meshed-Lightning-peer list has a real, deterministic, deduplicated data source, and a node can advertise its own URI to a chosen peer.
<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>
- `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.
<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>
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.