From ce684848e25fed3aabdde4ffba6d2d8c40afa030 Mon Sep 17 00:00:00 2001 From: bndw Date: Mon, 9 Mar 2026 07:47:34 -0700 Subject: expand phase 2 plan with relay architecture and muxstr patterns --- PLAN.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 18 deletions(-) diff --git a/PLAN.md b/PLAN.md index ab277d9..b4e3b9a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,24 +34,98 @@ These vectors must be committed to the repo and validated by the JS client in Ph ## Phase 2: Relay -- WebSocket server (stdlib `net/http` + `golang.org/x/net/websocket` or `nhooyr.io/websocket`) -- Challenge/Auth handshake on connect -- Allowlist: authorized pubkeys in a flat config file or SQLite table -- Ingest pipeline: - 1. Unmarshal msgpack envelope - 2. Verify signature using Phase 1 core - 3. Reject duplicates (id PRIMARY KEY) - 4. Reject expired job requests (check `expires_at` tag) - 5. Reject events exceeding 64KB content limit - 6. Write to index + store - 7. Fanout to matching subscribers -- SQLite storage via `database/sql` + `mattn/go-sqlite3` or `modernc.org/sqlite` (pure Go, no CGo) -- Subscription management: filter matching, per-connection subscription map -- Ephemeral events (kind 3000–3999): fanout only, skip storage -- Error responses with HTTP-borrowed codes -- WebSocket keepalive: ping every 30s, close after two missed - -**Go dependencies:** sqlite driver, websocket library. Both should be pure Go where possible. +The relay lives in `relay/` as a separate Go module (`axon/relay`). It references the core package via a local `replace` directive during development. The muxstr project (a Nostr relay) provides several patterns we can lift directly: custom WebSocket framing, non-blocking fanout, SQLite setup, and filter query building. + +### Module structure + +``` +relay/ + go.mod — module "axon/relay"; replace axon => ../ + go.sum + main.go — entry point, config, wiring, graceful shutdown + config.go — YAML config: listen addr, db path, allowlist + server.go — HTTP server, WebSocket upgrade, connection lifecycle + handler.go — per-connection message dispatch + websocket/ + websocket.go — raw WebSocket framing (lifted from muxstr, zero deps) + subscription/ + manager.go — in-memory subscriptions, filter matching, fanout + storage/ + storage.go — SQLite init, schema, WAL config + events.go — store, query, dedup +``` + +### Components + +**WebSocket layer** (`websocket/websocket.go`) +- Lifted from muxstr with minimal changes — handles RFC 6455 framing, ping/pong, TLS +- No external WebSocket dependency +- Relay sends ping every 30s; connections missing two consecutive pings are closed + +**Connection auth** (`handler.go`) +- On connect: relay sends `Challenge { nonce: 32 random bytes }` +- Client responds: `Auth { pubkey, sig }` where `sig = ed25519.Sign(privkey, SHA256(nonce || relay_url))` +- Relay verifies sig, checks pubkey against allowlist, responds `Ok` or `Error { 401 }` + close +- All subsequent messages rejected until auth completes + +**Ingest pipeline** (`handler.go` → `storage/events.go`) +1. Unmarshal msgpack `[msg_type, payload]` frame +2. Dispatch by `msg_type` (uint16) +3. For `Publish`: verify sig via axon core, reject if content > 64KB, reject duplicates +4. For job requests (kind 5000–5999): check `expires_at` tag, reject if expired +5. Ephemeral events (kind 3000–3999): fanout only, skip storage +6. Persist to SQLite, fanout to matching subscribers + +**Subscription manager** (`subscription/manager.go`) +- Lifted from muxstr: buffered channel per subscription (cap 100), non-blocking sends (drop-on-full) +- Per-subscription goroutine streams events to the client connection +- Filter matching: ids (prefix), authors (prefix), kinds, since/until, tag filters +- Multiple filters per subscription are OR'd + +**Storage** (`storage/`) +- `modernc.org/sqlite` — pure Go, no CGo +- WAL mode, 40MB cache, memory-mapped I/O +- Schema: + +```sql +CREATE TABLE events ( + id BLOB PRIMARY KEY, + pubkey BLOB NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + envelope_bytes BLOB NOT NULL +) STRICT; + +CREATE TABLE tags ( + event_id BLOB NOT NULL REFERENCES events(id), + name TEXT NOT NULL, + value TEXT NOT NULL +) STRICT; + +CREATE INDEX idx_events_pubkey ON events(pubkey, created_at DESC); +CREATE INDEX idx_events_kind ON events(kind, created_at DESC); +CREATE INDEX idx_events_created_at ON events(created_at DESC); +CREATE INDEX idx_tags_name_value ON tags(name, value); +CREATE INDEX idx_tags_event_id ON tags(event_id); +``` + +**Config** (`config.go`) +```yaml +addr: ":8080" +db: "axon.db" +relay_url: "ws://localhost:8080" +allowlist: + - "" + - "" +``` + +### Dependencies + +- `modernc.org/sqlite` — pure Go SQLite driver +- Custom WebSocket (no external lib) +- Core `axon` package via local replace + +No other external dependencies. --- -- cgit v1.2.3