Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit e9c69062fe
1936 changed files with 443175 additions and 0 deletions
@@ -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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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>