Anchor: add EXT-009 scoped presence query

Lets a client ask "is peer X currently online in network Y?" without
joining that network first — a stateless O(1) lookup against the
anchor's existing client registry. Scoped to (net, id) rather than a
global lookup by id alone, so it can't be used as a cross-network
"is this pubkey online anywhere" oracle: the caller must already know
a network the peer belongs to, matching the knowledge already required
to join it and observe presence the slow way via peer-join/peer-leave.

Motivated by flit's known-devices list, where each pairing is its own
isolated anchor network and the app holds no persistent connection
while idle — today the only way to know if a device is reachable is
to attempt a full connect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-09 15:06:11 +02:00
parent b649f4d012
commit 586d39e3b8
3 changed files with 256 additions and 97 deletions

View File

@@ -379,3 +379,109 @@ clients receive the full reaction state on reconnect.
- Incoming `reaction` wire frames are dispatched as `reaction` IPC events.
- `BrowserAdapter.send()` handles `send_reaction` commands and both broadcasts to all peers and emits a local `reaction` event.
- `sendChat` includes the `mid` in the wire frame so reactions can reference it correctly across peers.
---
## EXT-009 — Scoped Presence Query
**Status:** proposed
**Affects:** anchor WS protocol only (new request/response pair); no DataChannel or peer-to-peer wire changes
### Motivation
YAW/2 §5.2 presence (`peer-join` / `peer-leave`) is push-only and scoped to peers
who are simultaneously joined to the same network — you only learn someone is
online by already being in the room with them. That's fine for chat, but it's
the wrong shape for flit's "known devices" list: each paired device is its own
isolated network (`net = NetHash(PairRoomName(idA, idB))`), and the app doesn't
hold a persistent anchor connection per pairing while idle. Today the only way
to find out if a known device is reachable is to attempt a full connect.
This extension adds a lightweight, stateless query: "is peer X currently
online in network Y?" — answerable from the anchor's existing in-memory
`clients` registry with an O(1) lookup, no new server-side state.
**Deliberately not** a global "is this pubkey online anywhere" query. The
anchor's `clients` map is keyed globally by peer id, so an unscoped query
would let anyone who knows a pubkey probe its online status across every
network on the anchor, with no relationship required — a cross-identity
presence oracle. Scoping the query to `(net, id)` keeps the access model
identical to today's: knowing a network's hash is already the bar for
joining it and observing presence the slow way (§5.1 `joined`, §5.2
`peer-join`); this extension only removes the need to actually join and wait.
### Wire messages
Sent over the anchor WS connection. Does **not** require a prior `join`
the query is anchor-local and stateless, so a client may ask before joining,
after leaving, or without ever joining any network on this connection.
#### `presence_query`
```json
{ "type": "presence_query", "net": "<64-hex net hash>", "id": "<64-hex peer id>" }
```
| Field | Type | Description |
|-------|--------|--------------|
| `net` | string | Network hash, computed the same way as `join` (§5.1). |
| `id` | string | Peer id being queried. |
#### `presence`
```json
{ "type": "presence", "net": "<64-hex net hash>", "id": "<64-hex peer id>", "online": true }
```
`online` is `true` iff a client is currently registered with that exact
`(net, id)` pair. Unknown `net`/`id` combinations return `online: false`,
identical in shape to a peer who simply isn't connected — the anchor does
not distinguish "never seen this net" from "peer not currently in it."
### Anchor implementation notes
No new registry. `a.clients` is already keyed globally by peer id
(`cmd/anchor/main.go`); the query handler does:
```go
a.mu.RLock()
c, ok := a.clients[id]
online := ok && c.net == net
a.mu.RUnlock()
```
Same lock, same map, no writes. YAW/2-only anchors that don't implement this
extension simply never emit a `presence` reply — per §8, unknown request
types are ignored by clients, so callers should treat "no reply within a
short timeout" the same as `online: false` rather than blocking indefinitely.
### Security considerations
- **No new information beyond what §5.2 already permits** — the requester
must already know both `net` and `id` to ask, exactly the knowledge
required to join that network and observe presence natively. This
extension is a latency/statefulness shortcut, not a new capability.
- **Does not enable identity-wide presence enumeration.** Because the query
is scoped to a specific `net`, checking whether pubkey X is online
requires already knowing at least one network X is a member of. An anchor
operator or a client cannot use this to ask "where is X online" across
all networks it serves.
- **Rate limiting recommended** at the anchor (e.g. per-connection token
bucket) to prevent a connection from being used to hammer many `(net,
id)` guesses cheaply. This mirrors the existing `history_request`
rate-limit precedent (EXT-007, one request per peer/room per 60s) even
though the abuse shape here is different — presence queries are free of
disk I/O but still worth bounding.
- **No change to what the anchor can already infer.** An anchor could
already tell that two ids are members of the same net by virtue of
relaying between them; this extension doesn't let the anchor learn new
relationships, only lets *clients* ask a question the anchor already had
the answer to.
### Client usage (flit)
Each known device already has a deterministic `net` (`PairRoomName(idA,
idB)`, hashed). A "known devices" list can send one `presence_query` per
entry — no persistent connection required per pairing — and render
online/offline before the user taps Connect. A short client-side cache
(a few seconds) is recommended to avoid re-querying on every render.

