Files
waste-go/EXTENSIONS.md
Fredrik Johansson 586d39e3b8 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>
2026-07-09 15:06:11 +02:00

488 lines
17 KiB
Markdown

# waste-go Protocol Extensions
These are additive extensions to [YAW/2](PROTOCOL.md) implemented by waste-go.
They do **not** break compatibility — YAW/2-only peers silently ignore all new
fields. Where a waste-go peer connects to a YAW/2-only peer, the extension
simply has no effect on that peer.
---
## EXT-001 — Signed Invites
**Status:** implemented
**Affects:** `waste:` invite format, `hello` DataChannel message
### Motivation
The base YAW/2 network model is open to anyone who knows the anchor URL and
network name (or hash). This extension adds opt-in cryptographic membership
gating: invites are signed by an existing peer, and peers that enforce
`RequireInvite` reject hellos that carry no valid signed invite.
### Invite format changes
The `waste:` invite payload (base64-encoded JSON) gains two optional fields:
```json
{
"anchor": "wss://...",
"network": "friends",
"net": "<64-hex SHA-256(yaw2-net:name)>",
"inviter": "<64-hex Ed25519 pubkey of signing peer>",
"sig": "<hex Ed25519 signature>"
}
```
The signature covers the following bytes (null-separated):
```
anchor \x00 network \x00 net \x00 inviter
```
Unsigned invites (`inviter`/`sig` absent) remain valid for backward compat.
### Hello message extension
The YAW/2 §6 hello message gains one optional field:
```json
{
"type": "hello",
"id": "<hex pubkey>",
"nick": "alice",
"caps": ["chat", "file"],
"sig": "<DTLS binding sig>",
"invite": "waste:eyJ..."
}
```
`invite` carries the full `waste:` string the connecting peer used to join.
YAW/2-only peers ignore this field.
### Enforcement
Per-network flag `RequireInvite` (set via `join_network` IPC command).
When enabled:
1. A peer that presents no `invite` in hello is disconnected immediately.
2. A peer that presents an invite with no signature is disconnected.
3. A peer whose invite signature is invalid is disconnected.
4. A peer whose invite was signed by an unknown peer ID (not in the store or
currently connected) is disconnected.
The inviter's key must be a **known peer** — i.e. previously connected and
stored in the per-network SQLite store, or currently connected. This forms a
chain of trust: Alice (founder) invites Bob; Bob's key is now known; Bob can
invite Carol, whose invite Alice will also accept.
**Default:** off. Networks opt in. Existing networks with no RequireInvite
behave exactly as before.
---
## EXT-002 — Hash-based Hang Link
**Status:** implemented
**Affects:** web UI URL handling only, no wire changes
### Motivation
A shareable URL that pre-fills the join form without conveying cryptographic
membership. Suitable for public announcements ("come hang out here"). The
fragment is never sent to the server, keeping the network name opaque to
server logs and HTTP intermediaries.
### Format
```
https://host/#waste:eyJ...
```
The fragment payload is the standard `waste:` base64 JSON with only `network`
and `anchor` fields — no `inviter`, no `sig`. This does **not** grant
membership on networks with `RequireInvite` enabled; it only pre-fills the
join form.
The web UI generates hang links via the 🔗 button in the Networks sidebar
section. Arriving users see the join form pre-populated and still need a
proper signed invite (if the network enforces it) to be accepted by peers.
---
## EXT-003 — Multi-Share Configuration
**Status:** implemented
**Affects:** IPC protocol only, no peer-to-peer wire changes
### New IPC commands
```jsonc
{"type":"add_share","path":"/home/alice/Music"} // global
{"type":"add_share","path":"/home/alice/Docs","network_ids":["abc123"]} // scoped
{"type":"remove_share","path":"/home/alice/Music"}
{"type":"list_shares"}
```
### New IPC event
```jsonc
{"type":"shares_list","shares":[{"path":"...","networks":["*"]}]}
```
### Persistence
`shares.json` in the data directory (next to `identity.json`). Each entry:
```json
{ "path": "/absolute/path", "networks": ["*"] }
```
`networks: ["*"]` = global (all networks). Specific network IDs = scoped.
Coexists with the legacy `set_share_dir` single-dir mechanism.
File listings returned by `get_file_list` and `MsgFileListReq` include
entries from all applicable share roots, with relative `path` fields
(e.g. `"path": "docs/report.pdf"`).
---
## EXT-004 — TURN Relay (browser mode)
**Status:** implemented (browser mode + daemon mode)
**Affects:** ICE server configuration only, no wire changes
The browser adapter reads `WASTE_CONFIG.turnURL` and fetches short-lived
credentials from the anchor's `GET /turn-credentials` endpoint (derived from
`WASTE_CONFIG.signalURL`, or overridden via `WASTE_CONFIG.turnCredentialsURL`).
The anchor computes the credential using HMAC-SHA1 of the username (coturn
`use-auth-secret` scheme) — the shared secret itself is never sent to the
browser. Daemon mode does the equivalent computation locally, since the
daemon already holds `-turn-secret` server-side.
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
opt-in via server configuration and does not affect peers that omit it.
---
## EXT-005 — Per-Network Path in FileEntry
**Status:** implemented
**Affects:** `MsgFileListResp` wire message (additive field)
`FileEntry` gains an optional `path` field carrying the file's relative path
within its share root (e.g. `"docs/report.pdf"`). Peers that don't understand
this field continue to use `name` for display and download requests.
`MsgFileListReq` / `get` requests use `path` as the lookup key when present,
falling back to `name` for backward compat with peers that don't send `path`.
---
## EXT-006 — File Transfer Resume
**Status:** implemented (daemon mode)
**Affects:** `file-accept` wire message (additive field)
### Motivation
A transfer interrupted mid-stream (peer disconnect, network drop) can be
continued from where it left off on the next offer of the same file, avoiding
a full re-download.
### Protocol change
`file-accept` gains one optional field:
```json
{ "type": "file-accept", "xid": "...", "resume_offset": 65536 }
```
`resume_offset` is the number of bytes the receiver already has on disk.
When non-zero, the sender seeks to that byte position before streaming.
Peers that don't understand this field ignore it and send from the start —
the receiver detects this by comparing incoming data to expected offset and
will still verify the final SHA-256, but the partial bytes from the interrupted
session will be overwritten (YAW/2-only interop degrades gracefully to a full
re-download, not corruption).
### Receiver behaviour
1. On `file-offer`, the receiver scans its download directory for a `.tmp.meta`
sidecar whose `sha256` matches the offer.
2. If found, the corresponding `.tmp` file's size is the resume offset. This is
sent back in `file-accept`.
3. On DC open, the receiver opens the existing `.tmp` in append mode and
re-hashes its existing bytes to restore the SHA-256 state.
4. On DC close with all bytes received, SHA-256 is verified. Success → sidecar
removed, file renamed to final path. Hash mismatch → both files removed.
5. On DC close with fewer bytes than expected (interrupted again) → both files
kept for the next resume attempt.
### Sidecar format
Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar:
```json
{ "name": "archive.zip", "sha256": "abc...", "from": "<peer-id>", "size": 1048576 }
```
The sidecar is written when the transfer starts and removed on completion or
corruption. Interrupted transfers keep the sidecar indefinitely.
---
## EXT-007 — P2P Message History Gossip
**Status:** implemented (daemon mode)
**Affects:** peer-to-peer wire (two new message types); IPC (new event)
### Motivation
When a peer joins a network for the first time (or reconnects after an
absence), they have no history. This extension lets them request recent
messages from an existing peer over the already-established encrypted
DataChannel, without involving the anchor.
### Wire messages
#### `history_request`
Sent by the newly-connected peer to the first peer whose hello is verified.
One request per room.
```json
{
"type": "history_request",
"room": "general",
"since": 1700000000000,
"limit": 200
}
```
| Field | Type | Description |
|---------|---------------|-------------|
| `room` | string | Room to request history for. |
| `since` | int64 (ms) | Only return messages with `ts > since`. 0 = return up to `limit` most recent. |
| `limit` | int (max 500) | Maximum messages to return. Responder may return fewer. |
#### `history_chunk`
```json
{
"type": "history_chunk",
"room": "general",
"history": [
{ "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
],
"history_done": true
}
```
| Field | Type | Description |
|----------------|--------|-------------|
| `history` | array | Messages, oldest-first. |
| `history_done` | bool | Always `true` (single-chunk response). |
### Deduplication
`mid` is the deduplication key. The store uses `INSERT OR IGNORE` on `mid`,
so receiving a message twice (live or via gossip) is a no-op. Messages
without a `mid` are assigned one at receive time and are not gossipped.
### Behaviour
- The **receiver** sends one `history_request` per known room immediately
after hello verification with the **first** peer it connects to. Requesting
only the first peer avoids fan-out amplification.
- The **responder** queries its SQLite store and replies with a single
`history_chunk`. `limit` is capped at 500 server-side. Rate-limited to one
request per (peer, room) per 60 seconds.
- Received history messages are saved to the local store (`INSERT OR IGNORE`)
and emitted as `history_loaded` IPC events so the UI can display them.
### IPC event
```json
{ "type": "history_loaded", "room": "general", "messages": [...] }
```
Emitted once per room after a `history_chunk` is fully processed. The UI
should render these messages with a visual separator from live messages.
---
## EXT-008 — Message Reactions
### Wire message (`PeerMessage`)
```json
{
"type": "reaction",
"reaction_mid": "<32-hex mid of the target message>",
"reaction_emoji": "👍"
}
```
Sent on the normal mesh DataChannel (same as `chat`). No signing beyond
the existing channel-level encryption.
### Semantics
- A reaction is idempotent: the same `(mid, emoji, from_peer)` triple is
stored with `INSERT OR IGNORE` — receiving a duplicate is a no-op.
- There is no "un-react" wire message. Toggling off a reaction in the UI
is a local-only operation in the current implementation.
- `reaction_mid` must reference a message that exists in the local store;
unknown mids are silently ignored.
### Storage
SQLite table added as a migration:
```sql
CREATE TABLE IF NOT EXISTS reactions (
mid TEXT NOT NULL,
emoji TEXT NOT NULL,
from_peer TEXT NOT NULL,
reacted_at DATETIME NOT NULL,
PRIMARY KEY (mid, emoji, from_peer)
)
```
### IPC
**Command** — send a reaction (daemon and browser mode):
```json
{ "type": "send_reaction", "network_id": "...", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
```
**Event** — reaction received or replayed from history:
```json
{ "type": "reaction", "network_id": "...", "peer_id": "<64-hex>", "reaction_mid": "<hex>", "reaction_emoji": "👍" }
```
Stored reactions are replayed as `reaction` IPC events when history is
loaded (`sendStoredHistory`), so the UI always sees reactions alongside
their messages.
### History replay
When `sendStoredHistory` sends a `history_chunk`, it also queries
`ReactionsForRoom` and emits one `reaction` event per stored reaction so
clients receive the full reaction state on reconnect.
### Browser mode
`browser.ts` mirrors the daemon behaviour independently:
- `PeerConn.sendReaction(mid, emoji)` broadcasts `{ type: "reaction", reaction_mid, reaction_emoji }` over the DataChannel.
- 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.