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,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 &amp;&amp; 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 &amp;&amp; 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>