View File

@@ -69,8 +69,30 @@ type client struct {
net string // hashed network name, set after join
send chan proto.AnchorMessage
conn *websocket.Conn
// EXT-009 presence_query rate limiting. Only ever touched from this
// connection's own read-loop goroutine, so no lock needed.
presenceQueryCount int
presenceWindowStart time.Time
}
// allowPresenceQuery implements a simple per-connection token bucket for
// EXT-009 presence_query: presenceQueryLimit requests per presenceQueryWindow.
func (c *client) allowPresenceQuery() bool {
now := time.Now()
if now.Sub(c.presenceWindowStart) > presenceQueryWindow {
c.presenceWindowStart = now
c.presenceQueryCount = 0
}
c.presenceQueryCount++
return c.presenceQueryCount <= presenceQueryLimit
}
const (
presenceQueryLimit = 20
presenceQueryWindow = 10 * time.Second
)
type anchor struct {
mu sync.RWMutex
clients map[string]*client // keyed by hex peer id
@@ -109,6 +131,16 @@ func (a *anchor) unregister(c *client) {
log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))])
}
// isOnline reports whether a client is currently registered with the exact
// (net, id) pair — EXT-009. O(1): reuses the existing global clients map,
// no new state.
func (a *anchor) isOnline(net, id string) bool {
a.mu.RLock()
defer a.mu.RUnlock()
c, ok := a.clients[id]
return ok && c.net == net
}
// networkPeerIDs returns the hex ids of all peers in the same network as netHash.
func (a *anchor) networkPeerIDs(netHash, excludeID string) []string {
a.mu.RLock()
@@ -225,6 +257,24 @@ func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
}
log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers))
case proto.AnchorPresenceQuery:
// EXT-009. Stateless and anchor-local: does not require the
// querying connection to have joined any network. Scoped to
// (net, id) — never a global "is this pubkey online anywhere"
// lookup, to avoid a cross-network presence oracle.
if msg.Net == "" || msg.ID == "" {
continue
}
if !c.allowPresenceQuery() {
continue
}
online := a.isOnline(msg.Net, msg.ID)
resp := proto.AnchorMessage{Type: proto.AnchorPresence, Net: msg.Net, ID: msg.ID, Online: &online}
select {
case c.send <- resp:
default:
}
case proto.AnchorTo:
if c.id == "" {
continue // not joined yet

View File

@@ -248,6 +248,8 @@ const (
AnchorTo AnchorMsgType = "to"
AnchorFrom AnchorMsgType = "from"
AnchorNoPeer AnchorMsgType = "no-peer"
AnchorPresenceQuery AnchorMsgType = "presence_query" // waste-go ext EXT-009
AnchorPresence AnchorMsgType = "presence" // waste-go ext EXT-009
)
// AnchorMessage covers all WebSocket frames to/from the anchor.
@@ -261,6 +263,7 @@ type AnchorMessage struct {
To string `json:"to,omitempty"` // target peer hex id
From string `json:"from,omitempty"` // sender peer hex id
Box string `json:"box,omitempty"` // base64 nacl/box sealed payload
Online *bool `json:"online,omitempty"` // presence: true iff (net, id) is currently connected
}
// ── IPC protocol (daemon ↔ local UI) ─────────────────────────────────────────