Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit adcd0f7ce8
1826 changed files with 403825 additions and 0 deletions
@@ -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