feat(companion): npub-first pairing — node self-anchor + guaranteed public anchor in the QR

The pairing QR was effectively IP-first: the phone dialed the scanned LAN
host and went dark when the LAN renumbered or it left home. FIPS peers on
npubs; IPs are only dial hints. fanchors now leads with the paired node
ITSELF (fnpub @ current host, npub-keyed so LAN contact is direct p2p over
FIPS and survives DHCP), and fips.pair-info always includes the Archipelago
vps2 public anchor (pairing hint only — the node's own anchor file is not
modified) so the phone can rendezvous through the public mesh when away.
Contract doc updated with the REQUIRED app-side changes (built on the Mac).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-23 15:30:40 -04:00
parent e59ea1da69
commit c58f84c6e9
3 changed files with 53 additions and 6 deletions

View File

@ -30,9 +30,21 @@ impl RpcHandler {
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
// isn't directly dialable (phone away from home, node behind NAT).
let anchors = fips::anchors::load(&self.config.data_dir)
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
.await
.unwrap_or_default()
.unwrap_or_default();
// Pairing must always carry a public rendezvous point: without one the
// phone is IP-bound to the LAN host it scanned and goes dark the
// moment it leaves that network. This is a pairing hint only — the
// node's own anchor file is not modified, so an operator's removal of
// the default anchors still sticks for the node itself.
if !anchor_list
.iter()
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
{
anchor_list.push(fips::anchors::archy_anchor());
}
let anchors = anchor_list
.into_iter()
.map(|a| {
serde_json::json!({

View File

@ -38,7 +38,7 @@ Query parameters:
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors (the node's seed-anchor list, capped at 4). The phone peers with these too so it can route to the node via the public mesh when the direct endpoint is unreachable (away from home / NAT). |
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors, capped at 4. **The FIRST entry is the paired node itself** (`fnpub` at its current LAN host — the addr is a dial *hint*, the npub is the identity). The remaining entries are the node's seed anchors, and the node guarantees the Archipelago public anchor (vps2, `146.59.87.168:8444/tcp`) is present even if the operator trimmed their own list — so the phone can always rendezvous through the public mesh when the LAN endpoint is unreachable (away from home / NAT). |
Examples the web UI actually emits:
@ -87,6 +87,31 @@ Examples the web UI actually emits:
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
automatically when the LAN address stops answering.
## npub-first connectivity (2026-07-23 — REQUIRED app-side changes)
The QR used to be effectively IP-first: the app connected to `url`/`fhost` and
broke as soon as the LAN renumbered or the phone left home. FIPS peers on
**npubs**; IPs are only dial hints. The app must treat them that way:
1. **Identity = `fnpub`.** The saved server entry is keyed by the node's npub
(fall back to origin only when the QR has no FIPS params). Re-scanning the
same npub updates the entry even if every address changed.
2. **Peer with the node itself as the first anchor** (`fanchors[0]`): on LAN
this is direct p2p over FIPS (mDNS/known-endpoint dial via archy-fips-core
v0.4+, which discovers LAN peers without any IP pinning), so it keeps
working after DHCP renumbering.
3. **Peer with the public anchors too** (remaining `fanchors` entries — the
Archipelago vps2 anchor is always included by the node): away from LAN the
phone routes to the node's npub via the public mesh and reaches the UI at
`http://[<fip>]` (the fips0 ULA).
4. **Address selection order** for the WebView: LAN `url` when it answers →
ULA `fip` over the mesh otherwise. Never hard-fail because the scanned LAN
IP stopped existing.
(Node-side counterpart shipped 2026-07-23: `fips.pair-info` always includes
the vps2 public anchor, and the web UI prepends the node's self-anchor to
`fanchors`.)
## Testing checklist (app side)
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.

View File

@ -348,9 +348,19 @@ async function buildPairingUrl(): Promise<string> {
if (info.udp_port) params.set('fudp', String(info.udp_port))
if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
// Cap keeps the QR at a camera-friendly density; the node lists its
// most reachable anchors first.
const anchors = (info.anchors || []).slice(0, 4)
// FIRST entry is the paired node ITSELF: the phone peers with it by
// npub (the fhost addr is only a dial hint), so LAN contact is direct
// p2p over FIPS and survives DHCP renumbering; the node's public
// anchors follow for reaching it away from home. Cap keeps the QR at a
// camera-friendly density; the node lists its most reachable anchors
// first.
const selfAnchor = info.tcp_port
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
: []
const anchors = [
...selfAnchor,
...(info.anchors || []).filter((a) => a.npub !== info.npub),
].slice(0, 4)
if (anchors.length) {
params.set(
'fanchors',