Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-06-29 12:39:02 -04:00
committed by GitHub
parent 92a5f1fc66
commit 14fba21e57
201 changed files with 23435 additions and 15457 deletions

View File

@@ -34,14 +34,14 @@ Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc.
│ buzz-relay (Axum) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │
│ │ NIP-42 │ │ EVENT │ │ REQ │ │ REST API │ │
│ │ auth │ │ pipeline │ │ handler │ │ /api/channels │ │
│ └──────────┘ └──────────┘ └──────────┘ │ /api/search │ │
│ │ /api/feed │ │
│ ┌──────────────────────────────────────┐ │ /api/workflows │ │
│ │ SubscriptionRegistry │ │ /api/presence │ │
│ │ DashMap: (channel_id, kind) → conns │ │ /api/agents │ │
│ └──────────────────────────────────────┘ │ /api/approvals │ │
│ │ NIP-42 │ │ EVENT │ │ REQ │ │ HTTP bridge │ │
│ │ auth │ │ pipeline │ │ handler │ │ /events │ │
│ └──────────┘ └──────────┘ └──────────┘ │ /query │ │
│ │ /count │ │
│ ┌──────────────────────────────────────┐ │ /hooks/{id} │ │
│ │ SubscriptionRegistry │ │ /media/* │ │
│ │ DashMap: (channel_id, kind) → conns │ │ /git/* │ │
│ └──────────────────────────────────────┘ │ /info, NIP-05 │ │
│ └─────────────────────┘ │
└──────────┬──────────────┬──────────────────────────────────────────┘
│ │
@@ -64,8 +64,8 @@ Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc.
(multi-node fan-out wired; local-echo dedup via AppState.local_event_ids).
┌──────────────┐
│ Typesense │ ← buzz-search (bounded worker queue)
│ (full-text │
│ Postgres │ ← buzz-search (FTS over the search_tsv
│ (full-text │ generated column + GIN index)
│ search) │
└──────────────┘
```
@@ -80,14 +80,13 @@ buzz-core (zero I/O — types, verification, filter matching, kind registry)
├── buzz-db (Postgres: events, channels, tokens, workflows, audit)
├── buzz-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting)
├── buzz-pubsub (Redis pub/sub, presence, typing indicators)
├── buzz-search (Typesense: index, query, delete)
├── buzz-search (Postgres FTS: query, delete)
├── buzz-audit (hash-chain tamper-evident log)
└── buzz-workflow (YAML-as-code automation engine)
│
└── buzz-relay (ties everything together — the server)
buzz-acp (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC)
buzz-proxy (NIP-28 compatibility proxy — translates standard Nostr clients ↔ Buzz relay)
buzz-sdk (typed Nostr event builders — used by buzz-acp and buzz-cli)
buzz-media (Blossom/S3 media storage)
buzz-cli (agent-first CLI)
@@ -95,7 +94,7 @@ buzz-admin (operator CLI: relay membership + key generation)
buzz-test-client (integration test harness + manual CLI)
```
**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. `buzz-proxy` connects to the relay as a WebSocket client and translates NIP-28 events between standard Nostr clients and the Buzz relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags.
**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags.
---
@@ -192,7 +191,7 @@ The client must respond with `["AUTH", <signed-event>]` before submitting events
| Path | Mechanism | Use Case |
|------|-----------|---------|
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients |
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on HTTP bridge endpoints | HTTP clients |
On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.
@@ -368,7 +367,7 @@ Handles authentication paths, scope enforcement, and token operations.
| Path | Entry Point | Notes |
|------|-------------|-------|
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event |
| NIP-98 HTTP Auth | `validate_nip98_auth()` | HTTP bridge endpoints; Schnorr-signed `kind:27235` event |
**Key types:**
@@ -462,21 +461,32 @@ EXPIRE buzz:typing:{channel_id} 60
---
### buzz-search — Typesense Integration
### buzz-search — Postgres FTS Integration
Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-API-KEY`. In multi-community mode, indexed documents and every query filter include `community_id`; the shared Typesense collection is infrastructure, not a cross-community result space.
**Collection schema (7 fields):** `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]).
Full-text search via Postgres FTS. Events are searchable through the
`events.search_tsv` generated `tsvector` column (populated on insert, indexed
by a GIN index) — there is no separate search service or out-of-band indexer.
Privacy-sensitive kinds are excluded at the storage level (the `search_tsv`
`CASE WHEN kind IN (...)` yields `NULL`, which never matches `@@`). In
multi-community mode every query filter includes `community_id`, so the shared
`events` table is infrastructure, not a cross-community result space; the relay
re-authorizes every candidate hit before returning it.
**Key behaviors:**
- `ensure_collection()` is idempotent: handles 409 race condition (another process created it between check and create).
- Tag flattening uses `\x1f` (ASCII unit separator) to avoid ambiguity with tag values containing colons (e.g., URLs in `r` tags).
- Upsert indexing: `POST /documents?action=upsert` (single), `POST /documents/import?action=upsert` (batch JSONL).
- `delete_event()` validates event ID (64-char hex) before constructing the URL — prevents path injection.
- `delete_event()` is idempotent: 404 treated as success.
- Permission filtering is **caller's responsibility** — `buzz-search` provides the `filter_by` mechanism but does not enforce access policy.
- `SearchService::new(pool)` wraps a `PgPool`; `search(&SearchQuery)` runs a
parameterized FTS query against the `events.search_tsv` GIN index and returns
`SearchResult` (candidate `SearchHit`s).
- `ChannelScope` makes the channel constraint explicit (`Any` /
`ChannelLessOnly` / `Channels` / `ChannelsOrChannelLess`), closing the
ambiguity the old `Option<Vec<Uuid>> + bool` matrix could not express.
- Every query carries `community_id`; the FTS predicate is BitmapAnd-ed with
the community-leading btree filters so a query never crosses tenants.
- Permission filtering is **caller's responsibility** — `buzz-search` returns
candidate hits; the relay re-authorizes each one (channel membership, `#p`,
owner gates) before delivering it.
**Does NOT:** enforce channel membership or access control. Does NOT store events in Postgres.
**Does NOT:** enforce channel membership or access control. Does NOT write
events (indexing is the `search_tsv` generated column on the `events` insert).
---
@@ -547,50 +557,6 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
---
### buzz-proxy — NIP-28 Compatibility Proxy
Lets standard Nostr clients (Coracle, nak, Amethyst, nostr-tools, nostr-sdk) read and write Buzz channels using the NIP-28 Public Chat Channels protocol. Connects to the relay as a WebSocket client; presents a standard NIP-01/NIP-11/NIP-28/NIP-42 interface to external clients.
**Key modules:** `server.rs` (Axum WebSocket server, NIP-11, NIP-42 auth, filter splitting), `translate.rs` (bidirectional kind/tag translation), `upstream.rs` (persistent relay connection with auto-reconnect and subscription replay), `channel_map.rs` (bidirectional UUID ↔ kind:40 event ID mapping), `shadow_keys.rs` (deterministic keypair derivation), `guest_store.rs` (pubkey-based guest registry), `invite_store.rs` (token-based invite system).
**Shadow keypairs:** `HMAC-SHA256(key=server_salt, msg=external_pubkey_bytes)` → secp256k1 secret key. Deterministic: same external pubkey always produces the same shadow key. Empty salt rejected. Cache: `DashMap` with `MAX_CACHE_SIZE = 10,000`. Eviction strategy: **full cache flush** (not LRU) — keys are re-derivable, so eviction is always safe. Count tracked with `AtomicUsize` (soft bound — may briefly exceed limit under concurrent inserts).
**Kind translation (lossy):**
`KindTranslator` defines the full mapping between standard Nostr kinds and Buzz kinds. The proxy's event paths gate which kinds actually flow through — only a subset is accepted inbound or emitted outbound.
*Inbound (client → relay) — accepted kinds:*
| Standard Kind | Buzz Kind | Note |
|--------------|-------------|------|
| 1, 42 | KIND_STREAM_MESSAGE | Multiple → one (lossy) |
| 41 | KIND_STREAM_MESSAGE_EDIT | Channel message edit |
| 7 | KIND_REACTION | Reaction (pass-through kind) |
Kind 5 (deletion) is intentionally blocked inbound — the relay's deletion handler lacks author-match authorization for proxy clients. Kinds 4, 40, 43, 44 are defined in `KindTranslator` but not accepted by the proxy's inbound path.
*Outbound (relay → client) — emitted kinds:*
| Buzz Kind | Standard Kind | Note |
|-------------|--------------|------|
| KIND_STREAM_MESSAGE | 42 | NIP-28 channel message |
| KIND_STREAM_MESSAGE_V2 | 42 | Rich format collapses to plain kind:42 |
| KIND_STREAM_MESSAGE_EDIT | 41 | NIP-28 channel message edit |
| KIND_REACTION | 7 | Reaction |
| KIND_DELETION | 5 | Standard NIP-09 deletion |
`to_buzz(to_standard(k))` is NOT lossless for secondary mappings (e.g., kind:1 → KIND_STREAM_MESSAGE → kind:42). Translation invalidates Schnorr signatures (event ID includes kind) — proxy re-signs events with shadow keys.
**Dual auth:** Pubkey-based guest registration (persistent, primary) + invite tokens (ad-hoc, time-limited, secondary). Both use NIP-42 for the authentication handshake. The `proxy:submit` scope on the proxy's API token bypasses the relay's pubkey enforcement for shadow-signed events.
**Channel map:** Loaded at startup from the relay's REST API. kind:40 events are synthesized locally only. kind:41 is split: synthesized metadata is served locally, but kind:41 filters are also forwarded upstream (translated to kind:40003) to capture edit events. Channels created after proxy start require a restart to appear.
**State is in-memory.** Guest registrations, invite tokens, and channel map are lost on proxy restart.
**Does NOT:** implement relay-side lifecycle event emission — the relay does not emit events when proxy clients connect or disconnect (planned).
---
### Huddle Audio — WebSocket Opus Relay
Real-time voice lives inside `buzz-relay` (`src/audio/`), not a separate crate. A WebSocket endpoint (`wss://.../huddle/{channel_id}/audio`) authenticates each participant with a NIP-42 challenge, checks channel membership, admits them to an in-memory room, and forwards opaque Opus frames between peers. No external SFU.
@@ -641,47 +607,26 @@ pub struct ConnectionState {
pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
```
**REST API endpoints:**
**HTTP endpoints:**
| Method | Path | Handler |
|--------|------|---------|
| GET | `/api/channels` | List accessible channels |
| GET | `/api/channels/{channel_id}` | Get channel detail + metadata |
| GET | `/api/channels/{channel_id}/members` | List channel members |
| GET | `/api/channels/{channel_id}/canvas` | Get channel canvas |
| GET | `/api/channels/{channel_id}/messages` | List channel messages |
| GET | `/api/channels/{channel_id}/threads/{event_id}` | Get message thread |
| GET/POST | `/api/channels/{channel_id}/workflows` | List/create channel workflows |
| GET | `/api/search` | Full-text search via Typesense |
| GET | `/api/agents` | List agent accounts |
| GET/PUT | `/api/presence` | Presence status (bulk) / set presence |
| GET | `/api/feed` | Personalized feed (mentions/needs-action/activity) |
| POST | `/api/events` | Submit event via REST (no WebSocket) |
| GET | `/api/events/{id}` | Get event by ID |
| GET/PUT/DELETE | `/api/workflows/{id}` | Workflow CRUD |
| GET | `/api/workflows/{id}/runs` | Execution history |
| GET | `/api/workflows/{id}/runs/{run_id}/approvals` | List run approvals |
| POST | `/api/workflows/{id}/trigger` | Manual trigger |
| POST | `/api/workflows/{id}/webhook` | Webhook trigger (HMAC-verified) |
| POST | `/api/approvals/{token}/grant` | Approve a workflow step (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) |
| POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) |
| GET | `/api/dms` | List DM channels |
| POST | `/api/dms` | Open a DM channel |
| POST | `/api/dms/{channel_id}/members` | Add DM member |
| POST | `/api/dms/{channel_id}/hide` | Hide a DM channel |
| GET | `/api/messages/{event_id}/reactions` | List reactions on a message |
| GET | `/api/users/me/profile` | Get own profile |
| PUT | `/api/users/me/channel-add-policy` | Set channel add policy |
| GET | `/api/users/search` | Search users |
| GET | `/api/users/{pubkey}/profile` | Get user profile by pubkey |
| POST | `/api/users/batch` | Batch-fetch user profiles |
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
| GET | `/` | WebSocket upgrade or NIP-11 relay info |
| GET | `/info` | NIP-11 relay info |
| GET | `/.well-known/nostr.json` | NIP-05 identity |
| GET | `/health` | Health check |
| GET | `/_liveness` | Liveness probe |
| GET | `/_readiness` | Readiness probe |
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |
| POST | `/hooks/{id}` | Workflow webhook trigger (secret-authenticated) |
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
| GET | `/git/{owner}/{repo}/info/refs` | Git smart HTTP advertisement |
| POST | `/git/{owner}/{repo}/git-upload-pack` | Git smart HTTP fetch |
| POST | `/git/{owner}/{repo}/git-receive-pack` | Git smart HTTP push |
| POST | `/internal/git/policy` | Internal git hook policy check |
**Constants:**
@@ -757,10 +702,7 @@ The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/bu
| `tests/e2e_relay.rs` | 27 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
| `tests/e2e_media.rs` | 7 | Media upload/download (Blossom) |
| `tests/e2e_media_extended.rs` | 18 | Extended media scenarios |
| `tests/e2e_nostr_interop.rs` | 15 | NIP-28 proxy interoperability |
| `tests/e2e_rest_api.rs` | 40 | REST API (channels, search, presence, agents, feed) |
| `tests/e2e_tokens.rs` | 20 | Token auth and scope enforcement |
| `tests/e2e_workflows.rs` | 7 | Workflow CRUD, trigger, and execution |
| `tests/e2e_nostr_interop.rs` | 15 | Nostr interoperability: NIP-50 search, NIP-10 threads, NIP-17 gift wraps, DM discovery |
All e2e tests are `#[ignore]` — require a running relay. Total: **134 e2e tests**.
@@ -831,9 +773,8 @@ Docker Compose provides the full local development stack. All services include h
| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit |
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit; full-text search (`search_tsv` GIN) |
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
@@ -859,9 +800,16 @@ Docker Compose provides the full local development stack. All services include h
| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
### Typesense Collection
### Full-Text Search (Postgres FTS)
Single collection (`events` by default, configurable via `TYPESENSE_COLLECTION`). Schema today: `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]). Multi-community mode adds faceted `community_id` and either prefixes document IDs with community or makes all upsert/delete/refetch paths carry community context.
Search runs over the `events.search_tsv` generated `tsvector` column on the
`events` table (no separate collection or service). The column is populated on
insert — `to_tsvector('simple', content)` — and excludes privacy-sensitive
kinds via `CASE WHEN kind IN (1059, 30300, 30622) THEN NULL`, so those rows are
storage-level unsearchable (a `NULL` tsvector never matches `@@`). A GIN index
(`idx_events_search_tsv`) backs the `@@` probe; in multi-community mode the
community-leading btree filters BitmapAnd with the GIN probe so every query is
fenced to its `community_id`.
---