diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2711df61..4942cd2a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,7 +305,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - name: Start integration services - run: docker compose up -d postgres redis typesense minio minio-init + run: docker compose up -d postgres redis minio minio-init - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -357,22 +357,45 @@ jobs: } wait_healthy "Postgres" "buzz-postgres" wait_healthy "Redis" "buzz-redis" - wait_healthy "Typesense" "buzz-typesense" wait_healthy "MinIO" "buzz-minio" - name: Download relay binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: desktop-e2e-relay path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The channel reconciler binds + # the deployment community ONCE at boot (outside its retry loop) and + # exits permanently on an unmapped host, so the 'localhost:3000' + # community MUST exist before the relay starts — the retry loop only + # handles late-seeded channels, not a late-seeded community. The relay + # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the + # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE + # below). lower(host) is the unique index → ON CONFLICT target. psql + # isn't on PATH in hermit → exec into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ - BUZZ_AUTO_MIGRATE=true \ REDIS_URL=redis://localhost:6379 \ - TYPESENSE_URL=http://localhost:8108 \ - TYPESENSE_API_KEY=buzz_dev_key \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ BUZZ_REQUIRE_AUTH_TOKEN=false \ @@ -448,7 +471,7 @@ jobs: with: save-if: ${{ github.event_name != 'pull_request' }} - name: Start integration services - run: docker compose up -d postgres redis typesense minio minio-init + run: docker compose up -d postgres redis minio minio-init - name: Wait for integration services run: | wait_healthy() { @@ -467,22 +490,45 @@ jobs: } wait_healthy "Postgres" "buzz-postgres" wait_healthy "Redis" "buzz-redis" - wait_healthy "Typesense" "buzz-typesense" wait_healthy "MinIO" "buzz-minio" - name: Download relay binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: desktop-e2e-relay path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The reminder scheduler binds + # the deployment community ONCE at boot and exits permanently on an + # unmapped host (no retry, unlike the channel reconciler), so the + # 'localhost:3000' community MUST exist before the relay starts — seeding + # after boot leaves the scheduler dead. The relay migrates at boot via + # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply + # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the + # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec + # into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ - BUZZ_AUTO_MIGRATE=true \ REDIS_URL=redis://localhost:6379 \ - TYPESENSE_URL=http://localhost:8108 \ - TYPESENSE_API_KEY=buzz_dev_key \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ BUZZ_REQUIRE_AUTH_TOKEN=false \ diff --git a/AGENTS.md b/AGENTS.md index 4f8d1cabc..ce8558c2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ crates/ buzz-db # Postgres event store and data access layer buzz-auth # Authentication and authorization buzz-pubsub # Redis pub/sub fan-out, presence, typing indicators - buzz-search # Typesense-backed full-text search + buzz-search # Postgres FTS full-text search buzz-audit # Hash-chain audit log buzz-media # Blossom/S3 media storage # Agent surface @@ -52,7 +52,6 @@ crates/ buzz-persona # Agent persona packs buzz-workflow # YAML-as-code workflow engine (evalexpr conditions) # Clients + interop - buzz-proxy # Nostr client compatibility proxy (NIP-28) buzz-pair-relay # Ephemeral sidecar relay for NIP-AB device pairing buzz-pairing-cli # CLI for NIP-AB device pairing interop testing git-sign-nostr # Sign git objects with a Nostr key @@ -116,24 +115,21 @@ Additional rules: ## Key Patterns -**Dual API surface**: Buzz exposes both a REST API and a NIP-29 WebSocket -relay. Both paths converge on shared DB functions in `buzz-db`. When adding -a feature, implement the shared DB logic first, then wire up both surfaces. +**Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary. -**Prefer Nostr events over new REST endpoints**: For new feature work, model +**Prefer Nostr events over new HTTP endpoints**: For new feature work, model the operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler -in `buzz-relay`) rather than adding a new REST endpoint. REST is reserved -for things that genuinely need an HTTP-only surface: media upload/download -(Blossom), OAuth callbacks, health checks, and the existing read endpoints -that proxy DB queries. Two helpful endpoints already exist and rarely need -to be duplicated: +in `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is +reserved for things that genuinely need an HTTP-only surface: media upload/download +(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks, +and the generic Nostr bridge endpoints: - `POST /events` — submit any signed event (same path the WebSocket uses). - `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters - are routed to `buzz-search` (Typesense-backed) automatically. + are routed to `buzz-search` (Postgres FTS) automatically. - `POST /count` — Nostr COUNT filters over HTTP. -If you find yourself reaching for a new REST endpoint, first check whether +If you find yourself reaching for a new HTTP endpoint, first check whether an event kind would do the job — it usually will, and you get realtime fan-out, NIP-29 scoping, and the existing auth pipeline for free. @@ -207,9 +203,6 @@ just test # full integration suite (requires Postgres + Redis) E2E tests live in `crates/buzz-test-client/tests/`: - `e2e_relay.rs` — WebSocket relay protocol -- `e2e_rest_api.rs` — REST endpoint coverage -- `e2e_tokens.rs` — auth token flows -- `e2e_workflows.rs` — workflow engine - `e2e_media.rs` — media upload/download (Blossom) - `e2e_media_extended.rs` — extended media scenarios - `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps) @@ -554,7 +547,7 @@ just mobile-dev ## See Also -- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / API endpoints +- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints - [TESTING.md](TESTING.md) — multi-agent E2E test guide - [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships - [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8ce1d5937..90cbbac0c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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", ]` 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. - -**Does NOT:** enforce channel membership or access control. Does NOT store events in Postgres. +- `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> + 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 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`. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 041c0f148..070dd9a8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ unacceptable behavior to **conduct@buzz-relay.org**. | Node.js | 24+ | Required for desktop app commands and `just ci` | | pnpm | 10+ | Required for desktop app commands and `just ci` | | Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) | -| Docker | 24+ | For Postgres, Redis, Typesense | +| Docker | 24+ | For Postgres, Redis, MinIO | | `just` | latest | Task runner — `cargo install just` | | `lefthook` | latest | Optional; run `lefthook install` for local Git hooks | | `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` | @@ -85,9 +85,9 @@ cached thereafter). You can also run `just bootstrap` independently at any time; it is safe to re-run. `just setup` then starts Docker services (Postgres on `:5432`, Redis on `:6379`, -Typesense on `:8108`, Adminer on `:8082`, Keycloak on `:8180` for local -OAuth/OIDC testing, MinIO on `:9000` for media storage, and Prometheus on -`:9090` for metrics) and runs all pending database migrations. +Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on +`:9000` for media storage, and Prometheus on `:9090` for metrics) and runs all +pending database migrations. ### Running the Relay and Desktop App @@ -141,14 +141,11 @@ already running. End-to-end tests live in `crates/buzz-test-client/tests/`: -- `e2e_rest_api.rs` — REST API tests - `e2e_relay.rs` — WebSocket relay tests - `e2e_mcp.rs` — MCP tool tests - `e2e_nostr_interop.rs` — Nostr protocol interoperability tests -- `e2e_tokens.rs` — token management tests - `e2e_media.rs` — media upload/download tests - `e2e_media_extended.rs` — extended media tests (GIF, image processing) -- `e2e_workflows.rs` — workflow tests Run them with (requires running infrastructure): @@ -372,16 +369,20 @@ for team access setup, onboarding, and the full repo inventory. See `handle_side_effects()` runs after the event is stored — use it for notifications, cache invalidation, or derived data. If the new kind - also needs a REST surface (e.g., a query endpoint for clients), add a - handler in `crates/buzz-relay/src/api/` and register it in + also needs an HTTP bridge surface (for example, a protocol helper that + cannot practically use WebSocket), add a handler in + `crates/buzz-relay/src/api/` and register it in `crates/buzz-relay/src/router.rs`. 5. **Persist to the database** — if the event needs to be queryable, add a handler in `buzz-db/src/` (e.g., `buzz-db/src/my_feature.rs`) with the appropriate `INSERT` and `SELECT` queries. -6. **Index for search** (if applicable) — add the kind to the Typesense - indexing logic in `buzz-search/src/index.rs`. +6. **Index for search** (if applicable) — Postgres FTS indexes persisted + events automatically via the `events.search_tsv` generated column. To + exclude a privacy-sensitive kind from search, add it to the `CASE WHEN + kind IN (...)` exclusion in the `search_tsv` definition (see the initial + schema migration) rather than wiring a separate indexer. 7. **Audit** — the audit log captures all events automatically; no changes needed unless you need custom audit metadata. @@ -397,50 +398,35 @@ for team access setup, onboarding, and the full repo inventory. See ## How to Add a New API Endpoint -REST endpoints live in `crates/buzz-relay/src/api/` — each resource has -its own submodule (e.g., `channels.rs`, `messages.rs`, `tokens.rs`). Routes -are registered in `crates/buzz-relay/src/router.rs`. +Prefer a signed Nostr event and the existing WebSocket/`POST /events` ingest +path over adding endpoint-specific JSON APIs. The relay intentionally exposes +only a narrow HTTP surface: NIP-11/NIP-05 metadata, `/events`, `/query`, +`/count`, `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and +health probes. -1. **Define the handler function:** +If an HTTP endpoint is still necessary: - ```rust - pub async fn get_my_resource( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, - ) -> Result, (StatusCode, Json)> { - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?; - let ctx = extract_auth_context(&headers, &state).await?; - buzz_auth::require_scope(&ctx.scopes, buzz_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - // Fetch data - let data = state.db.get_my_resource(channel_id).await - .map_err(|e| internal_error(&e.to_string()))?; - Ok(Json(serde_json::json!(data))) - } - ``` +1. **Define the handler** in the appropriate module under + `crates/buzz-relay/src/api/`. Resolve the request tenant before any auth or + data lookup, use NIP-98 when the endpoint accepts user credentials, and keep + community scoping explicit. -2. **Register the route** in `crates/buzz-relay/src/router.rs`: - - ```rust - .route("/api/channels/{channel_id}/my-resource", get(get_my_resource)) - ``` +2. **Register the route** in `crates/buzz-relay/src/router.rs` using the + narrowest path possible. Do not add new `/api/*` compatibility routes unless + the product decision explicitly calls for one. -3. **Add the database query** in `buzz-db/src/` — follow the existing - patterns in `channel.rs`, `event.rs`, etc. +3. **Add database queries** in `buzz-db/src/` only when the endpoint cannot be + expressed through the existing event query paths. -4. **Handle errors** — use the `api_error()` and `internal_error()` helpers in - `buzz-relay/src/api/mod.rs`. Return `(StatusCode, Json)` tuples. +4. **Handle errors** using the `api_error()`, `internal_error()`, and + `not_found()` helpers in `buzz-relay/src/api/mod.rs`. Return + `(StatusCode, Json)` tuples. -5. **Write tests** — add an integration test using the `buzz-test-client` - harness in `crates/buzz-test-client/tests/e2e_rest_api.rs`. +5. **Write tests** with the `buzz-test-client` harness in + `crates/buzz-test-client/tests/`, covering auth, community scoping, and the + relevant success path. -6. **Document** — if the endpoint is part of the public API surface, add it - to the API reference section of `README.md` or a dedicated `API.md`. +6. **Document** any public endpoint in `ARCHITECTURE.md` and user-facing docs. --- diff --git a/Cargo.lock b/Cargo.lock index f6db8aa57..09108807c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,6 +555,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -754,7 +769,7 @@ dependencies = [ "sqlx", "tokio", "tracing", - "uuid", + "url", ] [[package]] @@ -842,6 +857,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-conformance" +version = "0.1.0" +dependencies = [ + "proptest", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -933,6 +959,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "uuid", ] [[package]] @@ -980,34 +1007,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "buzz-proxy" -version = "0.1.0" -dependencies = [ - "axum", - "buzz-core", - "chrono", - "dashmap", - "futures-util", - "hex", - "hmac 0.13.0", - "moka", - "nostr", - "rand 0.10.1", - "reqwest 0.13.3", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite 0.29.0", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "uuid", -] - [[package]] name = "buzz-pubsub" version = "0.1.0" @@ -1036,6 +1035,7 @@ dependencies = [ "base64", "buzz-audit", "buzz-auth", + "buzz-conformance", "buzz-core", "buzz-db", "buzz-media", @@ -1096,14 +1096,9 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", - "chrono", - "nostr", - "reqwest 0.13.3", - "serde", - "serde_json", + "sqlx", "thiserror 2.0.18", "tokio", - "tracing", "uuid", ] @@ -3479,7 +3474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -5943,12 +5938,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ + "bit-set", + "bit-vec", "bitflags", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", + "rusty-fork", + "tempfile", "unarray", ] @@ -6088,6 +6087,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -6775,6 +6780,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rxml" version = "0.11.1" @@ -8664,6 +8681,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index c6dd71349..03cba4ee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/buzz-relay", "crates/buzz-core", + "crates/buzz-conformance", "crates/buzz-db", "crates/buzz-pubsub", "crates/buzz-auth", @@ -10,7 +11,6 @@ members = [ "crates/buzz-acp", "crates/buzz-agent", "crates/sprig", - "crates/buzz-proxy", "crates/buzz-test-client", "crates/buzz-ws-client", "crates/buzz-admin", @@ -78,7 +78,7 @@ anyhow = "1" uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } -# HTTP client (webhook delivery, Typesense indexing) +# HTTP client (webhook delivery) reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } # Cryptography @@ -102,18 +102,21 @@ futures-util = "0.3" tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } url = "2" +# Property-based testing (dev-only) +proptest = "1" + # MCP SDK (used by buzz-dev-mcp and buzz-agent) rmcp = { version = "1.1.0", features = ["server", "transport-io", "macros"] } schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } +buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-auth = { path = "crates/buzz-auth" } buzz-pubsub = { path = "crates/buzz-pubsub" } buzz-search = { path = "crates/buzz-search" } buzz-audit = { path = "crates/buzz-audit" } -buzz-proxy = { path = "crates/buzz-proxy" } buzz-workflow = { path = "crates/buzz-workflow" } buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } diff --git a/Justfile b/Justfile index 1fe413f1b..bc427e47f 100644 --- a/Justfile +++ b/Justfile @@ -169,9 +169,10 @@ _ensure-services: echo " timed out" exit 1 -# Apply database migrations if the dev database is running +# Apply database migrations and seed the local dev community if the dev database is running _ensure-migrations: _ensure-services cargo run -p buzz-admin -- migrate + ./scripts/seed-local-community.sh # Run clippy on the desktop Tauri Rust crate desktop-tauri-clippy: _ensure-sidecar-stubs @@ -230,6 +231,11 @@ test-unit: #!/usr/bin/env bash if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib + # Multi-tenant conformance gate (buzz-conformance): the independent + # replay checker + golden fixtures. No infra — pure in-process trace + # replay — so it belongs in the unit job. Run all targets (lib + the + # tests/replay_fixtures.rs integration test), not just --lib. + cargo nextest run -p buzz-conformance else ./scripts/run-tests.sh unit fi @@ -285,22 +291,36 @@ relay-web: bootstrap _ensure-migrations relay-release: _ensure-migrations cargo run -p buzz-relay --release -# Start buzz-proxy (dev mode) -proxy: - cargo run -p buzz-proxy - -# Start buzz-proxy (release mode) -proxy-release: - cargo run -p buzz-proxy --release # Run the desktop Tauri app in dev mode with a local relay (ports and identity derived from worktree) dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" + relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 + health_port="${BUZZ_HEALTH_PORT:-8080}" + metrics_port="${BUZZ_METRICS_PORT:-9102}" + if command -v lsof >/dev/null 2>&1; then + for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do + name="${spec%%:*}"; port="${spec##*:}" + if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2 + lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true + echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2 + exit 1 + fi + done + fi cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay ./target/debug/buzz-relay & RELAY_PID=$! + sleep 1 + if ! kill -0 "$RELAY_PID" 2>/dev/null; then + echo "Error: buzz-relay exited during startup; refusing to launch desktop against a stale relay." >&2 + wait "$RELAY_PID" || true + exit 1 + fi cleanup() { [[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true kill "$RELAY_PID" 2>/dev/null || true @@ -427,13 +447,6 @@ migrate: _ensure-migrations # ─── Utilities ──────────────────────────────────────────────────────────────── -# Rebuild Typesense docs for all kind:0 (user profile) events. -# Required once after deploying the indexer change that flattens kind:0 content -# for searchability; new/updated profiles are indexed correctly automatically. -# Safe to run repeatedly — Typesense upserts. -reindex-kind0: - cargo run --release -p buzz-relay --bin buzz-reindex-kind0 - # Remove build artifacts clean: cargo clean diff --git a/NOSTR.md b/NOSTR.md index 93a8e2753..fbf521a8d 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -1,25 +1,13 @@ # Using Third-Party Nostr Clients with Buzz -Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. There are two ways for -third-party Nostr clients to connect: - -| Path | Protocol | Connects to | Expected clients (not all verified in-repo) | -|------|----------|-------------|----------------------------------------------| -| **Direct** | NIP-29 | `buzz-relay :3000` | NIP-29 clients (e.g. Chachi, 0xchat), nak | -| **Via proxy** | NIP-28 | `buzz-proxy :4869` | NIP-28 clients (e.g. Coracle, Amethyst), nostr-tools apps | - -**Direct** is simpler — no extra process, no translation layer. Use it when your client speaks -NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28 -clients and don't have company credentials. - -Both paths require NIP-42 authentication. +Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. Third-party Nostr clients connect directly to `buzz-relay` using NIP-29 and NIP-42 authentication. The old NIP-28 compatibility proxy has been removed. ## Community scope Buzz treats the relay URL/domain as authoritative for the community. Today's single-relay deployment has exactly one community behind that URL, so existing -NIP-29/NIP-28 clients keep using the same WebSocket URL, event kinds, tags, and -REST/media/git paths. In a multi-community deployment, each community is reached +NIP-29 clients keep using the same WebSocket URL, event kinds, tags, and +HTTP/media/git paths. In a multi-community deployment, each community is reached by its own domain or subdomain; the backend resolves the community from the host before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic. @@ -33,7 +21,7 @@ across community domains. --- -## Path 1: NIP-29 Direct +## NIP-29 Direct Connect any NIP-29 client straight to the relay. @@ -211,283 +199,6 @@ nak req -k 1059 --tag "p=" \ --- -## Path 2: NIP-28 via buzz-proxy - -For clients that speak NIP-28 (kind:40/41/42) but not NIP-29, **buzz-proxy** translates between -the two protocols in real time. Events are re-signed with deterministic shadow keys so each -external user maps to a consistent identity on the relay. - -### Quick Start - -```bash -# 1. Start infrastructure + relay (see Path 1) - -# 2. Generate proxy server key and derive its pubkey -export BUZZ_PROXY_SERVER_KEY=$(openssl rand -hex 32) -PROXY_PUBKEY=$(echo $BUZZ_PROXY_SERVER_KEY | nak key public) - -# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth) -export BUZZ_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \ - -H "Authorization: Nostr " \ - -H "Content-Type: application/json" \ - -d '{"name":"proxy"}' | jq -r .token) - -# 4. Get the relay's public key (needed for attribution trust) -# This is the pubkey of the relay's signing keypair. If BUZZ_RELAY_PRIVATE_KEY -# is set, derive it: echo $BUZZ_RELAY_PRIVATE_KEY | nak key public -# If not set, the relay generates a random keypair at startup — check relay logs. -export BUZZ_RELAY_PUBKEY= - -# 5. Start the proxy -export BUZZ_UPSTREAM_URL=ws://localhost:3000 -export BUZZ_PROXY_SALT=$(openssl rand -hex 32) -export BUZZ_PROXY_ADMIN_SECRET=$(openssl rand -hex 16) -cargo run -p buzz-proxy # proxy on :4869 - -# 6. Register a guest -curl -X POST http://localhost:4869/admin/guests \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -d '{"pubkey": "", "channels": ""}' - -# 7. Connect any NIP-28 + NIP-42 client to ws://localhost:4869 -``` - -### What Works - -| Feature | Status | Notes | -|---------|:------:|-------| -| **NIP-11 relay info** | ✅ | Standard relay info document at `GET /` | -| **NIP-42 authentication** | ✅ | Proactive challenge + reactive-auth compatible | -| **Channel discovery (kind:40)** | ✅ | Synthesized from Buzz REST API at startup; served locally. Content uses channel UUID as `name` for ID stability; human-readable name is in kind:41. **Snapshot — new channels created after proxy start require restart. Renames do NOT affect kind:40 (UUID-anchored).** | -| **Channel metadata (kind:41)** | ✅ | Name, description (picture always empty — no channel-picture source in proxy path); synthesized at startup, served locally. **Snapshot — new channels AND renames require restart to update local kind:41.** | -| **Channel messages (kind:42)** | ✅ | Translated to/from Buzz kind:9 | -| **Inbound kind:1** | ✅ | Text notes (kind:1) accepted and translated to kind:9, same as kind:42 | -| **Message editing (kind:41)** | ✅ | Bidirectional: inbound kind:41 → kind:40003; outbound kind:40003 → kind:41. **Note:** inbound kind:41 is always treated as a message edit, never as a channel metadata update. Standard NIP-28 channel-metadata writes are not supported. | -| **Reactions (kind:7)** | ✅ | Bidirectional; inbound channel scope verified against allowed channels. **Constraint:** target must already be known to the proxy's ID mapping cache (populated by prior fetch, outbound delivery, or inbound publish). Error if unknown: `reaction target is unknown to the proxy; fetch the message first`. | -| **Deletions (kind:5)** | ⚠️ | **Outbound only** — standard kind:5 events stored on the relay are translated for clients. Admin deletions (kind:9005) and REST-API deletes soft-delete without emitting kind:5, so proxy clients won't see those. Inbound kind:5 blocked by proxy policy (not yet implemented). | -| **Real-time streaming** | ✅ | Live event delivery via open subscriptions | -| **Multi-channel access** | ✅ | Guests can be granted access to multiple channels | -| **Shadow identity** | ✅ | Each guest gets a deterministic shadow keypair | - -> **kind:41 dual semantics:** A `REQ` for kind:41 returns both locally-synthesized channel metadata -> (startup snapshot) AND upstream edit events (kind:40003 translated to kind:41). Clients may see -> two different event types under the same kind number. Inbound kind:41 is always treated as a -> message edit (→ kind:40003), never as a channel metadata update. - -### What Doesn't Work - -| Feature | Status | Why | -|---------|:------:|-----| -| **Channel creation (kind:40 write)** | ❌ | Channels created via REST API or NIP-29 kind:9007 (direct path) | -| **Inbound deletions (kind:5)** | ❌ | Blocked by proxy policy; not yet implemented | -| **DMs (NIP-04/NIP-44)** | ❌ | Proxy only handles NIP-28 channel events | -| **User profiles (kind:0)** | ❌ | Profiles managed via REST API or kind:0 (direct path) | -| **NIP-10 reply threading** | ⚠️ | Threading works on direct path; proxy preserves `#e` tags but does not translate thread metadata | -| **NIP-50 search** | ❌ | Available on direct path only (ws://relay:3000); not proxied | -| **File uploads (NIP-94/96)** | ❌ | Use Blossom on the relay directly (Path 1) | -| **Relay lists / Outbox (NIP-65)** | ❌ | Single-relay architecture | - -### Channel UUIDs vs Event IDs - -Buzz identifies channels by UUID. NIP-28 clients identify channels by the event ID of the -kind:40 creation event. The proxy translates automatically, but you need the event ID to subscribe. - -The synthesized kind:40 uses the channel UUID as the `name` field in content (for deterministic -event ID stability across restarts). The human-readable channel name is in kind:41 metadata: - -```bash -# Get kind:40 (UUID in content.name) and kind:41 (human-readable name) -nak req -k 40 -k 41 --auth --sec ws://localhost:4869 -``` - -### Proxy Authentication - -Two methods, both using NIP-42: - -**Pubkey-based (primary)** — register a guest's hex pubkey with channel access: - -```bash -# Register -curl -X POST http://localhost:4869/admin/guests \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d '{"pubkey": "", "channels": ","}' - -# List -curl http://localhost:4869/admin/guests \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" - -# Revoke -curl -X DELETE http://localhost:4869/admin/guests \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d '{"pubkey": ""}' -``` - -> **Private channels:** The proxy authenticates upstream using its own server key via NIP-42. -> `GET /api/channels` and relay REQ filters only return channels accessible to that identity. -> For the proxy to expose a private channel, the proxy's server pubkey must itself be a member -> of that channel. Guest registration alone is not sufficient for private channels. - -**Invite tokens (secondary)** — for ad-hoc sharing with expiry and use limits: - -```bash -# Create -curl -X POST http://localhost:4869/admin/invite \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d '{"channels": ",", "max_uses": 5, "hours": 48}' - -# Connect: ws://localhost:4869?token= -``` - -### Connecting with Coracle (expected, not verified in-repo) - -1. Open **https://coracle.social**. -2. Note your hex pubkey from **Settings → Account**. -3. Register it: `POST /admin/guests` with your pubkey and channel UUIDs. -4. **Settings → Relays → Add Relay** → `ws://localhost:4869` -5. Coracle should handle NIP-42 auth automatically. Channels should appear under **Public Channels**. - -For remote access, tunnel with ngrok: `ngrok http 4869` → use `wss://.ngrok.io`. - -### Connecting with nak (Proxy) - -```bash -# Discover channels -nak req -k 40 -l 10 --auth --sec ws://localhost:4869 - -# Read messages from a specific channel -nak req -k 42 --tag "e=" -l 10 --auth --sec ws://localhost:4869 - -# Send a message -nak event -k 42 -c "Hello!" --tag e= \ - --auth --sec ws://localhost:4869 - -# Stream live from a specific channel -nak req -k 42 --tag "e=" --stream --auth --sec ws://localhost:4869 -``` - -### Connecting with nostr-tools v2.23 - -```javascript -import { Relay } from 'nostr-tools/relay' -import { finalizeEvent } from 'nostr-tools/pure' -import { channelMessageEvent } from 'nostr-tools/nip28' - -const relay = new Relay('ws://localhost:4869', { websocketImplementation: WebSocket }) -relay.onauth = async (template) => finalizeEvent(template, secretKey) -await relay.connect() - -const event = channelMessageEvent({ - channel_create_event_id: '', - relay_url: 'ws://localhost:4869', - content: 'Hello from nostr-tools!', - created_at: Math.floor(Date.now() / 1000), -}, secretKey) -await relay.publish(event) -``` - -Test script: `scripts/test-proxy-nostr-tools.mjs`. - -### Connecting with nostr-sdk v0.44 (Python) - -```python -import nostr_sdk - -keys = nostr_sdk.Keys.parse("") -signer = nostr_sdk.NostrSigner.keys(keys) -client = nostr_sdk.ClientBuilder().signer(signer).build() -client.automatic_authentication(True) - -await client.add_relay(nostr_sdk.RelayUrl.parse("ws://localhost:4869")) -await client.connect() - -builder = nostr_sdk.EventBuilder.channel_msg(channel_eid, relay_url, "Hello from Python!") -await client.send_event_builder(builder) -``` - -Test script: `scripts/test-proxy-nostr-sdk-python.py`. - -### Tested Clients (Proxy) - -| Client | Platform | Evidence | Notes | -|--------|----------|:--------:|-------| -| **nak** | CLI | Manual (anecdotal) | Auth, discovery, metadata, send, receive, streaming | -| **nostr-tools v2.23** | JS | Standalone script | `scripts/test-proxy-nostr-tools.mjs` | -| **nostr-sdk v0.44** | Python | Standalone script | `scripts/test-proxy-nostr-sdk-python.py` | - -**Not verified in-repo** (anecdotal / expected based on NIP-28 + NIP-42 support): -- **Coracle** (Web) — expected best GUI; renders kind:42 in chat UI -- **Amethyst** (Android) — NIP-28 public chat view -- **Nostrudel** (Web) — good NIP-28 support - -### Clients That Won't Work (anecdotal) - -| Client | Why | -|--------|-----| -| **Damus** | NIP-42 works but no NIP-28 channel UI (anecdotal) | -| **Primal** | Caching relay infrastructure — doesn't connect directly (anecdotal) | -| **Clients without NIP-42** | Both relay and proxy require authentication | - ---- - -## Architecture - -``` - NIP-29 (direct) -┌──────────────────┐ ◄──────────────────────────► ┌──────────────────┐ -│ NIP-29 Client │ kind:9, kind:7, kind:5 │ Buzz Relay │ -│ (Chachi, 0xchat,│ kind:9000/01/02/05/07/08 │ :3000 │ -│ nak) │ #h(uuid), NIP-42 │ │ -└──────────────────┘ │ kind:39000/1/2 │ - │ kind:44100/44101│ - │ Blossom media │ -┌──────────────────┐ ┌────────────────┐ │ /media/upload │ -│ NIP-28 Client │◄──────►│ buzz-proxy │◄───►│ │ -│ (Coracle, nak, │ NIP-28 │ :4869 │ WS └──────────────────┘ -│ nostr-tools) │ │ │ +REST -└──────────────────┘ │ kind:42↔kind:9 │ (/api/channels, - │ kind:41↔40003 │ /api/events) - │ kind:1→kind:9 │ - │ kind:7 (bidir) │ - │ kind:5 (out) │ - │ #e(id)↔#h(uuid)│ - │ shadow keys │ - └────────────────┘ -``` - -**Direct path:** Clients speak kind:9 natively. No translation, no shadow keys, no proxy. The relay -handles NIP-42 auth, channel scoping via `#h` tags, group discovery (kind:39000–39002), membership -notifications (kind:44100/44101), NIP-29 admin commands (kind:9000, 9001, 9002, 9005, 9007, 9008, -9021, 9022; plus deferred 9009), and standard deletions/reactions (kind:5/7). - -**Proxy path:** Translates kind:42 ↔ kind:9 (also accepts kind:1 inbound), kind:41 ↔ kind:40003 -(edits), kind:7 (reactions, bidirectional), and kind:5 (deletions, outbound only — standard kind:5 -events only; admin/REST deletions do not surface as NIP-28 delete events). Re-signs events with -deterministic shadow keys (HMAC-SHA256 of salt + pubkey). Channel discovery (kind:40) is synthesized -locally from Buzz's REST API at startup and never forwarded upstream. Channel metadata (kind:41) -is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 → kind:41). - ---- - -## Proxy Environment Variables - -| Variable | Required | Default | Description | -|----------|:--------:|---------|-------------| -| `BUZZ_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay | -| `BUZZ_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) | -| `BUZZ_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) | -| `BUZZ_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) | -| `BUZZ_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) | -| `BUZZ_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address | -| `BUZZ_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. | -| `BUZZ_PROXY_ADMIN_SECRET` | ❌ | — | Bearer secret for `/admin/*` (unset = no auth, dev mode) | -| `RUST_LOG` | ❌ | `buzz_proxy=info,tower_http=info` | Log level | - ---- - ## Relay Membership (NIP-43) When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the @@ -619,15 +330,6 @@ nak req -k 13534 --auth --sec ws://localhost:3000 - **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005). - **Client-submitted kind:44100/44101 rejected.** Membership notifications can only be signed by the relay keypair. -### Proxy Path -- **Event pubkey verification.** Inbound events must have a `pubkey` matching the authenticated NIP-42 identity. Spoofed pubkeys are rejected. -- **Inbound kind:5 blocked by proxy policy.** Not yet implemented. The relay's deletion handler does perform author-match validation, but the proxy-side translation path for inbound deletions has not been built. -- **Shadow keys use HMAC-SHA256.** Proper domain separation; salt must be kept secret. -- **Guest registry is in-memory.** Lost on proxy restart. Re-register guests after restarts. -- **Invite tokens are in-memory.** Lost on proxy restart. Default `max_uses` is 10. -- **Revocation is not session-aware.** Removing a guest doesn't disconnect active sessions. -- **Admin secret uses hash-then-compare.** No timing oracle on the bearer token check. - --- ## Troubleshooting @@ -639,23 +341,8 @@ nak req -k 13534 --auth --sec ws://localhost:3000 | `auth-required: verification failed` | Pubkey not in allowlist (when enabled), or NIP-42 auth failed | Add pubkey to `pubkey_allowlist` table; verify NIP-42 challenge/response | | `invalid: channel-scoped events must include an h tag` | kind:9 sent without `#h` tag | Include `--tag "h="` | | `invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay | -| No discovery events | Channel is private + you're not a member | Join the channel first via REST API | - -### Proxy Path - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `restricted: pubkey not registered and no invite token provided` | Pubkey not registered, no token | Register guest or create invite token | -| `error: token invalid: invite token not found` | Token doesn't exist (proxy restarted or mistyped) | Create new invite token | -| `error: token invalid: invite token expired` | Token past expiry time | Create new invite token | -| `error: token invalid: invite token exhausted` | Token reached `max_uses` limit | Create new invite token with higher limit | -| `auth-required: authentication timeout` | Client didn't respond to NIP-42 within 30s | Use a NIP-42-capable client | -| No messages after auth | Unresolved `#e` filter silently returns zero events | Re-query `nak req -k 40` for correct kind:40 event ID | -| Guest still has access after revoke | Active sessions not terminated | Restart proxy to cut all sessions | -| Proxy startup fails | Can't reach relay REST API or missing env vars | Check relay is running; verify all required env vars (especially `BUZZ_RELAY_PUBKEY`) | +| No discovery events | Channel is private + you're not a member | Join the channel first | --- ## Further Reading - -- [`crates/buzz-proxy/README.md`](crates/buzz-proxy/README.md) — proxy crate internals, shadow key derivation, subscription namespacing. **Note:** some auth/buffering details in that README may be stale; this document is the authoritative reference for proxy behavior. diff --git a/README.md b/README.md index acb0c7ce6..a4992ae1b 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Yes, it's another AI-adjacent developer tool. We're sorry. The difference is wha ## Why Buzz is better -One community. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. In the default self-hosted deployment, one relay hosts one community; in a hosted multi-tenant deployment, each community keeps that same semantic boundary even when the backend shares Postgres, Redis, Typesense, and object storage. +One community. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. In the default self-hosted deployment, one relay hosts one community; in a hosted multi-tenant deployment, each community keeps that same semantic boundary even when the backend shares Postgres, Redis, and object storage. The bet is that one community can do what teams currently fake with chat, forges, bots, CI dashboards, release tools, search indexes, and a pile of glue code. Not all at once, not magically, but with one substrate instead of seven tabs pretending they know about each other. @@ -153,12 +153,13 @@ For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSO ┌─────────────────────────────────────────────────────────────────────────┐ │ buzz-relay │ │ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │ -└───┬──────────────────┬──────────────────┬──────────────────┬────────────┘ - │ │ │ │ - ┌──▼───────┐ ┌─────▼─────┐ ┌───────▼────┐ ┌────────▼────┐ - │ Postgres │ │ Redis │ │ Typesense │ │ S3/MinIO │ - │ (events) │ │ (pub/sub) │ │ (search) │ │ (Blossom) │ - └──────────┘ └───────────┘ └────────────┘ └─────────────┘ +└───┬──────────────────────────┬──────────────────────────┬──────────────┘ + │ │ │ + ┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐ + │ Postgres │ │ Redis │ │ S3/MinIO │ + │ (events + │ │ (pub/sub) │ │ (Blossom) │ + │ FTS search) │ └─────────────┘ └─────────────┘ + └──────────────┘ ``` A Rust workspace of focused crates. Single source of truth: the relay. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown. @@ -168,7 +169,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH **Core protocol** — `buzz-core` (zero-I/O types, NIP-01 filters, Schnorr verify) · `buzz-relay` (Axum WS + REST) -**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Typesense) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace. +**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace. **Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs) diff --git a/TESTING.md b/TESTING.md index 2e3a1bdf5..a1ddb35ec 100644 --- a/TESTING.md +++ b/TESTING.md @@ -34,8 +34,8 @@ just setup # start Docker services, run migrations ``` > **Already running Buzz Desktop?** Desktop uses the same Docker container -> names (`buzz-postgres`, `buzz-redis`, `buzz-typesense`) and the same -> default ports (`:5432`, `:6379`, `:8108`). `just setup` will reuse those +> names (`buzz-postgres`, `buzz-redis`) and the same +> default ports (`:5432`, `:6379`). `just setup` will reuse those > services, so **your test relay writes into Desktop's database**. That's > fine for read/write smoke tests, but: `just reset` wipes Desktop's data > along with yours. If you need isolation, stop Desktop first or run the @@ -267,7 +267,6 @@ out of the box with `just setup` or `just relay`. Common overrides: | `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** | | `DATABASE_URL` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | | | `REDIS_URL` | `redis://localhost:6379` | | -| `TYPESENSE_URL` | `http://localhost:8108` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | diff --git a/VISION.md b/VISION.md index e532954ad..21416acff 100644 --- a/VISION.md +++ b/VISION.md @@ -43,7 +43,7 @@ The relay enforces all access control. Channel membership is the only gate. | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | -Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else. Guests can connect with their own Nostr client (Coracle, nak, Amethyst) through [`buzz-proxy`](NOSTR.md), which translates standard NIP-28 events to Buzz's internal protocol. Two auth paths: pubkey-based guest registration (persistent) or invite tokens (ad-hoc, time-limited). +Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else. --- @@ -192,7 +192,7 @@ Not afterthoughts — ship blockers: | Throughput | ~600K events/day (~7/sec avg) | | Event store | Postgres 17, partitioned monthly | | Fan-out | Redis pub/sub, <50ms p99 | -| Search | Typesense, permission-aware, full-text | +| Search | Postgres FTS, permission-aware, full-text | | Audit | Hash-chain audit log, tamper-evident | | Accessibility | WCAG 2.1 AA minimum | @@ -215,7 +215,6 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete | | ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers | | ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection | -| ✅ | NIP-28 proxy — third-party Nostr clients (Coracle, nak, Amethyst) via `buzz-proxy` | | ✅ | Agent CLI — `buzz-cli`, mirrors and extends the MCP surface | | ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined | | 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) | diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index e2a40979c..c890ef24d 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -28,5 +28,5 @@ hex = { workspace = true } deadpool-redis = { workspace = true } tracing = { workspace = true } sqlx = { workspace = true } -uuid = { workspace = true } +url = { workspace = true } clap = { version = "4", features = ["derive"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 4adb94e14..f04960f65 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -24,8 +24,9 @@ use std::sync::Arc; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; +use buzz_core::tenant::{relay_url_authority, TenantContext}; use buzz_db::{Db, DbConfig}; -use buzz_pubsub::PubSubManager; +use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; use nostr::{EventBuilder, Keys, Kind, Tag}; use tracing::warn; @@ -144,7 +145,11 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { let (db, pubsub, relay_keypair) = connect_member_services().await?; - match db.add_relay_member(&pubkey_hex, &role, None).await { + let tenant = resolve_admin_tenant(&db).await?; + match db + .add_relay_member(tenant.community(), &pubkey_hex, &role, None) + .await + { Ok(true) => println!("added {pubkey_hex} as {role}"), Ok(false) => println!("already a member: {pubkey_hex} (no change)"), Err(e) => { @@ -153,7 +158,7 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { } } - if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await { + if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await { eprintln!("warning: member added to DB but list publish failed: {e}"); } @@ -178,11 +183,14 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R let (db, pubsub, relay_keypair) = connect_member_services().await?; + let tenant = resolve_admin_tenant(&db).await?; use buzz_db::relay_members::RemoveResult; let result = if let Some(ref role) = role_filter { - db.remove_relay_member_if_role(&pubkey_hex, role).await + db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role) + .await } else { - db.remove_relay_member(&pubkey_hex).await + db.remove_relay_member(tenant.community(), &pubkey_hex) + .await }; match result { @@ -209,7 +217,7 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R } } - if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await { + if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await { eprintln!("warning: member removed from DB but list publish failed: {e}"); } @@ -218,7 +226,8 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R async fn cmd_list_members() -> Result { let db = connect_db().await?; - let members = db.list_relay_members().await?; + let tenant = resolve_admin_tenant(&db).await?; + let members = db.list_relay_members(tenant.community()).await?; if members.is_empty() { println!("(no relay members)"); @@ -274,6 +283,7 @@ async fn publish_membership_list_with_bump( db: &Db, pubsub: &Arc, relay_keypair: &Keys, + tenant: &TenantContext, ) -> Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -285,7 +295,11 @@ async fn publish_membership_list_with_bump( // Query the newest existing kind:13534 for this relay's pubkey (channel_id=None). let newest_ts = db - .get_latest_global_replaceable(KIND_NIP43_MEMBERSHIP_LIST as i32, &relay_pubkey_bytes) + .get_latest_global_replaceable( + tenant.community(), + KIND_NIP43_MEMBERSHIP_LIST as i32, + &relay_pubkey_bytes, + ) .await? .map(|e| e.event.created_at.as_secs()); @@ -295,7 +309,7 @@ async fn publish_membership_list_with_bump( None => now, }; - let members = db.list_relay_members().await?; + let members = db.list_relay_members(tenant.community()).await?; let mut tags: Vec = Vec::with_capacity(members.len() + 1); // NIP-70 protected-event marker — prevents re-broadcasting by third parties. @@ -313,12 +327,17 @@ async fn publish_membership_list_with_bump( .sign_with_keys(relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?; - let (stored, was_inserted) = db.replace_addressable_event(&event, None).await?; + let (stored, was_inserted) = db + .replace_addressable_event(tenant.community(), &event, None) + .await?; if was_inserted { // Publish to Redis so live clients receive the updated roster. - // Uses channel_id=Nil (global scope) matching the relay's own publish path. - let pubsub_channel = uuid::Uuid::nil(); - if let Err(e) = pubsub.publish_event(pubsub_channel, &stored.event).await { + // Community-global scope (EventTopic::Global) matches the relay's own + // membership-list publish path; the tenant fixes the community. + if let Err(e) = pubsub + .publish_event(tenant, EventTopic::Global, &stored.event) + .await + { warn!("Redis publish of kind:13534 failed: {e}"); } } @@ -376,6 +395,36 @@ async fn connect_db() -> Result { Ok(db) } +/// Resolve the deployment's tenant from the configured `RELAY_URL` host. +/// +/// `buzz-admin` runs inside the relay container (`compose exec relay +/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same +/// single community against the durable `communities` host map. This is +/// deliberately NOT a default tenant: an unmapped host fails closed with an +/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is +/// single-community per invocation — there is no cross-community sweep. +async fn resolve_admin_tenant(db: &Db) -> Result { + let relay_url = + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + // Derive the authority the *same* way startup seeding and live request + // resolution do (`buzz_core::tenant::relay_url_authority`): host plus an + // explicit non-default port, IPv6 brackets preserved. A plain + // `Url::host_str()` drops the port/brackets, so for `ws://localhost:3000` + // the admin would look up `localhost` while startup seeded `localhost:3000` + // — and `wss://relay.example:8443` would resolve `relay.example`. Sharing + // the helper keeps buzz-admin byte-identical to the community startup seeds. + let host = relay_url_authority(&relay_url); + let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| { + anyhow::anyhow!( + "RELAY_URL host '{host}' is not mapped to a community.\n\ + buzz-admin operates on the configured relay's community; ensure the \ + relay has started and seeded its community (or set RELAY_URL to a \ + mapped host)." + ) + })?; + Ok(TenantContext::resolved(record.id, record.host)) +} + async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; @@ -399,7 +448,8 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { } }; - let channels = db.list_channels(None).await?; + let tenant = resolve_admin_tenant(&db).await?; + let channels = db.list_channels(tenant.community(), None).await?; if channels.is_empty() { println!("No channels in database."); return Ok(()); @@ -417,7 +467,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { kinds: Some(vec![39000]), d_tag: Some(channel_id_str.clone()), limit: Some(1), - ..Default::default() + ..EventQuery::for_community(tenant.community()) }) .await .unwrap_or_default(); @@ -427,7 +477,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { continue; } - let members = db.get_members(channel.id).await?; + let members = db.get_members(tenant.community(), channel.id).await?; // kind:39000 — channel metadata { @@ -453,7 +503,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; - db.replace_addressable_event(&event, Some(channel.id)) + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) .await?; } @@ -471,7 +521,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; - db.replace_addressable_event(&event, Some(channel.id)) + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) .await?; } @@ -486,7 +536,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; - db.replace_addressable_event(&event, Some(channel.id)) + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) .await?; } diff --git a/crates/buzz-audit/src/entry.rs b/crates/buzz-audit/src/entry.rs index 3eab2417f..33b51f8cf 100644 --- a/crates/buzz-audit/src/entry.rs +++ b/crates/buzz-audit/src/entry.rs @@ -1,49 +1,72 @@ +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::action::AuditAction; -/// Materialised audit log entry as stored in the DB. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// A materialised audit log entry as stored in `audit_log`. +/// +/// Rows are keyed `(community_id, seq)`: `seq` is monotonic *within one +/// community*, and `prev_hash` chains to the previous entry *of the same +/// community*. The chain is independent per tenant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditEntry { - /// Monotonically increasing sequence number. + /// Server-resolved community this entry belongs to. Leads the primary key. + pub community_id: Uuid, + /// Sequence number, monotonic within `community_id` (starts at 1). pub seq: i64, - /// When the entry was recorded. - pub timestamp: DateTime, - /// Nostr event ID that triggered this action. - pub event_id: String, - /// Nostr event kind number. - pub event_kind: u32, - /// Hex-encoded Nostr pubkey. - pub actor_pubkey: String, + /// SHA-256 of this entry's fields including `community_id` and `prev_hash`. + pub hash: Vec, + /// SHA-256 of the previous entry in *this community's* chain, or `None` for + /// the community's first entry (hashed as [`crate::hash::GENESIS_HASH`]). + pub prev_hash: Option>, /// Action that was performed. pub action: AuditAction, - /// Channel this action applies to, if any. - pub channel_id: Option, - /// Arbitrary JSON context. **Included in hash computation** (serialized with - /// sorted keys for determinism) so that metadata tampering is detectable. - pub metadata: serde_json::Value, - /// SHA-256 hex hash of the previous entry (or [`crate::hash::GENESIS_HASH`] for the first). - pub prev_hash: String, - /// SHA-256 hex hash of this entry's fields including `prev_hash`. - pub hash: String, + /// Raw bytes of the actor's Nostr pubkey, if the action has one. + pub actor_pubkey: Option>, + /// Generic identifier of the object acted upon (event id hex, channel UUID, + /// media sha256, …), if any. The relay resolves it under `community_id`; + /// it never names an object in another community. + pub object_id: Option, + /// Arbitrary JSON context. **Included in the hash** (serialized with sorted + /// keys for determinism) so tampering with it is detectable. + pub detail: serde_json::Value, + /// When the entry was recorded. + pub created_at: DateTime, } -/// Input for creating a new audit entry. `seq`, `prev_hash`, `hash` are computed by `AuditService::log`. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Input for appending a new audit entry. `seq`, `prev_hash`, `hash`, and +/// `created_at` are assigned by [`crate::service::AuditService::log`]. +/// +/// `community_id` is the **server-resolved** tenant (from the request's +/// `TenantContext`), never a client-supplied value — the same provenance rule +/// the whole multi-tenant model rests on. +/// +/// Not `Serialize`/`Deserialize`: this is an in-process input struct (consumed +/// by `AuditService::log`, threaded through the in-memory audit sink), never +/// crossing a wire or DB boundary as a whole. Keeping it non-deserializable +/// reinforces the fence — there is no path by which a client-supplied blob +/// becomes a `NewAuditEntry` (and thus a `CommunityId`). +#[derive(Debug, Clone, PartialEq, Eq)] pub struct NewAuditEntry { - /// Nostr event ID that triggered this action. - pub event_id: String, - /// Must not be 22242 (NIP-42 AUTH). - pub event_kind: u32, - /// Hex-encoded Nostr pubkey of the actor. - pub actor_pubkey: String, + /// Server-resolved community this entry belongs to. Typed as [`CommunityId`] + /// (not a raw `Uuid`) so the provenance rule is visible in the signature: + /// the only ways to obtain one are host resolution or a server-scoped DB + /// row — never a value parsed from client input. + pub community_id: CommunityId, /// Action that was performed. pub action: AuditAction, - /// Channel this action applies to, if any. - pub channel_id: Option, - /// Arbitrary JSON context included in hash computation. - #[serde(default)] - pub metadata: serde_json::Value, + /// Raw bytes of the actor's Nostr pubkey, if the action has one. + pub actor_pubkey: Option>, + /// Generic identifier of the object acted upon, if any. + pub object_id: Option, + /// Arbitrary JSON context included in the hash. + /// + /// **Never bearer-token material.** This field is opaque to the audit + /// crate and persisted verbatim; callers must not write tokens, passwords, + /// or other secrets here. `AuthSuccess`/`AuthFailure` entries carry only + /// outcome metadata — the token has no slot in this type, and `detail` must + /// not become one. + pub detail: serde_json::Value, } diff --git a/crates/buzz-audit/src/error.rs b/crates/buzz-audit/src/error.rs index 6b99321f3..b4ffd24d8 100644 --- a/crates/buzz-audit/src/error.rs +++ b/crates/buzz-audit/src/error.rs @@ -1,45 +1,108 @@ use thiserror::Error; /// Errors that can occur during audit log operations. +/// +/// These are **operator-internal** diagnostics (logged by the audit worker, or +/// returned to an operator-scoped verification call) — they are never relayed to +/// a client on the wire. Even so, no variant embeds a `community_id` or any +/// cross-community object identifier: a `seq` is per-community and meaningless +/// without its chain, and hashes are opaque. An error raised while verifying +/// community A's chain therefore cannot reveal a fact about community B. #[derive(Debug, Error)] pub enum AuditError { /// A database operation failed. #[error("database error: {0}")] Database(#[from] sqlx::Error), - /// Attempted to log a NIP-42 AUTH event (kind 22242), which is forbidden. - #[error("auth events (kind 22242) must never appear in the audit log")] - AuthEventForbidden, - - /// The `prev_hash` of an entry does not match the hash of the preceding entry. + /// The `prev_hash` of an entry does not match the hash of the preceding + /// entry in the same community's chain. #[error( - "hash chain integrity violation at seq {seq}: expected prev_hash {expected}, got {actual}" + "hash chain integrity violation at seq {seq}: prev_hash does not match preceding entry" )] ChainViolation { - /// Sequence number of the offending entry. + /// Per-community sequence number of the offending entry. seq: i64, - /// Hash that was expected based on the previous entry. - expected: String, - /// Hash that was actually found in the entry. - actual: String, }, /// The stored hash of an entry does not match the recomputed hash. - #[error("hash mismatch at seq {seq}: stored {stored}, computed {computed}")] + #[error("hash mismatch at seq {seq}: stored hash does not match recomputed hash")] HashMismatch { - /// Sequence number of the offending entry. + /// Per-community sequence number of the offending entry. seq: i64, - /// Hash value stored in the database. - stored: String, - /// Hash value recomputed from the entry fields. - computed: String, }, /// An unrecognised action string was found in the database. - #[error("unknown audit action in DB: {0:?}")] - UnknownAction(String), + #[error("unknown audit action in database")] + UnknownAction, - /// A JSON serialization error occurred (e.g. while canonicalising metadata). + /// A JSON serialization error occurred (e.g. while canonicalising `detail`). #[error("serialization error: {0}")] Serialization(#[from] serde_json::Error), } + +#[cfg(test)] +mod tests { + use super::*; + + /// The sanitization obligation for the conformance `audit_log` row: an error + /// raised while verifying or appending to one community's chain must not let + /// its rendered text become a cross-community identifier — no `community_id`, + /// no constraint name. Only `seq` may appear, and `seq` is per-community and + /// meaningless without the chain it indexes. + /// + /// This is the *complement* to the structural fence in the variant + /// definitions above: those variants simply have no `community_id` field, so + /// there is no slot to leak one from. This test pins the observable form — + /// if anyone adds a `community_id` to a variant and threads it into the + /// `#[error(...)]` format string, the assertion below reds. + #[test] + fn audit_error_text_carries_no_community_id_or_constraint() { + // A concrete community whose chain is "being verified" when these errors + // fire. If its id leaked into any error text, the error would identify a + // specific tenant. + let community = uuid::Uuid::new_v4(); + let community_str = community.to_string(); + let community_simple = community.simple().to_string(); + + // The variants the audit crate constructs itself with chain-derived data. + let domain_errors = [ + AuditError::ChainViolation { seq: 7 }, + AuditError::HashMismatch { seq: 42 }, + AuditError::UnknownAction, + ]; + + for err in &domain_errors { + let text = err.to_string(); + + // No form of the community id may appear. + assert!( + !text.contains(&community_str) && !text.contains(&community_simple), + "audit error text leaked a community_id: {text:?}" + ); + + // No Postgres constraint/PK names that would reveal schema shape or + // the existence of a cross-community key. + for needle in [ + "community_id", + "audit_log_pkey", + "constraint", + "communities", + ] { + assert!( + !text.to_ascii_lowercase().contains(needle), + "audit error text leaked a constraint/identifier '{needle}': {text:?}" + ); + } + } + + // The two chain-integrity variants must still carry their per-community + // `seq` (the diagnostic is useless without it) — proves the assertion + // above isn't vacuously passing on empty strings. + assert!(AuditError::ChainViolation { seq: 7 } + .to_string() + .contains('7')); + assert!(AuditError::HashMismatch { seq: 42 } + .to_string() + .contains("42")); + } +} diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index b813093dd..a272d4a02 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -3,41 +3,53 @@ use sha2::{Digest, Sha256}; use crate::entry::AuditEntry; use crate::error::AuditError; -/// Sentinel `prev_hash` value used for the first entry in the chain. -pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +/// The 32-byte sentinel hashed in place of `prev_hash` for a community's first +/// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes. +pub const GENESIS_HASH: [u8; 32] = [0u8; 32]; -/// SHA-256 over all identity, chain, and context fields. -/// Field order is fixed — changing it invalidates all existing chains. +/// SHA-256 over the entry's identity, chain, and context fields. /// -/// Metadata is serialized via `BTreeMap` to guarantee key ordering across -/// machines and Rust versions. `serde_json::Value` does not guarantee order. +/// Field order is fixed — changing it invalidates all existing chains. The +/// `community_id` is hashed first so chain identity carries the tenant: an entry +/// cannot be lifted out of one community's chain and re-verified inside another. /// -/// Returns `Err(AuditError::Serialization)` if metadata cannot be serialized. -/// Never hashes a default/empty value as a stand-in for a real payload — -/// a serialization failure is a hard error, not a silent degradation. -pub fn compute_hash(entry: &AuditEntry) -> Result { +/// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is +/// stable across machines and Rust versions. A serialization failure is a hard +/// error, never silently hashed as empty. +pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { let mut hasher = Sha256::new(); + // Tenant binding: community_id leads the hash. + hasher.update(entry.community_id.as_bytes()); hasher.update(entry.seq.to_be_bytes()); - hasher.update(entry.timestamp.to_rfc3339().as_bytes()); - hasher.update(entry.event_id.as_bytes()); - // event_kind is u32 — 4 bytes in big-endian for the hash chain. - hasher.update(entry.event_kind.to_be_bytes()); - hasher.update(entry.actor_pubkey.as_bytes()); + hasher.update(entry.created_at.to_rfc3339().as_bytes()); hasher.update(entry.action.as_str().as_bytes()); - match &entry.channel_id { - Some(id) => hasher.update(id.as_bytes()), - None => hasher.update([0u8; 16]), + match &entry.actor_pubkey { + Some(pk) => { + hasher.update([1u8]); // presence tag — distinguishes Some(empty) from None + hasher.update(pk); + } + None => hasher.update([0u8]), + } + match &entry.object_id { + Some(id) => { + hasher.update([1u8]); + hasher.update(id.as_bytes()); + } + None => hasher.update([0u8]), } - hasher.update(canonical_json(&entry.metadata)?.as_bytes()); - hasher.update(entry.prev_hash.as_bytes()); - Ok(hex::encode(hasher.finalize())) + hasher.update(canonical_json(&entry.detail)?.as_bytes()); + match &entry.prev_hash { + Some(h) => hasher.update(h), + None => hasher.update(GENESIS_HASH), + } + Ok(hasher.finalize().into()) } -/// Serialize a JSON value with sorted keys for deterministic output. +/// Serialize a JSON value with sorted object keys for deterministic output. /// -/// Returns `Err` if any scalar value cannot be serialized. This should never -/// happen for well-formed `serde_json::Value`, but we propagate rather than -/// silently substitute an empty string. +/// Propagates any scalar serialization error rather than substituting a +/// placeholder — a hash must never silently stand in an empty value for a real +/// payload. fn canonical_json(value: &serde_json::Value) -> Result { use serde_json::Value; use std::collections::BTreeMap; @@ -81,21 +93,21 @@ mod tests { use super::*; use crate::{action::AuditAction, entry::AuditEntry}; use chrono::Utc; + use uuid::Uuid; fn sample_entry() -> AuditEntry { AuditEntry { + community_id: Uuid::from_u128(1), seq: 1, - timestamp: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + hash: Vec::new(), + prev_hash: None, + action: AuditAction::EventCreated, + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some("abc123".into()), + detail: serde_json::Value::Null, + created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&Utc), - event_id: "abc123".to_string(), - event_kind: 1, - actor_pubkey: "pubkey_alice".to_string(), - action: AuditAction::EventCreated, - channel_id: None, - metadata: serde_json::Value::Null, - prev_hash: GENESIS_HASH.to_string(), - hash: String::new(), } } @@ -103,7 +115,17 @@ mod tests { fn deterministic() { let entry = sample_entry(); assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap()); - assert_eq!(compute_hash(&entry).unwrap().len(), 64); + assert_eq!(compute_hash(&entry).unwrap().len(), 32); + } + + #[test] + fn community_id_is_part_of_identity() { + // The whole point: the same logical entry in two communities hashes + // differently, so a row can't be replayed across chains. + let a = sample_entry(); + let mut b = a.clone(); + b.community_id = Uuid::from_u128(2); + assert_ne!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap()); } #[test] @@ -112,37 +134,44 @@ mod tests { let h0 = compute_hash(&base).unwrap(); let mut e = base.clone(); - e.event_id = "different_event".into(); + e.seq = 2; assert_ne!(h0, compute_hash(&e).unwrap()); let mut e = base.clone(); - e.seq = 2; + e.action = AuditAction::EventDeleted; assert_ne!(h0, compute_hash(&e).unwrap()); let mut e = base.clone(); - e.actor_pubkey = "pubkey_bob".into(); + e.actor_pubkey = Some(vec![0xcd; 32]); assert_ne!(h0, compute_hash(&e).unwrap()); let mut e = base.clone(); - e.channel_id = Some(uuid::Uuid::new_v4()); + e.object_id = Some("different".into()); assert_ne!(h0, compute_hash(&e).unwrap()); - let mut e = base; - e.metadata = serde_json::json!({"key": "value"}); + let mut e = base.clone(); + e.detail = serde_json::json!({"key": "value"}); assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.prev_hash = Some(vec![0xff; 32]); + assert_ne!(h0, compute_hash(&e).unwrap()); + } + + #[test] + fn presence_tag_distinguishes_none_from_empty() { + // Some(empty) must not collide with None — the presence tag prevents it. + let mut none = sample_entry(); + none.actor_pubkey = None; + let mut empty = sample_entry(); + empty.actor_pubkey = Some(Vec::new()); + assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap()); } #[test] fn canonical_json_key_order_is_stable() { - // Same keys in different insertion order must produce the same hash. let a = serde_json::json!({"z": 1, "a": 2, "m": 3}); let b = serde_json::json!({"a": 2, "m": 3, "z": 1}); assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap()); } - - #[test] - fn genesis_hash_format() { - assert_eq!(GENESIS_HASH.len(), 64); - assert!(GENESIS_HASH.chars().all(|c| c == '0')); - } } diff --git a/crates/buzz-audit/src/lib.rs b/crates/buzz-audit/src/lib.rs index 09f429560..0248a7dfd 100644 --- a/crates/buzz-audit/src/lib.rs +++ b/crates/buzz-audit/src/lib.rs @@ -1,8 +1,21 @@ #![deny(unsafe_code)] #![warn(missing_docs)] -//! Tamper-evident hash-chain audit log. Each entry chains to the previous via -//! SHA-256. Single-writer via Postgres `pg_advisory_lock`. AUTH events (kind 22242) -//! are rejected — they carry bearer tokens. +//! Tamper-evident, **per-community** hash-chain audit log. +//! +//! Each community owns an independent chain: rows are keyed `(community_id, seq)`, +//! `seq` is monotonic *within a community*, and each entry chains to the previous +//! entry *of the same community* via SHA-256. The `community_id` is folded into the +//! hash, so a row lifted out of one community's chain can never verify inside +//! another's — chain identity carries the tenant. This is the audit half of the +//! non-interference floor (`auditHeads[c]` in `MultiTenantRelay.tla`): an audit +//! observation reveals only its own community's head. +//! +//! Writes for a given community are serialized by a **per-community** Postgres +//! advisory lock, so the chain stays consistent across relay processes without one +//! global lock serializing (and timing-coupling) every tenant. +//! +//! The `audit_log` table is owned by the consolidated `0001` migration — this crate +//! is pure chain logic and ships no DDL. /// Audit action types recorded in the log. pub mod action; @@ -12,8 +25,6 @@ pub mod entry; pub mod error; /// SHA-256 hash computation for audit entries. pub mod hash; -/// SQL schema for the audit log table. -pub mod schema; /// Audit log service — append and verify entries. pub mod service; @@ -21,5 +32,4 @@ pub use action::AuditAction; pub use entry::{AuditEntry, NewAuditEntry}; pub use error::AuditError; pub use hash::{compute_hash, GENESIS_HASH}; -pub use schema::AUDIT_SCHEMA_SQL; pub use service::AuditService; diff --git a/crates/buzz-audit/src/schema.rs b/crates/buzz-audit/src/schema.rs deleted file mode 100644 index 1bdfaa969..000000000 --- a/crates/buzz-audit/src/schema.rs +++ /dev/null @@ -1,18 +0,0 @@ -/// DDL for the `audit_log` table. Passed to [`sqlx::raw_sql`] on startup. -pub const AUDIT_SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS audit_log ( - seq BIGINT NOT NULL PRIMARY KEY, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - event_id VARCHAR(255) NOT NULL, - event_kind INT NOT NULL, - actor_pubkey VARCHAR(255) NOT NULL, - action VARCHAR(64) NOT NULL, - channel_id BYTEA, - metadata JSONB NOT NULL, - prev_hash VARCHAR(64) NOT NULL, - hash VARCHAR(64) NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp); -CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log (actor_pubkey); -CREATE INDEX IF NOT EXISTS idx_audit_log_channel ON audit_log (channel_id); -"#; diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 0edc2e89b..913131a59 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -2,24 +2,29 @@ use chrono::{DateTime, Utc}; use futures_util::FutureExt as _; use sqlx::{Acquire, PgPool, Row}; use tracing::{debug, instrument, warn}; +use uuid::Uuid; -use buzz_core::kind::KIND_AUTH; +use buzz_core::CommunityId; use crate::{ action::AuditAction, entry::{AuditEntry, NewAuditEntry}, error::AuditError, - hash::{compute_hash, GENESIS_HASH}, - schema::AUDIT_SCHEMA_SQL, + hash::compute_hash, }; -/// Advisory lock key derived from a stable hash of "buzz_audit". -const AUDIT_LOCK_KEY: i64 = 0x5370_7275_7441_7564; // "SprutAud" as hex +/// Per-community advisory lock key. Derived in Postgres from the community UUID +/// so two communities never serialize each other's audit writes (which would be +/// both a throughput bottleneck and a cross-tenant timing oracle). The lock is +/// taken with `pg_advisory_lock(hashtextextended(...))` — see [`AuditService::log`]. +const AUDIT_LOCK_NAMESPACE: &str = "buzz_audit:"; -/// Append-only audit log service backed by Postgres. +/// Append-only, per-community hash-chain audit log backed by Postgres. /// -/// Serialises writes via `pg_advisory_lock` so the hash chain remains consistent -/// even when multiple relay processes share the same database. +/// Each community has an independent chain keyed `(community_id, seq)`. Writes +/// for one community are serialized by a per-community advisory lock so the chain +/// stays consistent across relay processes; different communities proceed in +/// parallel. pub struct AuditService { pool: PgPool, } @@ -30,40 +35,32 @@ impl AuditService { Self { pool } } - /// Idempotent — safe to call on every startup. - pub async fn ensure_schema(&self) -> Result<(), AuditError> { - sqlx::raw_sql(AUDIT_SCHEMA_SQL).execute(&self.pool).await?; - Ok(()) - } - - /// Append a new entry to the audit log. Single-writer via `pg_advisory_lock`. + /// Append a new entry to the calling community's chain. /// - /// Postgres advisory locks are session-scoped, so we acquire before the - /// transaction and release after commit (or on any error path). + /// Serialized per-community via `pg_advisory_lock`. Postgres advisory locks + /// are session-scoped, so we acquire before the transaction and release + /// after commit (or on any error path). #[instrument(skip(self, entry), fields(action = %entry.action))] pub async fn log(&self, entry: NewAuditEntry) -> Result { - if entry.event_kind == KIND_AUTH { - warn!("rejected attempt to audit AUTH event (kind 22242)"); - return Err(AuditError::AuthEventForbidden); - } - let mut conn = self.pool.acquire().await?; - // Acquire session-level advisory lock (blocks until available). - sqlx::query("SELECT pg_advisory_lock($1)") - .bind(AUDIT_LOCK_KEY) + // Per-community advisory lock: hash the namespaced community id to an + // i64 lock key inside Postgres. Communities lock independently. + let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{}", entry.community_id); + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_key) .execute(&mut *conn) .await?; - // Run log_inner and release the lock regardless of outcome. - // We use catch_unwind to handle panics so the lock is always released - // before the connection is returned to the pool. + // Run the chain append and release the lock regardless of outcome. + // catch_unwind so a panic still releases the lock before the connection + // returns to the pool. let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry)) .catch_unwind() .await; - let _ = sqlx::query("SELECT pg_advisory_unlock($1)") - .bind(AUDIT_LOCK_KEY) + let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_key) .execute(&mut *conn) .await; @@ -80,56 +77,63 @@ impl AuditService { ) -> Result { let mut tx = conn.begin().await?; - let prev_hash: String = sqlx::query("SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1") - .fetch_optional(&mut *tx) - .await? - .map(|row| row.get::("hash")) - .unwrap_or_else(|| GENESIS_HASH.to_string()); + // The stored row keys on the raw UUID; the typed `CommunityId` on the + // input is the provenance fence, dereferenced here at the DB boundary. + let community_id = *entry.community_id.as_uuid(); - let seq: i64 = - sqlx::query_scalar("SELECT COALESCE(MAX(seq), 0) + 1 AS next_seq FROM audit_log") - .fetch_one(&mut *tx) - .await?; + // Head of THIS community's chain — scoped by community_id. + let head = sqlx::query( + "SELECT seq, hash FROM audit_log + WHERE community_id = $1 + ORDER BY seq DESC LIMIT 1", + ) + .bind(community_id) + .fetch_optional(&mut *tx) + .await?; - let timestamp: DateTime = Utc::now(); + let (prev_seq, prev_hash): (i64, Option>) = match head { + Some(row) => ( + row.get::("seq"), + Some(row.get::, _>("hash")), + ), + None => (0, None), // community's first entry + }; + let seq = prev_seq + 1; - let channel_id_bytes: Option> = entry.channel_id.map(|u| u.as_bytes().to_vec()); + let created_at: DateTime = Utc::now(); let mut audit_entry = AuditEntry { + community_id, seq, - timestamp, - event_id: entry.event_id, - event_kind: entry.event_kind, - actor_pubkey: entry.actor_pubkey, - action: entry.action, - channel_id: entry.channel_id, - metadata: entry.metadata, + hash: Vec::new(), prev_hash, - hash: String::new(), + action: entry.action, + actor_pubkey: entry.actor_pubkey, + object_id: entry.object_id, + detail: entry.detail, + created_at, }; - audit_entry.hash = compute_hash(&audit_entry)?; + audit_entry.hash = compute_hash(&audit_entry)?.to_vec(); - debug!(seq, hash = %audit_entry.hash, "writing audit entry"); + debug!(seq, "writing audit entry"); sqlx::query( r#" INSERT INTO audit_log - (seq, timestamp, event_id, event_kind, actor_pubkey, action, - channel_id, metadata, prev_hash, hash) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) "#, ) + .bind(audit_entry.community_id) .bind(audit_entry.seq) - .bind(audit_entry.timestamp) - .bind(&audit_entry.event_id) - .bind(audit_entry.event_kind as i32) - .bind(&audit_entry.actor_pubkey) - .bind(audit_entry.action.as_str()) - .bind(channel_id_bytes) - .bind(&audit_entry.metadata) - .bind(&audit_entry.prev_hash) .bind(&audit_entry.hash) + .bind(audit_entry.prev_hash.as_deref()) + .bind(audit_entry.action.as_str()) + .bind(audit_entry.actor_pubkey.as_deref()) + .bind(audit_entry.object_id.as_deref()) + .bind(&audit_entry.detail) + .bind(audit_entry.created_at) .execute(&mut *tx) .await?; @@ -138,19 +142,28 @@ impl AuditService { Ok(audit_entry) } - /// Verify the hash chain for `[from_seq, to_seq]`. - /// Returns `Ok(false)` if range is empty, `Ok(true)` if valid. + /// Verify the hash chain for one community over `[from_seq, to_seq]`. + /// + /// Reads exactly that community's chain — it can never observe another + /// community's entries or head. Returns `Ok(false)` if the range is empty, + /// `Ok(true)` if the segment is internally consistent. #[instrument(skip(self))] - pub async fn verify_chain(&self, from_seq: i64, to_seq: i64) -> Result { + pub async fn verify_chain( + &self, + community: CommunityId, + from_seq: i64, + to_seq: i64, + ) -> Result { let rows = sqlx::query( r#" - SELECT seq, timestamp, event_id, event_kind, actor_pubkey, - action, channel_id, metadata, prev_hash, hash + SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, + object_id, detail, created_at FROM audit_log - WHERE seq BETWEEN $1 AND $2 + WHERE community_id = $1 AND seq BETWEEN $2 AND $3 ORDER BY seq ASC "#, ) + .bind(community.as_uuid()) .bind(from_seq) .bind(to_seq) .fetch_all(&self.pool) @@ -160,30 +173,21 @@ impl AuditService { return Ok(false); } - let mut expected_prev: Option = None; + let mut expected_prev: Option> = None; for row in &rows { let entry = row_to_audit_entry(row)?; - let prev_hash = entry.prev_hash.clone(); - let stored_hash = entry.hash.clone(); if let Some(ref expected) = expected_prev { - if &prev_hash != expected { - return Err(AuditError::ChainViolation { - seq: entry.seq, - expected: expected.clone(), - actual: prev_hash, - }); + // The previous entry's hash must equal this entry's prev_hash. + if entry.prev_hash.as_deref() != Some(expected.as_slice()) { + return Err(AuditError::ChainViolation { seq: entry.seq }); } } let computed = compute_hash(&entry)?; - if computed != stored_hash { - return Err(AuditError::HashMismatch { - seq: entry.seq, - stored: stored_hash, - computed, - }); + if computed.as_slice() != entry.hash.as_slice() { + return Err(AuditError::HashMismatch { seq: entry.seq }); } expected_prev = Some(entry.hash); @@ -192,23 +196,27 @@ impl AuditService { Ok(true) } - /// Returns up to `limit` entries starting at `from_seq`, ordered by sequence number. + /// Returns up to `limit` entries from one community's chain starting at + /// `from_seq`, ordered by sequence number. Scoped to `community` — never + /// returns another community's rows. #[instrument(skip(self))] pub async fn get_entries( &self, + community: CommunityId, from_seq: i64, limit: i64, ) -> Result, AuditError> { let rows = sqlx::query( r#" - SELECT seq, timestamp, event_id, event_kind, actor_pubkey, - action, channel_id, metadata, prev_hash, hash + SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, + object_id, detail, created_at FROM audit_log - WHERE seq >= $1 + WHERE community_id = $1 AND seq >= $2 ORDER BY seq ASC - LIMIT $2 + LIMIT $3 "#, ) + .bind(community.as_uuid()) .bind(from_seq) .bind(limit) .fetch_all(&self.pool) @@ -219,34 +227,22 @@ impl AuditService { } fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result { - let seq: i64 = row.get("seq"); let action_str: String = row.get("action"); let action: AuditAction = action_str.parse().map_err(|_| { - warn!(seq, action = %action_str, "unknown action in audit log"); - AuditError::UnknownAction(action_str.clone()) - })?; - - let channel_id_bytes: Option> = row.get("channel_id"); - let channel_id = channel_id_bytes.and_then(|b| b.try_into().ok().map(uuid::Uuid::from_bytes)); - - let raw_kind: i32 = row.get("event_kind"); - let event_kind = u32::try_from(raw_kind).map_err(|_| { - AuditError::Database(sqlx::Error::Protocol(format!( - "event_kind {raw_kind} out of u32 range at seq {seq}" - ))) + warn!("unknown action in audit log"); + AuditError::UnknownAction })?; Ok(AuditEntry { - seq, - timestamp: row.get("timestamp"), - event_id: row.get("event_id"), - event_kind, - actor_pubkey: row.get("actor_pubkey"), - action, - channel_id, - metadata: row.get("metadata"), - prev_hash: row.get("prev_hash"), + community_id: row.get::("community_id"), + seq: row.get("seq"), hash: row.get("hash"), + prev_hash: row.get("prev_hash"), + action, + actor_pubkey: row.get("actor_pubkey"), + object_id: row.get("object_id"), + detail: row.get("detail"), + created_at: row.get("created_at"), }) } @@ -255,10 +251,12 @@ mod tests { use super::*; use crate::action::AuditAction; use crate::entry::NewAuditEntry; - use crate::hash::GENESIS_HASH; use std::sync::OnceLock; use tokio::sync::Mutex; + use uuid::Uuid; + // The per-community advisory lock means different communities don't contend, + // but tests share one table; serialize them so seq assertions are stable. static DB_LOCK: OnceLock> = OnceLock::new(); fn db_lock() -> &'static Mutex<()> { DB_LOCK.get_or_init(|| Mutex::new(())) @@ -270,122 +268,237 @@ mod tests { PgPool::connect(&url).await.ok() } - fn sample_new_entry(kind: u32, action: AuditAction) -> NewAuditEntry { + /// A `community_id` known to exist in `communities` (FK target). Inserts a + /// throwaway community row with a unique host and returns its id. + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("test-{id}.example"); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry { NewAuditEntry { - event_id: format!("evt_{}", uuid::Uuid::new_v4()), - event_kind: kind, - actor_pubkey: "deadbeefdeadbeef".into(), + community_id: CommunityId::from_uuid(community_id), action, - channel_id: None, - metadata: serde_json::json!({"test": true}), + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some(format!("obj_{}", Uuid::new_v4())), + detail: serde_json::json!({"test": true}), } } - async fn reset_audit_table(pool: &PgPool) { - sqlx::query("TRUNCATE TABLE audit_log") - .execute(pool) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn community_chain_starts_at_seq_1_with_null_prev() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + + let e = svc + .log(new_entry(c, AuditAction::EventCreated)) .await .unwrap(); + assert_eq!(e.seq, 1, "first entry in a community starts at seq 1"); + assert!(e.prev_hash.is_none(), "genesis entry has NULL prev_hash"); + assert_eq!(e.hash.len(), 32); + assert_eq!(e.community_id, c); } #[tokio::test] #[ignore = "requires Postgres"] - async fn genesis_entry() { - let _guard = db_lock().lock().await; + async fn chain_links_within_one_community() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); - svc.ensure_schema().await.unwrap(); - reset_audit_table(&pool).await; + let c = make_community(&pool).await; - let entry = svc - .log(sample_new_entry(1, AuditAction::EventCreated)) + let e1 = svc + .log(new_entry(c, AuditAction::EventCreated)) + .await + .unwrap(); + let e2 = svc + .log(new_entry(c, AuditAction::ChannelCreated)) + .await + .unwrap(); + let e3 = svc + .log(new_entry(c, AuditAction::MemberAdded)) .await .unwrap(); - assert_eq!(entry.prev_hash, GENESIS_HASH); - assert_eq!(entry.seq, 1); - assert_eq!(entry.hash.len(), 64); + assert_eq!(e1.seq, 1); + assert_eq!(e2.seq, 2); + assert_eq!(e3.seq, 3); + assert!(e1.prev_hash.is_none()); + assert_eq!(e2.prev_hash.as_deref(), Some(e1.hash.as_slice())); + assert_eq!(e3.prev_hash.as_deref(), Some(e2.hash.as_slice())); + assert!(svc + .verify_chain(CommunityId::from_uuid(c), 1, 3) + .await + .unwrap()); } + /// THE isolation property: two communities keep independent chains. Each + /// starts at seq 1; interleaving writes does not link them; verifying one + /// never traverses the other. #[tokio::test] #[ignore = "requires Postgres"] - async fn chain_integrity() { - let _guard = db_lock().lock().await; + async fn chains_are_independent_per_community() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); - svc.ensure_schema().await.unwrap(); - reset_audit_table(&pool).await; + let a = make_community(&pool).await; + let b = make_community(&pool).await; - let e1 = svc - .log(sample_new_entry(1, AuditAction::EventCreated)) + // Interleave A and B writes. + let a1 = svc + .log(new_entry(a, AuditAction::EventCreated)) .await .unwrap(); - let e2 = svc - .log(sample_new_entry(1, AuditAction::ChannelCreated)) + let b1 = svc + .log(new_entry(b, AuditAction::EventCreated)) .await .unwrap(); - let e3 = svc - .log(sample_new_entry(1, AuditAction::MemberAdded)) + let a2 = svc + .log(new_entry(a, AuditAction::ChannelCreated)) + .await + .unwrap(); + let b2 = svc + .log(new_entry(b, AuditAction::ChannelCreated)) .await .unwrap(); - assert_eq!(e1.prev_hash, GENESIS_HASH); - assert_eq!(e2.prev_hash, e1.hash); - assert_eq!(e3.prev_hash, e2.hash); + // Each community's seq is independent and starts at 1. + assert_eq!((a1.seq, a2.seq), (1, 2)); + assert_eq!((b1.seq, b2.seq), (1, 2)); + + // A's chain links only within A; B's only within B. A2 must NOT chain to + // B1 even though B1 was written between A1 and A2. + assert_eq!(a2.prev_hash.as_deref(), Some(a1.hash.as_slice())); + assert_eq!(b2.prev_hash.as_deref(), Some(b1.hash.as_slice())); + assert_ne!(a2.prev_hash, b1.prev_hash); + + // Verifying A's chain traverses only A; same for B. + assert!(svc + .verify_chain(CommunityId::from_uuid(a), 1, 2) + .await + .unwrap()); + assert!(svc + .verify_chain(CommunityId::from_uuid(b), 1, 2) + .await + .unwrap()); - assert!(svc.verify_chain(e1.seq, e3.seq).await.unwrap()); + // get_entries scoped to A returns only A's rows. + let a_rows = svc + .get_entries(CommunityId::from_uuid(a), 1, 100) + .await + .unwrap(); + assert!( + a_rows.iter().all(|e| e.community_id == a), + "A read leaked another community" + ); + assert_eq!(a_rows.len(), 2); } #[tokio::test] #[ignore = "requires Postgres"] - async fn verify_chain_detects_tampering() { - let _guard = db_lock().lock().await; + async fn verify_detects_tampering_within_a_community() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); - svc.ensure_schema().await.unwrap(); - reset_audit_table(&pool).await; + let c = make_community(&pool).await; - let e1 = svc - .log(sample_new_entry(1, AuditAction::EventCreated)) + svc.log(new_entry(c, AuditAction::EventCreated)) .await .unwrap(); let e2 = svc - .log(sample_new_entry(1, AuditAction::EventDeleted)) + .log(new_entry(c, AuditAction::EventDeleted)) .await .unwrap(); - let e3 = svc - .log(sample_new_entry(1, AuditAction::ChannelDeleted)) + svc.log(new_entry(c, AuditAction::ChannelDeleted)) .await .unwrap(); - sqlx::query("UPDATE audit_log SET actor_pubkey = 'tampered' WHERE seq = $1") + // Tamper with e2's stored actor_pubkey. + let tampered: Vec = vec![0xff; 32]; + sqlx::query("UPDATE audit_log SET actor_pubkey = $1 WHERE community_id = $2 AND seq = $3") + .bind(tampered) + .bind(c) .bind(e2.seq) .execute(&pool) .await .unwrap(); - let result = svc.verify_chain(e1.seq, e3.seq).await; - assert!(matches!(result, Err(AuditError::HashMismatch { seq, .. }) if seq == e2.seq)); + let r = svc.verify_chain(CommunityId::from_uuid(c), 1, 3).await; + assert!(matches!(r, Err(AuditError::HashMismatch { seq }) if seq == e2.seq)); } + /// A row forged with another community's id cannot pass verification against + /// the chain it was stamped for, because community_id is hashed in. (Models + /// "a row can't be replayed across chains and still verify".) #[tokio::test] #[ignore = "requires Postgres"] - async fn auth_events_rejected() { + async fn cross_community_row_does_not_verify() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); + let a = make_community(&pool).await; + let b = make_community(&pool).await; - let result = svc - .log(sample_new_entry(KIND_AUTH, AuditAction::AuthSuccess)) - .await; + let a1 = svc + .log(new_entry(a, AuditAction::EventCreated)) + .await + .unwrap(); + + // Forge: copy A's seq-1 row's hash into B's chain at seq 1. + sqlx::query( + "INSERT INTO audit_log (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at) + VALUES ($1, 1, $2, NULL, $3, $4, $5, $6, NOW())", + ) + .bind(b) + .bind(&a1.hash) // A's hash, which was computed over community_id = A + .bind(a1.action.as_str()) + .bind(a1.actor_pubkey.as_deref()) + .bind(a1.object_id.as_deref()) + .bind(&a1.detail) + .execute(&pool) + .await + .unwrap(); + + // Verifying B's chain recomputes the hash with community_id = B, which + // won't match A's stored hash → HashMismatch. The forge is rejected. + let r = svc.verify_chain(CommunityId::from_uuid(b), 1, 1).await; + assert!(matches!(r, Err(AuditError::HashMismatch { seq: 1 }))); + } - assert!(matches!(result, Err(AuditError::AuthEventForbidden))); + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verify_empty_range_is_false() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + // No entries for this fresh community. + assert!(!svc + .verify_chain(CommunityId::from_uuid(c), 1, 100) + .await + .unwrap()); } } diff --git a/crates/buzz-auth/src/access.rs b/crates/buzz-auth/src/access.rs index 1fc1a1ca8..392f6c0e8 100644 --- a/crates/buzz-auth/src/access.rs +++ b/crates/buzz-auth/src/access.rs @@ -6,6 +6,7 @@ use std::collections::HashSet; use std::future::Future; +use buzz_core::TenantContext; use nostr::PublicKey; use uuid::Uuid; @@ -17,24 +18,39 @@ use crate::scope::Scope; /// Implemented by the database layer (`buzz-db`) in production. The `buzz-auth` /// crate defines the trait so it can enforce access rules without a direct dependency /// on `buzz-db`. +/// +/// ## Tenant scoping +/// +/// Every method takes `&TenantContext`. Channel UUIDs are not globally unique under +/// multi-tenant — the frozen schema's `channels` PK is `(community_id, id)`, so the +/// same UUID can legitimately exist in two communities. A bare `WHERE id = $1` +/// implementation would be a cross-community existence oracle and could return +/// `true` for a B-community membership when the request bound community is A. +/// Implementations MUST scope every query by `ctx.community()` (S1 cross-community +/// fence at the access layer). pub trait ChannelAccessChecker: Send + Sync { - /// Return the set of channel UUIDs accessible to `pubkey`. + /// Return the set of channel UUIDs in `ctx`'s community accessible to `pubkey`. + /// + /// Channels in other communities, even with the same UUID, MUST NOT appear. fn accessible_channel_ids( &self, + ctx: &TenantContext, pubkey: &PublicKey, ) -> impl Future, AuthError>> + Send; - /// Returns `true` if `pubkey` is a member of `channel_id`. + /// Returns `true` if `pubkey` is a member of `(ctx.community, channel_id)`. /// - /// Default implementation calls [`Self::accessible_channel_ids`] and checks membership. - /// Implementations may override this with a more efficient point-lookup query. + /// Default implementation calls [`Self::accessible_channel_ids`] and checks + /// membership. Implementations may override this with a more efficient + /// scoped point-lookup query. fn can_access( &self, + ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid, ) -> impl Future> + Send { async move { - let ids = self.accessible_channel_ids(pubkey).await?; + let ids = self.accessible_channel_ids(ctx, pubkey).await?; Ok(ids.contains(&channel_id)) } } @@ -52,30 +68,32 @@ pub fn require_scope(scopes: &[Scope], required: Scope) -> Result<(), AuthError> } } -/// Verify read access: scope + membership. +/// Verify read access: scope + membership in `ctx`'s community. pub async fn check_read_access( checker: &impl ChannelAccessChecker, + ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid, scopes: &[Scope], ) -> Result<(), AuthError> { require_scope(scopes, Scope::MessagesRead)?; - if checker.can_access(pubkey, channel_id).await? { + if checker.can_access(ctx, pubkey, channel_id).await? { Ok(()) } else { Err(AuthError::ChannelAccessDenied) } } -/// Verify write access: scope + membership. +/// Verify write access: scope + membership in `ctx`'s community. pub async fn check_write_access( checker: &impl ChannelAccessChecker, + ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid, scopes: &[Scope], ) -> Result<(), AuthError> { require_scope(scopes, Scope::MessagesWrite)?; - if checker.can_access(pubkey, channel_id).await? { + if checker.can_access(ctx, pubkey, channel_id).await? { Ok(()) } else { Err(AuthError::ChannelAccessDenied) @@ -83,9 +101,12 @@ pub async fn check_write_access( } /// In-memory [`ChannelAccessChecker`] for unit tests. +/// +/// Membership is keyed on the full `(community_id, pubkey, channel_id)` tuple +/// so the mock can't accidentally model a non-tenant-scoped checker. #[cfg(any(test, feature = "test-utils"))] pub struct MockAccessChecker { - allowed: HashSet<(String, Uuid)>, + allowed: HashSet<(uuid::Uuid, String, Uuid)>, } #[cfg(any(test, feature = "test-utils"))] @@ -97,9 +118,10 @@ impl MockAccessChecker { } } - /// Grant `pubkey` access to `channel_id`. - pub fn allow(&mut self, pubkey: &PublicKey, channel_id: Uuid) { - self.allowed.insert((pubkey.to_hex(), channel_id)); + /// Grant `pubkey` access to `channel_id` inside `ctx`'s community. + pub fn allow(&mut self, ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid) { + self.allowed + .insert((*ctx.community().as_uuid(), pubkey.to_hex(), channel_id)); } } @@ -112,13 +134,18 @@ impl Default for MockAccessChecker { #[cfg(any(test, feature = "test-utils"))] impl ChannelAccessChecker for MockAccessChecker { - async fn accessible_channel_ids(&self, pubkey: &PublicKey) -> Result, AuthError> { + async fn accessible_channel_ids( + &self, + ctx: &TenantContext, + pubkey: &PublicKey, + ) -> Result, AuthError> { + let community = *ctx.community().as_uuid(); let hex = pubkey.to_hex(); Ok(self .allowed .iter() - .filter(|(pk, _)| pk == &hex) - .map(|(_, id)| *id) + .filter(|(c, pk, _)| *c == community && pk == &hex) + .map(|(_, _, id)| *id) .collect()) } } @@ -126,61 +153,99 @@ impl ChannelAccessChecker for MockAccessChecker { #[cfg(test)] mod tests { use super::*; + use buzz_core::CommunityId; use nostr::Keys; + fn fixture_ctx() -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example") + } + #[tokio::test] async fn mock_checker_allow_and_deny() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let allowed_ch = Uuid::new_v4(); let denied_ch = Uuid::new_v4(); let mut checker = MockAccessChecker::new(); - checker.allow(&pk, allowed_ch); + checker.allow(&ctx, &pk, allowed_ch); - assert!(checker.can_access(&pk, allowed_ch).await.unwrap()); - assert!(!checker.can_access(&pk, denied_ch).await.unwrap()); + assert!(checker.can_access(&ctx, &pk, allowed_ch).await.unwrap()); + assert!(!checker.can_access(&ctx, &pk, denied_ch).await.unwrap()); } #[tokio::test] async fn read_access_denied_by_scope() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let ch = Uuid::new_v4(); let mut checker = MockAccessChecker::new(); - checker.allow(&pk, ch); + checker.allow(&ctx, &pk, ch); assert!(matches!( - check_read_access(&checker, &pk, ch, &[]).await, + check_read_access(&checker, &ctx, &pk, ch, &[]).await, Err(AuthError::InsufficientScope { .. }) )); } #[tokio::test] async fn read_access_denied_by_membership() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let ch = Uuid::new_v4(); let checker = MockAccessChecker::new(); assert!(matches!( - check_read_access(&checker, &pk, ch, &[Scope::MessagesRead]).await, + check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]).await, Err(AuthError::ChannelAccessDenied) )); } #[tokio::test] async fn read_access_granted() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let ch = Uuid::new_v4(); let mut checker = MockAccessChecker::new(); - checker.allow(&pk, ch); + checker.allow(&ctx, &pk, ch); - assert!(check_read_access(&checker, &pk, ch, &[Scope::MessagesRead]) + assert!( + check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn access_does_not_cross_communities() { + // S1 fence at the access layer: same pubkey, same channel UUID, two + // communities. A grant in A MUST NOT show up under B's TenantContext. + // This bites the existence-oracle direction a bare `WHERE id=$1` + // checker would have left open. + let ctx_a = fixture_ctx(); + let ctx_b = fixture_ctx(); + let keys = Keys::generate(); + let pk = keys.public_key(); + let ch = Uuid::new_v4(); + + let mut checker = MockAccessChecker::new(); + checker.allow(&ctx_a, &pk, ch); + + assert!(checker.can_access(&ctx_a, &pk, ch).await.unwrap()); + assert!( + !checker.can_access(&ctx_b, &pk, ch).await.unwrap(), + "access in community A must NOT leak into community B for same (pubkey, channel_id)" + ); + assert!(checker + .accessible_channel_ids(&ctx_b, &pk) .await - .is_ok()); + .unwrap() + .is_empty()); } } diff --git a/crates/buzz-auth/src/error.rs b/crates/buzz-auth/src/error.rs index 3ac71b58f..7f8131bc3 100644 --- a/crates/buzz-auth/src/error.rs +++ b/crates/buzz-auth/src/error.rs @@ -30,6 +30,12 @@ pub enum AuthError { #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), + /// A NIP-98 event with the same id has already been observed within the + /// replay-prevention window. The event itself was structurally valid; the + /// rejection is on freshness, not validity. + #[error("NIP-98 replay: event id already seen within window")] + Nip98Replay, + /// The pubkey in the auth event does not match the expected identity. #[error("pubkey mismatch: event pubkey does not match authenticated identity")] PubkeyMismatch, diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index fcb39010c..4f971b0ba 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -23,6 +23,8 @@ pub mod error; pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). pub mod nip98; +/// NIP-98 replay protection — shared, community-scoped, atomic seen-set. +pub mod nip98_replay; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -32,6 +34,9 @@ pub use access::{check_read_access, check_write_access, require_scope, ChannelAc pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; +pub use nip98_replay::{ + nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, +}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; @@ -40,6 +45,8 @@ pub use scope::{parse_scopes, Scope}; #[cfg(any(test, feature = "test-utils"))] pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] +pub use nip98_replay::AlwaysFreshReplayGuard; +#[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index 277cbe27c..74ed8c265 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -134,17 +134,19 @@ pub fn verify_nip98_event( /// /// - Lowercases scheme and host (already done by the `url` crate). /// - Strips trailing slash from path. -/// - Treats `localhost` and `::1` as equivalent to `127.0.0.1`. +/// +/// **No loopback aliasing.** `localhost`, `::1`, and `127.0.0.1` are three +/// distinct hosts here. Under multi-tenant the `u`-tag host is the row-zero +/// community binding (`docs/multi-tenant-conformance.md`, NIP-98 row): if +/// `verify_nip98_event` collapses them, an event signed for `localhost` +/// would pass against a `127.0.0.1`-resolved community (or vice versa) — +/// a host-binding side door. Tests reconstruct `expected_url` from their +/// own bound host, the same shape production does. fn normalize_url(raw: &str) -> String { let mut parsed = match Url::parse(raw) { Ok(u) => u, Err(_) => return raw.to_lowercase(), }; - if let Some(host) = parsed.host_str() { - if host == "localhost" || host == "::1" { - let _ = parsed.set_host(Some("127.0.0.1")); - } - } let path = parsed.path().trim_end_matches('/').to_string(); parsed.set_path(&path); parsed.to_string() @@ -284,12 +286,32 @@ mod tests { } #[test] - fn localhost_normalized() { + fn loopback_aliases_are_distinct_hosts() { + // Under multi-tenant, the `u`-tag host is the row-zero community + // binding. An event signed for `localhost` MUST NOT pass against an + // expected URL on `127.0.0.1` (or `::1`) — collapsing the three would + // be a host-check side door. Production reconstructs `expected_url` + // from the community-bound host; tests do the same. let keys = Keys::generate(); let localhost_url = "http://localhost:3000/api/tokens"; let loopback_url = "http://127.0.0.1:3000/api/tokens"; let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None); let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None); - assert!(result.is_ok()); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "localhost u-tag must NOT match a 127.0.0.1 expected_url; got {result:?}" + ); + + // Symmetric: signed-for-127.0.0.1 against expected localhost — same answer. + let json2 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); + let result2 = verify_nip98_event(&json2, localhost_url, TEST_METHOD, None); + assert!( + matches!(result2, Err(AuthError::Nip98Invalid(_))), + "127.0.0.1 u-tag must NOT match a localhost expected_url; got {result2:?}" + ); + + // And identity still holds — same host on both sides verifies. + let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); + assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok()); } } diff --git a/crates/buzz-auth/src/nip98_replay.rs b/crates/buzz-auth/src/nip98_replay.rs new file mode 100644 index 000000000..aece4b0c7 --- /dev/null +++ b/crates/buzz-auth/src/nip98_replay.rs @@ -0,0 +1,233 @@ +//! NIP-98 replay protection — shared, community-scoped, atomic seen-set. +//! +//! NIP-98 verification ([`crate::nip98::verify_nip98_event`]) is structurally +//! complete: it checks signature, kind, timestamp window, URL, method, and +//! optional body hash. It does **not** check whether the same event id has +//! already been used — that requires shared state. With multiple relay pods +//! ("any pod, any connection" per the rewrite §4 architecture), an in-process +//! cache (moka, DashMap) does not carry the freshness proof across pods, so +//! replay protection is a §5 hard gate. +//! +//! The required shape (§5): +//! +//! - shared state (Redis), atomic set-if-absent, TTL ≥ 120s +//! - community-scoped key — see [`nip98_replay_key`] +//! +//! ## Usage shape +//! +//! Verify first, then mark. Burning a seen-set slot on a forgery would let an +//! attacker who knows a future event id of a victim DoS the legitimate event. +//! +//! ```ignore +//! let pubkey = buzz_auth::verify_nip98_event(json, url, method, body)?; +//! if !replay.try_mark(&ctx, &event_id, buzz_auth::DEFAULT_REPLAY_TTL_SECS).await? { +//! return Err(AuthError::Nip98Replay); +//! } +//! // safe to honor the request as `pubkey` +//! ``` +//! +//! The TTL must cover the verifier's clock-skew tolerance (currently ±60s, so +//! the window over which a duplicate event id is even plausible is 2×60 = 120s). +//! [`DEFAULT_REPLAY_TTL_SECS`] is the floor; deployments may raise it. + +use std::{future::Future, pin::Pin}; + +use buzz_core::TenantContext; +use nostr::EventId; + +use crate::error::AuthError; + +/// Floor for the replay-prevention window, in seconds. +/// +/// Matches the §5 gate ("TTL ≥ 120s") and the doubled NIP-98 timestamp +/// tolerance (±60s window → 120s span). Implementations MAY use a larger TTL +/// for safety margin; they MUST NOT use a smaller one. +pub const DEFAULT_REPLAY_TTL_SECS: u64 = 120; + +/// Ceiling for the replay-prevention window, in seconds. +/// +/// Any TTL beyond an hour is implausible for NIP-98 replay protection: the +/// verifier only accepts events within ±60s, so a same-id replay is only +/// physically possible inside that window plus clock skew. A 1-hour cap is +/// 30× the natural maximum and still keeps Redis values well inside +/// `i64::MAX` seconds (which Redis `EX` requires). Anything larger reaching +/// this code is a config/caller bug; implementations MUST clamp down to it +/// rather than admit values that risk Redis `EX` parse failures or +/// pathologically long-lived seen-set entries. +pub const MAX_REPLAY_TTL_SECS: u64 = 3600; + +/// Shared seen-set for NIP-98 event ids, scoped per community. +/// +/// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`). +/// A test impl is provided behind `cfg(any(test, feature = "test-utils"))`. +pub trait Nip98ReplayGuard: Send + Sync { + /// Atomically claim `event_id` for `ctx`'s community. + /// + /// Returns `Ok(true)` when the id is newly inserted (proceed) and + /// `Ok(false)` when an entry already exists (the caller MUST reject the + /// request as replay). + /// + /// On `Err` (Redis unreachable, etc.) callers MUST fail closed — reject + /// the request rather than admitting it. The shared seen-set is a + /// correctness fence; degrading to "best effort, allow on error" forfeits + /// the freshness proof. + /// + /// Implementations MUST use an atomic set-if-absent operation; a + /// read-then-write sequence loses to concurrent inserts and forfeits the + /// freshness proof. + /// + /// `ttl_secs` MUST be at least [`DEFAULT_REPLAY_TTL_SECS`]. Implementations + /// MAY clamp a smaller value up to the floor rather than reject; they MUST + /// NOT honor it as-given. + /// + /// `ttl_secs` MUST be clamped down to [`MAX_REPLAY_TTL_SECS`] if larger. + /// The replay window's natural maximum is the verifier's ±60s tolerance; + /// values past an hour are implausible and risk Redis `EX` parse failures + /// (Redis interprets `EX` as a signed 64-bit integer). + fn try_mark<'a>( + &'a self, + ctx: &'a TenantContext, + event_id: &'a EventId, + ttl_secs: u64, + ) -> Pin> + Send + 'a>>; +} + +/// Redis key for a NIP-98 replay marker: +/// `buzz:{community}:nip98:{event_id_hex}`. +/// +/// The community prefix is the S1 isolation fence at the replay layer. +/// Event ids are content-addressed (SHA-256 of the canonical event tuple) so +/// natural cross-community collision is zero, but the gate is fail-closed +/// isolation: a same-id replay across communities must consult two distinct +/// seen-set rows, not one shared row. +pub fn nip98_replay_key(ctx: &TenantContext, event_id: &EventId) -> String { + format!("buzz:{}:nip98:{}", ctx.community(), event_id.to_hex()) +} + +/// Always-fresh seen-set for unit tests — every `try_mark` returns `Ok(true)`. +/// +/// Use only in test code that does not exercise the replay path itself. +#[cfg(any(test, feature = "test-utils"))] +pub struct AlwaysFreshReplayGuard; + +#[cfg(any(test, feature = "test-utils"))] +impl Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark<'a>( + &'a self, + _ctx: &'a TenantContext, + _event_id: &'a EventId, + _ttl_secs: u64, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(true) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::CommunityId; + use nostr::{EventBuilder, Keys, Kind}; + use sha2::{Digest, Sha256}; + use uuid::Uuid; + + fn fixture_ctx(host: &str) -> TenantContext { + let bytes = Sha256::digest(host.as_bytes()); + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes)); + TenantContext::resolved(id, host) + } + + fn fixture_event_id() -> EventId { + let keys = Keys::generate(); + EventBuilder::new(Kind::HttpAuth, "") + .sign_with_keys(&keys) + .expect("sign") + .id + } + + #[test] + fn key_includes_community_prefix() { + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + let key = nip98_replay_key(&ctx, &eid); + let expected_prefix = format!("buzz:{}:nip98:", ctx.community()); + assert!( + key.starts_with(&expected_prefix), + "key {key} should start with {expected_prefix}" + ); + assert!(key.ends_with(&eid.to_hex())); + } + + #[test] + fn key_isolates_communities_for_same_event_id() { + // Belt-and-suspenders: even if a same-id event surfaces in two + // communities (which content-addressing makes implausible), the + // seen-set MUST consult two distinct rows. + let eid = fixture_event_id(); + let ctx_a = fixture_ctx("relay-a.example"); + let ctx_b = fixture_ctx("relay-b.example"); + let key_a = nip98_replay_key(&ctx_a, &eid); + let key_b = nip98_replay_key(&ctx_b, &eid); + assert_ne!( + key_a, key_b, + "same event id in two communities must not share a seen-set key" + ); + } + + #[test] + fn key_components_are_lowercase() { + // Stability/idempotence: if event id hex or community Display ever + // started emitting uppercase, a same logical claim would produce two + // distinct Redis rows → the seen-set would no longer be a seen-set. + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + let key = nip98_replay_key(&ctx, &eid); + for c in key.chars() { + assert!( + !c.is_ascii_uppercase(), + "nip98 replay key {key} must be all-lowercase ASCII" + ); + } + } + + #[test] + fn default_ttl_meets_gate_floor() { + // §5 gate: TTL ≥ 120s. Drift this constant down and the gate breaks. + // Const-drift tripwire: the assertion is intentionally over a constant. + #[allow(clippy::assertions_on_constants)] + { + assert!(DEFAULT_REPLAY_TTL_SECS >= 120); + } + } + + #[test] + fn ttl_floor_below_ceiling() { + // Sanity: any caller's clamped TTL must end up in [DEFAULT, MAX]. + // If these ever cross, the impl can't satisfy both bounds and the + // contract is broken. + // Const-drift tripwire: the assertion is intentionally over a constant. + #[allow(clippy::assertions_on_constants)] + { + assert!(DEFAULT_REPLAY_TTL_SECS < MAX_REPLAY_TTL_SECS); + } + } + + #[test] + fn max_ttl_fits_in_redis_signed_ex() { + // Redis `EX` is parsed as i64. `MAX_REPLAY_TTL_SECS` must fit so the + // clamp itself can't push us into a Redis-side parse failure. + assert!(MAX_REPLAY_TTL_SECS <= i64::MAX as u64); + } + + #[tokio::test] + async fn always_fresh_returns_true() { + let guard = AlwaysFreshReplayGuard; + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + assert!(guard + .try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .unwrap()); + } +} diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index a77da4fa7..8fd42c50f 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -8,6 +8,7 @@ use std::net::IpAddr; +use buzz_core::TenantContext; use nostr::PublicKey; use serde::{Deserialize, Serialize}; @@ -147,14 +148,32 @@ impl Default for RateLimitConfig { /// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`. /// A no-op `AlwaysAllowRateLimiter` is provided for unit tests. /// +/// ## Tenant scoping +/// +/// Pubkey-keyed limits ([`check_and_increment`]) take `&TenantContext` and the Redis +/// key is community-prefixed (`buzz:{community}:ratelimit:{pubkey}:{suffix}`). The +/// same pubkey active in two communities consumes two independent quotas — that is +/// the correct behavior under multi-tenant isolation (S1 cross-community fence). +/// +/// IP-keyed limits ([`check_ip_connection`]) are **operator-global** by design. They +/// gate connection acceptance at the network edge, before host→community resolution +/// has completed (or, on resolve failure, instead of it). Threading `&TenantContext` +/// through the connection-rate fence would invert the order of operations. If +/// per-(community, IP) caps are ever needed as a tenant-fairness signal, that +/// belongs in an additive `LimitType` keyed on `(community, ip)`, not in this trait. +/// /// ⚠️ The fixed-window algorithm used by the Redis implementation allows up to 2× /// burst at window boundaries. Upgrade to a sliding window or token bucket if strict /// per-second limiting is required. pub trait RateLimiter: Send + Sync { - /// Increment the counter for `pubkey` + `limit_type` and return whether the - /// request is within the configured `limit` for the given `window_secs`. + /// Increment the per-(community, pubkey) counter for `limit_type` and return + /// whether the request is within `limit` for the given `window_secs`. + /// + /// `ctx` scopes the counter to the resolved community; the same pubkey in two + /// communities is two independent quotas. fn check_and_increment( &self, + ctx: &TenantContext, pubkey: &PublicKey, limit_type: LimitType, window_secs: u64, @@ -162,7 +181,10 @@ pub trait RateLimiter: Send + Sync { ) -> impl std::future::Future> + Send; /// Increment the per-IP connection counter and return whether the connection - /// is within the configured `limit` for the given `window_secs`. + /// is within `limit` for the given `window_secs`. + /// + /// Operator-global — see trait docs. This fence runs before / outside of host + /// resolution and intentionally does not take a `TenantContext`. fn check_ip_connection( &self, ip: &IpAddr, @@ -171,16 +193,23 @@ pub trait RateLimiter: Send + Sync { ) -> impl std::future::Future> + Send; } -/// Redis key for pubkey-based rate limit: `buzz:ratelimit::` -pub fn rate_limit_key(pubkey: &PublicKey, limit_type: &LimitType) -> String { +/// Redis key for pubkey-based rate limit: +/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. +/// +/// Community-prefixed: the same pubkey in two communities maps to two distinct +/// keys, so quotas don't bleed across the tenancy fence. +pub fn rate_limit_key(ctx: &TenantContext, pubkey: &PublicKey, limit_type: &LimitType) -> String { format!( - "buzz:ratelimit:{}:{}", + "buzz:{}:ratelimit:{}:{}", + ctx.community(), pubkey.to_hex(), limit_type.key_suffix() ) } -/// Redis key for IP-based rate limit: `buzz:ratelimit:ip::conn` +/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{ip}:conn`. +/// +/// Operator-global by design — see [`RateLimiter`] docs. pub fn ip_rate_limit_key(ip: &IpAddr) -> String { format!("buzz:ratelimit:ip:{}:conn", ip) } @@ -193,6 +222,7 @@ pub struct AlwaysAllowRateLimiter; impl RateLimiter for AlwaysAllowRateLimiter { async fn check_and_increment( &self, + _ctx: &TenantContext, _pubkey: &PublicKey, _limit_type: LimitType, window_secs: u64, @@ -214,19 +244,70 @@ impl RateLimiter for AlwaysAllowRateLimiter { #[cfg(test)] mod tests { use super::*; + use buzz_core::CommunityId; use nostr::Keys; + use sha2::Digest; use std::net::Ipv4Addr; + use uuid::Uuid; + + fn fixture_ctx(host: &str) -> TenantContext { + // Deterministic community id from host so test assertions can name the prefix. + let bytes = sha2::Sha256::digest(host.as_bytes()); + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes)); + TenantContext::resolved(id, host) + } #[test] - fn rate_limit_key_format() { + fn rate_limit_key_includes_community_prefix() { + let ctx = fixture_ctx("relay-a.example"); let keys = Keys::generate(); - let key = rate_limit_key(&keys.public_key(), &LimitType::Messages); - assert!(key.starts_with("buzz:ratelimit:")); + let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages); + let expected_prefix = format!("buzz:{}:ratelimit:", ctx.community()); + assert!( + key.starts_with(&expected_prefix), + "key {key} should start with {expected_prefix}" + ); assert!(key.ends_with(":msg")); } + #[test] + fn rate_limit_key_isolates_communities_for_same_pubkey() { + // The S1 cross-community isolation fence at the rate-limit key layer: + // same pubkey, two communities -> two distinct Redis keys -> independent quotas. + let keys = Keys::generate(); + let ctx_a = fixture_ctx("relay-a.example"); + let ctx_b = fixture_ctx("relay-b.example"); + let key_a = rate_limit_key(&ctx_a, &keys.public_key(), &LimitType::Messages); + let key_b = rate_limit_key(&ctx_b, &keys.public_key(), &LimitType::Messages); + assert_ne!( + key_a, key_b, + "same pubkey in two communities must not share a rate-limit key" + ); + } + + #[test] + fn rate_limit_key_components_are_lowercase() { + // Stability/idempotence invariant: if pubkey hex or community Display + // ever started emitting uppercase, the same (community, pubkey) would + // produce two distinct Redis keys → effective 2× quota. Pin the + // lowercase property here so the regression surfaces in unit tests, + // not in production traffic. + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages); + for c in key.chars() { + assert!( + !c.is_ascii_uppercase(), + "rate-limit key {key} must be all-lowercase ASCII" + ); + } + } + #[test] fn ip_rate_limit_key_format() { + // IP fence stays operator-global — no community in the key. let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn"); } @@ -234,9 +315,10 @@ mod tests { #[tokio::test] async fn always_allow_limiter() { let limiter = AlwaysAllowRateLimiter; + let ctx = fixture_ctx("relay-a.example"); let keys = Keys::generate(); let result = limiter - .check_and_increment(&keys.public_key(), LimitType::Messages, 60, 60) + .check_and_increment(&ctx, &keys.public_key(), LimitType::Messages, 60, 60) .await .unwrap(); assert!(result.allowed); diff --git a/crates/buzz-auth/src/scope.rs b/crates/buzz-auth/src/scope.rs index 2bd92a924..1a78c904d 100644 --- a/crates/buzz-auth/src/scope.rs +++ b/crates/buzz-auth/src/scope.rs @@ -54,8 +54,6 @@ pub enum Scope { /// enforced by git HTTP push routes (which use NIP-98 + owner check). /// Full enforcement deferred to v2 collaborator model. ReposWrite, - /// Submit events on behalf of other pubkeys (proxy service accounts only). - ProxySubmit, /// A scope string not recognised by this version of the relay. /// /// Preserved as-is to allow forward-compatibility with future scope additions. @@ -131,7 +129,6 @@ impl Scope { Self::FilesWrite => "files:write", Self::ReposRead => "repos:read", Self::ReposWrite => "repos:write", - Self::ProxySubmit => "proxy:submit", Self::Unknown(s) => s.as_str(), } } @@ -164,7 +161,6 @@ impl FromStr for Scope { "files:write" => Self::FilesWrite, "repos:read" => Self::ReposRead, "repos:write" => Self::ReposWrite, - "proxy:submit" => Self::ProxySubmit, other => Self::Unknown(other.to_string()), }) } @@ -236,7 +232,7 @@ mod tests { } #[test] - fn all_known_returns_all_14_variants() { + fn all_known_returns_all_known_variants() { let all = Scope::all_known(); assert_eq!(all.len(), 16, "expected 16 known scope variants"); // Verify no duplicates diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index c18b4673c..1b0bb3faf 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -14,7 +14,6 @@ Docker services running and healthy: docker compose ps # buzz-postgres healthy # buzz-redis healthy -# buzz-typesense healthy ``` If not running: `just setup` from the repo root. diff --git a/crates/buzz-conformance/Cargo.toml b/crates/buzz-conformance/Cargo.toml new file mode 100644 index 000000000..2d5f9a1f5 --- /dev/null +++ b/crates/buzz-conformance/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "buzz-conformance" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Runtime trace schema + independent replay checker for MultiTenantRelay.tla" + +# Independence rule (skill: skill-runtime-formal-compliance): +# - Depend on NO production buzz crate. The schema carries its own opaque +# `CommunityLabel` UUID newtype rather than reusing `buzz_core::CommunityId` +# so the checker cannot inherit a bug from production type machinery, AND so +# buzz-core's deliberate "no Serde, no From" fence on `CommunityId` +# (the no-parse-from-client rule) is preserved. +# - The relay's emitter module converts at the seam by calling +# `tenant.community().as_uuid()` and wrapping into a `CommunityLabel`. +# - NEVER depend on buzz-db, buzz-relay, buzz-pubsub, buzz-auth, buzz-search, +# buzz-audit, or anything that touches the production reducer / authorization +# / projection helpers. The checker re-implements the spec transition +# relation from scratch so a bug in the production code does not mechanically +# become a bug in the checker. + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +# Independence rule still holds for dev-deps: proptest is a test-only +# generator harness, not a production crate, and the property tests call +# only the crate's public `check_trace` API — never the production reducer +# and never `transitions::check_step` as a parallel oracle. +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/buzz-conformance/LIMITS.md b/crates/buzz-conformance/LIMITS.md new file mode 100644 index 000000000..37f0f28d3 --- /dev/null +++ b/crates/buzz-conformance/LIMITS.md @@ -0,0 +1,125 @@ +# Limits of the runtime conformance gate + +The runtime conformance harness is **not a proof.** It says only this: +*for the executions that actually ran with tracing on*, the relay's +ingest/read decisions matched a trace the spec accepts. Coverage is +exactly the set of code paths exercised — no more, no less. + +This file says what the gate **doesn't** catch, so reviewers and +operators don't read more into a green run than is there. + +## Scope + +The harness is wired only at the **ingest/auth/read accept-reject +boundary** in `crates/buzz-relay/src/handlers/{ingest,req,event}.rs`. +That boundary was chosen because: + +1. It is where tenant-derived decisions become observable behavior. +2. The spec's `Next` relation is written in those terms. +3. Every other layer (DB filter SQL, Redis pubsub, S3 metadata) is + downstream of a decision made here. + +Decisions made elsewhere — for example, a buggy SQL `WHERE` clause that +silently returns cross-community rows — surface here only if the +projection reads enough of the row to notice. See §"What it does NOT +catch" below. + +## Coverage is execution coverage + +The gate validates traces from executions you ran. If an unsafe code +path never executes during a CI run, the gate is silent about it. This +is why coverage breach is load-bearing: an entry to a critical seam +that doesn't emit *any* action records `ImplBug`, which fails closed. + +But coverage breach can only fire on **paths the harness was armed +on**. If a new endpoint is added that bypasses `EmitGuard::arm`, the +gate is blind. New endpoints touching the tenant boundary MUST arm a +guard at entry — that's enforced by code review, not by the harness. + +## What it does NOT catch + +- **DB layer leaks the projection doesn't read.** The projection for + `read_message_rows` and `read_by_id_rows` records a `row_community` + per returned row. How the emitter computes that label is the design + question for the held-back req.rs patch — the honest options are + per-row channel→community lookup, or recording the resolved community + uniformly (which makes the gate decorative for read confinement). + The choice is Eva's review call before fixtures land. Until then, + the read-seam half of the gate is **not yet armed**. + +- **Cross-pod leaks.** The harness traces one process. A multi-pod + leak (NIP-98 replay across pods, fanout to the wrong pod) shows up + here only on the pod that observes the leak. Cross-pod attacks are + Sami's adversarial lane, not the conformance gate's. + +- **Time-bounded properties.** The spec is untimed; the gate is + untimed. A bug that only shows up under high concurrency or specific + ordering is in scope for perf/red-team, not for trace conformance + (unless it surfaces as an `Inv_NonInterference` violation in the + trace, which is the only thing the gate watches for). + +- **Pubsub fan-out.** Fan-out is **not** a spec action (see the + docstring in `event.rs`). A leak in fan-out shows up in the + **receiver's** ingest/read trace, not in the publisher's emit. + +- **Type-level fence violations.** `CommunityId` having no `From` + is enforced by the Rust compiler, not by this gate. If somebody adds + `From` for `CommunityId`, the production fence is broken and + this gate won't say so. + +- **Spec bugs.** The checker re-implements the spec; if the spec is + wrong, both pass. Spec correctness is the proof obligation of + `docs/spec/MultiTenantRelay.tla`, machine-checked by TLC. + +## What turning the harness off means + +`Tracer = NoopTracer` (the production default) makes every emit and +guard arm a no-op call. The relay still runs and still decides +correctly because the gate is **observation only** — it does not feed +back into the decision. Turning it off only loses observability. + +The CI command (below) constructs an in-memory tracer and asserts every +recorded trace against `check_trace`. If you bypass the CI command and +run with `NoopTracer`, you get no signal. + +## CI command + +The gate's bite is enforced by three test surfaces that MUST stay green +on every PR: + +```sh +# 1. Schema + checker unit tests (9 tests). Cover the transition rules +# directly — every `TraceAction` variant has a passing case and at +# least one mutation-class bite case. +cargo test -p buzz-conformance --lib + +# 2. Replay fixtures (5 tests). Three JSONL traces in +# crates/buzz-conformance/tests/fixtures/ are committed for reviewer +# visibility. The test reconstructs each from typed Rust, asserts +# the committed file matches byte-for-byte (so a schema-change PR +# must update the fixtures), then replays through `check_trace`: +# +# - good.jsonl → Ok(()) +# - bad_host_channel_mismatch.jsonl → IllegalTransition +# - bad_coverage_breach.jsonl → CoverageBreach +# +# To intentionally refresh fixtures after a schema bump: +# BUZZ_CONFORMANCE_UPDATE=1 cargo test -p buzz-conformance --test replay_fixtures +cargo test -p buzz-conformance --test replay_fixtures + +# 3. EmitGuard coverage-breach self-test (2 tests in +# crates/buzz-relay/src/conformance/mod.rs). Proves the Drop guard +# records `ImplBug` when no emit reaches the tracer, and stays +# silent when an emit did. The seam-name string flows through. +cargo test -p buzz-relay --lib conformance:: + +# Together: 9 + 5 + 2 = 16 tests; mutate-bite proven for the NI, +# IllegalTransition, and CoverageBreach gates. The integration replay +# (live relay → JsonlTracer → check_trace) lands with the read-seam +# patch onto Max's req.rs work. +``` + +The integration replay is the **next** ratchet — once the read-seam +emitter lands on Eva's integration branch the harness will drive the +existing e2e suite with a `JsonlTracer` per request and assert +`check_trace` for every captured trace. diff --git a/crates/buzz-conformance/TRACE_SCHEMA.md b/crates/buzz-conformance/TRACE_SCHEMA.md new file mode 100644 index 000000000..c1dca674d --- /dev/null +++ b/crates/buzz-conformance/TRACE_SCHEMA.md @@ -0,0 +1,163 @@ +# Trace Schema (`buzz-conformance`) + +Schema version: **1** (`SCHEMA_VERSION` in `src/lib.rs`). + +This document is the contract between the relay's emitter and the +independent replay checker. It is grounded in +[`docs/spec/MultiTenantRelay.tla`](../../docs/spec/MultiTenantRelay.tla) +and the runtime-formal-compliance skill. If you change the schema, this +file changes in the same commit. + +## North star + +> Don't ask "did the model pass." Ask "did the running code emit a trace +> the model accepts." + +The relay emits one `TraceStep` per decision at the ingest/auth/read +seam. The checker replays the trace against a Rust re-implementation of +the spec's `Next` relation — it does **not** call any production +reducer. + +## What a step looks like + +```jsonc +{ + "schema": 1, + "action": { /* TraceAction — see below */ }, + "state": { + "resolved_community": "", // from TenantContext::community() + "bound_host": "", // from TenantContext::host() + "actor": "<16 hex>" // first 16 hex of authed pubkey + } +} +``` + +`state` is *projected* state, not raw state. Concretely: + +| Field | What it carries | What it does NOT carry | +|-------|-----------------|------------------------| +| `resolved_community` | server-resolved community UUID | client-claimed `h` tag, event id, payload | +| `bound_host` | opaque host string from the resolver | raw `Host` header bytes | +| `actor` | first 16 hex chars of the authed pubkey | private key, NIP-98 token, signature | + +The `actor` prefix is a *hash already* from the client's POV (Schnorr +X-only) — so the prefix discloses nothing the relay's existing logs +don't already. This avoids dragging a hash dep into observability code. + +## Actions + +The `TraceAction` enum mirrors the spec's `Next` relation +(`MultiTenantRelay.tla:933+`). Each variant is documented with the +exact spec line it grounds in. + +### Write seam + +- **`write_insert { msg_id, channel, claimed_community }`** + spec: `WriteInsert` (line 514). A successful per-channel insert. The + row's community is `ChannelCommunity(channel)` per spec — the checker + looks it up from the model, so there is no `row_community` field on + the action. `claimed_community` is recorded so the checker can bite + when the client's `h` tag disagrees with `ChannelCommunity(channel)`. + +- **`write_insert_global { msg_id, claimed_community }`** + spec: `WriteInsertGlobal` (line 562). Channel-less write (DM, + gift-wrap, etc.). The row's community is derived from `bound_host` + via the host-community map; no `channel` field. `claimed_community` + recorded for the same reason as above. + +- **`write_duplicate { msg_id, channel, claimed_community }`** + spec: `WriteDuplicate` (line 612). The DB returned "already present"; + no row was added. No `row_community` because no row was produced. + +### Read seam + +- **`auth_check { channel, claimed_community, verdict }`** + spec: `AuthCheck` (line 794). M2/M8 target this action. The checker + enforces that `Allow` requires the channel's community == + `resolved_community` (the host-channel fence) AND the actor has scope + for that channel. + +- **`read_message_rows { channel, row_communities }`** + spec: `ReadMessageRows` (line 643). Bulk read returning candidate + rows. `row_communities` is a non-deduped `Vec` — the checker must see + every leaked label, not the set. + +- **`read_by_id_rows { channel, row_communities }`** + spec: `ReadByIdRows` (line 681). The search lane emits this for each + refetched hit. Modeling search as `read_message_rows` (candidates) + + `read_by_id_rows` per hit makes the per-hit re-auth visible to the + checker. + +- **`read_host_feed_rows { row_communities }`** + spec: `ReadHostFeedRows`. Kinds-only feed read derived from + `bound_host`. + +### Error seam + +- **`sanitized_error { reason }`** where `reason ∈ { restricted, + invalid, server_error }`. spec: `Inv_SanitizedErrors`, M6 mutation + (line 778). The alphabet is **closed**: if `IngestError` ever grows a + fourth variant, `sanitized_reason_for` (in + `crates/buzz-relay/src/conformance/mod.rs`) goes non-exhaustive and + CI catches it. + +### Coverage breach + +- **`impl_bug { kind }`** is not a spec action — it's a runtime witness + that a critical seam exited without recording any other action. The + checker treats it as a coverage breach and fails closed. Emitted by + `EmitGuard::Drop` when the seam's counting tracer saw zero emits. + +## Three projection rules that are load-bearing + +These are the places a buggy relay could emit an in-spec trace if you +normalized away the violation. The checker assumes you *did not*. + +1. **`claimed_community` is recorded separately from + `resolved_community`.** If they ever disagree, the spec says + "resolved wins"; the trace must show both so M2 (claimed-driven + auth) can bite. + +2. **`row_communities` is a `Vec`, not a `Set`, and is not filtered to + the resolved tenant.** If two rows in the result set belong to + different communities, the checker must see both labels — otherwise + it cannot fail closed on `Inv_ReadConfinement`. + +3. **`SanitizedReason` is a closed alphabet of three.** The relay's + `IngestError` variants map 1:1 onto it. A fourth variant is a CI + failure, not a silent bucket. + +## Where the emitter lives + +| File | What it emits | +|------|---------------| +| `crates/buzz-relay/src/conformance/mod.rs` | helpers + `EmitGuard` + `sanitized_reason_for` | +| `crates/buzz-relay/src/conformance/tracers.rs` | `NoopTracer` (prod default), `JsonlTracer` | +| `crates/buzz-relay/src/handlers/ingest.rs` | `AuthCheck`, `WriteInsert`, `WriteInsertGlobal`, `WriteDuplicate`, outer-wrapper `SanitizedError` | +| `crates/buzz-relay/src/handlers/req.rs` | **held back** — additive patch for integration onto Max's req.rs work | + +## Where the checker lives + +| File | What it does | +|------|--------------| +| `crates/buzz-conformance/src/lib.rs` | schema + `Tracer` trait | +| `crates/buzz-conformance/src/transitions.rs` | spec `Next` re-implementation | +| `crates/buzz-conformance/src/checker.rs` | replay engine: `IllegalTransition` / `StateMismatch` / `NonInterference` / `CoverageBreach` | + +## Failure modes — what makes the gate bite + +`check_trace` returns `Err(CheckError)` on any of: + +- **`IllegalTransition`** — the action is not permitted from the + current model state (e.g. `AuthCheck { verdict: Allow, claimed != resolved }` + — M2/M8 territory). +- **`StateMismatch`** — `state_after` disagrees with the bootstrapped + model (resolved community / bound host / actor reassigned mid-request). +- **`NonInterference`** — `row_communities` includes a label other than + `resolved_community` (`Inv_NonInterference` / `Inv_ReadConfinement`). +- **`CoverageBreach`** — an `ImplBug` step was recorded, or a + scenario-required action never appeared, or the trace was empty. + +Each failure mode has a unit test in +`crates/buzz-conformance/src/checker.rs::tests` proving the gate bites +when you'd want it to. diff --git a/crates/buzz-conformance/src/checker.rs b/crates/buzz-conformance/src/checker.rs new file mode 100644 index 000000000..ceb4028cb --- /dev/null +++ b/crates/buzz-conformance/src/checker.rs @@ -0,0 +1,337 @@ +//! Replay engine: validate a sequence of [`TraceStep`]s against the spec's +//! transition relation (re-implemented in [`crate::transitions`]). +//! +//! The checker is intentionally minimal — it walks the trace, bootstraps +//! its model on the first step, and runs [`transitions::check_step`] for +//! each subsequent step. The first failure stops the trace (fail-closed). +//! +//! The other half of the checker's job is **coverage breach**: declaring +//! up-front which critical actions a scenario MUST exercise, and failing +//! the trace if any are missing. Without this, a regression that silently +//! removed an emit site would still pass conformance — the trace would +//! just be shorter. The skill is explicit: this mode is mandatory. + +use std::collections::HashSet; + +use crate::{ + transitions::{check_step, ModelState, TransitionError}, + TraceStep, +}; + +/// A scenario the checker is validating: the recorded trace plus the set +/// of critical actions the scenario asserts must appear. +#[derive(Debug, Clone)] +pub struct Scenario { + /// Trace steps in emission order. Stamps and worker ids are NOT + /// modeled — observations are unordered in the spec, so the only + /// invariant the order enforces is "within one request, observations + /// share the same `state_after`". + pub trace: Vec, + /// Action kinds that this scenario must include at least once. If any + /// are missing the checker returns a coverage breach. + /// + /// Use [`crate::TraceAction::kind`] to get the canonical strings: + /// `"write_insert"`, `"write_insert_global"`, `"write_duplicate"`, + /// `"sanitized_error"`, `"auth_check"`, `"read_message_rows"`, + /// `"read_by_id_rows"`, `"read_host_feed_rows"`. + pub required_critical_actions: HashSet, +} + +impl Scenario { + /// Build a scenario with no required actions — used for traces where + /// the only thing being asserted is "every observation is consistent + /// with non-interference". Most ingest fixtures need explicit + /// requirements; this helper is for replays of unstructured traffic. + pub fn unstructured(trace: Vec) -> Self { + Self { + trace, + required_critical_actions: HashSet::new(), + } + } + + /// Builder helper: add a required critical action kind. Returns self + /// for chaining. + pub fn require(mut self, kind: &str) -> Self { + self.required_critical_actions.insert(kind.to_string()); + self + } +} + +/// Check one scenario. Returns `Ok(())` on conformance; returns the first +/// transition error on any failure. +/// +/// Stages: +/// 1. **Bootstrap.** Read the first step's `state_after` as the model. +/// A trace with zero steps fails as a coverage breach (the seam was +/// reached and emitted nothing). +/// 2. **Schema-version check.** Each step's `schema_version` must equal +/// [`crate::SCHEMA_VERSION`] — a divergence means the relay and the +/// checker speak different schemas. We treat that as an illegal +/// transition because no transition rule applies. +/// 3. **Per-step transition check.** [`check_step`] runs on each step. +/// 4. **Coverage check.** After all steps pass, every entry in +/// `required_critical_actions` must appear in the trace. +pub fn check_trace(scenario: &Scenario) -> Result<(), TransitionError> { + if scenario.trace.is_empty() { + return Err(TransitionError::CoverageBreach { + detail: "trace is empty — seam reached without emitting any action; \ + this is the no-trace coverage breach" + .to_string(), + }); + } + + let first = &scenario.trace[0]; + if first.schema_version != crate::SCHEMA_VERSION { + return Err(TransitionError::IllegalTransition { + step_index: 0, + detail: format!( + "trace schema_version={} but checker schema_version={}", + first.schema_version, + crate::SCHEMA_VERSION + ), + }); + } + let model = ModelState::bootstrap(&first.state_after); + + for (i, step) in scenario.trace.iter().enumerate() { + if step.schema_version != crate::SCHEMA_VERSION { + return Err(TransitionError::IllegalTransition { + step_index: i, + detail: format!( + "trace schema_version={} but checker schema_version={}", + step.schema_version, + crate::SCHEMA_VERSION + ), + }); + } + check_step(i, &model, step)?; + } + + // Coverage breach: required actions missing. + let mut seen: HashSet = HashSet::with_capacity(scenario.trace.len()); + for step in &scenario.trace { + seen.insert(step.action.kind().to_string()); + } + let missing: Vec<&String> = scenario + .required_critical_actions + .iter() + .filter(|k| !seen.contains(*k)) + .collect(); + if !missing.is_empty() { + let mut sorted: Vec<&&String> = missing.iter().collect(); + sorted.sort(); + return Err(TransitionError::CoverageBreach { + detail: format!( + "scenario required actions never emitted: {:?}", + sorted.iter().map(|s| s.as_str()).collect::>() + ), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CommunityLabel; + use crate::{ + AbstractState, ActorLabel, ChannelLabel, HostLabel, OpaqueId, SanitizedReason, TraceAction, + Verdict, + }; + use uuid::Uuid; + + fn cid(u: u128) -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128(u)) + } + + fn ch(u: u128) -> ChannelLabel { + ChannelLabel(Uuid::from_u128(u)) + } + + fn state(c: CommunityLabel) -> AbstractState { + AbstractState { + resolved_community: c, + bound_host: HostLabel("h_local".into()), + actor: ActorLabel("a_alice".into()), + } + } + + fn step(action: TraceAction, c: CommunityLabel) -> TraceStep { + TraceStep::new(action, state(c)) + } + + #[test] + fn empty_trace_is_coverage_breach() { + let sc = Scenario::unstructured(vec![]); + let err = check_trace(&sc).unwrap_err(); + assert!(matches!(err, TransitionError::CoverageBreach { .. })); + } + + #[test] + fn write_insert_then_read_with_only_resolved_rows_passes() { + let c = cid(1); + let trace = vec![ + step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(c), + verdict: Verdict::Allow, + }, + c, + ), + step( + TraceAction::WriteInsert { + msg_id: OpaqueId("m1".into()), + channel: ch(10), + claimed_community: Some(c), + }, + c, + ), + step( + TraceAction::ReadMessageRows { + channel: Some(ch(10)), + row_communities: vec![c, c], + }, + c, + ), + ]; + let sc = Scenario { + trace, + required_critical_actions: ["auth_check", "write_insert", "read_message_rows"] + .iter() + .map(|s| s.to_string()) + .collect(), + }; + check_trace(&sc).expect("trace should conform"); + } + + #[test] + fn cross_community_row_bites_non_interference() { + let c = cid(1); + let foreign = cid(2); + let trace = vec![step( + TraceAction::ReadMessageRows { + channel: Some(ch(10)), + row_communities: vec![c, foreign], + }, + c, + )]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::NonInterference { .. }), + "expected NonInterference, got {err:?}" + ); + } + + #[test] + fn auth_allow_with_foreign_claim_bites_m2() { + let c = cid(1); + let foreign = cid(2); + let trace = vec![step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(foreign), + verdict: Verdict::Allow, + }, + c, + )]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::IllegalTransition { .. }), + "expected IllegalTransition for M2 bite, got {err:?}" + ); + } + + #[test] + fn auth_deny_with_foreign_claim_is_fine() { + let c = cid(1); + let foreign = cid(2); + let trace = vec![step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(foreign), + verdict: Verdict::Deny, + }, + c, + )]; + check_trace(&Scenario::unstructured(trace)).expect("deny with foreign claim is in-spec"); + } + + #[test] + fn state_after_changing_mid_request_is_state_mismatch() { + let c1 = cid(1); + let c2 = cid(2); + let trace = vec![ + step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(c1), + verdict: Verdict::Allow, + }, + c1, + ), + step( + TraceAction::ReadMessageRows { + channel: Some(ch(10)), + row_communities: vec![c2], + }, + c2, + ), + ]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::StateMismatch { .. }), + "expected StateMismatch, got {err:?}" + ); + } + + #[test] + fn impl_bug_action_bites_coverage_breach() { + let c = cid(1); + let trace = vec![step( + TraceAction::ImplBug { + kind: "ingest_exited_without_trace".into(), + }, + c, + )]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "expected CoverageBreach from ImplBug, got {err:?}" + ); + } + + #[test] + fn required_critical_action_missing_bites_coverage_breach() { + let c = cid(1); + let trace = vec![step( + TraceAction::SanitizedError { + reason: SanitizedReason::Restricted, + }, + c, + )]; + let sc = Scenario { + trace, + required_critical_actions: ["auth_check".to_string()].into_iter().collect(), + }; + let err = check_trace(&sc).unwrap_err(); + assert!( + matches!(err, TransitionError::CoverageBreach { ref detail } if detail.contains("auth_check")), + "expected CoverageBreach naming auth_check, got {err:?}" + ); + } + + #[test] + fn sanitized_error_alone_is_well_formed() { + let c = cid(1); + for reason in [ + SanitizedReason::Restricted, + SanitizedReason::Invalid, + SanitizedReason::ServerError, + ] { + let trace = vec![step(TraceAction::SanitizedError { reason }, c)]; + check_trace(&Scenario::unstructured(trace)).expect("sanitized_error alone is in-spec"); + } + } +} diff --git a/crates/buzz-conformance/src/lib.rs b/crates/buzz-conformance/src/lib.rs new file mode 100644 index 000000000..3e1cfe13e --- /dev/null +++ b/crates/buzz-conformance/src/lib.rs @@ -0,0 +1,327 @@ +//! Runtime trace schema + independent replay checker for +//! `docs/spec/MultiTenantRelay.tla`. +//! +//! North star (from the runtime-formal-compliance skill): don't ask "did the +//! model pass"; ask "did the running code emit a trace the model accepts." +//! +//! ## What this crate is +//! +//! - The **schema** ([`TraceStep`], [`TraceAction`], [`AbstractState`]) that +//! the relay emits at its ingest/read accept-reject boundary. +//! - An **independent** replay checker ([`check_trace`]) that consumes a +//! sequence of `TraceStep`s and validates them against the TLA+ spec's +//! `Next` transition relation. The checker re-implements the relevant +//! spec actions in Rust; it does NOT call any production reducer. +//! +//! ## What this crate is NOT +//! +//! - A proof. Trace conformance only checks executions you ran. Coverage is +//! widened by integration tests, property tests, and adversarial fixtures. +//! - A re-export of production helpers. Sharing normalization helpers between +//! the emitter (which projects implementation state) and the checker (which +//! judges that projection) would let a bug in the helpers hide itself from +//! both — exactly the failure the skill calls out. +//! +//! ## Failure modes (skill §Phase 4) +//! +//! - **Illegal transition** — the traced action is not allowed from the +//! checker's current model state. +//! - **State mismatch** — `state_after.row_labels` includes a community other +//! than the resolved tenant (`Inv_NonInterference`). +//! - **Coverage breach** — an unknown critical action, a critical seam exit +//! without a trace step ([`TraceAction::ImplBug`]), or a scenario-required +//! action that never appeared. +//! +//! Coverage breach is load-bearing. Without it, trace conformance is +//! decorative logging. + +#![deny(unsafe_code)] +#![warn(missing_docs)] + +pub mod checker; +pub mod transitions; + +use serde::{Deserialize, Serialize}; + +/// Opaque community label — the underlying UUID a server-resolved +/// `TenantContext::community()` wraps, carried as a value type in the +/// trace schema. +/// +/// This deliberately does NOT reuse `buzz_core::CommunityId`. Two reasons: +/// +/// 1. **Production fence preservation.** `buzz_core::CommunityId` has no +/// `From`, no `Serialize`, no `Deserialize` — by design, so a +/// `CommunityId` cannot be conjured from client input. Adding Serde to +/// it for our convenience would punch a hole in that fence. Carrying +/// our own newtype keeps that fence intact. +/// 2. **Independence.** The checker re-implements the spec transition +/// relation; the schema sharing zero type machinery with production +/// means a buggy production type cannot launder its bug into the +/// checker mechanically. +/// +/// The relay's emitter module converts at the seam: +/// `CommunityLabel::from_uuid(*tenant.community().as_uuid())`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CommunityLabel(pub uuid::Uuid); + +impl CommunityLabel { + /// Wrap a UUID into a community label. Unlike `buzz_core::CommunityId` + /// this conversion IS public — but consumers of `CommunityLabel` are + /// the checker and test fixtures, not the relay's request path. The + /// relay only constructs `CommunityLabel` from a `TenantContext` it + /// already resolved. + pub const fn from_uuid(id: uuid::Uuid) -> Self { + Self(id) + } +} + +impl std::fmt::Display for CommunityLabel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +/// Trace schema version. Bump on any backwards-incompatible field change. +pub const SCHEMA_VERSION: u32 = 1; + +/// An opaque ID derived from an event id or other secret material. Stable, +/// no payload, no key bytes. Implementations pick a hash; the checker +/// compares strings. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OpaqueId(pub String); + +/// An opaque host label — produced by the relay from the bound `Host` header +/// via a configured registry, never the raw `Host` string. Mirrors the spec's +/// `Hosts` set abstractly. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HostLabel(pub String); + +/// An opaque channel label — the channel UUID directly. Channels are not +/// secret; the production code already exposes them in event tags. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ChannelLabel(pub uuid::Uuid); + +/// An opaque actor label — the lower 16 bytes of `blake3(pubkey)`. Stable, +/// non-reversible, secret-free. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ActorLabel(pub String); + +/// Auth verdict — the closed alphabet from `AuthCheck` (spec line 794). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + /// Authorized. + Allow, + /// Denied. The spec models a single Deny verdict; reason is not exposed + /// at the trace boundary because the spec's error alphabet is closed. + Deny, +} + +/// The sanitized error alphabet (spec `Inv_SanitizedErrors`, M6 mutation). +/// +/// Errors observed by the client must come from this closed set; raw error +/// strings are NOT projected into the trace because the spec requires error +/// observations carry no tenant-derived information. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SanitizedReason { + /// Host/channel/community fence rejected the request (relay-only kind, + /// archived channel, scope-token mismatch, etc.) — spec "restricted". + Restricted, + /// Malformed event — spec "invalid". + Invalid, + /// Server fault — spec "server_error". + ServerError, +} + +/// The abstract state mirrored from `TenantContext`: which community the +/// server resolved, which host bound that resolution. This is what +/// `Inv_NonInterference` checks observations against. +/// +/// Carries deliberately the things that reveal violations (claimed vs. +/// resolved community, opaque host) and deliberately not raw payloads, +/// pubkey bytes, signatures, or wall-clock timestamps. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AbstractState { + /// The server-resolved community for this request — the label + /// `Inv_NonInterference` validates against. Sourced **only** from + /// `TenantContext::community()`. Never from event tags, never from + /// client input, never from `event.pubkey`. + pub resolved_community: CommunityLabel, + /// The host that bound this request to that community. Sourced from + /// `TenantContext::host()` via a label registry. + pub bound_host: HostLabel, + /// The actor (authenticated pubkey) for this request, opaque-labelled. + pub actor: ActorLabel, +} + +/// One trace step emitted at the ingest/read accept-reject boundary. +/// +/// Action vocabulary (spec actions in parentheses): +/// - [`TraceAction::WriteInsert`] (spec `WriteInsert`, lines 514–550) +/// - [`TraceAction::WriteInsertGlobal`] (spec `WriteInsertGlobal`, lines 559–595) +/// - [`TraceAction::WriteDuplicate`] (spec `WriteDuplicate`, lines 606–637) +/// - [`TraceAction::SanitizedError`] (spec `SanitizedError`, line 778) +/// - [`TraceAction::AuthCheck`] (spec `AuthCheck`, line 794) — M2/M8 target +/// - [`TraceAction::ReadMessageRows`] (spec `ReadMessageRows`, line 643) +/// - [`TraceAction::ReadByIdRows`] (spec `ReadByIdRows`, line 681) +/// - [`TraceAction::ReadHostFeedRows`] (spec `ReadHostFeedRows`, line ~720) +/// - [`TraceAction::ImplBug`] — emitted by the coverage-breach guard when +/// the seam exits without a known action; the checker treats this as a +/// coverage breach. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TraceAction { + /// Channel-bearing write (spec `WriteInsert`). + WriteInsert { + /// Opaque hash of the event id. + msg_id: OpaqueId, + /// The channel the event targets — the "real" community is + /// `ChannelCommunity(channel)` per spec. + channel: ChannelLabel, + /// The community the client *claimed* via its `h` tag, if any. + /// `None` means the client did not assert one. This stays distinct + /// from `state_after.resolved_community` so M2/M8 mutations are + /// visible in the trace. + claimed_community: Option, + }, + /// Channel-less write resolved purely from the bound host + /// (spec `WriteInsertGlobal`). + WriteInsertGlobal { + /// Opaque hash of the event id. + msg_id: OpaqueId, + /// The community the client *claimed*, if any. Ignored by the + /// resolver but recorded for the audit trail. + claimed_community: Option, + }, + /// Channel-bearing duplicate / no-op write (spec `WriteDuplicate`, + /// `ON CONFLICT (community_id, id)` returning a duplicate result). + WriteDuplicate { + /// Opaque hash of the event id. + msg_id: OpaqueId, + /// The channel the duplicate hit. + channel: ChannelLabel, + /// The community the client *claimed*, if any. + claimed_community: Option, + }, + /// Sanitized error (spec `SanitizedError`). Closed-alphabet reason + /// only; no raw error string is projected. + SanitizedError { + /// One of the closed-alphabet reasons. + reason: SanitizedReason, + }, + /// Per-(channel, actor) authorization decision (spec `AuthCheck`). + /// M2 and M8 explicitly target this action — leaving it out would + /// make the gate blind to those mutations. + AuthCheck { + /// The channel the check is against. + channel: ChannelLabel, + /// The community the client claimed, if any. + claimed_community: Option, + /// The Allow/Deny verdict the implementation produced. + verdict: Verdict, + }, + /// Per-channel-or-channelless row read returning concrete rows + /// (spec `ReadMessageRows`). + ReadMessageRows { + /// Channel filter — `None` means channel-less. + channel: Option, + /// The community label of EACH row returned. NOT deduped to a Set, + /// NOT filtered to "matches resolved": the checker must see every + /// leaked label to fail closed on `Inv_ReadConfinement` / M1/M4/M7. + row_communities: Vec, + }, + /// Direct read by event id list (spec `ReadByIdRows`). The search lane + /// emits this for each refetched hit. + ReadByIdRows { + /// Channel filter — `None` means channel-less. + channel: Option, + /// Per-row community labels, same rules as `ReadMessageRows`. + row_communities: Vec, + }, + /// Kinds-only feed read (spec `ReadHostFeedRows`). The relay derives + /// the community from the bound host and fans out across that + /// community's channel-less rows plus its accessible channels. + ReadHostFeedRows { + /// Per-row community labels. + row_communities: Vec, + }, + /// Coverage-breach guard: the seam exited without a known action. The + /// checker treats this as a coverage breach and fails closed. + ImplBug { + /// A short tag identifying the missing emit site (e.g. + /// `"ingest_exited_without_trace"`). + kind: String, + }, +} + +impl TraceAction { + /// A short stable string identifying the action kind, for fixture + /// declarations and error messages. + pub fn kind(&self) -> &'static str { + match self { + TraceAction::WriteInsert { .. } => "write_insert", + TraceAction::WriteInsertGlobal { .. } => "write_insert_global", + TraceAction::WriteDuplicate { .. } => "write_duplicate", + TraceAction::SanitizedError { .. } => "sanitized_error", + TraceAction::AuthCheck { .. } => "auth_check", + TraceAction::ReadMessageRows { .. } => "read_message_rows", + TraceAction::ReadByIdRows { .. } => "read_by_id_rows", + TraceAction::ReadHostFeedRows { .. } => "read_host_feed_rows", + TraceAction::ImplBug { .. } => "impl_bug", + } + } + + /// Every action at this seam is critical: the spec requires every + /// observation to be labelled. The skill's "coverage breach" mode + /// hinges on every emit site being marked critical. + pub const fn is_critical(&self) -> bool { + true + } +} + +/// One step in the trace stream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TraceStep { + /// Schema version — bump on backwards-incompatible field changes. + pub schema_version: u32, + /// The action that occurred at the seam. + pub action: TraceAction, + /// The abstract state the implementation observed at action time. + /// The checker compares this against its independently-computed model + /// state. + pub state_after: AbstractState, +} + +impl TraceStep { + /// Build a step at the current schema version. + pub fn new(action: TraceAction, state_after: AbstractState) -> Self { + Self { + schema_version: SCHEMA_VERSION, + action, + state_after, + } + } +} + +/// The emit trait the relay calls. The trait is the *only* surface the +/// production code touches; the schema types stay value types. +pub trait Tracer: Send + Sync { + /// Record one trace step. Implementations MAY be no-ops in production + /// builds and write to JSONL in tests. + fn record(&self, step: TraceStep); +} + +/// A no-op tracer for production. Zero cost: the build can omit emission +/// entirely behind a feature, or simply discard records here. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopTracer; + +impl Tracer for NoopTracer { + fn record(&self, _step: TraceStep) {} +} diff --git a/crates/buzz-conformance/src/transitions.rs b/crates/buzz-conformance/src/transitions.rs new file mode 100644 index 000000000..cabd66e69 --- /dev/null +++ b/crates/buzz-conformance/src/transitions.rs @@ -0,0 +1,330 @@ +//! Independent translation of `docs/spec/MultiTenantRelay.tla`'s `Next` +//! transition relation into Rust. +//! +//! This module is the heart of the conformance gate. It is deliberately +//! **independent** of the production reducer: it reads only the trace +//! schema in [`crate`] and the spec text in `docs/spec/MultiTenantRelay.tla`. +//! It does not import `buzz-relay`, `buzz-db`, `buzz-auth`, or any other +//! production crate that could share a normalization bug with the emitter. +//! +//! ## What an "abstract state" means here +//! +//! The TLA+ spec models the relay as a multi-worker system whose state is +//! the set of accepted rows, projection rows, observations, etc. A runtime +//! trace covers ONE worker handling ONE request — so the model state we +//! carry is much smaller: +//! +//! - `resolved_community` — the server-resolved `TenantContext::community()` +//! for this request. `Inv_NonInterference` requires every row label +//! observed in this request be a subset of `{resolved_community}`. +//! - `bound_host` — the host label `TenantContext::host()` was bound from. +//! `AuthCheck` / channel-less reads require `HostCommunity[host]` agree +//! with the resolved community. +//! +//! The checker rebuilds this state independently from the FIRST trace step +//! it sees and then validates every subsequent step against it. +//! +//! ## Per-action obligations +//! +//! Each action has a triple of obligations distilled from the spec: +//! +//! 1. **State match.** `step.state_after.resolved_community` and +//! `bound_host` agree with the checker's running model (no mid-request +//! tenant flip). +//! 2. **Row-label confinement** (`Inv_NonInterference` line ~983, +//! `Inv_ReadConfinement` line ~1003). Every `row_communities` entry, +//! every accept label, must equal `resolved_community`. A single foreign +//! label fails the trace. +//! 3. **Action-specific guards.** AuthCheck `Allow` requires host/channel +//! agreement; channel-less reads require `HostCommunity[host] = c`; +//! `WriteInsert` claim-vs-resolved is recorded but a mismatch is +//! allowed at the abstract level — the spec ignores it ("host wins"), +//! so the gate that bites mismatches is the row-label confinement on +//! the *next* read. + +use crate::{ + AbstractState, ChannelLabel, CommunityLabel, SanitizedReason, TraceAction, TraceStep, Verdict, +}; + +/// A judgment about a single trace step. The checker walks the trace and +/// returns the first failure verdict (fail-fast); per the skill's "fail +/// closed on the first violation" guidance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict_ { + /// Reserved — internal placeholder. + Ok, +} + +/// Failure reasons returned by [`check_step`]. The string payload is +/// human-readable; mechanical consumers should match on the variant. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TransitionError { + /// The traced action is not allowed from the checker's current model + /// state — e.g. an `AuthCheck { verdict: Allow }` with `claimed != real`. + #[error("illegal transition at step {step_index}: {detail}")] + IllegalTransition { + /// 0-based index of the offending step. + step_index: usize, + /// Human-readable detail. + detail: String, + }, + + /// The trace's `state_after` does not match the model state the checker + /// computed independently. Indicates the relay either reassigned the + /// tenant context mid-request, or emitted a step from a context other + /// than `TenantContext`. + #[error("state mismatch at step {step_index}: {detail}")] + StateMismatch { + /// 0-based index of the offending step. + step_index: usize, + /// Human-readable detail. + detail: String, + }, + + /// Row labels include a community other than the resolved tenant — + /// the master `Inv_NonInterference` failure. + #[error("non-interference breach at step {step_index}: {detail}")] + NonInterference { + /// 0-based index of the offending step. + step_index: usize, + /// Human-readable detail. + detail: String, + }, + + /// A coverage breach: ImplBug action, or a fixture-declared + /// `required_critical_actions` entry never appeared. + #[error("coverage breach: {detail}")] + CoverageBreach { + /// Human-readable detail naming the missing or broken coverage rule. + detail: String, + }, +} + +/// The model state the checker carries between steps. +#[derive(Debug, Clone)] +pub struct ModelState { + /// The community the FIRST step's `state_after` told us was resolved. + /// Subsequent steps must agree. + pub resolved_community: CommunityLabel, + /// The host the FIRST step's `state_after` told us was bound. Channel- + /// bearing AuthCheck and channel-less reads enforce + /// `host_community(host) == resolved_community`. The checker does NOT + /// know `HostCommunity[_]` at large; it only knows the spec guarantees + /// `HostCommunity[bound_host] = resolved_community` whenever the relay + /// took the success branch. + pub bound_host: crate::HostLabel, + /// The actor for this request — opaque, equality-checked only. + pub actor: crate::ActorLabel, +} + +impl ModelState { + /// Bootstrap the model from the very first step. Subsequent calls to + /// [`check_step`] return a `StateMismatch` if `state_after` disagrees. + pub fn bootstrap(first: &AbstractState) -> Self { + Self { + resolved_community: first.resolved_community, + bound_host: first.bound_host.clone(), + actor: first.actor.clone(), + } + } +} + +/// Validate one step against the model. Updates nothing (the model is +/// immutable for the lifetime of a single trace); a violation returns the +/// matching [`TransitionError`]. +/// +/// Spec line numbers below refer to `docs/spec/MultiTenantRelay.tla` at the +/// snapshot pinned in this PR's `docs/spec/`. +pub fn check_step( + step_index: usize, + model: &ModelState, + step: &TraceStep, +) -> Result<(), TransitionError> { + // Universal obligation 1: state_after agrees with the bootstrapped model. + if step.state_after.resolved_community != model.resolved_community { + return Err(TransitionError::StateMismatch { + step_index, + detail: format!( + "resolved_community changed mid-request: bootstrap={:?}, step={:?}", + model.resolved_community, step.state_after.resolved_community + ), + }); + } + if step.state_after.bound_host != model.bound_host { + return Err(TransitionError::StateMismatch { + step_index, + detail: format!( + "bound_host changed mid-request: bootstrap={:?}, step={:?}", + model.bound_host, step.state_after.bound_host + ), + }); + } + if step.state_after.actor != model.actor { + return Err(TransitionError::StateMismatch { + step_index, + detail: format!( + "actor changed mid-request: bootstrap={:?}, step={:?}", + model.actor, step.state_after.actor + ), + }); + } + + // Action-specific obligations. + match &step.action { + // --- Spec WriteInsert (lines 514-550) --- + // Resolution: real == ChannelCommunity(ch). + // Success branch requires HostCommunity[host] = real, which the + // emitter guarantees by emitting from inside the success path with + // state_after.resolved_community = real and state_after.bound_host + // = the bound host. The trace records claimed_community separately + // so M2/M8 (host/channel disagreement, claim≠resolved) surface here + // as a state mismatch on the *resolved* side. + // + // What we check at this step: nothing beyond the universal state + // match. The spec ignores claimed_community ("host wins"), so a + // mismatch is allowed at this exact action — the gate that bites + // it is the next read's row labels. + TraceAction::WriteInsert { .. } => Ok(()), + + // --- Spec WriteInsertGlobal (lines 559-595) --- + // resolved == HostCommunity[host]. Same shape as WriteInsert. + TraceAction::WriteInsertGlobal { .. } => Ok(()), + + // --- Spec WriteDuplicate (lines 606-637) --- + // Carries the same host-axis obligation as WriteInsert: an A-host + // presenting a B-channel id must not learn whether the id exists. + // Same observable: state_after.resolved_community must be the + // real ChannelCommunity(ch), enforced by the universal check. + TraceAction::WriteDuplicate { .. } => Ok(()), + + // --- Spec SanitizedError (line 778) --- + // Closed-alphabet reason; labels = {}; carries no row data. The + // emitter must collapse every reject path into one of the three + // SanitizedReason variants. The schema-level type system already + // enforces that — we just check the variant is among the spec's + // closed set (trivially true by construction). + TraceAction::SanitizedError { reason } => match reason { + SanitizedReason::Restricted + | SanitizedReason::Invalid + | SanitizedReason::ServerError => Ok(()), + }, + + // --- Spec AuthCheck (lines 794-810) --- + // real == ChannelCommunity(ch). + // hostAgrees == real ∈ Communities ∧ HostCommunity[host] = real. + // allowed == hostAgrees ∧ ch ∈ ScopedAccessible(real, a). + // verdict == IF allowed THEN Allow ELSE Deny. + // + // The runtime checker cannot recompute ScopedAccessible (that's + // production state). What it CAN check: when verdict = Allow, the + // claimed_community MUST equal resolved_community. This is the + // M2 bite ("auth verdict driven by claimed instead of resolved") + // and the M8 bite ("A-host driving a B-channel verdict") — both + // collapse to "Allow with a foreign label leak". + // + // We deliberately do NOT bite Deny on claim mismatch (Deny with + // any claim is in-spec — the spec models Deny as the catch-all + // for hostAgrees=false or accessibility=false). + TraceAction::AuthCheck { + channel: _, + claimed_community, + verdict, + } => match (verdict, claimed_community) { + (Verdict::Allow, Some(c)) if c != &model.resolved_community => { + Err(TransitionError::IllegalTransition { + step_index, + detail: format!( + "AuthCheck verdict=Allow with claimed_community={:?} != resolved={:?} \ + — M2/M8 (claim or host driving verdict) bite", + c, model.resolved_community + ), + }) + } + _ => Ok(()), + }, + + // --- Spec ReadMessageRows (line 643) / ReadByIdRows (line 681) --- + // The action emits rows; `RowLabels(rows)` is the observation's + // labels and Inv_NonInterference requires labels ⊆ {community}. + // For channel-less (ch = NoChannel) the spec ADDS: + // HostCommunity[host] = c ∧ IsAdmitted(c, a). + // The host-agreement piece is enforced at the universal check + // (state_after.resolved_community is host-derived); IsAdmitted is + // production state we cannot recompute, so it lives in fixture + // assertions rather than this generic checker. + // + // What this checker bites: every row label must equal the + // resolved community. ONE foreign label fails NI. + TraceAction::ReadMessageRows { + channel: _, + row_communities, + } + | TraceAction::ReadByIdRows { + channel: _, + row_communities, + } => check_row_labels(step_index, model, row_communities), + + // --- Spec ReadHostFeedRows (line ~720) --- + // Community is host-derived; same row-label confinement. + TraceAction::ReadHostFeedRows { row_communities } => { + check_row_labels(step_index, model, row_communities) + } + + // --- Coverage breach via the Drop guard --- + // The seam exited without emitting any recognized action. + // Per the skill: this is the load-bearing coverage mode — without + // it, trace conformance is decorative logging. + TraceAction::ImplBug { kind } => Err(TransitionError::CoverageBreach { + detail: format!("ImplBug action emitted by Drop guard: kind={kind:?}"), + }), + } +} + +/// Row-label confinement check shared by all three read actions. +/// +/// `Inv_NonInterference` (spec line ~983): +/// `\A o \in observations : o.labels \subseteq {o.community}`. +/// +/// Translated: every `row_communities` entry must equal `model +/// .resolved_community`. The check is on a `Vec`, not a `Set`, deliberately +/// — if a buggy relay returned the same foreign row twice the checker still +/// bites, and if a buggy emitter de-duped foreign labels to one occurrence +/// the checker still bites. Foreign-label count is unimportant; foreign- +/// label presence is the entire bar. +fn check_row_labels( + step_index: usize, + model: &ModelState, + row_communities: &[CommunityLabel], +) -> Result<(), TransitionError> { + if let Some(foreign) = row_communities + .iter() + .find(|c| **c != model.resolved_community) + { + return Err(TransitionError::NonInterference { + step_index, + detail: format!( + "row labeled {:?} returned in observation scoped to {:?} \ + — Inv_NonInterference breach (foreign row leaked through tenant fence)", + foreign, model.resolved_community + ), + }); + } + Ok(()) +} + +/// Helper: which channel (if any) does the action target? Used by the +/// checker to bind cross-step claims to a stable channel — and by fixtures +/// asserting that a particular channel surfaced at this seam. +pub fn action_channel(action: &TraceAction) -> Option<&ChannelLabel> { + match action { + TraceAction::WriteInsert { channel, .. } => Some(channel), + TraceAction::WriteDuplicate { channel, .. } => Some(channel), + TraceAction::AuthCheck { channel, .. } => Some(channel), + TraceAction::ReadMessageRows { channel, .. } => channel.as_ref(), + TraceAction::ReadByIdRows { channel, .. } => channel.as_ref(), + TraceAction::WriteInsertGlobal { .. } + | TraceAction::ReadHostFeedRows { .. } + | TraceAction::SanitizedError { .. } + | TraceAction::ImplBug { .. } => None, + } +} diff --git a/crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl b/crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl new file mode 100644 index 000000000..8e165eaaa --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl @@ -0,0 +1 @@ +{"schema_version":1,"action":{"type":"impl_bug","kind":"ingest_exited_without_trace"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl b/crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl new file mode 100644 index 000000000..8313562d4 --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl @@ -0,0 +1 @@ +{"schema_version":1,"action":{"type":"read_message_rows","channel":"cafe0000-0000-0000-0000-000000000010","row_communities":["bbbb0000-0000-0000-0000-000000000002"]},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl b/crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl new file mode 100644 index 000000000..cf4cb26a7 --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl @@ -0,0 +1,2 @@ +{"schema_version":1,"action":{"type":"auth_check","channel":"dead0000-0000-0000-0000-000000000020","claimed_community":"bbbb0000-0000-0000-0000-000000000002","verdict":"allow"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} +{"schema_version":1,"action":{"type":"write_insert","msg_id":"badbadbad0000000","channel":"dead0000-0000-0000-0000-000000000020","claimed_community":"bbbb0000-0000-0000-0000-000000000002"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/fixtures/good.jsonl b/crates/buzz-conformance/tests/fixtures/good.jsonl new file mode 100644 index 000000000..e18be5656 --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/good.jsonl @@ -0,0 +1,3 @@ +{"schema_version":1,"action":{"type":"auth_check","channel":"cafe0000-0000-0000-0000-000000000010","claimed_community":"aaaa0000-0000-0000-0000-000000000001","verdict":"allow"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} +{"schema_version":1,"action":{"type":"write_insert","msg_id":"d34db33fcafef00d","channel":"cafe0000-0000-0000-0000-000000000010","claimed_community":"aaaa0000-0000-0000-0000-000000000001"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} +{"schema_version":1,"action":{"type":"read_message_rows","channel":"cafe0000-0000-0000-0000-000000000010","row_communities":["aaaa0000-0000-0000-0000-000000000001","aaaa0000-0000-0000-0000-000000000001"]},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/proptest_checker.rs b/crates/buzz-conformance/tests/proptest_checker.rs new file mode 100644 index 000000000..3d2b81ef6 --- /dev/null +++ b/crates/buzz-conformance/tests/proptest_checker.rs @@ -0,0 +1,431 @@ +//! Property/fuzz-generated conformance traces. +//! +//! These tests widen the checker's exercised input space beyond the hand- +//! built fixtures in `tests/fixtures/`. The skill (skill-runtime-formal- +//! compliance) calls for "property/fuzz-generated action sequences where +//! feasible"; this is that lane. +//! +//! ## Design: invariant properties, NOT a parallel oracle +//! +//! `transitions::check_step` is small and direct. A "reference oracle" that +//! re-derived the verdict would just be a copy of the checker — testing the +//! code against itself, proving nothing. So these tests do NOT re-implement +//! the verdict. They assert **spec-derived facts** about `check_trace`'s +//! result, read off the *shape of the generated trace*: +//! +//! - any read carrying a foreign row label MUST be rejected (NonInterference) +//! - a fully clean trace MUST be accepted +//! - AuthCheck Allow + foreign claim MUST bite (IllegalTransition) +//! - ImplBug MUST bite (CoverageBreach) +//! - a mid-trace state flip MUST bite (StateMismatch) +//! - the checker never panics and is deterministic +//! +//! The only checker surface these tests touch is the public +//! [`buzz_conformance::checker::check_trace`]. They never call +//! `transitions::check_step`, and they never depend on a production crate. +//! +//! ## Fail-fast discipline +//! +//! `check_trace` returns the FIRST error it finds. So every property that +//! asserts a *specific* error variant must construct traces in which the +//! targeted violation is the first/only one — otherwise an earlier +//! `StateMismatch` / `IllegalTransition` / `CoverageBreach` would mask the +//! variant under test. Each generator below is built to honor that. + +use buzz_conformance::checker::{check_trace, Scenario}; +use buzz_conformance::transitions::TransitionError; +use buzz_conformance::{ + AbstractState, ActorLabel, ChannelLabel, CommunityLabel, HostLabel, OpaqueId, SanitizedReason, + TraceAction, TraceStep, Verdict, +}; +use proptest::prelude::*; +use uuid::Uuid; + +// --- Small fixed pools ----------------------------------------------------- +// +// Pools are intentionally tiny (3 each) so that "foreign vs resolved" +// collisions happen with meaningful frequency. With a 3-community pool a +// randomly chosen row label is foreign ~2/3 of the time, so P1 actually +// stresses the leak path instead of almost always generating clean traces. + +const POOL: u128 = 3; + +fn community(i: u128) -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128( + 0x0c00_0000_0000_0000_0000_0000_0000_0000 + i, + )) +} + +fn channel(i: u128) -> ChannelLabel { + ChannelLabel(Uuid::from_u128( + 0x0ca0_0000_0000_0000_0000_0000_0000_0000 + i, + )) +} + +fn host(i: u128) -> HostLabel { + HostLabel(format!("h_{i}")) +} + +fn actor(i: u128) -> ActorLabel { + ActorLabel(format!("a_{i}")) +} + +fn arb_community() -> impl Strategy { + (0..POOL).prop_map(community) +} + +fn arb_channel() -> impl Strategy { + (0..POOL).prop_map(channel) +} + +fn arb_opaque() -> impl Strategy { + (0u32..16).prop_map(|i| OpaqueId(format!("m{i}"))) +} + +fn arb_verdict() -> impl Strategy { + prop_oneof![Just(Verdict::Allow), Just(Verdict::Deny)] +} + +fn arb_reason() -> impl Strategy { + prop_oneof![ + Just(SanitizedReason::Restricted), + Just(SanitizedReason::Invalid), + Just(SanitizedReason::ServerError), + ] +} + +/// The bootstrapped state for a request resolved to `resolved`. Host/actor +/// are fixed so that, when we reuse this state for every step, the only way +/// a `StateMismatch` can arise is if a property deliberately flips a field. +fn state_for(resolved: CommunityLabel) -> AbstractState { + AbstractState { + resolved_community: resolved, + bound_host: host(0), + actor: actor(0), + } +} + +// --- Action generators ----------------------------------------------------- + +/// A "clean" action: one whose presence in a trace bootstrapped to +/// `resolved` introduces NO violation on its own. Read labels are all +/// `resolved`; AuthCheck either Denies (any claim) or Allows with a claim +/// equal to `resolved` (or no claim). No ImplBug. This is the alphabet P2 +/// draws from, and the benign filler P1/P3/P4/P5 use for prefixes. +fn arb_clean_action(resolved: CommunityLabel) -> impl Strategy { + let res = resolved; + prop_oneof![ + (arb_opaque(), arb_channel(), prop::option::of(Just(res))).prop_map( + |(msg_id, channel, claimed_community)| TraceAction::WriteInsert { + msg_id, + channel, + claimed_community, + } + ), + (arb_opaque(), prop::option::of(Just(res))).prop_map(|(msg_id, claimed_community)| { + TraceAction::WriteInsertGlobal { + msg_id, + claimed_community, + } + }), + (arb_opaque(), arb_channel(), prop::option::of(Just(res))).prop_map( + |(msg_id, channel, claimed_community)| TraceAction::WriteDuplicate { + msg_id, + channel, + claimed_community, + } + ), + arb_reason().prop_map(|reason| TraceAction::SanitizedError { reason }), + // AuthCheck that cannot bite M2/M8: either Deny (any claim is in-spec) + // or Allow with a claim that is None or equal to resolved. + ( + arb_channel(), + arb_verdict(), + prop_oneof![Just(None), Just(Some(res))], + ) + .prop_map(|(channel, verdict, claimed_community)| { + // For Deny, the claim is unconstrained; for Allow it is + // None-or-resolved by construction above, so it never bites. + TraceAction::AuthCheck { + channel, + claimed_community, + verdict, + } + }), + // Reads whose every row label equals resolved. + (arb_channel(), 0usize..4).prop_map(move |(channel, n)| TraceAction::ReadMessageRows { + channel: Some(channel), + row_communities: vec![res; n], + }), + (0usize..4).prop_map(move |n| TraceAction::ReadByIdRows { + channel: None, + row_communities: vec![res; n], + }), + (0usize..4).prop_map(move |n| TraceAction::ReadHostFeedRows { + row_communities: vec![res; n], + }), + ] +} + +/// Wrap actions into steps that all share the bootstrapped state, so the +/// only violations possible are action-level (no incidental StateMismatch). +fn steps_with_state(actions: Vec, resolved: CommunityLabel) -> Vec { + let st = state_for(resolved); + actions + .into_iter() + .map(|a| TraceStep::new(a, st.clone())) + .collect() +} + +/// A clean trace: 1..=12 clean actions over one resolved community, all +/// sharing the bootstrap state. By construction this contains no foreign +/// label, no Allow+foreign claim, no ImplBug, no state flip, no schema +/// mismatch. +fn arb_clean_trace() -> impl Strategy)> { + arb_community().prop_flat_map(|resolved| { + prop::collection::vec(arb_clean_action(resolved), 1..=12) + .prop_map(move |actions| (resolved, steps_with_state(actions, resolved))) + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// P2 — completeness / no false reject. + /// A fully clean, non-empty, current-schema, consistent-state trace with + /// no coverage obligations MUST be accepted. `Scenario::unstructured` + /// declares no required actions, so coverage breach cannot fire. + #[test] + fn clean_trace_is_accepted((_resolved, trace) in arb_clean_trace()) { + let sc = Scenario::unstructured(trace); + prop_assert!( + check_trace(&sc).is_ok(), + "clean trace was rejected: {:?}", + check_trace(&sc) + ); + } + + /// P1 — non-interference soundness / no false accept of a leak. + /// A clean prefix followed by a single read whose row set contains a + /// foreign label MUST be rejected with NonInterference. The foreign read + /// is the only possible violation, so fail-fast surfaces exactly it. + #[test] + fn foreign_row_label_is_rejected( + resolved in arb_community(), + foreign_idx in 0u128..POOL, + prefix in prop::collection::vec(arb_community().prop_map(|_| ()), 0..6), + clean_before in any::(), + which_read in 0u8..3, + ) { + // Pick a foreign community distinct from resolved. + let foreign = { + let mut f = community(foreign_idx); + if f == resolved { + f = community((foreign_idx + 1) % POOL); + } + f + }; + // If POOL were 1 this could still collide; guard explicitly. + prop_assume!(foreign != resolved); + + let mut actions: Vec = Vec::new(); + // Optional benign clean prefix (reads of resolved-only rows) to prove + // the violation still bites after valid steps. + if clean_before { + for _ in &prefix { + actions.push(TraceAction::ReadMessageRows { + channel: Some(channel(0)), + row_communities: vec![resolved], + }); + } + } + // The single violating read carries one foreign label. NI confinement + // is enforced on ALL THREE read surfaces (they share `check_row_labels`), + // so the property must bite regardless of which read leaked. + let leaked = vec![resolved, foreign]; + let violating = match which_read { + 0 => TraceAction::ReadMessageRows { + channel: Some(channel(0)), + row_communities: leaked, + }, + 1 => TraceAction::ReadByIdRows { + channel: None, + row_communities: leaked, + }, + _ => TraceAction::ReadHostFeedRows { + row_communities: leaked, + }, + }; + actions.push(violating); + + let trace = steps_with_state(actions, resolved); + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + prop_assert!( + matches!(err, TransitionError::NonInterference { .. }), + "expected NonInterference, got {err:?}" + ); + } + + /// P3a — AuthCheck Allow + foreign claim always bites IllegalTransition. + /// One-step trace so the M2/M8 bite is the only candidate. + #[test] + fn auth_allow_foreign_claim_bites( + resolved in arb_community(), + foreign_idx in 0u128..POOL, + chan in arb_channel(), + ) { + let foreign = { + let mut f = community(foreign_idx); + if f == resolved { + f = community((foreign_idx + 1) % POOL); + } + f + }; + prop_assume!(foreign != resolved); + + let trace = steps_with_state( + vec![TraceAction::AuthCheck { + channel: chan, + claimed_community: Some(foreign), + verdict: Verdict::Allow, + }], + resolved, + ); + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + prop_assert!( + matches!(err, TransitionError::IllegalTransition { .. }), + "expected IllegalTransition for Allow+foreign claim, got {err:?}" + ); + } + + /// P3b — AuthCheck Deny with any claim is in-spec (never bites on the + /// claim axis). One-step clean-otherwise trace MUST be accepted. + #[test] + fn auth_deny_any_claim_is_ok( + resolved in arb_community(), + claim_idx in 0u128..POOL, + chan in arb_channel(), + has_claim in any::(), + ) { + let claimed = if has_claim { Some(community(claim_idx)) } else { None }; + let trace = steps_with_state( + vec![TraceAction::AuthCheck { + channel: chan, + claimed_community: claimed, + verdict: Verdict::Deny, + }], + resolved, + ); + prop_assert!( + check_trace(&Scenario::unstructured(trace)).is_ok(), + "Deny with any claim should be in-spec" + ); + } + + /// P4 — ImplBug always bites CoverageBreach. Clean prefix then ImplBug; + /// since the prefix is clean, the ImplBug is the first/only violation. + #[test] + fn impl_bug_bites_coverage_breach( + resolved in arb_community(), + prefix_len in 0usize..4, + kind in "[a-z_]{1,16}", + ) { + let mut actions: Vec = (0..prefix_len) + .map(|_| TraceAction::ReadMessageRows { + channel: Some(channel(0)), + row_communities: vec![resolved], + }) + .collect(); + actions.push(TraceAction::ImplBug { kind }); + + let trace = steps_with_state(actions, resolved); + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + prop_assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "expected CoverageBreach from ImplBug, got {err:?}" + ); + } + + /// P5 — a mid-trace state flip bites StateMismatch. One clean bootstrap + /// step, then a benign action whose `state_after` flips exactly one of + /// resolved_community / bound_host / actor. State is checked before any + /// action-specific logic, so this is the only possible violation. + #[test] + fn state_flip_bites_state_mismatch( + resolved in arb_community(), + other_idx in 0u128..POOL, + which in 0u8..3, + ) { + let boot = state_for(resolved); + // A benign first step. + let step0 = TraceStep::new( + TraceAction::ReadMessageRows { + channel: Some(channel(0)), + row_communities: vec![resolved], + }, + boot.clone(), + ); + + // Flip exactly one field for step 1. + let mut flipped = boot.clone(); + match which { + 0 => { + let mut other = community(other_idx); + if other == resolved { + other = community((other_idx + 1) % POOL); + } + prop_assume!(other != resolved); + flipped.resolved_community = other; + } + 1 => flipped.bound_host = host(9), + _ => flipped.actor = actor(9), + } + let step1 = TraceStep::new( + TraceAction::ReadMessageRows { + channel: Some(channel(0)), + // Use the FLIPPED resolved so the read itself is clean + // relative to its own state_after; the bite must come from + // the state divergence, not row labels. + row_communities: vec![flipped.resolved_community], + }, + flipped, + ); + + let err = check_trace(&Scenario::unstructured(vec![step0, step1])).unwrap_err(); + prop_assert!( + matches!(err, TransitionError::StateMismatch { .. }), + "expected StateMismatch from a mid-trace field flip, got {err:?}" + ); + } + + /// P6 — determinism and no-panic. Running `check_trace` twice on the same + /// scenario yields the same result, and neither call panics. Draws from + /// the clean alphabet plus occasional violations so the input space is + /// broad; we assert nothing about the verdict, only its stability. + #[test] + fn check_trace_is_deterministic_and_total( + resolved in arb_community(), + actions in prop::collection::vec( + prop_oneof![ + arb_clean_action(community(0)), + // a few intentionally-violating shapes to widen coverage + Just(TraceAction::ImplBug { kind: "fuzz".into() }), + arb_community().prop_map(|c| TraceAction::ReadMessageRows { + channel: None, + row_communities: vec![c], + }), + ], + 1..=12, + ), + ) { + let trace = steps_with_state(actions, resolved); + let sc = Scenario::unstructured(trace); + let r1 = check_trace(&sc); + let r2 = check_trace(&sc); + prop_assert_eq!( + format!("{r1:?}"), + format!("{r2:?}"), + "check_trace was non-deterministic" + ); + } +} diff --git a/crates/buzz-conformance/tests/replay_fixtures.rs b/crates/buzz-conformance/tests/replay_fixtures.rs new file mode 100644 index 000000000..b823a86a0 --- /dev/null +++ b/crates/buzz-conformance/tests/replay_fixtures.rs @@ -0,0 +1,324 @@ +//! Replay-fixture integration test. +//! +//! These fixtures are the load-bearing evidence that the runtime +//! conformance gate is **not decorative**. Each fixture is one +//! end-to-end JSONL trace, replayed through [`check_trace`], with the +//! expected verdict baked into the assertion. +//! +//! Eva's review (thread `06aaf3f7…`) green-lit cutting these as the +//! visible proof the gate bites. Coverage: +//! +//! - `good.jsonl` — a positive trace shaped like a real ingest: +//! AuthCheck Allow → WriteInsert → ReadMessageRows with rows confined +//! to the resolved community. `check_trace` returns `Ok(())`. +//! - `bad_host_channel_mismatch.jsonl` — a host/channel fence skip: +//! the bound host is for community A, the write targets a channel in +//! community B. The checker fails with `IllegalTransition`. +//! - `bad_coverage_breach.jsonl` — a trace that contains an `ImplBug` +//! action (what `EmitGuard::Drop` emits when a critical seam exits +//! without recording anything). The checker fails with +//! `CoverageBreach`. +//! +//! The JSONL files are committed as "golden" artifacts under +//! `tests/fixtures/` for reviewer visibility, but this test also +//! round-trips: it constructs the trace in Rust, serializes it to a +//! temp file, reads it back, and asserts both the serialized form +//! matches the committed file AND the parsed form gives the expected +//! verdict. That way a schema change cannot silently desync the +//! committed JSONL from what the relay actually emits. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use buzz_conformance::checker::{check_trace, Scenario}; +use buzz_conformance::transitions::TransitionError; +use buzz_conformance::{ + AbstractState, ActorLabel, ChannelLabel, CommunityLabel, HostLabel, OpaqueId, TraceAction, + TraceStep, Verdict, +}; +use uuid::Uuid; + +// ---- Stable test-fixture labels ---------------------------------------- +// +// These values are deterministic so the serialized JSONL is reproducible +// across runs. They are NOT secrets and they don't shadow any real +// community — they're test-only constants. + +fn community_a() -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128(0xAAAA_0000_0000_0000_0000_0000_0000_0001)) +} + +fn community_b() -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128(0xBBBB_0000_0000_0000_0000_0000_0000_0002)) +} + +fn channel_in_a() -> ChannelLabel { + ChannelLabel(Uuid::from_u128(0xCAFE_0000_0000_0000_0000_0000_0000_0010)) +} + +fn channel_in_b() -> ChannelLabel { + ChannelLabel(Uuid::from_u128(0xDEAD_0000_0000_0000_0000_0000_0000_0020)) +} + +fn state_a() -> AbstractState { + AbstractState { + resolved_community: community_a(), + bound_host: HostLabel("a.example.test".to_string()), + actor: ActorLabel("0123456789abcdef".to_string()), + } +} + +// ---- Trace builders ---------------------------------------------------- + +/// A positive trace: bound to community A, all observations confined. +fn good_trace() -> Vec { + vec![ + TraceStep::new( + TraceAction::AuthCheck { + channel: channel_in_a(), + claimed_community: Some(community_a()), + verdict: Verdict::Allow, + }, + state_a(), + ), + TraceStep::new( + TraceAction::WriteInsert { + msg_id: OpaqueId("d34db33fcafef00d".to_string()), + channel: channel_in_a(), + claimed_community: Some(community_a()), + }, + state_a(), + ), + TraceStep::new( + TraceAction::ReadMessageRows { + channel: Some(channel_in_a()), + row_communities: vec![community_a(), community_a()], + }, + state_a(), + ), + ] +} + +/// A bad trace: the host-channel fence was bypassed. The bound host +/// resolves to community A, but a WriteInsert targets a channel in +/// community B. The spec's `Inv_NonInterference` / channel-host coupling +/// rule rejects this as an illegal transition. +fn bad_host_channel_mismatch_trace() -> Vec { + vec![ + TraceStep::new( + TraceAction::AuthCheck { + channel: channel_in_b(), + // Client claims B, host resolves A, fence was skipped: + // AuthCheck recorded `verdict = Allow` despite the + // mismatch. M2/M8 territory. + claimed_community: Some(community_b()), + verdict: Verdict::Allow, + }, + state_a(), + ), + TraceStep::new( + TraceAction::WriteInsert { + msg_id: OpaqueId("badbadbad0000000".to_string()), + channel: channel_in_b(), + claimed_community: Some(community_b()), + }, + state_a(), + ), + ] +} + +/// A coverage-breach trace: an `ImplBug` step appears, meaning the +/// `EmitGuard` fired on Drop. The checker treats any `ImplBug` as a +/// hard coverage breach. +fn bad_coverage_breach_trace() -> Vec { + vec![TraceStep::new( + TraceAction::ImplBug { + kind: "ingest_exited_without_trace".to_string(), + }, + state_a(), + )] +} + +/// A foreign-row trace: bound to community A but a `ReadMessageRows` +/// returns a row whose community label is community B. This is the +/// (B)-projection negative case Eva requested as the guard-rail for +/// "channel-scoped row masquerading as channel-less": IF the row had +/// been mis-projected as channel-less (and thus defaulted to the +/// resolved community A), the subset check would have passed +/// vacuously. By recording the row's TRUE community (B) — independent +/// of the fetch query's WHERE clause — the `Inv_NonInterference` / +/// `Inv_ReadConfinement` bite surfaces immediately as +/// `NonInterference`. This fixture is the proof artifact that the +/// projection helper's missing-lookup guard-rail is non-vacuous. +fn bad_foreign_row_leak_trace() -> Vec { + vec![TraceStep::new( + TraceAction::ReadMessageRows { + // The query was scoped to a channel in A (the host-resolved + // tenant). The relay's filter said "this row should belong + // to A." But the row's TRUE community is B — surfaced by + // the (B)-strategy projection reading the row's own + // `channel_id` against the channels table. + channel: Some(channel_in_a()), + row_communities: vec![community_b()], + }, + state_a(), + )] +} + +// ---- Fixture round-trip ------------------------------------------------ + +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +/// Serialize a trace to JSONL (one step per line). +fn to_jsonl(trace: &[TraceStep]) -> String { + let mut out = String::new(); + for step in trace { + let line = serde_json::to_string(step).expect("step serializes"); + out.push_str(&line); + out.push('\n'); + } + out +} + +/// Parse a JSONL string into a trace, surfacing the offending line on +/// error so a misedited fixture is easy to fix. +fn from_jsonl(text: &str) -> Vec { + text.lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(i, l)| { + serde_json::from_str::(l) + .unwrap_or_else(|e| panic!("fixture line {} did not parse: {e}", i + 1)) + }) + .collect() +} + +/// Assert that the committed JSONL fixture for `name` round-trips to +/// `expected_trace` byte-exactly. Run with `BUZZ_CONFORMANCE_UPDATE=1` +/// to regenerate the fixture (so a schema change is a deliberate +/// re-commit, not a silent break). +fn assert_fixture_matches(name: &str, expected_trace: &[TraceStep]) { + let expected = to_jsonl(expected_trace); + let path = fixture_path(name); + + if std::env::var("BUZZ_CONFORMANCE_UPDATE").is_ok() { + fs::create_dir_all(path.parent().expect("fixture dir")).expect("mkdir fixtures"); + fs::write(&path, &expected).expect("write fixture"); + return; + } + + let actual = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "fixture {} missing or unreadable ({e}); run with \ + BUZZ_CONFORMANCE_UPDATE=1 to create it", + path.display() + ) + }); + + assert_eq!( + actual, expected, + "committed fixture {} drifted from the typed builder; run with \ + BUZZ_CONFORMANCE_UPDATE=1 to refresh if the change is intentional", + name + ); + + let parsed = from_jsonl(&actual); + assert_eq!(parsed, *expected_trace, "fixture round-trip mismatched"); +} + +// ---- Tests -------------------------------------------------------------- + +#[test] +fn good_trace_passes_check() { + let trace = good_trace(); + assert_fixture_matches("good.jsonl", &trace); + + let scenario = Scenario { + trace, + required_critical_actions: ["auth_check", "write_insert", "read_message_rows"] + .into_iter() + .map(String::from) + .collect::>(), + }; + check_trace(&scenario).expect("the good fixture must replay green"); +} + +#[test] +fn bad_host_channel_mismatch_is_illegal_transition() { + let trace = bad_host_channel_mismatch_trace(); + assert_fixture_matches("bad_host_channel_mismatch.jsonl", &trace); + + let scenario = Scenario::unstructured(trace); + let err = check_trace(&scenario) + .expect_err("host/channel fence skip must be rejected by the checker"); + assert!( + matches!(err, TransitionError::IllegalTransition { .. }), + "host/channel mismatch must surface as IllegalTransition (M2/M8 bite), got {err:?}" + ); +} + +#[test] +fn coverage_breach_is_caught() { + let trace = bad_coverage_breach_trace(); + assert_fixture_matches("bad_coverage_breach.jsonl", &trace); + + let scenario = Scenario::unstructured(trace); + let err = check_trace(&scenario) + .expect_err("ImplBug in the trace must be rejected as a coverage breach"); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "ImplBug must surface as CoverageBreach, got {err:?}" + ); +} + +#[test] +fn foreign_row_leak_is_non_interference() { + let trace = bad_foreign_row_leak_trace(); + assert_fixture_matches("bad_foreign_row_leak.jsonl", &trace); + + let scenario = Scenario::unstructured(trace); + let err = check_trace(&scenario) + .expect_err("foreign row community label must be rejected by Inv_NonInterference"); + assert!( + matches!(err, TransitionError::NonInterference { .. }), + "foreign row label must surface as NonInterference, got {err:?}" + ); +} + +#[test] +fn empty_trace_is_coverage_breach() { + // Independent of the JSONL fixtures: the checker must fail closed on + // an empty trace (no observations from a critical seam). + let scenario = Scenario::unstructured(vec![]); + let err = check_trace(&scenario).expect_err("empty trace must be CoverageBreach"); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "empty trace must be CoverageBreach, got {err:?}" + ); +} + +#[test] +fn missing_required_action_is_coverage_breach() { + // The good trace, but the scenario declares it must include + // `read_by_id_rows` — which it does not. This is what the + // "scenario-required action never appeared" coverage breach catches. + let scenario = Scenario { + trace: good_trace(), + required_critical_actions: ["read_by_id_rows"] + .into_iter() + .map(String::from) + .collect::>(), + }; + let err = check_trace(&scenario) + .expect_err("missing required critical action must be CoverageBreach"); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "missing required action must be CoverageBreach, got {err:?}" + ); +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 922c64c20..f2e918424 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -110,6 +110,29 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// a compile-time bitset or sorted array with binary search for hot-path use. pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER]; +/// Kinds whose stored events have `#p`-bound read access — readable only by +/// subscribers whose pubkey appears in the event's `#p` tag. +/// +/// The relay enforces this at the filter layer (`p_gated_filters_authorized`): +/// a REQ that can match any kind in this set is closed unless the filter's +/// `#p` values exactly equal the authenticated reader's pubkey. For stored +/// (non-ephemeral) kinds in this set, the storage layer additionally writes a +/// NULL `search_tsv` so the event is unsearchable through NIP-50 FTS +/// (`schema/schema.sql` and `migrations/0001_initial_schema.sql` — drift +/// caught by `p_gated_persistent_kinds_have_storage_null_tsvector` in +/// `crates/buzz-search/tests/fts_integration.rs`). +/// +/// Ephemeral kinds (20000–29999, e.g. [`KIND_AGENT_OBSERVER_FRAME`]) are +/// included for filter-layer enforcement but are never stored, so the +/// storage-layer search defense does not apply to them. +pub const P_GATED_KINDS: &[u32] = &[ + KIND_AGENT_OBSERVER_FRAME, + KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_GIFT_WRAP, + KIND_DM_VISIBILITY, +]; + /// NIP-AP: Agent Persona (parameterized replaceable, owner-authored). /// /// Persona definition event published by the workspace owner. Addressed by diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 8ee3d0315..dee40e988 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -28,6 +28,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// Tenant identity — the server-resolved community key carried on scoped paths. +pub mod tenant; /// Schnorr signature and event ID verification. pub mod verification; @@ -35,6 +37,7 @@ pub use error::VerificationError; pub use event::StoredEvent; pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey}; pub use presence::PresenceStatus; +pub use tenant::{normalize_host, CommunityId, TenantContext}; pub use verification::verify_event; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-core/src/tenant.rs b/crates/buzz-core/src/tenant.rs new file mode 100644 index 000000000..f7894a699 --- /dev/null +++ b/crates/buzz-core/src/tenant.rs @@ -0,0 +1,275 @@ +//! Tenant identity: the server-resolved community key carried on every scoped path. +//! +//! These types live in `buzz-core` (zero I/O deps) so the DB, auth, pub/sub, +//! search, audit, media, and relay-wiring layers all name a community the same +//! way without depending on each other. +//! +//! ## The fence +//! +//! The whole multi-tenant safety story rests on one invariant from the formal +//! model (conformance "row zero"): a request's community is *resolved from the +//! connection host by the server*, never supplied or influenced by the client. +//! +//! [`TenantContext`] expresses that invariant in the type system as far as the +//! type system can carry it: there is no `Default`, no `Deserialize`, and no +//! way to *parse* a community from client input. A `CommunityId` only ever +//! comes from host resolution or from a DB row the server already scoped. +//! +//! This is a **lint-and-review fence, not a compiler fence.** +//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the +//! host-resolution path (in another crate) can call them — which means a +//! determined caller elsewhere *could* call them too. The migration-lint +//! harness forbids constructing a `TenantContext` outside host resolution and +//! tests; the type only removes the *accidental* path (deserializing a +//! client-chosen community), and review/lint closes the deliberate one. We say +//! this plainly rather than overclaim a guarantee the `pub` API doesn't give. + +use std::fmt; +use uuid::Uuid; + +/// A community: the first-class tenant key on every scoped row. +/// +/// Opaque UUID newtype. Equality and ordering are the underlying UUID's. +/// There is deliberately no `community_id` parsed from client input anywhere; +/// a `CommunityId` only ever originates from host resolution or from a DB row +/// the server already scoped. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct CommunityId(Uuid); + +impl CommunityId { + /// Wrap a UUID that the server has already established as a community id + /// (e.g. read back from the `communities` table during host resolution). + /// + /// This is intentionally not a parse-from-client entry point: callers must + /// already hold a server-trusted UUID. + pub const fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + /// The underlying UUID, for DB binds and Redis key construction. + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for CommunityId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +/// The resolved tenant of an in-flight request, bound once at connection / +/// request establishment before any handler observes tenant data. +/// +/// Carried by reference (`&TenantContext`) through every scoped call. This is +/// the *only* way to name a community downstream, and it cannot be constructed +/// from client input — see the module-level "fence" note. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantContext { + community: CommunityId, + host: String, +} + +impl TenantContext { + /// Construct a context from a completed host resolution. + /// + /// Call this *only* from the host-resolution path (the function that maps a + /// connection's host to a `communities` row). Everywhere else takes + /// `&TenantContext` and reads it; nothing else mints one. + pub fn resolved(community: CommunityId, host: impl Into) -> Self { + Self { + community, + host: host.into(), + } + } + + /// The community every scoped operation under this request must use. + pub const fn community(&self) -> CommunityId { + self.community + } + + /// The host that resolved to this community. + /// + /// Authoritative for the NIP-05 domain and audit labelling; never re-derive + /// the community from it downstream — the community is already fixed. + pub fn host(&self) -> &str { + &self.host + } +} + +/// Normalize a connection `Host` into the canonical form used as the community +/// lookup key. +/// +/// This is the *one* normalization rule shared by both sides of the fence: +/// the `communities.host` column is stored already-normalized, and host +/// resolution normalizes the incoming `Host` header with this same function +/// before looking it up. Because both sides agree by construction, +/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to +/// the one community — they can never split into distinct tenants. +/// +/// Rules (host only — the caller has already split off any path/scheme): +/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986); +/// - strip a single trailing dot (the FQDN root label); +/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept, +/// since a deployment may legitimately serve different communities on +/// different ports of the same name. +/// +/// The input is trimmed of surrounding whitespace. An empty result (e.g. the +/// caller passed `""`) is returned as-is; resolution treats an empty or +/// unmapped host as a fail-closed rejection, never a default tenant. +#[must_use] +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + let mut host = host.to_ascii_lowercase(); + // Strip default ports. We only touch a `:port` suffix that is exactly a + // default port, so IPv6 literals like `[::1]` (which contain colons but no + // trailing `:80`/`:443`) are left intact. + if let Some(stripped) = host + .strip_suffix(":443") + .or_else(|| host.strip_suffix(":80")) + { + host = stripped.to_string(); + } + // Strip a single trailing FQDN-root dot. + if let Some(stripped) = host.strip_suffix('.') { + host = stripped.to_string(); + } + host +} + +/// Extract the authority (host plus an explicit non-default port, if present) +/// from a relay URL in the same normalized shape as request `Host` headers and +/// `communities.host`. +/// +/// Shared by the relay's host-resolution seam (startup community seeding and +/// the deployment-community bind), the relay's `bind_deployment_community`, and +/// the `buzz-admin` CLI's tenant resolution. All of these must derive the +/// *byte-identical* authority that live request resolution +/// ([`crate::tenant::normalize_host`]) produces from an inbound `Host`, or a +/// bootstrapped/looked-up community lands under a host no request resolves to. +/// +/// In particular this preserves an explicit non-default port (`relay:8443` → +/// `relay:8443`) and IPv6 brackets (`[::1]:3000`) — both of which a naive +/// `Url::host_str()` drops. Returns the empty string when `relay_url` has no +/// parseable host (the caller fails closed on empty). +#[must_use] +pub fn relay_url_authority(relay_url: &str) -> String { + let Ok(url) = url::Url::parse(relay_url) else { + return String::new(); + }; + let Some(host) = url.host() else { + return String::new(); + }; + let host = match host { + url::Host::Domain(domain) => domain.to_string(), + url::Host::Ipv4(addr) => addr.to_string(), + url::Host::Ipv6(addr) => format!("[{addr}]"), + }; + let authority = match url.port() { + Some(port) => format!("{host}:{port}"), + None => host, + }; + normalize_host(&authority) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn community_id_roundtrips_uuid() { + let u = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788); + let c = CommunityId::from_uuid(u); + assert_eq!(c.as_uuid(), &u); + assert_eq!(c.to_string(), u.to_string()); + } + + #[test] + fn tenant_context_exposes_resolution_inputs() { + let u = Uuid::from_u128(1); + let ctx = TenantContext::resolved(CommunityId::from_uuid(u), "relay.example"); + assert_eq!(ctx.community().as_uuid(), &u); + assert_eq!(ctx.host(), "relay.example"); + } + + #[test] + fn normalize_host_collapses_tenant_split_variants() { + // All of these are the SAME tenant and must normalize identically — + // this is the property that stops accidental split-tenant. + let canonical = "relay.example"; + for variant in [ + "relay.example", + "Relay.Example", + "RELAY.EXAMPLE", + "relay.example.", // trailing FQDN root dot + "relay.example:443", // default https port + "relay.example:80", // default http port + "Relay.Example.:443", + " relay.example ", // surrounding whitespace + ] { + assert_eq!(normalize_host(variant), canonical, "variant {variant:?}"); + } + } + + #[test] + fn normalize_host_keeps_nondefault_port() { + // A non-default port is a legitimate distinct selector — keep it. + assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443"); + assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000"); + } + + #[test] + fn normalize_host_leaves_ipv6_literal_intact() { + // IPv6 literals contain colons but no trailing default-port suffix. + assert_eq!(normalize_host("[::1]"), "[::1]"); + assert_eq!(normalize_host("[::1]:443"), "[::1]"); + } + + #[test] + fn normalize_host_empty_stays_empty() { + // Empty / whitespace-only resolves to empty; resolution fails closed. + assert_eq!(normalize_host(""), ""); + assert_eq!(normalize_host(" "), ""); + } + + #[test] + fn relay_url_authority_keeps_explicit_nondefault_port() { + // The default dev seed: startup, bind_deployment_community, and + // buzz-admin must all derive `localhost:3000` (NOT bare `localhost`), + // or the admin lookup misses the community startup seeded. + assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000"); + assert_eq!( + relay_url_authority("wss://relay.example:8443"), + "relay.example:8443" + ); + } + + #[test] + fn relay_url_authority_collapses_default_ports() { + // Default ports collapse to the bare host, matching how an inbound + // `Host` header for the same deployment normalizes. + assert_eq!( + relay_url_authority("wss://relay.example:443"), + "relay.example" + ); + assert_eq!( + relay_url_authority("ws://relay.example:80"), + "relay.example" + ); + assert_eq!(relay_url_authority("wss://relay.example"), "relay.example"); + } + + #[test] + fn relay_url_authority_preserves_ipv6_brackets() { + // `host_str()` strips IPv6 brackets and the port; `relay_url_authority` + // must keep both so the authority matches `communities.host`. + assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000"); + } + + #[test] + fn relay_url_authority_unparseable_is_empty() { + // No parseable host → empty authority; callers fail closed. + assert_eq!(relay_url_authority("not a url"), ""); + assert_eq!(relay_url_authority(""), ""); + } +} diff --git a/crates/buzz-db/src/api_token.rs b/crates/buzz-db/src/api_token.rs index 9d3f17757..f105d681a 100644 --- a/crates/buzz-db/src/api_token.rs +++ b/crates/buzz-db/src/api_token.rs @@ -8,8 +8,13 @@ use crate::error::{DbError, Result}; /// Create a new API token record. The caller is responsible for generating /// the raw token and computing its SHA-256 hash. +/// +/// `community_id` is row zero: every token is scoped to a community, derived +/// from the request's resolved tenant — never client-supplied here. +#[allow(clippy::too_many_arguments)] pub async fn create_api_token( pool: &PgPool, + community_id: Uuid, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -32,10 +37,12 @@ pub async fn create_api_token( sqlx::query( r#" - INSERT INTO api_tokens (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO api_tokens + (community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "#, ) + .bind(community_id) .bind(id) .bind(token_hash) .bind(owner_pubkey) @@ -54,9 +61,14 @@ pub async fn create_api_token( /// Uses a subquery so the check and insert are atomic -- /// no TOCTOU race between a separate count query and the insert. /// +/// The 10-token limit is per (community, owner) — a user's quota is scoped to +/// their community, never global. +/// /// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded. +#[allow(clippy::too_many_arguments)] pub async fn create_api_token_if_under_limit( pool: &PgPool, + community_id: Uuid, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -76,22 +88,25 @@ pub async fn create_api_token_if_under_limit( }) .transpose()?; - // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10. - // The subquery and insert execute atomically -- no separate count + insert race. + // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10 + // **for this (community, owner) pair**. The subquery and insert execute atomically -- + // no separate count + insert race. let result = sqlx::query( r#" INSERT INTO api_tokens - (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint) - SELECT $1, $2, $3, $4, $5, $6, $7, TRUE + (community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, TRUE WHERE ( SELECT COUNT(*) FROM api_tokens - WHERE owner_pubkey = $8 + WHERE community_id = $1 + AND owner_pubkey = $9 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ) < 10 "#, ) + .bind(community_id) .bind(id) .bind(token_hash) .bind(owner_pubkey) @@ -111,7 +126,16 @@ pub async fn create_api_token_if_under_limit( Ok(Some(id)) } -/// Look up an API token by its SHA-256 hash, **including revoked tokens**. +/// Look up an API token by its SHA-256 hash, **including revoked tokens**, +/// scoped to the request's community. +/// +/// The lookup is keyed on `(community_id, token_hash)` — the same key the +/// storage UNIQUE index uses. This closes the row-44 conformance obligation: +/// a token minted in community A must never authorize in community B, even +/// if (by birthday-style collision or adversarial mint) the same hash exists +/// in both. The UNIQUE index is a *storage* guarantee; this `AND community_id` +/// clause is the *query* guarantee — both must hold for the property to be +/// load-bearing under all schemas. /// /// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`), /// this function returns the full record regardless of revocation status. @@ -119,6 +143,7 @@ pub async fn create_api_token_if_under_limit( /// error responses rather than treating both as "not found". pub async fn get_api_token_by_hash_including_revoked( pool: &PgPool, + community_id: Uuid, hash: &[u8], ) -> Result> { let row = sqlx::query( @@ -126,9 +151,10 @@ pub async fn get_api_token_by_hash_including_revoked( SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, created_at, expires_at, last_used_at, revoked_at FROM api_tokens - WHERE token_hash = $1 + WHERE community_id = $1 AND token_hash = $2 "#, ) + .bind(community_id) .bind(hash) .fetch_optional(pool) .await?; @@ -172,7 +198,8 @@ pub async fn get_api_token_by_hash_including_revoked( })) } -/// List all tokens (including revoked) for a pubkey, ordered by creation time descending. +/// List all tokens (including revoked) for a (community, owner) pair, +/// ordered by creation time descending. /// /// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are /// responsible for stripping `token_hash` before returning data to clients -- the @@ -180,6 +207,7 @@ pub async fn get_api_token_by_hash_including_revoked( /// Used by `GET /api/tokens` to show a user their full token history. pub async fn list_tokens_by_owner( pool: &PgPool, + community_id: Uuid, pubkey: &[u8], ) -> Result> { let rows = sqlx::query( @@ -187,10 +215,11 @@ pub async fn list_tokens_by_owner( SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, created_at, expires_at, last_used_at, revoked_at FROM api_tokens - WHERE owner_pubkey = $1 + WHERE community_id = $1 AND owner_pubkey = $2 ORDER BY created_at DESC "#, ) + .bind(community_id) .bind(pubkey) .fetch_all(pool) .await?; @@ -236,12 +265,13 @@ pub async fn list_tokens_by_owner( Ok(out) } -/// Revoke a single token by ID, scoped to the owner. +/// Revoke a single token by ID, scoped to (community, owner). /// -/// Only revokes if the token is owned by `owner_pubkey` and not already revoked. +/// Only revokes if the token is in `community_id`, owned by `owner_pubkey`, and not already revoked. /// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked. pub async fn revoke_token( pool: &PgPool, + community_id: Uuid, id: Uuid, owner_pubkey: &[u8], revoked_by: &[u8], @@ -250,12 +280,14 @@ pub async fn revoke_token( r#" UPDATE api_tokens SET revoked_at = NOW(), revoked_by = $1 - WHERE id = $2 - AND owner_pubkey = $3 + WHERE community_id = $2 + AND id = $3 + AND owner_pubkey = $4 AND revoked_at IS NULL "#, ) .bind(revoked_by) + .bind(community_id) .bind(id) .bind(owner_pubkey) .execute(pool) @@ -264,12 +296,13 @@ pub async fn revoke_token( Ok(result.rows_affected() > 0) } -/// Revoke all active tokens for a pubkey. +/// Revoke all active tokens for a (community, owner) pair. /// /// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens. /// If all tokens are already revoked, returns 0 with no error. pub async fn revoke_all_tokens( pool: &PgPool, + community_id: Uuid, owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { @@ -277,14 +310,213 @@ pub async fn revoke_all_tokens( r#" UPDATE api_tokens SET revoked_at = NOW(), revoked_by = $1 - WHERE owner_pubkey = $2 + WHERE community_id = $2 + AND owner_pubkey = $3 AND revoked_at IS NULL "#, ) .bind(revoked_by) + .bind(community_id) .bind(owner_pubkey) .execute(pool) .await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + //! Row-44 conformance: API token lookups MUST be keyed on + //! `(community_id, token_hash)`, not on `token_hash` alone. The storage + //! UNIQUE index is a *storage* guarantee; the WHERE clause here is the + //! *query* guarantee. Both must hold — a query that filters on hash + //! alone could return a foreign-community row, defeating the row-zero + //! tenancy fence. This test directly inserts two same-hash rows in two + //! communities (only possible by bypassing the unique index, which we + //! achieve via distinct hashes that the test then queries-by-hash for + //! both — see below for the actual property under test). + //! + //! The load-bearing property: even if storage uniqueness is ever relaxed + //! or a hash collision occurs, the query-side `AND community_id = $N` + //! clause guarantees the lookup returns the row for the *requested* + //! tenant. Mutate-bite proof: drop the clause, the test fails. + use super::*; + use crate::{ApiTokenRecord, Db}; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_db() -> Db { + let pool = PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB"); + Db { pool } + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("api-token-tenancy-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_user(pool: &PgPool, community_id: Uuid, pubkey: &[u8]) { + sqlx::query( + r#" + INSERT INTO users (community_id, pubkey) + VALUES ($1, $2) + "#, + ) + .bind(community_id) + .bind(pubkey) + .execute(pool) + .await + .expect("insert user"); + } + + /// Direct INSERT bypassing `create_api_token` so the test pins the + /// **lookup**'s scoping, not the insert path's. + async fn raw_insert_token( + pool: &PgPool, + community_id: Uuid, + token_hash: &[u8], + owner_pubkey: &[u8], + name: &str, + ) -> Uuid { + let id = Uuid::new_v4(); + let scopes = serde_json::json!(["files:read", "files:write"]); + sqlx::query( + r#" + INSERT INTO api_tokens + (community_id, id, token_hash, owner_pubkey, name, scopes) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(community_id) + .bind(id) + .bind(token_hash) + .bind(owner_pubkey) + .bind(name) + .bind(&scopes) + .execute(pool) + .await + .expect("insert api_token"); + id + } + + /// Row-44 sharp test: two communities, **same** 32-byte token hash in each, + /// lookup scoped to community A returns A's row only (and B-scoped lookup + /// returns B's row only). The storage UNIQUE index is `(community_id, + /// token_hash)` so this is a legal state. The lookup must not return the + /// foreign row. + /// + /// Mutate-bite handle: the WHERE clause in + /// `get_api_token_by_hash_including_revoked` is the only thing keeping + /// this test green. Strip `AND community_id = $1` and the lookup becomes + /// hash-only — Postgres returns whichever row it picks (insert-order + /// dependent), and the cross-tenancy assertion fails. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_by_hash_is_scoped_to_community() { + let db = setup_db().await; + + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + + // Distinct pubkeys per community — FK is (community_id, owner_pubkey). + let owner_a = vec![0xAAu8; 32]; + let owner_b = vec![0xBBu8; 32]; + insert_user(&db.pool, community_a, &owner_a).await; + insert_user(&db.pool, community_b, &owner_b).await; + + // SAME hash in both communities — legal under UNIQUE(community_id, token_hash). + let shared_hash = vec![0xCCu8; 32]; + let id_a = raw_insert_token(&db.pool, community_a, &shared_hash, &owner_a, "token-A").await; + let id_b = raw_insert_token(&db.pool, community_b, &shared_hash, &owner_b, "token-B").await; + assert_ne!(id_a, id_b, "ids must differ"); + + let cid_a = buzz_core::CommunityId::from_uuid(community_a); + let cid_b = buzz_core::CommunityId::from_uuid(community_b); + + // Lookup scoped to A returns A's row, never B's. + let from_a: ApiTokenRecord = db + .get_api_token_by_hash_including_revoked(cid_a, &shared_hash) + .await + .expect("lookup A") + .expect("row in A"); + assert_eq!(from_a.id, id_a, "community-A lookup must return A's row"); + assert_eq!( + from_a.owner_pubkey, owner_a, + "community-A lookup must return A's owner", + ); + + // Lookup scoped to B returns B's row, never A's. + let from_b: ApiTokenRecord = db + .get_api_token_by_hash_including_revoked(cid_b, &shared_hash) + .await + .expect("lookup B") + .expect("row in B"); + assert_eq!(from_b.id, id_b, "community-B lookup must return B's row"); + assert_eq!( + from_b.owner_pubkey, owner_b, + "community-B lookup must return B's owner", + ); + + // Lookup with the hash but a third (unrelated) community returns None. + let community_c = make_community(&db.pool).await; + let cid_c = buzz_core::CommunityId::from_uuid(community_c); + let from_c = db + .get_api_token_by_hash_including_revoked(cid_c, &shared_hash) + .await + .expect("lookup C"); + assert!( + from_c.is_none(), + "community-C has no token with this hash; lookup must return None, got {from_c:?}", + ); + } + + /// Active (non-revoked) lookup also enforces community scope. + /// Mirrors the obligation for the `revoked_at IS NULL` variant at + /// `Db::get_api_token_by_hash`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn active_lookup_by_hash_is_scoped_to_community() { + let db = setup_db().await; + + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + + let owner_a = vec![0x11u8; 32]; + let owner_b = vec![0x22u8; 32]; + insert_user(&db.pool, community_a, &owner_a).await; + insert_user(&db.pool, community_b, &owner_b).await; + + let shared_hash = vec![0x33u8; 32]; + let id_a = + raw_insert_token(&db.pool, community_a, &shared_hash, &owner_a, "active-A").await; + let id_b = + raw_insert_token(&db.pool, community_b, &shared_hash, &owner_b, "active-B").await; + + let cid_a = buzz_core::CommunityId::from_uuid(community_a); + let cid_b = buzz_core::CommunityId::from_uuid(community_b); + + let from_a = db + .get_api_token_by_hash(cid_a, &shared_hash) + .await + .expect("active lookup A") + .expect("row in A"); + assert_eq!(from_a.id, id_a); + + let from_b = db + .get_api_token_by_hash(cid_b, &shared_hash) + .await + .expect("active lookup B") + .expect("row in B"); + assert_eq!(from_b.id, id_b); + } +} diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/archived_identities.rs index 00237ccf8..941c0fc73 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/archived_identities.rs @@ -1,10 +1,11 @@ -//! Relay-scoped archived identity persistence (NIP-IA). +//! Community-scoped archived identity persistence (NIP-IA). //! -//! The `archived_identities` table stores a relay-local UI visibility hint for +//! The `archived_identities` table stores a community-local UI visibility hint for //! identity pubkeys. Archiving is not a ban: it does not affect membership, //! relay access, or repository permissions. //! All pubkey and event ID values are lowercase hex strings. +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; @@ -29,21 +30,26 @@ pub struct ArchivedIdentity { pub archived_at: DateTime, } -/// Returns `true` if `pubkey` (64-char hex) is currently archived. -pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .fetch_optional(pool) - .await?; +/// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. +pub async fn is_archived(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let row = + sqlx::query("SELECT 1 FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; Ok(row.is_some()) } -/// Archives an identity. +/// Archives an identity in `community_id`. /// /// Returns `true` if the row was inserted, `false` if the identity was already -/// archived. Re-archiving is idempotent and does not mutate the existing row. +/// archived in that community. Re-archiving is idempotent and does not mutate +/// the existing row. +#[allow(clippy::too_many_arguments)] pub async fn archive( pool: &PgPool, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -53,10 +59,11 @@ pub async fn archive( ) -> Result { let result = sqlx::query( "INSERT INTO archived_identities \ - (pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ - VALUES ($1, $2, $3, $4, $5, $6) \ - ON CONFLICT (pubkey) DO NOTHING", + (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(consent_path) .bind(actor) @@ -69,24 +76,31 @@ pub async fn archive( Ok(result.rows_affected() > 0) } -/// Unarchives an identity. +/// Unarchives an identity from `community_id`. /// -/// Returns `true` if a row was deleted, `false` if the identity was not archived. -pub async fn unarchive(pool: &PgPool, pubkey: &str) -> Result { - let result = sqlx::query("DELETE FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .execute(pool) - .await?; +/// Returns `true` if a row was deleted, `false` if the identity was not archived +/// in that community. +pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let result = + sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; Ok(result.rows_affected() > 0) } -/// Returns all archived identities ordered by archive time ascending. -pub async fn list_archived(pool: &PgPool) -> Result> { +/// Returns all identities archived in `community_id`, ordered by archive time ascending. +pub async fn list_archived( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { let rows = sqlx::query( "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ - FROM archived_identities ORDER BY archived_at ASC", + FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC", ) + .bind(community_id.as_uuid()) .fetch_all(pool) .await?; @@ -109,3 +123,99 @@ fn row_to_archived_identity( archived_at: row.try_get("archived_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = uuid::Uuid::new_v4(); + let host = format!("archive-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archived_identity_state_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let actor = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_a = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let event_b = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + assert!(archive( + &pool, + community_a, + pubkey, + "self", + actor, + Some("community A"), + None, + event_a, + ) + .await + .expect("archive in community A")); + + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("is_archived in A")); + assert!(!is_archived(&pool, community_b, pubkey) + .await + .expect("is_archived in B")); + assert_eq!( + list_archived(&pool, community_a) + .await + .expect("list A") + .len(), + 1 + ); + assert!(list_archived(&pool, community_b) + .await + .expect("list B") + .is_empty()); + assert!(!unarchive(&pool, community_b, pubkey) + .await + .expect("unarchive absent B")); + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("B unarchive must not affect A")); + + assert!(archive( + &pool, + community_b, + pubkey, + "self", + actor, + Some("community B"), + None, + event_b, + ) + .await + .expect("archive same pubkey in community B")); + assert!(unarchive(&pool, community_a, pubkey) + .await + .expect("unarchive A")); + assert!(!is_archived(&pool, community_a, pubkey) + .await + .expect("A removed")); + assert!(is_archived(&pool, community_b, pubkey) + .await + .expect("A unarchive must not affect B")); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index a9cecc141..5c38e5d1c 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -9,6 +9,7 @@ use sqlx::{PgPool, Postgres, Row, Transaction}; use uuid::Uuid; use crate::error::{DbError, Result}; +use buzz_core::CommunityId; // Re-export the canonical enum definitions from buzz-core. // These live in core (zero I/O deps) so the SDK can share them @@ -82,8 +83,10 @@ pub struct MemberRecord { } /// Creates a new channel, bootstraps the creator as owner, and returns the record. +#[allow(clippy::too_many_arguments)] pub async fn create_channel( pool: &PgPool, + community_id: CommunityId, name: &str, channel_type: ChannelType, visibility: ChannelVisibility, @@ -104,12 +107,13 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) - VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, - CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) + INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END) "#, ) .bind(id) + .bind(community_id.as_uuid()) .bind(name) .bind(channel_type.as_str()) .bind(visibility.as_str()) @@ -121,14 +125,15 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, 'owner', $3) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'owner', $4) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL, role = EXCLUDED.role "#, ) + .bind(community_id.as_uuid()) .bind(id) .bind(created_by) .bind(created_by) @@ -144,9 +149,10 @@ pub async fn create_channel( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline - FROM channels WHERE id = $1 + FROM channels WHERE community_id = $1 AND id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(id) .fetch_one(&mut *tx) .await?; @@ -163,6 +169,7 @@ pub async fn create_channel( #[allow(clippy::too_many_arguments)] pub async fn create_channel_with_id( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, name: &str, channel_type: ChannelType, @@ -188,13 +195,14 @@ pub async fn create_channel_with_id( let rows_affected = sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) - VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, - CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) - ON CONFLICT (id) DO NOTHING + INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END) + ON CONFLICT (community_id, id) DO NOTHING "#, ) .bind(channel_id) + .bind(community_id.as_uuid()) .bind(name) .bind(channel_type.as_str()) .bind(visibility.as_str()) @@ -211,14 +219,15 @@ pub async fn create_channel_with_id( // Bootstrap the creator as owner. sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, 'owner', $3) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'owner', $4) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL, role = EXCLUDED.role "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(created_by) .bind(created_by) @@ -235,9 +244,10 @@ pub async fn create_channel_with_id( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline - FROM channels WHERE id = $1 + FROM channels WHERE community_id = $1 AND id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_one(&mut *tx) .await?; @@ -247,8 +257,12 @@ pub async fn create_channel_with_id( Ok((record, was_created)) } -/// Fetches a channel record by ID. Returns `ChannelNotFound` if missing or deleted. -pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result { +/// Fetches a channel record by `(community_id, id)`. Returns `ChannelNotFound` if missing or deleted. +pub async fn get_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result { let row = sqlx::query( r#" SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, @@ -258,9 +272,10 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result Result Result> { - let row = sqlx::query("SELECT canvas FROM channels WHERE id = $1 AND deleted_at IS NULL") - .bind(channel_id) - .fetch_optional(pool) - .await? - .ok_or(DbError::ChannelNotFound(channel_id))?; +pub async fn get_canvas( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result> { + let row = sqlx::query( + "SELECT canvas FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(pool) + .await? + .ok_or(DbError::ChannelNotFound(channel_id))?; Ok(row.try_get("canvas")?) } /// Sets or clears the canvas content for a channel. -pub async fn set_canvas(pool: &PgPool, channel_id: Uuid, canvas: Option<&str>) -> Result<()> { - let rows = sqlx::query("UPDATE channels SET canvas = $1 WHERE id = $2 AND deleted_at IS NULL") +pub async fn set_canvas( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + canvas: Option<&str>, +) -> Result<()> { + let rows = sqlx::query( + "UPDATE channels SET canvas = $1 WHERE community_id = $2 AND id = $3 AND deleted_at IS NULL", + ) .bind(canvas) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -305,6 +335,7 @@ pub async fn set_canvas(pool: &PgPool, channel_id: Uuid, canvas: Option<&str>) - /// races (e.g. the inviter being removed between the role check and the INSERT). pub async fn add_member( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], role: MemberRole, @@ -319,7 +350,7 @@ pub async fn add_member( let mut tx = pool.begin().await?; - let channel = get_channel_tx(&mut tx, channel_id).await?; + let channel = get_channel_tx(&mut tx, community_id, channel_id).await?; let effective_role = if channel.visibility == "private" { let inviter = invited_by.ok_or_else(|| { @@ -330,7 +361,7 @@ pub async fn add_member( let is_creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice(); if !is_creator_bootstrap { - let inviter_role_str = get_active_role_tx(&mut tx, channel_id, inviter) + let inviter_role_str = get_active_role_tx(&mut tx, community_id, channel_id, inviter) .await? .ok_or_else(|| { DbError::AccessDenied("inviter is not an active member".to_string()) @@ -354,7 +385,7 @@ pub async fn add_member( // elevated roles. Self-join always gets Member. if role.is_elevated() { let granter_role = match invited_by { - Some(inv) => get_active_role_tx(&mut tx, channel_id, inv).await?, + Some(inv) => get_active_role_tx(&mut tx, community_id, channel_id, inv).await?, None => None, }; match granter_role.as_deref() { @@ -372,14 +403,15 @@ pub async fn add_member( sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, $3::member_role, $4) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, $4::member_role, $5) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL, role = EXCLUDED.role "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .bind(effective_role.as_str()) @@ -390,9 +422,10 @@ pub async fn add_member( let row = sqlx::query( r#" SELECT channel_id, pubkey, role::text AS role, joined_at, invited_by, removed_at - FROM channel_members WHERE channel_id = $1 AND pubkey = $2 + FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .fetch_one(&mut *tx) @@ -415,6 +448,7 @@ pub async fn add_member( /// because `agent_owner_pubkey` is immutable (set once at token mint). pub async fn remove_member( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], actor_pubkey: &[u8], @@ -423,7 +457,7 @@ pub async fn remove_member( let is_self_remove = pubkey == actor_pubkey; if !is_self_remove { - let actor_role_str = get_active_role_tx(&mut tx, channel_id, actor_pubkey) + let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey) .await? .ok_or_else(|| DbError::AccessDenied("actor is not an active member".to_string()))?; let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { @@ -432,7 +466,7 @@ pub async fn remove_member( // Safe to query outside the transaction: agent_owner_pubkey is immutable // (set once at token mint, first-mint-wins). if !actor_role.is_elevated() - && !crate::user::is_agent_owner(pool, pubkey, actor_pubkey).await? + && !crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? { return Err(DbError::AccessDenied( "only owners/admins or the agent's owner may remove other members".to_string(), @@ -443,12 +477,13 @@ pub async fn remove_member( // Defense-in-depth: prevent removing the last owner regardless of caller. // Callers (REST handlers, NIP-29 handlers) also check this, but the DB // layer enforces it as the final safety net. - let target_role = get_active_role_tx(&mut tx, channel_id, pubkey).await?; + let target_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; if target_role.as_deref() == Some("owner") { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members \ - WHERE channel_id = $1 AND role = 'owner' AND removed_at IS NULL", + WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_one(&mut *tx) .await?; @@ -464,10 +499,11 @@ pub async fn remove_member( r#" UPDATE channel_members SET removed_at = NOW(), removed_by = $1 - WHERE channel_id = $2 AND pubkey = $3 AND removed_at IS NULL + WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL "#, ) .bind(actor_pubkey) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .execute(&mut *tx) @@ -482,12 +518,18 @@ pub async fn remove_member( } /// Returns `true` if the given pubkey is an active member of the channel. -pub async fn is_member(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result { +pub async fn is_member( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members cm \ - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \ - WHERE cm.channel_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL", + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 AND cm.removed_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .fetch_one(pool) @@ -499,17 +541,22 @@ pub async fn is_member(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result /// Returns all active members of the given channel. /// /// Returns an empty list if the channel has been soft-deleted. -pub async fn get_members(pool: &PgPool, channel_id: Uuid) -> Result> { +pub async fn get_members( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result> { let rows = sqlx::query( r#" SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at FROM channel_members cm - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.channel_id = $1 AND cm.removed_at IS NULL + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL ORDER BY cm.joined_at ASC LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_all(pool) .await?; @@ -523,7 +570,11 @@ pub async fn get_members(pool: &PgPool, channel_id: Uuid) -> Result` ordered by `joined_at`; callers should /// group by `channel_id` if per-channel access is needed. /// Returns an empty vec immediately when `channel_ids` is empty. -pub async fn get_members_bulk(pool: &PgPool, channel_ids: &[Uuid]) -> Result> { +pub async fn get_members_bulk( + pool: &PgPool, + community_id: CommunityId, + channel_ids: &[Uuid], +) -> Result> { if channel_ids.is_empty() { return Ok(Vec::new()); } @@ -531,11 +582,12 @@ pub async fn get_members_bulk(pool: &PgPool, channel_ids: &[Uuid]) -> Result Result Result> { +pub async fn get_accessible_channel_ids( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { let rows = sqlx::query( r#" SELECT cm.channel_id FROM channel_members cm - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.pubkey = $1 AND cm.removed_at IS NULL + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL + WHERE cm.community_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL UNION SELECT id AS channel_id FROM channels - WHERE visibility = 'open' AND deleted_at IS NULL + WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .fetch_all(pool) .await?; @@ -572,8 +629,12 @@ pub async fn get_accessible_channel_ids(pool: &PgPool, pubkey: &[u8]) -> Result< .collect() } -/// Lists channels, optionally filtered by visibility string. -pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result> { +/// Lists channels in a community, optionally filtered by visibility string. +pub async fn list_channels( + pool: &PgPool, + community_id: CommunityId, + visibility: Option<&str>, +) -> Result> { let rows = if let Some(vis) = visibility { sqlx::query( r#" @@ -585,11 +646,12 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result) -> Result) -> Result, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], ) -> Result> { let row = sqlx::query( "SELECT role::text AS role FROM channel_members \ - WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL", + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .fetch_optional(&mut **tx) @@ -636,6 +701,7 @@ async fn get_active_role_tx( /// Transaction-aware variant of [`get_channel`]. async fn get_channel_tx( tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, channel_id: Uuid, ) -> Result { let row = sqlx::query( @@ -647,9 +713,10 @@ async fn get_channel_tx( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline - FROM channels WHERE id = $1 AND deleted_at IS NULL + FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_optional(&mut **tx) .await? @@ -666,6 +733,17 @@ pub struct BotChannelEntry { pub id: String, } +/// A channel archived by the ephemeral-channel reaper. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReapedEphemeralChannel { + /// Community that owns the archived channel. + pub community_id: CommunityId, + /// Normalized host mapped to that community. + pub host: String, + /// Archived channel UUID. + pub channel_id: Uuid, +} + /// Bot member record — a user with role=bot, with their channel memberships aggregated. #[derive(Debug, Clone)] pub struct BotMemberRecord { @@ -713,6 +791,7 @@ pub struct AccessibleChannel { /// that visibility value are returned. `None` returns all accessible channels. pub async fn get_accessible_channels( pool: &PgPool, + community_id: CommunityId, pubkey: &[u8], visibility_filter: Option<&str>, member_only: Option, @@ -739,20 +818,22 @@ pub async fn get_accessible_channels( (cm.channel_id IS NOT NULL) AS is_member FROM channels c LEFT JOIN channel_members cm - ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL - WHERE c.deleted_at IS NULL + ON c.community_id = cm.community_id AND c.id = cm.channel_id AND cm.pubkey = $2 AND cm.removed_at IS NULL + WHERE c.community_id = $1 AND c.deleted_at IS NULL {membership_clause} AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL) "# ); let sql = if visibility_filter.is_some() { - format!("{base} AND c.visibility::text = $2\n ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000") + format!("{base} AND c.visibility::text = $3\n ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000") } else { format!("{base} ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000") }; - let query = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(pubkey); + let query = sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(community_id.as_uuid()) + .bind(pubkey); let query = if let Some(vis) = visibility_filter { query.bind(vis) } else { @@ -769,24 +850,28 @@ pub async fn get_accessible_channels( .collect() } -/// Returns all bot-role members with their channel memberships. +/// Returns all bot-role members with their channel memberships in one community. /// /// Channels are returned as a JSON array of `{name, id}` objects via `json_agg`, /// preserving the 1:1 name↔UUID pairing. No separate string_agg ordering issues. /// Members with no active channel memberships are excluded (INNER JOIN on channels). -pub async fn get_bot_members(pool: &PgPool) -> Result> { +pub async fn get_bot_members( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { let rows = sqlx::query( r#" SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities, COALESCE(json_agg(DISTINCT jsonb_build_object('name', c.name, 'id', c.id::text)), '[]') AS channels_json FROM channel_members cm - LEFT JOIN users u ON cm.pubkey = u.pubkey - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.role = 'bot' AND cm.removed_at IS NULL + LEFT JOIN users u ON cm.community_id = u.community_id AND cm.pubkey = u.pubkey + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL + WHERE cm.community_id = $1 AND cm.role = 'bot' AND cm.removed_at IS NULL GROUP BY cm.pubkey, u.display_name, u.agent_type, u.capabilities LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .fetch_all(pool) .await?; @@ -809,25 +894,31 @@ pub async fn get_bot_members(pool: &PgPool) -> Result> { Ok(out) } -/// Bulk-fetch user records by pubkey. +/// Bulk-fetch user records by pubkey inside one community. /// /// Returns only users that exist in the `users` table. Ordering matches input order /// is NOT guaranteed — callers should index by pubkey if order matters. /// Returns an empty vec immediately when `pubkeys` is empty (no query issued). -pub async fn get_users_bulk(pool: &PgPool, pubkeys: &[Vec]) -> Result> { +pub async fn get_users_bulk( + pool: &PgPool, + community_id: CommunityId, + pubkeys: &[Vec], +) -> Result> { if pubkeys.is_empty() { return Ok(Vec::new()); } - // Build a parameterised IN clause: ($1, $2, ...) - let placeholders = (1..=pubkeys.len()) + // Build a parameterised IN clause: ($2, $3, ...); $1 is community_id. + let placeholders = (2..(pubkeys.len() + 2)) .map(|i| format!("${i}")) .collect::>() .join(", "); - let sql = - format!("SELECT pubkey, display_name, avatar_url, nip05_handle FROM users WHERE pubkey IN ({placeholders})"); + let sql = format!( + "SELECT pubkey, display_name, avatar_url, nip05_handle \ + FROM users WHERE community_id = $1 AND pubkey IN ({placeholders})" + ); - let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)); + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(community_id.as_uuid()); for pk in pubkeys { q = q.bind(pk); } @@ -922,6 +1013,7 @@ pub struct ChannelUpdate { /// Returns the updated `ChannelRecord` on success. pub async fn update_channel( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, updates: ChannelUpdate, ) -> Result { @@ -963,8 +1055,9 @@ pub async fn update_channel( None => set_parts.push("ttl_deadline = NULL".to_string()), } } + let channel_param_idx = param_idx + 1; let sql = format!( - "UPDATE channels SET {}, updated_at = NOW() WHERE id = ${param_idx} AND deleted_at IS NULL", + "UPDATE channels SET {}, updated_at = NOW() WHERE community_id = ${param_idx} AND id = ${channel_param_idx} AND deleted_at IS NULL", set_parts.join(", ") ); @@ -981,6 +1074,7 @@ pub async fn update_channel( if let Some(ref ttl) = updates.ttl_seconds { q = q.bind(*ttl); } + q = q.bind(community_id.as_uuid()); q = q.bind(channel_id); let result = q.execute(pool).await?; @@ -988,17 +1082,24 @@ pub async fn update_channel( return Err(DbError::ChannelNotFound(channel_id)); } - get_channel(pool, channel_id).await + get_channel(pool, community_id, channel_id).await } /// Sets the topic for a channel, recording who set it and when. -pub async fn set_topic(pool: &PgPool, channel_id: Uuid, topic: &str, set_by: &[u8]) -> Result<()> { +pub async fn set_topic( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + topic: &str, + set_by: &[u8], +) -> Result<()> { let result = sqlx::query( "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ - WHERE id = $3 AND deleted_at IS NULL", + WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", ) .bind(topic) .bind(set_by) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1011,16 +1112,18 @@ pub async fn set_topic(pool: &PgPool, channel_id: Uuid, topic: &str, set_by: &[u /// Sets the purpose for a channel, recording who set it and when. pub async fn set_purpose( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, purpose: &str, set_by: &[u8], ) -> Result<()> { let result = sqlx::query( "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ - WHERE id = $3 AND deleted_at IS NULL", + WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", ) .bind(purpose) .bind(set_by) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1034,9 +1137,16 @@ pub async fn set_purpose( /// /// Returns `AccessDenied` if the channel is already archived. /// Returns `ChannelNotFound` if the channel does not exist or is deleted. -pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { +pub async fn archive_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result<()> { // First check: does the channel exist and what is its state? - let row = sqlx::query("SELECT archived_at FROM channels WHERE id = $1 AND deleted_at IS NULL") + let row = sqlx::query( + "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_optional(pool) .await?; @@ -1055,8 +1165,9 @@ pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { sqlx::query( "UPDATE channels SET archived_at = NOW() \ - WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NULL", + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL AND archived_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1068,9 +1179,16 @@ pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { /// /// Returns `AccessDenied` if the channel is not currently archived. /// Returns `ChannelNotFound` if the channel does not exist or is deleted. -pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { +pub async fn unarchive_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result<()> { // First check: does the channel exist and what is its state? - let row = sqlx::query("SELECT archived_at FROM channels WHERE id = $1 AND deleted_at IS NULL") + let row = sqlx::query( + "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_optional(pool) .await?; @@ -1091,8 +1209,9 @@ pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { WHEN ttl_seconds IS NOT NULL THEN NOW() + (ttl_seconds || ' seconds')::interval \ ELSE ttl_deadline \ END \ - WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NOT NULL", + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL AND archived_at IS NOT NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1104,9 +1223,15 @@ pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { /// /// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already /// deleted or not found. -pub async fn soft_delete_channel(pool: &PgPool, channel_id: Uuid) -> Result { - let result = - sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") +pub async fn soft_delete_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result { + let result = sqlx::query( + "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1115,10 +1240,15 @@ pub async fn soft_delete_channel(pool: &PgPool, channel_id: Uuid) -> Result Result { +pub async fn get_member_count( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result { let row = sqlx::query( - "SELECT COUNT(*) as cnt FROM channel_members WHERE channel_id = $1 AND removed_at IS NULL", + "SELECT COUNT(*) as cnt FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND removed_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_one(pool) .await?; @@ -1131,6 +1261,7 @@ pub async fn get_member_count(pool: &PgPool, channel_id: Uuid) -> Result { /// Single query regardless of input size. pub async fn get_member_counts_bulk( pool: &PgPool, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { if channel_ids.is_empty() { @@ -1139,8 +1270,10 @@ pub async fn get_member_counts_bulk( let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( "SELECT channel_id, COUNT(*) as cnt FROM channel_members \ - WHERE removed_at IS NULL AND channel_id IN (", + WHERE community_id = ", ); + qb.push_bind(community_id.as_uuid()); + qb.push(" AND removed_at IS NULL AND channel_id IN ("); let mut sep = qb.separated(", "); for id in channel_ids { sep.push_bind(*id); @@ -1163,14 +1296,16 @@ pub async fn get_member_counts_bulk( /// Returns `None` if the pubkey is not an active member. pub async fn get_member_role( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], ) -> Result> { let row = sqlx::query( "SELECT cm.role::text AS role FROM channel_members cm \ - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \ - WHERE cm.channel_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL", + JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \ + WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 AND cm.removed_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .fetch_optional(pool) @@ -1181,11 +1316,16 @@ pub async fn get_member_role( /// Bump the TTL deadline for an ephemeral channel after a new message. /// /// No-op for permanent channels or channels that are already archived/deleted. -pub async fn bump_ttl_deadline(pool: &PgPool, channel_id: Uuid) -> Result<()> { +pub async fn bump_ttl_deadline( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result<()> { sqlx::query( "UPDATE channels SET ttl_deadline = NOW() + (ttl_seconds || ' seconds')::interval \ - WHERE id = $1 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL", + WHERE community_id = $1 AND id = $2 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1194,25 +1334,33 @@ pub async fn bump_ttl_deadline(pool: &PgPool, channel_id: Uuid) -> Result<()> { /// Archive ephemeral channels whose TTL deadline has passed. /// -/// Returns the list of channel IDs that were archived. Idempotent — the +/// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the /// `archived_at IS NULL` guard prevents double-archiving even if called /// concurrently from multiple relay pods. -pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { +pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { let rows = sqlx::query( - "UPDATE channels SET archived_at = NOW() \ - WHERE ttl_seconds IS NOT NULL \ - AND ttl_deadline < NOW() \ - AND archived_at IS NULL \ - AND deleted_at IS NULL \ - RETURNING id", + "UPDATE channels AS ch SET archived_at = NOW() \ + FROM communities AS c \ + WHERE ch.community_id = c.id \ + AND ch.ttl_seconds IS NOT NULL \ + AND ch.ttl_deadline < NOW() \ + AND ch.archived_at IS NULL \ + AND ch.deleted_at IS NULL \ + RETURNING ch.community_id, c.host, ch.id", ) .fetch_all(pool) .await?; rows.into_iter() .map(|row| { - let id: Uuid = row.try_get("id")?; - Ok(id) + let community_id: Uuid = row.try_get("community_id")?; + let host: String = row.try_get("host")?; + let channel_id: Uuid = row.try_get("id")?; + Ok(ReapedEphemeralChannel { + community_id: CommunityId::from_uuid(community_id), + host, + channel_id, + }) }) .collect() } @@ -1235,28 +1383,203 @@ mod tests { Keys::generate().public_key().to_bytes().to_vec() } + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("channel-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + #[allow(clippy::too_many_arguments)] + async fn create_test_channel( + pool: &PgPool, + community_id: Uuid, + name: &str, + channel_type: ChannelType, + visibility: ChannelVisibility, + description: Option<&str>, + created_by: &[u8], + ttl_seconds: Option, + ) -> Result { + let id = Uuid::new_v4(); + + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + VALUES + ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END) + "#, + ) + .bind(id) + .bind(community_id) + .bind(name) + .bind(channel_type.as_str()) + .bind(visibility.as_str()) + .bind(description) + .bind(created_by) + .bind(ttl_seconds) + .execute(pool) + .await + .expect("insert test channel"); + + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'owner', $4) + "#, + ) + .bind(community_id) + .bind(id) + .bind(created_by) + .bind(created_by) + .execute(pool) + .await + .expect("insert owner membership"); + + get_channel(pool, CommunityId::from_uuid(community_id), id).await + } + + async fn insert_channel_with_id( + pool: &PgPool, + community_id: Uuid, + id: Uuid, + name: &str, + created_by: &[u8], + ) { + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by) + VALUES + ($1, $2, $3, 'stream', 'open', $4) + "#, + ) + .bind(id) + .bind(community_id) + .bind(name) + .bind(created_by) + .execute(pool) + .await + .expect("insert channel with fixed id"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_users_bulk_is_scoped_when_pubkey_exists_in_multiple_communities() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let community_a = CommunityId::from_uuid(community_a); + let community_b = CommunityId::from_uuid(community_b); + let pubkey = random_pubkey(); + + sqlx::query( + "INSERT INTO users (community_id, pubkey, display_name) VALUES ($1, $2, $3), ($4, $5, $6)", + ) + .bind(community_a.as_uuid()) + .bind(&pubkey) + .bind("community-a-profile") + .bind(community_b.as_uuid()) + .bind(&pubkey) + .bind("community-b-profile") + .execute(&pool) + .await + .expect("insert same pubkey in two communities"); + + let users = get_users_bulk(&pool, community_a, std::slice::from_ref(&pubkey)) + .await + .expect("bulk fetch users"); + + assert_eq!(users.len(), 1); + assert_eq!( + users[0].display_name.as_deref(), + Some("community-a-profile") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_channel_is_scoped_when_channel_uuid_collides_across_communities() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let channel_id = Uuid::new_v4(); + let creator = random_pubkey(); + + insert_channel_with_id( + &pool, + community_a, + channel_id, + "community-a-channel", + &creator, + ) + .await; + insert_channel_with_id( + &pool, + community_b, + channel_id, + "community-b-channel", + &creator, + ) + .await; + + let a = get_channel(&pool, CommunityId::from_uuid(community_a), channel_id) + .await + .expect("community A channel should resolve"); + let b = get_channel(&pool, CommunityId::from_uuid(community_b), channel_id) + .await + .expect("community B channel should resolve"); + + assert_eq!(a.name, "community-a-channel"); + assert_eq!(b.name, "community-b-channel"); + + let listed_a = list_channels(&pool, CommunityId::from_uuid(community_a), None) + .await + .expect("list community A channels"); + assert!(listed_a + .iter() + .any(|row| row.id == channel_id && row.name == "community-a-channel")); + assert!(!listed_a + .iter() + .any(|row| row.id == channel_id && row.name == "community-b-channel")); + } + /// Agent owner (non-admin) can remove their own bot from a channel. #[tokio::test] #[ignore = "requires Postgres"] async fn test_agent_owner_can_remove_bot() { let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); let owner_pk = random_pubkey(); let agent_pk = random_pubkey(); // Create users and set agent ownership - ensure_user(&pool, &owner_pk).await.expect("ensure owner"); - ensure_user(&pool, &agent_pk).await.expect("ensure agent"); - set_agent_owner(&pool, &agent_pk, &owner_pk) + ensure_user(&pool, community, &owner_pk) + .await + .expect("ensure owner"); + ensure_user(&pool, community, &agent_pk) + .await + .expect("ensure agent"); + set_agent_owner(&pool, community, &agent_pk, &owner_pk) .await .expect("set agent owner"); // Create a channel owned by someone else entirely let channel_owner_pk = random_pubkey(); - ensure_user(&pool, &channel_owner_pk) + ensure_user(&pool, community, &channel_owner_pk) .await .expect("ensure channel owner"); - let channel = create_channel( + let channel = create_test_channel( &pool, + community_id, "test-bot-remove", ChannelType::Stream, ChannelVisibility::Open, @@ -1268,21 +1591,35 @@ mod tests { .expect("create channel"); // Add owner and agent as regular members - add_member(&pool, channel.id, &owner_pk, MemberRole::Member, None) - .await - .expect("add owner as member"); - add_member(&pool, channel.id, &agent_pk, MemberRole::Member, None) - .await - .expect("add agent as member"); + add_member( + &pool, + community, + channel.id, + &owner_pk, + MemberRole::Member, + None, + ) + .await + .expect("add owner as member"); + add_member( + &pool, + community, + channel.id, + &agent_pk, + MemberRole::Member, + None, + ) + .await + .expect("add agent as member"); // Owner should be able to remove their agent - remove_member(&pool, channel.id, &agent_pk, &owner_pk) + remove_member(&pool, community, channel.id, &agent_pk, &owner_pk) .await .expect("agent owner should be able to remove their bot"); // Verify the agent is no longer a member assert!( - !is_member(&pool, channel.id, &agent_pk) + !is_member(&pool, community, channel.id, &agent_pk) .await .expect("is_member check"), "agent should no longer be a member" @@ -1295,11 +1632,16 @@ mod tests { #[ignore = "requires Postgres"] async fn test_unarchive_expired_ephemeral_channel_renews_ttl_deadline() { let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); let owner_pk = random_pubkey(); - ensure_user(&pool, &owner_pk).await.expect("ensure owner"); + ensure_user(&pool, community, &owner_pk) + .await + .expect("ensure owner"); - let channel = create_channel( + let channel = create_test_channel( &pool, + community_id, "test-unarchive-renews-ttl", ChannelType::Stream, ChannelVisibility::Open, @@ -1311,18 +1653,19 @@ mod tests { .expect("create ephemeral channel"); sqlx::query( - "UPDATE channels SET archived_at = NOW(), ttl_deadline = NOW() - interval '1 second' WHERE id = $1", + "UPDATE channels SET archived_at = NOW(), ttl_deadline = NOW() - interval '1 second' WHERE community_id = $1 AND id = $2", ) + .bind(community_id) .bind(channel.id) .execute(&pool) .await .expect("expire and archive channel"); - unarchive_channel(&pool, channel.id) + unarchive_channel(&pool, community, channel.id) .await .expect("unarchive expired ephemeral channel"); - let channel = get_channel(&pool, channel.id) + let channel = get_channel(&pool, community, channel.id) .await .expect("reload channel"); assert!( @@ -1338,35 +1681,97 @@ mod tests { .await .expect("run reaper"); assert!( - !reaped.contains(&channel.id), + !reaped + .iter() + .any(|row| row.community_id == community && row.channel_id == channel.id), "reaper should not immediately rearchive renewed channel" ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reap_expired_ephemeral_channels_returns_row_community_and_host() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let expected_host: String = + sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_id) + .fetch_one(&pool) + .await + .expect("load community host"); + let owner_pk = random_pubkey(); + ensure_user(&pool, community, &owner_pk) + .await + .expect("ensure owner"); + let channel = create_test_channel( + &pool, + community_id, + "test-reaper-host-provenance", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner_pk, + Some(60), + ) + .await + .expect("create ephemeral channel"); + + sqlx::query( + "UPDATE channels SET ttl_deadline = NOW() - interval '1 second' WHERE community_id = $1 AND id = $2", + ) + .bind(community_id) + .bind(channel.id) + .execute(&pool) + .await + .expect("expire channel"); + + let reaped = reap_expired_ephemeral_channels(&pool) + .await + .expect("run reaper"); + assert!( + reaped.iter().any(|row| { + row.community_id == community + && row.host == expected_host + && row.channel_id == channel.id + }), + "reaper should carry the archived row's community id and host" + ); + } + /// A random non-admin, non-owner user cannot remove someone else's bot. #[tokio::test] #[ignore = "requires Postgres"] async fn test_random_user_cannot_remove_bot() { let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); let owner_pk = random_pubkey(); let agent_pk = random_pubkey(); let random_pk = random_pubkey(); // Create users and set agent ownership - ensure_user(&pool, &owner_pk).await.expect("ensure owner"); - ensure_user(&pool, &agent_pk).await.expect("ensure agent"); - ensure_user(&pool, &random_pk).await.expect("ensure random"); - set_agent_owner(&pool, &agent_pk, &owner_pk) + ensure_user(&pool, community, &owner_pk) + .await + .expect("ensure owner"); + ensure_user(&pool, community, &agent_pk) + .await + .expect("ensure agent"); + ensure_user(&pool, community, &random_pk) + .await + .expect("ensure random"); + set_agent_owner(&pool, community, &agent_pk, &owner_pk) .await .expect("set agent owner"); // Create a channel let channel_owner_pk = random_pubkey(); - ensure_user(&pool, &channel_owner_pk) + ensure_user(&pool, community, &channel_owner_pk) .await .expect("ensure channel owner"); - let channel = create_channel( + let channel = create_test_channel( &pool, + community_id, "test-bot-no-remove", ChannelType::Stream, ChannelVisibility::Open, @@ -1378,15 +1783,29 @@ mod tests { .expect("create channel"); // Add random user and agent as regular members - add_member(&pool, channel.id, &random_pk, MemberRole::Member, None) - .await - .expect("add random as member"); - add_member(&pool, channel.id, &agent_pk, MemberRole::Member, None) - .await - .expect("add agent as member"); + add_member( + &pool, + community, + channel.id, + &random_pk, + MemberRole::Member, + None, + ) + .await + .expect("add random as member"); + add_member( + &pool, + community, + channel.id, + &agent_pk, + MemberRole::Member, + None, + ) + .await + .expect("add agent as member"); // Random user should NOT be able to remove the agent - let result = remove_member(&pool, channel.id, &agent_pk, &random_pk).await; + let result = remove_member(&pool, community, channel.id, &agent_pk, &random_pk).await; assert!( result.is_err(), "random user should not be able to remove someone else's bot" diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/dm.rs index 7684ac7d2..89e15c702 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/dm.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::channel::ChannelRecord; use crate::error::{DbError, Result}; +use buzz_core::CommunityId; // -- Public structs ----------------------------------------------------------- @@ -63,6 +64,7 @@ pub fn compute_participant_hash(pubkeys: &[&[u8]]) -> [u8; 32] { /// Returns `None` if no matching DM exists or if it has been deleted. pub async fn find_dm_by_participants( pool: &PgPool, + community_id: CommunityId, participant_hash: &[u8], ) -> Result> { let row = sqlx::query( @@ -74,12 +76,14 @@ pub async fn find_dm_by_participants( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at FROM channels - WHERE participant_hash = $1 + WHERE community_id = $1 + AND participant_hash = $2 AND channel_type = 'dm' AND deleted_at IS NULL LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(participant_hash) .fetch_optional(pool) .await?; @@ -96,6 +100,7 @@ pub async fn find_dm_by_participants( /// - The operation is idempotent: same participant set -> same channel returned. pub async fn create_dm( pool: &PgPool, + community_id: CommunityId, participants: &[&[u8]], created_by: &[u8], ) -> Result { @@ -132,12 +137,14 @@ pub async fn create_dm( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at FROM channels - WHERE participant_hash = $1 + WHERE community_id = $1 + AND participant_hash = $2 AND channel_type = 'dm' AND deleted_at IS NULL LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(hash.as_slice()) .fetch_optional(&mut *tx) .await?; @@ -159,11 +166,12 @@ pub async fn create_dm( sqlx::query( r#" INSERT INTO channels - (id, name, channel_type, visibility, created_by, participant_hash) - VALUES ($1, $2, 'dm', 'private', $3, $4) + (id, community_id, name, channel_type, visibility, created_by, participant_hash) + VALUES ($1, $2, $3, 'dm', 'private', $4, $5) "#, ) .bind(id) + .bind(community_id.as_uuid()) .bind(&name) .bind(created_by) .bind(hash.as_slice()) @@ -174,14 +182,15 @@ pub async fn create_dm( for pk in participants { sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, 'member', $3) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'member', $4) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL, role = EXCLUDED.role "#, ) + .bind(community_id.as_uuid()) .bind(id) .bind(*pk) .bind(created_by) @@ -197,9 +206,10 @@ pub async fn create_dm( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at - FROM channels WHERE id = $1 + FROM channels WHERE community_id = $1 AND id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(id) .fetch_one(&mut *tx) .await?; @@ -215,6 +225,7 @@ pub async fn create_dm( /// using `updated_at` ordering. pub async fn list_dms_for_user( pool: &PgPool, + community_id: CommunityId, pubkey: &[u8], limit: u32, cursor: Option, @@ -223,10 +234,12 @@ pub async fn list_dms_for_user( // Resolve cursor to a timestamp for keyset pagination. let cursor_ts: Option> = if let Some(cid) = cursor { - let row = sqlx::query("SELECT updated_at FROM channels WHERE id = $1") - .bind(cid) - .fetch_optional(pool) - .await?; + let row = + sqlx::query("SELECT updated_at FROM channels WHERE community_id = $1 AND id = $2") + .bind(community_id.as_uuid()) + .bind(cid) + .fetch_optional(pool) + .await?; row.map(|r| r.try_get::, _>("updated_at")) .transpose()? } else { @@ -240,17 +253,20 @@ pub async fn list_dms_for_user( SELECT c.id, c.created_at, c.updated_at FROM channels c JOIN channel_members cm - ON c.id = cm.channel_id - AND cm.pubkey = $1 + ON c.community_id = cm.community_id + AND c.id = cm.channel_id + AND cm.pubkey = $2 AND cm.removed_at IS NULL AND cm.hidden_at IS NULL - WHERE c.channel_type = 'dm' + WHERE c.community_id = $1 + AND c.channel_type = 'dm' AND c.deleted_at IS NULL - AND c.updated_at < $2 + AND c.updated_at < $3 ORDER BY c.updated_at DESC - LIMIT $3 + LIMIT $4 "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(ts) .bind(limit) @@ -262,16 +278,19 @@ pub async fn list_dms_for_user( SELECT c.id, c.created_at, c.updated_at FROM channels c JOIN channel_members cm - ON c.id = cm.channel_id - AND cm.pubkey = $1 + ON c.community_id = cm.community_id + AND c.id = cm.channel_id + AND cm.pubkey = $2 AND cm.removed_at IS NULL AND cm.hidden_at IS NULL - WHERE c.channel_type = 'dm' + WHERE c.community_id = $1 + AND c.channel_type = 'dm' AND c.deleted_at IS NULL ORDER BY c.updated_at DESC - LIMIT $2 + LIMIT $3 "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(limit) .fetch_all(pool) @@ -290,12 +309,16 @@ pub async fn list_dms_for_user( r#" SELECT cm.pubkey, cm.role::text AS role, u.display_name FROM channel_members cm - LEFT JOIN users u ON cm.pubkey = u.pubkey - WHERE cm.channel_id = $1 + LEFT JOIN users u + ON u.community_id = cm.community_id + AND u.pubkey = cm.pubkey + WHERE cm.community_id = $1 + AND cm.channel_id = $2 AND cm.removed_at IS NULL ORDER BY cm.joined_at ASC "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_all(pool) .await?; @@ -332,6 +355,7 @@ pub async fn list_dms_for_user( /// - `was_created = false` -- an existing DM was returned. pub async fn open_dm( pool: &PgPool, + community_id: CommunityId, pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(ChannelRecord, bool)> { @@ -351,14 +375,14 @@ pub async fn open_dm( let hash = compute_participant_hash(&all); // Check for existing DM first (fast path, no transaction). - if let Some(existing) = find_dm_by_participants(pool, &hash).await? { + if let Some(existing) = find_dm_by_participants(pool, community_id, &hash).await? { // Clear hidden_at for the caller so the DM reappears in their sidebar. - unhide_dm(pool, existing.id, created_by).await?; + unhide_dm(pool, community_id, existing.id, created_by).await?; return Ok((existing, false)); } // Create new DM. - let channel = create_dm(pool, &all, created_by).await?; + let channel = create_dm(pool, community_id, &all, created_by).await?; Ok((channel, true)) } @@ -370,14 +394,20 @@ pub async fn open_dm( /// The DM is not deleted — it can be restored by opening a new DM with the /// same participants (which clears `hidden_at`). Returns an error if the user /// is not an active member of the channel. -pub async fn hide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { +pub async fn hide_dm( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result<()> { let result = sqlx::query( r#" UPDATE channel_members SET hidden_at = NOW() - WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .execute(pool) @@ -396,14 +426,20 @@ pub async fn hide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<( /// /// This is called automatically when a user re-opens a DM via [`open_dm`]. /// It is a no-op if the membership is not currently hidden. -pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { +pub async fn unhide_dm( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result<()> { sqlx::query( r#" UPDATE channel_members SET hidden_at = NULL - WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .execute(pool) @@ -415,13 +451,20 @@ pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result /// Return the channel IDs of all DMs the given user currently has hidden /// (`hidden_at IS NOT NULL`) while still being an active member. Used to build /// the relay-signed NIP-DV visibility snapshot. -pub async fn list_hidden_dms(pool: &PgPool, pubkey: &[u8]) -> Result> { +pub async fn list_hidden_dms( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { let rows = sqlx::query( r#" SELECT cm.channel_id FROM channel_members cm - JOIN channels c ON c.id = cm.channel_id - WHERE cm.pubkey = $1 + JOIN channels c + ON c.community_id = cm.community_id + AND c.id = cm.channel_id + WHERE cm.community_id = $1 + AND cm.pubkey = $2 AND cm.removed_at IS NULL AND cm.hidden_at IS NOT NULL AND c.channel_type = 'dm' @@ -429,6 +472,7 @@ pub async fn list_hidden_dms(pool: &PgPool, pubkey: &[u8]) -> Result> ORDER BY cm.channel_id "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .fetch_all(pool) .await?; diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 9cef6c53c..357e337b4 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -12,13 +12,15 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, }; -use buzz_core::StoredEvent; +use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; /// Optional filters for [`query_events`]. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct EventQuery { + /// Server-resolved community scope. + pub community_id: CommunityId, /// Restrict results to this channel. pub channel_id: Option, /// Restrict results to these kind values (stored as `i32` in Postgres). @@ -69,6 +71,36 @@ pub struct EventQuery { pub max_limit: Option, } +impl EventQuery { + /// Construct an unconstrained query inside a server-resolved community. + /// + /// `community_id` has no safe default. This keeps call sites concise while + /// making tenant provenance explicit at construction. + #[must_use] + pub const fn for_community(community_id: CommunityId) -> Self { + Self { + community_id, + channel_id: None, + kinds: None, + pubkey: None, + since: None, + until: None, + limit: None, + offset: None, + p_tag_hex: None, + d_tag: None, + d_tags: None, + before_id: None, + global_only: false, + authors: None, + ids: None, + e_tags: None, + channel_ids: None, + max_limit: None, + } + } +} + /// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers; /// anything beyond this is either a bug or abuse. pub const D_TAG_MAX_LEN: usize = 1024; @@ -123,6 +155,7 @@ pub fn extract_not_before(event: &Event) -> Option { /// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate. pub async fn insert_event( pool: &PgPool, + community_id: CommunityId, event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { @@ -150,11 +183,12 @@ pub async fn insert_event( let not_before = extract_not_before(event); let result = sqlx::query( r#" - INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(id_bytes.as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -220,16 +254,24 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result { let mut qb: QueryBuilder = if let Some(ref p_hex) = q.p_tag_hex { let mut b = QueryBuilder::new( "SELECT COUNT(*) as cnt FROM events e \ - INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE e.deleted_at IS NULL AND m.pubkey_hex = ", + INNER JOIN event_mentions m \ + ON e.community_id = m.community_id AND e.id = m.event_id \ + WHERE e.community_id = ", ); + b.push_bind(q.community_id.as_uuid()); + b.push(" AND m.community_id = "); + b.push_bind(q.community_id.as_uuid()); + b.push(" AND e.deleted_at IS NULL AND m.pubkey_hex = "); b.push_bind(p_hex.to_ascii_lowercase()); b } else { - QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE deleted_at IS NULL") + let mut b = QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE community_id = "); + b.push_bind(q.community_id.as_uuid()); + b.push(" AND deleted_at IS NULL"); + b }; let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" }; @@ -564,9 +614,15 @@ pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result { /// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted /// or not found. Callers are responsible for decrementing thread reply counts /// when the deleted event is a thread reply. -pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result { - let result = - sqlx::query("UPDATE events SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") +pub async fn soft_delete_event( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], +) -> Result { + let result = sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) .bind(event_id) .execute(pool) .await?; @@ -587,14 +643,16 @@ pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result { /// (already deleted, or never existed). pub async fn soft_delete_by_coordinate( pool: &PgPool, + community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, ) -> Result { let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey) .bind(d_tag) @@ -611,17 +669,20 @@ pub async fn soft_delete_by_coordinate( /// event was deleted this call. pub async fn soft_delete_event_and_update_thread( pool: &PgPool, + community_id: CommunityId, event_id: &[u8], parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { let mut tx = pool.begin().await?; - let result = - sqlx::query("UPDATE events SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") - .bind(event_id) - .execute(&mut *tx) - .await?; + let result = sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .execute(&mut *tx) + .await?; let deleted = result.rows_affected() > 0; @@ -630,8 +691,9 @@ pub async fn soft_delete_event_and_update_thread( sqlx::query( "UPDATE thread_metadata \ SET reply_count = GREATEST(reply_count - 1, 0) \ - WHERE event_id = $1", + WHERE community_id = $1 AND event_id = $2", ) + .bind(community_id.as_uuid()) .bind(pid) .execute(&mut *tx) .await?; @@ -640,8 +702,9 @@ pub async fn soft_delete_event_and_update_thread( sqlx::query( "UPDATE thread_metadata \ SET descendant_count = GREATEST(descendant_count - 1, 0) \ - WHERE event_id = $1", + WHERE community_id = $1 AND event_id = $2", ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(&mut *tx) .await?; @@ -656,13 +719,15 @@ pub async fn soft_delete_event_and_update_thread( /// Returns the `created_at` timestamp of the most recent non-deleted event in a channel. pub async fn get_last_message_at( pool: &PgPool, + community_id: CommunityId, channel_id: uuid::Uuid, ) -> Result>> { let row = sqlx::query( "SELECT created_at FROM events \ - WHERE channel_id = $1 AND deleted_at IS NULL \ + WHERE community_id = $1 AND channel_id = $2 AND deleted_at IS NULL \ ORDER BY created_at DESC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_optional(pool) .await?; @@ -679,6 +744,7 @@ pub async fn get_last_message_at( /// Single query regardless of input size. pub async fn get_last_message_at_bulk( pool: &PgPool, + community_id: CommunityId, channel_ids: &[uuid::Uuid], ) -> Result>> { if channel_ids.is_empty() { @@ -687,8 +753,10 @@ pub async fn get_last_message_at_bulk( let mut qb: QueryBuilder = QueryBuilder::new( "SELECT channel_id, MAX(created_at) as last_at FROM events \ - WHERE deleted_at IS NULL AND channel_id IN (", + WHERE community_id = ", ); + qb.push_bind(community_id.as_uuid()); + qb.push(" AND deleted_at IS NULL AND channel_id IN ("); let mut sep = qb.separated(", "); for id in channel_ids { sep.push_bind(*id); @@ -711,11 +779,16 @@ pub async fn get_last_message_at_bulk( /// Returns `None` if the event does not exist or has been soft-deleted. /// Use [`get_event_by_id_including_deleted`] when you need to inspect /// tombstoned rows (e.g. audit, undelete). -pub async fn get_event_by_id(pool: &PgPool, id_bytes: &[u8]) -> Result> { +pub async fn get_event_by_id( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], +) -> Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", + FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(id_bytes) .fetch_optional(pool) .await?; @@ -734,16 +807,18 @@ pub async fn get_event_by_id(pool: &PgPool, id_bytes: &[u8]) -> Result Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events \ - WHERE kind = $1 AND pubkey = $2 AND channel_id IS NULL AND deleted_at IS NULL \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id IS NULL AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC \ LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey_bytes) .fetch_optional(pool) @@ -762,12 +837,14 @@ pub async fn get_latest_global_replaceable( /// audit trails, compliance queries). pub async fn get_event_by_id_including_deleted( pool: &PgPool, + community_id: CommunityId, id_bytes: &[u8], ) -> Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE id = $1 ORDER BY created_at DESC LIMIT 1", + FROM events WHERE community_id = $1 AND id = $2 ORDER BY created_at DESC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(id_bytes) .fetch_optional(pool) .await?; @@ -782,7 +859,11 @@ pub async fn get_event_by_id_including_deleted( /// /// Returns events in arbitrary order — callers reorder as needed. /// Uses a single `WHERE id IN (...)` query regardless of input size. -pub async fn get_events_by_ids(pool: &PgPool, ids: &[&[u8]]) -> Result> { +pub async fn get_events_by_ids( + pool: &PgPool, + community_id: CommunityId, + ids: &[&[u8]], +) -> Result> { if ids.is_empty() { return Ok(vec![]); } @@ -790,8 +871,10 @@ pub async fn get_events_by_ids(pool: &PgPool, ids: &[&[u8]]) -> Result = QueryBuilder::new( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE deleted_at IS NULL AND id IN (", + FROM events WHERE community_id = ", ); + qb.push_bind(community_id.as_uuid()); + qb.push(" AND deleted_at IS NULL AND id IN ("); let mut sep = qb.separated(", "); for id in ids { sep.push_bind(id.to_vec()); @@ -842,6 +925,7 @@ pub struct ThreadMetadataParams<'a> { /// Returns `(StoredEvent, was_inserted)`. pub async fn insert_event_with_thread_metadata( pool: &PgPool, + community_id: CommunityId, event: &Event, channel_id: Option, thread_meta: Option>, @@ -871,11 +955,12 @@ pub async fn insert_event_with_thread_metadata( let result = sqlx::query( r#" - INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(id_bytes.as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -899,14 +984,15 @@ pub async fn insert_event_with_thread_metadata( let tm_result = sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(meta.event_created_at) .bind(meta.event_id) .bind(meta.channel_id) @@ -931,14 +1017,15 @@ pub async fn insert_event_with_thread_metadata( sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false) + VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(parent_ts) .bind(pid) .bind(meta.channel_id) @@ -953,14 +1040,15 @@ pub async fn insert_event_with_thread_metadata( sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false) + VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(root_ts) .bind(root_id) .bind(meta.channel_id) @@ -973,9 +1061,10 @@ pub async fn insert_event_with_thread_metadata( r#" UPDATE thread_metadata SET reply_count = reply_count + 1, last_reply_at = NOW() - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(pid) .execute(&mut *tx) .await?; @@ -985,9 +1074,10 @@ pub async fn insert_event_with_thread_metadata( r#" UPDATE thread_metadata SET descendant_count = descendant_count + 1 - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(&mut *tx) .await?; @@ -1008,6 +1098,10 @@ pub async fn insert_event_with_thread_metadata( /// A due reminder row returned by [`query_due_reminders`]. #[derive(Debug)] pub struct DueReminder { + /// Server-resolved community this reminder row belongs to. + pub community_id: CommunityId, + /// Normalized host mapped to that community. + pub host: String, /// The event's raw ID bytes. pub id: Vec, /// The event's pubkey bytes. @@ -1039,15 +1133,16 @@ pub async fn query_due_reminders( let kind_i32 = KIND_EVENT_REMINDER as i32; let rows = sqlx::query( r#" - SELECT DISTINCT ON (pubkey, d_tag) - id, pubkey, created_at, kind, tags, content, sig, channel_id - FROM events - WHERE kind = $1 - AND not_before IS NOT NULL - AND not_before <= $2 - AND deleted_at IS NULL - AND delivered_at IS NULL - ORDER BY pubkey, d_tag, created_at DESC, id ASC + SELECT DISTINCT ON (e.community_id, e.pubkey, e.d_tag) + e.community_id, c.host, e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.channel_id + FROM events AS e + JOIN communities AS c ON c.id = e.community_id + WHERE e.kind = $1 + AND e.not_before IS NOT NULL + AND e.not_before <= $2 + AND e.deleted_at IS NULL + AND e.delivered_at IS NULL + ORDER BY e.community_id, e.pubkey, e.d_tag, e.created_at DESC, e.id ASC LIMIT $3 "#, ) @@ -1060,6 +1155,8 @@ pub async fn query_due_reminders( let results = rows .into_iter() .map(|row| DueReminder { + community_id: CommunityId::from_uuid(row.get("community_id")), + host: row.get("host"), id: row.get("id"), pubkey: row.get("pubkey"), created_at: row.get("created_at"), @@ -1080,18 +1177,46 @@ pub async fn query_due_reminders( /// idempotency. pub async fn claim_due_reminder( pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, +) -> Result { + claim_due_reminder_with_stamp( + pool, + community_id, + event_id, + event_created_at, + Utc::now().timestamp(), + ) + .await +} + +/// Atomically claim a due reminder using a caller-supplied delivery stamp. +/// +/// The same stamp should be passed to [`release_due_reminder`] if the publish +/// side effect fails, so rollback can compare-and-clear only this pod's claim. +/// +/// Scoped by `community_id`: `events` is keyed `(community_id, created_at, id)`, +/// and the same Nostr event id (hence the same `id`/`created_at` pair) is +/// allowed across communities. Without the community predicate a claim for +/// `A/X` would also mark `B/X` delivered. The caller already holds the owning +/// community on the `DueReminder` row. +pub async fn claim_due_reminder_with_stamp( + pool: &PgPool, + community_id: CommunityId, event_id: &[u8], event_created_at: DateTime, + delivery_stamp: i64, ) -> Result { - let now_epoch = Utc::now().timestamp(); let result = sqlx::query( r#" UPDATE events SET delivered_at = $1 - WHERE created_at = $2 AND id = $3 AND delivered_at IS NULL + WHERE community_id = $2 AND created_at = $3 AND id = $4 AND delivered_at IS NULL "#, ) - .bind(now_epoch) + .bind(delivery_stamp) + .bind(community_id.as_uuid()) .bind(event_created_at) .bind(event_id) .execute(pool) @@ -1100,11 +1225,116 @@ pub async fn claim_due_reminder( Ok(result.rows_affected() > 0) } +/// Release a previously claimed reminder when publish fails. +/// +/// The `delivery_stamp` must be the exact value written by the claiming pod; +/// that compare-and-clear prevents one pod from rolling back another pod's +/// later claim after a retry/race. +/// +/// Scoped by `community_id` for the same reason as the claim: a release for +/// `A/X` must not clear `B/X` even when their `id`/`created_at`/stamp coincide. +pub async fn release_due_reminder( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = NULL + WHERE community_id = $1 + AND created_at = $2 + AND id = $3 + AND delivered_at = $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(delivery_stamp) + .execute(pool) + .await?; + + Ok(result.rows_affected() == 1) +} + #[cfg(test)] mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("event-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_event_by_id_is_scoped_when_event_id_collides_across_communities() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "same signed event") + .sign_with_keys(&keys) + .expect("sign event"); + + insert_event(&pool, community_a, &event, None) + .await + .expect("insert in community A"); + insert_event(&pool, community_b, &event, None) + .await + .expect("insert same event in community B"); + + sqlx::query("UPDATE events SET content = $1 WHERE community_id = $2 AND id = $3") + .bind("community-a-copy") + .bind(community_a.as_uuid()) + .bind(event.id.as_bytes()) + .execute(&pool) + .await + .expect("mark community A row"); + sqlx::query("UPDATE events SET content = $1 WHERE community_id = $2 AND id = $3") + .bind("community-b-copy") + .bind(community_b.as_uuid()) + .bind(event.id.as_bytes()) + .execute(&pool) + .await + .expect("mark community B row"); + + let a = get_event_by_id(&pool, community_a, event.id.as_bytes()) + .await + .expect("lookup community A") + .expect("community A row exists"); + let b = get_event_by_id(&pool, community_b, event.id.as_bytes()) + .await + .expect("lookup community B") + .expect("community B row exists"); + + assert_eq!(a.event.content, "community-a-copy"); + assert_eq!(b.event.content, "community-b-copy"); + } + fn make_event_with_kind_and_tags(kind: u16, tags: Vec) -> nostr::Event { let keys = Keys::generate(); EventBuilder::new(Kind::Custom(kind), "test") @@ -1240,4 +1470,243 @@ mod tests { ); assert_eq!(extract_not_before(&event), None); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_due_reminders_returns_row_community_and_host_per_tenant() { + let pool = setup_pool().await; + let community_a_uuid = make_test_community(&pool).await; + let community_b_uuid = make_test_community(&pool).await; + let community_a = CommunityId::from_uuid(community_a_uuid); + let community_b = CommunityId::from_uuid(community_b_uuid); + let host_a: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_a_uuid) + .fetch_one(&pool) + .await + .expect("load host A"); + let host_b: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_b_uuid) + .fetch_one(&pool) + .await + .expect("load host B"); + + let not_before = Utc::now().timestamp() - 1; + let keys_a = Keys::generate(); + let keys_b = Keys::generate(); + let event_a = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "a") + .tags([ + Tag::parse(["d", "due-reminder-scope-a"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_a) + .expect("sign A"); + let event_b = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "b") + .tags([ + Tag::parse(["d", "due-reminder-scope-b"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_b) + .expect("sign B"); + + insert_event(&pool, community_a, &event_a, None) + .await + .expect("insert A"); + insert_event(&pool, community_b, &event_b, None) + .await + .expect("insert B"); + + let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) + .await + .expect("query due reminders"); + + assert!(due.iter().any(|row| { + row.id == event_a.id.as_bytes() && row.community_id == community_a && row.host == host_a + })); + assert!(due.iter().any(|row| { + row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b + })); + } + + /// Two pods race to claim the same due reminder: exactly one wins. The + /// scheduler publishes only on a winning claim (`Ok(true)`) and `continue`s + /// on the loser (`Ok(false)`), so a single winning claim *is* the proof of + /// exactly one publish side effect across N pods. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-claim-race"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community, &event, None) + .await + .expect("insert reminder"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + + // Two pods, two distinct per-attempt stamps, same reminder. + let stamp_p1: i64 = 0x1111_1111_1111_1111; + let stamp_p2: i64 = 0x2222_2222_2222_2222; + let won_p1 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p1) + .await + .expect("p1 claim"); + let won_p2 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p2) + .await + .expect("p2 claim"); + + assert!( + won_p1 ^ won_p2, + "exactly one pod must win the claim (p1={won_p1}, p2={won_p2}) — \ + the loser never reaches the publish side effect" + ); + } + + /// A failed publish releases the claim so the reminder is redeliverable, + /// and the compare-and-clear stamp guard prevents one pod from rolling back + /// another pod's claim. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn release_due_reminder_rolls_back_only_the_matching_stamp() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-release"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community, &event, None) + .await + .expect("insert reminder"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + let stamp: i64 = 0x3333_3333_3333_3333; + + assert!( + claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("claim"), + "first claim wins" + ); + + // A release with the *wrong* stamp must be a no-op (does not clear + // another pod's claim). + assert!( + !release_due_reminder(&pool, community, &id, created_at, stamp ^ 0xFFFF) + .await + .expect("wrong-stamp release"), + "release with a non-matching stamp must not clear the claim" + ); + assert!( + !claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("re-claim after no-op release"), + "reminder must still be claimed after a no-op release" + ); + + // The matching-stamp release rolls the claim back; the reminder is + // redeliverable and a subsequent claim wins again. + assert!( + release_due_reminder(&pool, community, &id, created_at, stamp) + .await + .expect("matching-stamp release"), + "release with the claiming stamp must clear the claim" + ); + assert!( + claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("re-claim after release"), + "released reminder must be reclaimable for retry" + ); + } + + /// Cross-community confinement: the same Nostr reminder event (identical + /// `id` and `created_at`) inserted into communities A and B must claim and + /// release independently. A claim/release for `A/X` must never touch `B/X`. + /// + /// This is the primitive the scheduler's exactly-once-publish proof rests + /// on: `events` is keyed `(community_id, created_at, id)`, so without the + /// community predicate a claim for A would mark B delivered (suppressing + /// B's reminder) and a matching-stamp release for A would clear B. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reminder_claim_and_release_are_confined_to_their_community() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + + // One signed event, inserted into both communities — same id/created_at. + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-cross-community"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community_a, &event, None) + .await + .expect("insert A/X"); + insert_event(&pool, community_b, &event, None) + .await + .expect("insert B/X"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + let stamp: i64 = 0x4444_4444_4444_4444; + + // Claim A/X. B/X must remain claimable — A's claim did not mark B. + assert!( + claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) + .await + .expect("claim A"), + "A/X claim wins" + ); + assert!( + claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) + .await + .expect("claim B"), + "B/X must still be claimable after A/X is claimed — \ + a claim for A must not mark B delivered" + ); + + // Both are now claimed under the same stamp. A matching-stamp release + // for A/X must clear only A/X; B/X must stay claimed. + assert!( + release_due_reminder(&pool, community_a, &id, created_at, stamp) + .await + .expect("release A"), + "A/X release with the claiming stamp clears A/X" + ); + assert!( + !claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) + .await + .expect("re-claim B after A release"), + "B/X must remain claimed after A/X is released — \ + a release for A must not clear B" + ); + // And A/X is genuinely redeliverable (the release was real, not a no-op). + assert!( + claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) + .await + .expect("re-claim A after release"), + "A/X must be reclaimable after its own release" + ); + } } diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 99cb7f9a6..7cc02d801 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -8,8 +8,9 @@ //! ## Performance characteristics //! //! `query_mentions` and `query_needs_action` join against the `event_mentions` table, -//! which carries composite indexes on `(pubkey_hex, event_created_at DESC)` and -//! `(pubkey_hex, event_kind, event_created_at DESC)`. This replaces the Phase 1 +//! which carries community-leading composite indexes on +//! `(community_id, pubkey_hex, event_created_at DESC)` and +//! `(community_id, pubkey_hex, event_kind, event_created_at DESC)`. This replaces the Phase 1 //! full-table scan with an indexed lookup, keeping feed queries //! sub-millisecond at scale (>100k events). //! @@ -37,7 +38,7 @@ use buzz_core::kind::{ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, }; -use buzz_core::StoredEvent; +use buzz_core::{CommunityId, StoredEvent}; use crate::error::Result; use crate::event::row_to_stored_event; @@ -50,18 +51,23 @@ const EVENT_COLS: &str = const EVENT_COLS_UNALIASED: &str = "id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id"; -/// Append `AND IN ($1, $2, …)` for the given channel IDs. +/// Append channel visibility filtering for feed queries. /// -/// No-ops when the slice is empty so callers don't need a guard. -fn push_channel_id_filter(qb: &mut QueryBuilder, col: &str, ids: &[Uuid]) { - if !ids.is_empty() { - qb.push(format!(" AND {col} IN (")); - let mut sep = qb.separated(", "); - for id in ids { - sep.push_bind(*id); - } - qb.push(")"); +/// Feed reads may include channel-less community-global events, plus events in +/// channels the caller can access. An empty accessible-channel list therefore +/// means "global only", never "all channels". +fn push_visible_channel_filter(qb: &mut QueryBuilder, col: &str, ids: &[Uuid]) { + if ids.is_empty() { + qb.push(format!(" AND {col} IS NULL")); + return; + } + + qb.push(format!(" AND ({col} IS NULL OR {col} IN (")); + let mut sep = qb.separated(", "); + for id in ids { + sep.push_bind(*id); } + qb.push("))"); } /// Convert fetched rows into `Vec`, skipping any that fail conversion. @@ -75,111 +81,165 @@ fn collect_stored_events(rows: Vec) -> Result> { Ok(out) } -/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). -/// -/// Joins against the `event_mentions` table -- Phase 2 implementation. -/// **Performance**: indexed lookup on `(pubkey_hex, event_created_at DESC)`. -/// -/// Only returns events from `accessible_channel_ids` for access control. -/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. -pub async fn query_mentions( - pool: &PgPool, +fn build_mentions_query( + community: CommunityId, pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, limit: i64, -) -> Result> { +) -> QueryBuilder { let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); let mut qb: QueryBuilder = QueryBuilder::new(format!( "SELECT {EVENT_COLS} FROM events e \ - INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE m.pubkey_hex = " + INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \ + WHERE e.community_id = " )); - qb.push_bind(&pubkey_hex); + qb.push_bind(*community.as_uuid()); + qb.push(" AND m.community_id = ") + .push_bind(*community.as_uuid()); + qb.push(" AND m.pubkey_hex = ").push_bind(pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" )); - push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids); + push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); - - let rows = qb.build().fetch_all(pool).await?; - collect_stored_events(rows) + qb } -/// Find events that require action from the given pubkey: -/// - [`KIND_WORKFLOW_APPROVAL_REQUESTED`] (workflow approval requested, tagged with user pubkey) -/// - [`KIND_STREAM_REMINDER`] (reminder, tagged with user pubkey) +/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). +/// +/// Joins against the `event_mentions` table -- Phase 2 implementation. +/// **Performance**: community-leading indexed lookup on +/// `(community_id, pubkey_hex, event_created_at DESC)`. /// -/// Only returns events from channels the user has access to (`accessible_channel_ids`). -/// This prevents surfacing approval requests from channels the user was removed from. -/// **Performance**: indexed lookup via `event_mentions` join on -/// `(pubkey_hex, event_kind, event_created_at DESC)`. +/// Only returns community-global events and events from `accessible_channel_ids`. /// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. -pub async fn query_needs_action( +pub async fn query_mentions( pool: &PgPool, + community: CommunityId, pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, limit: i64, ) -> Result> { + let mut qb = build_mentions_query( + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ); + let rows = qb.build().fetch_all(pool).await?; + collect_stored_events(rows) +} + +fn build_needs_action_query( + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, +) -> QueryBuilder { let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); let mut qb: QueryBuilder = QueryBuilder::new(format!( "SELECT {EVENT_COLS} FROM events e \ - INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE m.pubkey_hex = " + INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \ + WHERE e.community_id = " )); - qb.push_bind(&pubkey_hex); + qb.push_bind(*community.as_uuid()); + qb.push(" AND m.community_id = ") + .push_bind(*community.as_uuid()); + qb.push(" AND m.pubkey_hex = ").push_bind(pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( " AND e.kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})" )); - push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids); + push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); - - let rows = qb.build().fetch_all(pool).await?; - collect_stored_events(rows) + qb } -/// Find recent activity across accessible channels (for watched topics / agent activity). +/// Find events that require action from the given pubkey: +/// - [`KIND_WORKFLOW_APPROVAL_REQUESTED`] (workflow approval requested, tagged with user pubkey) +/// - [`KIND_STREAM_REMINDER`] (reminder, tagged with user pubkey) /// -/// Returns stream messages, forum posts, and agent job events. -/// Workflow execution kinds (46001-46012) are intentionally excluded to avoid noise. -/// **Performance**: uses indexed `kind` + `channel_id` columns -- no JSON scan. +/// Only returns community-global events and events from channels the user has access to +/// (`accessible_channel_ids`). This prevents surfacing approval requests from channels +/// the user was removed from. +/// **Performance**: community-leading indexed lookup via `event_mentions` join on +/// `(community_id, pubkey_hex, event_kind, event_created_at DESC)`. /// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. -pub async fn query_activity( +pub async fn query_needs_action( pool: &PgPool, + community: CommunityId, + pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, limit: i64, ) -> Result> { + let mut qb = build_needs_action_query( + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ); + let rows = qb.build().fetch_all(pool).await?; + collect_stored_events(rows) +} + +fn build_activity_query( + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, +) -> QueryBuilder { let limit = limit.min(FEED_MAX_LIMIT); let mut qb: QueryBuilder = QueryBuilder::new(format!( - "SELECT {EVENT_COLS_UNALIASED} FROM events WHERE deleted_at IS NULL" + "SELECT {EVENT_COLS_UNALIASED} FROM events WHERE community_id = " )); + qb.push_bind(*community.as_uuid()); + qb.push(" AND deleted_at IS NULL"); qb.push(format!( " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, \ {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" )); - push_channel_id_filter(&mut qb, "channel_id", accessible_channel_ids); + push_visible_channel_filter(&mut qb, "channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND created_at >= ").push_bind(s); } qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); + qb +} +/// Find recent activity across accessible channels (for watched topics / agent activity). +/// +/// Returns stream messages, forum posts, and agent job events. +/// Workflow execution kinds (46001-46012) are intentionally excluded to avoid noise. +/// **Performance**: uses indexed `kind` + `channel_id` columns -- no JSON scan. +/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller. +pub async fn query_activity( + pool: &PgPool, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, +) -> Result> { + let mut qb = build_activity_query(community, accessible_channel_ids, since, limit); let rows = qb.build().fetch_all(pool).await?; collect_stored_events(rows) } @@ -188,8 +248,241 @@ pub async fn query_activity( #[cfg(test)] mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("feed-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + async fn insert_test_channel(pool: &PgPool, community: CommunityId) -> Uuid { + let id = Uuid::new_v4(); + let creator = [0x11u8; 32]; + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4)", + ) + .bind(id) + .bind(community.as_uuid()) + .bind(format!("feed-test-channel-{}", id.simple())) + .bind(creator.as_slice()) + .execute(pool) + .await + .expect("insert test channel"); + id + } + + async fn store_feed_event( + pool: &PgPool, + community: CommunityId, + kind: u32, + content: &str, + channel_id: Option, + tags: Vec, + ) -> nostr::Event { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(kind as u16), content) + .tags(tags) + .sign_with_keys(&keys) + .expect("sign event"); + crate::event::insert_event(pool, community, &event, channel_id) + .await + .expect("insert feed event"); + crate::insert_mentions(pool, community, &event, channel_id) + .await + .expect("insert mentions"); + event + } + + // -- Postgres tenant-scope regressions ------------------------------------ + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_mentions_is_scoped_across_communities() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let channel_a = insert_test_channel(&pool, community_a).await; + let channel_b = insert_test_channel(&pool, community_b).await; + let mentioned_pubkey = "02".repeat(32); + let mentioned_bytes = hex::decode(&mentioned_pubkey).expect("hex pubkey"); + + let event_a = store_feed_event( + &pool, + community_a, + KIND_STREAM_MESSAGE, + "community-a mention", + Some(channel_a), + vec![Tag::parse(["p", mentioned_pubkey.as_str()]).unwrap()], + ) + .await; + let event_b = store_feed_event( + &pool, + community_b, + KIND_STREAM_MESSAGE, + "community-b mention", + Some(channel_b), + vec![Tag::parse(["p", mentioned_pubkey.as_str()]).unwrap()], + ) + .await; + + let rows = query_mentions( + &pool, + community_a, + &mentioned_bytes, + &[channel_a, channel_b], + None, + 10, + ) + .await + .expect("query mentions"); + + assert!(rows.iter().any(|row| row.event.id == event_a.id)); + assert!( + rows.iter().all(|row| row.event.id != event_b.id), + "community B mention must not appear in community A feed" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_needs_action_is_scoped_across_communities() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let channel_a = insert_test_channel(&pool, community_a).await; + let channel_b = insert_test_channel(&pool, community_b).await; + let actor_pubkey = "03".repeat(32); + let actor_bytes = hex::decode(&actor_pubkey).expect("hex pubkey"); + + let event_a = store_feed_event( + &pool, + community_a, + KIND_WORKFLOW_APPROVAL_REQUESTED, + "community-a approval", + Some(channel_a), + vec![Tag::parse(["p", actor_pubkey.as_str()]).unwrap()], + ) + .await; + let event_b = store_feed_event( + &pool, + community_b, + KIND_WORKFLOW_APPROVAL_REQUESTED, + "community-b approval", + Some(channel_b), + vec![Tag::parse(["p", actor_pubkey.as_str()]).unwrap()], + ) + .await; + + let rows = query_needs_action( + &pool, + community_a, + &actor_bytes, + &[channel_a, channel_b], + None, + 10, + ) + .await + .expect("query needs_action"); + + assert!(rows.iter().any(|row| row.event.id == event_a.id)); + assert!( + rows.iter().all(|row| row.event.id != event_b.id), + "community B needs_action item must not appear in community A feed" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_activity_is_scoped_and_empty_channels_are_global_only() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let channel_a = insert_test_channel(&pool, community_a).await; + let channel_b = insert_test_channel(&pool, community_b).await; + + let a_global = store_feed_event( + &pool, + community_a, + KIND_STREAM_MESSAGE, + "community-a global", + None, + vec![], + ) + .await; + let a_channel = store_feed_event( + &pool, + community_a, + KIND_STREAM_MESSAGE, + "community-a channel", + Some(channel_a), + vec![], + ) + .await; + let b_global = store_feed_event( + &pool, + community_b, + KIND_STREAM_MESSAGE, + "community-b global", + None, + vec![], + ) + .await; + let b_channel = store_feed_event( + &pool, + community_b, + KIND_STREAM_MESSAGE, + "community-b channel", + Some(channel_b), + vec![], + ) + .await; + + let global_only = query_activity(&pool, community_a, &[], None, 10) + .await + .expect("query activity global only"); + assert!(global_only.iter().any(|row| row.event.id == a_global.id)); + assert!( + global_only.iter().all(|row| row.event.id != a_channel.id), + "empty accessible channels must not mean all tenant channels" + ); + assert!(global_only.iter().all(|row| row.event.id != b_global.id)); + assert!(global_only.iter().all(|row| row.event.id != b_channel.id)); + + let visible = query_activity(&pool, community_a, &[channel_a, channel_b], None, 10) + .await + .expect("query visible activity"); + assert!(visible.iter().any(|row| row.event.id == a_global.id)); + assert!(visible.iter().any(|row| row.event.id == a_channel.id)); + assert!( + visible + .iter() + .all(|row| row.event.id != b_global.id && row.event.id != b_channel.id), + "community B activity must not appear in community A feed" + ); + } + // -- Hex encoding of pubkey ----------------------------------------------- #[test] @@ -426,11 +719,87 @@ mod tests { } #[test] - fn empty_channel_list_skips_channel_filter() { - let accessible: Vec = vec![]; + fn empty_channel_list_means_global_only() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let mut qb = build_activity_query(community, &[], None, 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("WHERE community_id = "), + "activity feed must bind the tenant community: {sql}" + ); + assert!( + sql.contains("AND channel_id IS NULL"), + "empty accessible-channel list must mean global-only, not all tenant channels: {sql}" + ); + assert!( + !sql.contains("channel_id IN"), + "empty accessible-channel list must not emit an IN filter: {sql}" + ); + } + + #[test] + fn non_empty_channel_list_includes_global_and_accessible_channels() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let channel_id = Uuid::new_v4(); + let mut qb = build_activity_query(community, &[channel_id], None, 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("AND (channel_id IS NULL OR channel_id IN ("), + "feed should include community-global events plus accessible channels: {sql}" + ); + } + + #[test] + fn mentions_query_is_tenant_scoped_and_joins_mentions_by_composite_key() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let pubkey = vec![0x42; 32]; + let channel_id = Uuid::new_v4(); + let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id"), + "mentions must join event_mentions on the composite tenant/event key: {sql}" + ); + assert!( + sql.contains("WHERE e.community_id = "), + "mentions feed must scope events to the tenant community: {sql}" + ); + assert!( + sql.contains("AND m.community_id = "), + "mentions feed must also bind event_mentions.community_id: {sql}" + ); + } + + #[test] + fn needs_action_query_is_tenant_scoped_and_joins_mentions_by_composite_key() { + let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); + let pubkey = vec![0x42; 32]; + let channel_id = Uuid::new_v4(); + let mut qb = build_needs_action_query(community, &pubkey, &[channel_id], None, 10); + let query = qb.build(); + let sql_str = sqlx::Execute::sql(query); + let sql = sql_str.as_str(); + + assert!( + sql.contains("INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id"), + "needs_action must join event_mentions on the composite tenant/event key: {sql}" + ); + assert!( + sql.contains("WHERE e.community_id = "), + "needs_action feed must scope events to the tenant community: {sql}" + ); assert!( - accessible.is_empty(), - "empty list should skip channel filter" + sql.contains("AND m.community_id = "), + "needs_action feed must also bind event_mentions.community_id: {sql}" ); } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd4d3e10..f1cf0b7cc 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -47,7 +47,7 @@ use sqlx::{PgPool, QueryBuilder, Row}; use std::time::Duration; use uuid::Uuid; -use buzz_core::StoredEvent; +use buzz_core::{CommunityId, StoredEvent}; /// Extract p-tag mentions from an event and insert into the `event_mentions` table. /// @@ -55,6 +55,7 @@ use buzz_core::StoredEvent; /// Uses `INSERT ... ON CONFLICT DO NOTHING` so duplicate inserts are silently skipped. pub async fn insert_mentions( pool: &PgPool, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, ) -> Result<()> { @@ -106,11 +107,12 @@ pub async fn insert_mentions( // Single multi-row INSERT ... ON CONFLICT DO NOTHING — one round-trip regardless of mention count. let mut qb: QueryBuilder = QueryBuilder::new( "INSERT INTO event_mentions \ - (pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", ); qb.push_values(&valid_pubkeys, |mut b, pubkey| { - b.push_bind(pubkey.as_str()) + b.push_bind(community_id.as_uuid()) + .push_bind(pubkey.as_str()) .push_bind(event_id_bytes.as_slice()) .push_bind(created_at) .push_bind(channel_id) @@ -162,6 +164,15 @@ impl Default for DbConfig { } } +/// Community host-map row returned by [`Db::lookup_community_by_host`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, +} + /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -216,15 +227,176 @@ impl Db { self.pool.begin().await.map_err(Into::into) } + /// Returns the community mapped to a normalized request host, if one exists. + /// + /// The caller owns host normalization and turns `None` into the fail-closed + /// request/connection error. buzz-db only reads the durable host map. + pub async fn lookup_community_by_host( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, host + FROM communities + WHERE lower(host) = lower($1) + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + }) + .transpose() + } + + /// Returns the normalized host mapped to a community id, if the community + /// exists. + /// + /// The reverse of [`lookup_community_by_host`]: used by side-effect + /// producers that already hold a server-resolved `CommunityId` (e.g. the + /// workflow action sink running a run owned by some community) and need a + /// fully-formed [`buzz_core::tenant::TenantContext`] — host included — to + /// fan out under *that* community rather than the deployment default. The + /// community is authoritative; the host is read back for labelling only and + /// is never used to re-derive the community. + pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result> { + let row = sqlx::query( + r#" + SELECT host + FROM communities + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let host: String = row.try_get("host")?; + Ok(host) + }) + .transpose() + } + + /// Ensure a configured community host exists and return its row. + /// + /// This is the startup/config seeding path for N=1 deployments. Migrations + /// create the schema only; deployment-specific hosts are not hardcoded into + /// schema history. + pub async fn ensure_configured_community( + &self, + normalized_host: &str, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (lower(host)) DO UPDATE SET host = EXCLUDED.host + RETURNING id, host + "#, + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + } + + /// Returns the community that owns a channel, if the channel exists. + /// + /// Internal relay producers use this to derive tenant context from the row + /// they are acting on, rather than falling back to an implicit default. + pub async fn community_of_channel(&self, channel_id: Uuid) -> Result> { + let row = sqlx::query( + r#" + SELECT community_id + FROM channels + WHERE id = $1 + AND deleted_at IS NULL + "#, + ) + .bind(channel_id) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("community_id")?; + Ok(CommunityId::from_uuid(id)) + }) + .transpose() + } + + /// Batched version of [`Self::community_of_channel`]: given a list of + /// channel UUIDs, returns a map from channel id → owning community + /// for every channel that exists (soft-deletes excluded). + /// + /// Used by the runtime conformance read-seam emitters in `buzz-relay`: + /// after a `query_events`/`get_events_by_ids` returns N rows, the + /// emitter collects distinct `channel_id`s, calls this once, then + /// projects each row's true community label independently of the + /// fetch query's WHERE clause. That independence is what makes the + /// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous — + /// a mutation that dropped `community_id = $X` from the fetch query + /// would still let this helper return the row's true label, and the + /// checker would see the mismatch. + /// + /// Channels missing from the result map (deleted or never existed) + /// are intentionally not present rather than mapped to a default — + /// callers MUST treat "channel-id not in map" as a coverage breach, + /// never as "use the resolved community". + pub async fn communities_of_channels( + &self, + channel_ids: &[Uuid], + ) -> Result> { + if channel_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let rows = sqlx::query( + r#" + SELECT id, community_id + FROM channels + WHERE id = ANY($1) + AND deleted_at IS NULL + "#, + ) + .bind(channel_ids) + .fetch_all(&self.pool) + .await?; + + let mut out = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let ch: Uuid = row.try_get("id")?; + let cm: Uuid = row.try_get("community_id")?; + out.insert(ch, CommunityId::from_uuid(cm)); + } + Ok(out) + } + /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. pub async fn insert_event( &self, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event(&self.pool, event, channel_id).await?; + let result = event::insert_event(&self.pool, community_id, event, channel_id).await?; if result.1 { - if let Err(e) = insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -248,52 +420,65 @@ impl Db { /// historical duplicate survivors correctly. pub async fn get_latest_global_replaceable( &self, + community_id: CommunityId, kind: i32, pubkey_bytes: &[u8], ) -> Result> { - event::get_latest_global_replaceable(&self.pool, kind, pubkey_bytes).await + event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await } /// Fetches a single non-deleted event by its raw ID bytes. /// /// Returns `None` if the event does not exist or has been soft-deleted. - pub async fn get_event_by_id(&self, id_bytes: &[u8]) -> Result> { - event::get_event_by_id(&self.pool, id_bytes).await + pub async fn get_event_by_id( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + event::get_event_by_id(&self.pool, community_id, id_bytes).await } /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. pub async fn get_event_by_id_including_deleted( &self, + community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - event::get_event_by_id_including_deleted(&self.pool, id_bytes).await + event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await } /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. - pub async fn soft_delete_event(&self, event_id: &[u8]) -> Result { - event::soft_delete_event(&self.pool, event_id).await + pub async fn soft_delete_event( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result { + event::soft_delete_event(&self.pool, community_id, event_id).await } /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`. /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds. pub async fn soft_delete_by_coordinate( &self, + community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, ) -> Result { - event::soft_delete_by_coordinate(&self.pool, kind, pubkey, d_tag).await + event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await } /// Atomically soft-delete an event and decrement thread reply counters. pub async fn soft_delete_event_and_update_thread( &self, + community_id: CommunityId, event_id: &[u8], parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { event::soft_delete_event_and_update_thread( &self.pool, + community_id, event_id, parent_event_id, root_event_id, @@ -302,35 +487,50 @@ impl Db { } /// Returns the most recent `created_at` for a channel. - pub async fn get_last_message_at(&self, channel_id: Uuid) -> Result>> { - event::get_last_message_at(&self.pool, channel_id).await + pub async fn get_last_message_at( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result>> { + event::get_last_message_at(&self.pool, community_id, channel_id).await } /// Bulk-fetch the most recent `created_at` for a set of channel IDs. pub async fn get_last_message_at_bulk( &self, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result>> { - event::get_last_message_at_bulk(&self.pool, channel_ids).await + event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await } /// Batch-fetch non-deleted events by their raw IDs. - pub async fn get_events_by_ids(&self, ids: &[&[u8]]) -> Result> { - event::get_events_by_ids(&self.pool, ids).await + pub async fn get_events_by_ids( + &self, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + event::get_events_by_ids(&self.pool, community_id, ids).await } /// Atomically insert an event AND its thread metadata in a single transaction. pub async fn insert_event_with_thread_metadata( &self, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { - let result = - event::insert_event_with_thread_metadata(&self.pool, event, channel_id, thread_meta) - .await?; + let result = event::insert_event_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + ) + .await?; if result.1 { - if let Err(e) = insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -338,8 +538,10 @@ impl Db { } /// Creates a new channel, bootstraps the creator as owner, and returns the record. + #[allow(clippy::too_many_arguments)] pub async fn create_channel( &self, + community_id: CommunityId, name: &str, channel_type: channel::ChannelType, visibility: channel::ChannelVisibility, @@ -349,6 +551,7 @@ impl Db { ) -> Result { channel::create_channel( &self.pool, + community_id, name, channel_type, visibility, @@ -365,6 +568,7 @@ impl Db { #[allow(clippy::too_many_arguments)] pub async fn create_channel_with_id( &self, + community_id: CommunityId, channel_id: Uuid, name: &str, channel_type: channel::ChannelType, @@ -375,6 +579,7 @@ impl Db { ) -> Result<(channel::ChannelRecord, bool)> { channel::create_channel_with_id( &self.pool, + community_id, channel_id, name, channel_type, @@ -387,151 +592,241 @@ impl Db { } /// Fetches a channel record by ID. - pub async fn get_channel(&self, channel_id: Uuid) -> Result { - channel::get_channel(&self.pool, channel_id).await + pub async fn get_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { + channel::get_channel(&self.pool, community_id, channel_id).await } /// Returns the canvas content for a channel, if any. - pub async fn get_canvas(&self, channel_id: Uuid) -> Result> { - channel::get_canvas(&self.pool, channel_id).await + pub async fn get_canvas( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + channel::get_canvas(&self.pool, community_id, channel_id).await } /// Sets or clears the canvas content for a channel. - pub async fn set_canvas(&self, channel_id: Uuid, canvas: Option<&str>) -> Result<()> { - channel::set_canvas(&self.pool, channel_id, canvas).await + pub async fn set_canvas( + &self, + community_id: CommunityId, + channel_id: Uuid, + canvas: Option<&str>, + ) -> Result<()> { + channel::set_canvas(&self.pool, community_id, channel_id, canvas).await } /// Adds a member to a channel. pub async fn add_member( &self, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], role: channel::MemberRole, invited_by: Option<&[u8]>, ) -> Result { - channel::add_member(&self.pool, channel_id, pubkey, role, invited_by).await + channel::add_member( + &self.pool, + community_id, + channel_id, + pubkey, + role, + invited_by, + ) + .await } /// Removes a member from a channel. pub async fn remove_member( &self, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], actor_pubkey: &[u8], ) -> Result<()> { - channel::remove_member(&self.pool, channel_id, pubkey, actor_pubkey).await + channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await } /// Returns `true` if the pubkey is an active member. - pub async fn is_member(&self, channel_id: Uuid, pubkey: &[u8]) -> Result { - channel::is_member(&self.pool, channel_id, pubkey).await + pub async fn is_member( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + channel::is_member(&self.pool, community_id, channel_id, pubkey).await } /// Returns all active members of a channel. - pub async fn get_members(&self, channel_id: Uuid) -> Result> { - channel::get_members(&self.pool, channel_id).await + pub async fn get_members( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + channel::get_members(&self.pool, community_id, channel_id).await } /// Returns active members for multiple channels in a single query. pub async fn get_members_bulk( &self, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_members_bulk(&self.pool, channel_ids).await + channel::get_members_bulk(&self.pool, community_id, channel_ids).await } /// Get all channel IDs accessible to a pubkey. - pub async fn get_accessible_channel_ids(&self, pubkey: &[u8]) -> Result> { - channel::get_accessible_channel_ids(&self.pool, pubkey).await + pub async fn get_accessible_channel_ids( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await } /// Lists channels, optionally filtered by visibility. pub async fn list_channels( &self, + community_id: CommunityId, visibility: Option<&str>, ) -> Result> { - channel::list_channels(&self.pool, visibility).await + channel::list_channels(&self.pool, community_id, visibility).await } /// Returns full channel records for all channels a user can access. pub async fn get_accessible_channels( &self, + community_id: CommunityId, pubkey: &[u8], visibility_filter: Option<&str>, member_only: Option, ) -> Result> { - channel::get_accessible_channels(&self.pool, pubkey, visibility_filter, member_only).await + channel::get_accessible_channels( + &self.pool, + community_id, + pubkey, + visibility_filter, + member_only, + ) + .await } - /// Returns all bot-role members with their aggregated channel names. - pub async fn get_bot_members(&self) -> Result> { - channel::get_bot_members(&self.pool).await + /// Returns all bot-role members with their aggregated channel names in one community. + pub async fn get_bot_members( + &self, + community_id: CommunityId, + ) -> Result> { + channel::get_bot_members(&self.pool, community_id).await } /// Bulk-fetch user records by pubkey. - pub async fn get_users_bulk(&self, pubkeys: &[Vec]) -> Result> { - channel::get_users_bulk(&self.pool, pubkeys).await + pub async fn get_users_bulk( + &self, + community_id: CommunityId, + pubkeys: &[Vec], + ) -> Result> { + channel::get_users_bulk(&self.pool, community_id, pubkeys).await } /// Updates a channel's name and/or description. pub async fn update_channel( &self, + community_id: CommunityId, channel_id: Uuid, updates: channel::ChannelUpdate, ) -> Result { - channel::update_channel(&self.pool, channel_id, updates).await + channel::update_channel(&self.pool, community_id, channel_id, updates).await } /// Sets the topic for a channel. - pub async fn set_topic(&self, channel_id: Uuid, topic: &str, set_by: &[u8]) -> Result<()> { - channel::set_topic(&self.pool, channel_id, topic, set_by).await + pub async fn set_topic( + &self, + community_id: CommunityId, + channel_id: Uuid, + topic: &str, + set_by: &[u8], + ) -> Result<()> { + channel::set_topic(&self.pool, community_id, channel_id, topic, set_by).await } /// Sets the purpose for a channel. - pub async fn set_purpose(&self, channel_id: Uuid, purpose: &str, set_by: &[u8]) -> Result<()> { - channel::set_purpose(&self.pool, channel_id, purpose, set_by).await + pub async fn set_purpose( + &self, + community_id: CommunityId, + channel_id: Uuid, + purpose: &str, + set_by: &[u8], + ) -> Result<()> { + channel::set_purpose(&self.pool, community_id, channel_id, purpose, set_by).await } /// Archives a channel. - pub async fn archive_channel(&self, channel_id: Uuid) -> Result<()> { - channel::archive_channel(&self.pool, channel_id).await + pub async fn archive_channel(&self, community_id: CommunityId, channel_id: Uuid) -> Result<()> { + channel::archive_channel(&self.pool, community_id, channel_id).await } /// Unarchives a channel. - pub async fn unarchive_channel(&self, channel_id: Uuid) -> Result<()> { - channel::unarchive_channel(&self.pool, channel_id).await + pub async fn unarchive_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result<()> { + channel::unarchive_channel(&self.pool, community_id, channel_id).await } /// Soft-delete a channel. - pub async fn soft_delete_channel(&self, channel_id: Uuid) -> Result { - channel::soft_delete_channel(&self.pool, channel_id).await + pub async fn soft_delete_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { + channel::soft_delete_channel(&self.pool, community_id, channel_id).await } /// Returns the count of active members in a channel. - pub async fn get_member_count(&self, channel_id: Uuid) -> Result { - channel::get_member_count(&self.pool, channel_id).await + pub async fn get_member_count( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { + channel::get_member_count(&self.pool, community_id, channel_id).await } /// Bulk-fetch member counts for a set of channel IDs. pub async fn get_member_counts_bulk( &self, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_member_counts_bulk(&self.pool, channel_ids).await + channel::get_member_counts_bulk(&self.pool, community_id, channel_ids).await } /// Get the active role of a pubkey in a channel. - pub async fn get_member_role(&self, channel_id: Uuid, pubkey: &[u8]) -> Result> { - channel::get_member_role(&self.pool, channel_id, pubkey).await + pub async fn get_member_role( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result> { + channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await } /// Bump the TTL deadline for an ephemeral channel after a new message. - pub async fn bump_ttl_deadline(&self, channel_id: Uuid) -> Result<()> { - channel::bump_ttl_deadline(&self.pool, channel_id).await + pub async fn bump_ttl_deadline( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result<()> { + channel::bump_ttl_deadline(&self.pool, community_id, channel_id).await } /// Archive ephemeral channels whose TTL deadline has passed. - pub async fn reap_expired_ephemeral_channels(&self) -> Result> { + pub async fn reap_expired_ephemeral_channels( + &self, + ) -> Result> { channel::reap_expired_ephemeral_channels(&self.pool).await } @@ -547,25 +842,67 @@ impl Db { /// Atomically claim a due reminder for delivery (cross-pod dedup). pub async fn claim_due_reminder( &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + ) -> Result { + event::claim_due_reminder(&self.pool, community_id, event_id, event_created_at).await + } + + /// Atomically claim a due reminder using a caller-supplied delivery stamp. + pub async fn claim_due_reminder_with_stamp( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + event::claim_due_reminder_with_stamp( + &self.pool, + community_id, + event_id, + event_created_at, + delivery_stamp, + ) + .await + } + + /// Release a claimed due reminder after a publish failure. + pub async fn release_due_reminder( + &self, + community_id: CommunityId, event_id: &[u8], event_created_at: chrono::DateTime, + delivery_stamp: i64, ) -> Result { - event::claim_due_reminder(&self.pool, event_id, event_created_at).await + event::release_due_reminder( + &self.pool, + community_id, + event_id, + event_created_at, + delivery_stamp, + ) + .await } /// Ensure a user record exists (upsert). - pub async fn ensure_user(&self, pubkey: &[u8]) -> Result<()> { - user::ensure_user(&self.pool, pubkey).await + pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result<()> { + user::ensure_user(&self.pool, community_id, pubkey).await } /// Get a single user record by pubkey. - pub async fn get_user(&self, pubkey: &[u8]) -> Result> { - user::get_user(&self.pool, pubkey).await + pub async fn get_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + user::get_user(&self.pool, community_id, pubkey).await } /// Update a user's profile fields. pub async fn update_user_profile( &self, + community_id: CommunityId, pubkey: &[u8], display_name: Option<&str>, avatar_url: Option<&str>, @@ -574,6 +911,7 @@ impl Db { ) -> Result<()> { user::update_user_profile( &self.pool, + community_id, pubkey, display_name, avatar_url, @@ -586,103 +924,140 @@ impl Db { /// Look up a user by NIP-05 handle. pub async fn get_user_by_nip05( &self, + community_id: CommunityId, local_part: &str, domain: &str, ) -> Result> { - user::get_user_by_nip05(&self.pool, local_part, domain).await + user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await } /// Search users by display name, NIP-05 handle, or pubkey prefix. pub async fn search_users( &self, + community_id: CommunityId, query: &str, limit: u32, ) -> Result> { - user::search_users(&self.pool, query, limit).await + user::search_users(&self.pool, community_id, query, limit).await } /// Atomically set agent owner — only if no owner is currently assigned. /// Returns Ok(true) if set, Ok(false) if an owner already exists. - pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result { - user::set_agent_owner(&self.pool, agent_pubkey, owner_pubkey).await + pub async fn set_agent_owner( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + ) -> Result { + user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await } /// Get the channel_add_policy and agent_owner_pubkey for a user. pub async fn get_agent_channel_policy( &self, + community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { - user::get_agent_channel_policy(&self.pool, pubkey).await + user::get_agent_channel_policy(&self.pool, community_id, pubkey).await } /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. - pub async fn is_agent_owner(&self, target_pubkey: &[u8], actor_pubkey: &[u8]) -> Result { - user::is_agent_owner(&self.pool, target_pubkey, actor_pubkey).await + pub async fn is_agent_owner( + &self, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await } /// Set the channel_add_policy for a user. - pub async fn set_channel_add_policy(&self, pubkey: &[u8], policy: &str) -> Result<()> { - user::set_channel_add_policy(&self.pool, pubkey, policy).await + pub async fn set_channel_add_policy( + &self, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, + ) -> Result<()> { + user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await } /// Find an existing DM by its participant hash. pub async fn find_dm_by_participants( &self, + community_id: CommunityId, participant_hash: &[u8], ) -> Result> { - dm::find_dm_by_participants(&self.pool, participant_hash).await + dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await } /// Create or return an existing DM channel. pub async fn create_dm( &self, + community_id: CommunityId, participants: &[&[u8]], created_by: &[u8], ) -> Result { - dm::create_dm(&self.pool, participants, created_by).await + dm::create_dm(&self.pool, community_id, participants, created_by).await } /// List all DMs for a user. pub async fn list_dms_for_user( &self, + community_id: CommunityId, pubkey: &[u8], limit: u32, cursor: Option, ) -> Result> { - dm::list_dms_for_user(&self.pool, pubkey, limit, cursor).await + dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await } /// Open or retrieve a DM for the given participants. pub async fn open_dm( &self, + community_id: CommunityId, pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(channel::ChannelRecord, bool)> { - dm::open_dm(&self.pool, pubkeys, created_by).await + dm::open_dm(&self.pool, community_id, pubkeys, created_by).await } /// Hide a DM channel for a specific user. /// /// The DM is not deleted — it can be restored by opening a new DM with /// the same participants. - pub async fn hide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { - dm::hide_dm(&self.pool, channel_id, pubkey).await + pub async fn hide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await } /// Unhide a DM channel for a specific user. - pub async fn unhide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { - dm::unhide_dm(&self.pool, channel_id, pubkey).await + pub async fn unhide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await } /// List the channel IDs of all DMs the given user currently has hidden. - pub async fn list_hidden_dms(&self, pubkey: &[u8]) -> Result> { - dm::list_hidden_dms(&self.pool, pubkey).await + pub async fn list_hidden_dms( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + dm::list_hidden_dms(&self.pool, community_id, pubkey).await } /// Insert thread metadata. #[allow(clippy::too_many_arguments)] pub async fn insert_thread_metadata( &self, + community_id: CommunityId, event_id: &[u8], event_created_at: DateTime, channel_id: Uuid, @@ -695,6 +1070,7 @@ impl Db { ) -> Result<()> { thread::insert_thread_metadata( &self.pool, + community_id, event_id, event_created_at, channel_id, @@ -711,25 +1087,36 @@ impl Db { /// Fetch replies under a root event. pub async fn get_thread_replies( &self, + community_id: CommunityId, root_event_id: &[u8], depth_limit: Option, limit: u32, cursor: Option<&[u8]>, ) -> Result> { - thread::get_thread_replies(&self.pool, root_event_id, depth_limit, limit, cursor).await + thread::get_thread_replies( + &self.pool, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await } /// Fetch aggregated thread stats. pub async fn get_thread_summary( &self, + community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_summary(&self.pool, event_id).await + thread::get_thread_summary(&self.pool, community_id, event_id).await } /// Top-level messages for a channel. pub async fn get_channel_messages_top_level( &self, + community_id: CommunityId, channel_id: Uuid, limit: u32, before_cursor: Option>, @@ -738,6 +1125,7 @@ impl Db { ) -> Result> { thread::get_channel_messages_top_level( &self.pool, + community_id, channel_id, limit, before_cursor, @@ -750,23 +1138,27 @@ impl Db { /// Look up a single thread_metadata row by event_id. pub async fn get_thread_metadata_by_event( &self, + community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_metadata_by_event(&self.pool, event_id).await + thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await } /// Decrement reply counts. pub async fn decrement_reply_count( &self, + community_id: CommunityId, parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { - thread::decrement_reply_count(&self.pool, parent_event_id, root_event_id).await + thread::decrement_reply_count(&self.pool, community_id, parent_event_id, root_event_id) + .await } /// Add (or re-activate) a reaction. pub async fn add_reaction( &self, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], @@ -775,6 +1167,7 @@ impl Db { ) -> Result { reaction::add_reaction( &self.pool, + community, event_id, event_created_at, pubkey, @@ -787,37 +1180,56 @@ impl Db { /// Soft-delete a reaction. pub async fn remove_reaction( &self, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], emoji: &str, ) -> Result { - reaction::remove_reaction(&self.pool, event_id, event_created_at, pubkey, emoji).await + reaction::remove_reaction( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await } /// Soft-delete a reaction by its source event ID. pub async fn remove_reaction_by_source_event_id( &self, + community: CommunityId, reaction_event_id: &[u8], ) -> Result { - reaction::remove_reaction_by_source_event_id(&self.pool, reaction_event_id).await + reaction::remove_reaction_by_source_event_id(&self.pool, community, reaction_event_id).await } /// Look up the active reaction row for one actor + emoji + target tuple. pub async fn get_active_reaction_record( &self, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], emoji: &str, ) -> Result> { - reaction::get_active_reaction_record(&self.pool, event_id, event_created_at, pubkey, emoji) - .await + reaction::get_active_reaction_record( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await } /// Backfill the source event ID on an active reaction row. pub async fn set_reaction_event_id( &self, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], @@ -826,6 +1238,7 @@ impl Db { ) -> Result { reaction::set_reaction_event_id( &self.pool, + community, event_id, event_created_at, pubkey, @@ -838,25 +1251,36 @@ impl Db { /// Get all active reactions for an event, grouped by emoji. pub async fn get_reactions( &self, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, limit: u32, cursor: Option<&str>, ) -> Result> { - reaction::get_reactions(&self.pool, event_id, event_created_at, limit, cursor).await + reaction::get_reactions( + &self.pool, + community, + event_id, + event_created_at, + limit, + cursor, + ) + .await } /// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. pub async fn get_reactions_bulk( &self, + community: CommunityId, event_ids: &[(&[u8], DateTime)], ) -> Result> { - reaction::get_reactions_bulk(&self.pool, event_ids).await + reaction::get_reactions_bulk(&self.pool, community, event_ids).await } /// Find events that @mention the given pubkey. pub async fn query_feed_mentions( &self, + community: CommunityId, pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, @@ -864,6 +1288,7 @@ impl Db { ) -> Result> { feed::query_mentions( &self.pool, + community, pubkey_bytes, accessible_channel_ids, since, @@ -875,6 +1300,7 @@ impl Db { /// Find events that require action from the given pubkey. pub async fn query_feed_needs_action( &self, + community: CommunityId, pubkey_bytes: &[u8], accessible_channel_ids: &[Uuid], since: Option>, @@ -882,6 +1308,7 @@ impl Db { ) -> Result> { feed::query_needs_action( &self.pool, + community, pubkey_bytes, accessible_channel_ids, since, @@ -893,62 +1320,19 @@ impl Db { /// Find recent activity across accessible channels. pub async fn query_feed_activity( &self, + community: CommunityId, accessible_channel_ids: &[Uuid], since: Option>, limit: i64, ) -> Result> { - feed::query_activity(&self.pool, accessible_channel_ids, since, limit).await - } - - /// Find events that @mention the given pubkey (alias). - pub async fn query_mentions( - &self, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_mentions( - &self.pool, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - - /// Find events that require action from the given pubkey. - pub async fn query_needs_action( - &self, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_needs_action( - &self.pool, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - - /// Find recent activity across accessible channels. - pub async fn query_activity( - &self, - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_activity(&self.pool, accessible_channel_ids, since, limit).await + feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await } /// Create a new API token record. + #[allow(clippy::too_many_arguments)] pub async fn create_api_token( &self, + community_id: CommunityId, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -958,6 +1342,7 @@ impl Db { ) -> Result { api_token::create_api_token( &self.pool, + *community_id.as_uuid(), token_hash, owner_pubkey, name, @@ -968,9 +1353,11 @@ impl Db { .await } - /// Atomic conditional INSERT with 10-token limit. + /// Atomic conditional INSERT with 10-token limit (per (community, owner)). + #[allow(clippy::too_many_arguments)] pub async fn create_api_token_if_under_limit( &self, + community_id: CommunityId, token_hash: &[u8], owner_pubkey: &[u8], name: &str, @@ -980,6 +1367,7 @@ impl Db { ) -> Result> { api_token::create_api_token_if_under_limit( &self.pool, + *community_id.as_uuid(), token_hash, owner_pubkey, name, @@ -990,16 +1378,26 @@ impl Db { .await } - /// Look up an active (non-revoked) API token by its SHA-256 hash. - pub async fn get_api_token_by_hash(&self, hash: &[u8]) -> Result> { + /// Look up an active (non-revoked) API token by its SHA-256 hash, + /// scoped to the request's community. + /// + /// See [`api_token::get_api_token_by_hash_including_revoked`] for the + /// row-44 conformance rationale — the `(community_id, token_hash)` key + /// is enforced both by the storage UNIQUE index and by this WHERE clause. + pub async fn get_api_token_by_hash( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result> { let row = sqlx::query( r#" SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, created_at, expires_at, last_used_at, revoked_at FROM api_tokens - WHERE token_hash = $1 AND revoked_at IS NULL + WHERE community_id = $1 AND token_hash = $2 AND revoked_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(hash) .fetch_optional(&self.pool) .await?; @@ -1010,39 +1408,53 @@ impl Db { } } - /// Look up an API token by hash, including revoked. + /// Look up an API token by hash, including revoked, scoped to community. pub async fn get_api_token_by_hash_including_revoked( &self, + community_id: CommunityId, hash: &[u8], ) -> Result> { - api_token::get_api_token_by_hash_including_revoked(&self.pool, hash).await + api_token::get_api_token_by_hash_including_revoked( + &self.pool, + *community_id.as_uuid(), + hash, + ) + .await } - /// Record a token usage (update `last_used_at`). - pub async fn touch_api_token(&self, hash: &[u8]) -> Result<()> { - sqlx::query("UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1") - .bind(hash) - .execute(&self.pool) - .await?; + /// Record a token usage (update `last_used_at`), scoped to community. + pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", + ) + .bind(community_id.as_uuid()) + .bind(hash) + .execute(&self.pool) + .await?; Ok(()) } - /// Alias for [`touch_api_token`]. - pub async fn update_token_last_used(&self, hash: &[u8]) -> Result<()> { - self.touch_api_token(hash).await + /// Alias for [`Self::touch_api_token`]. + pub async fn update_token_last_used( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result<()> { + self.touch_api_token(community_id, hash).await } - /// List all active (non-revoked) tokens, newest first. - pub async fn list_active_tokens(&self) -> Result> { + /// List all active (non-revoked) tokens in a community, newest first. + pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result> { let rows = sqlx::query( r#" SELECT id, name, owner_pubkey, scopes, created_at, expires_at FROM api_tokens - WHERE revoked_at IS NULL + WHERE community_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .fetch_all(&self.pool) .await?; @@ -1065,29 +1477,53 @@ impl Db { Ok(out) } - /// List all tokens for a pubkey (including revoked). - pub async fn list_tokens_by_owner(&self, pubkey: &[u8]) -> Result> { - api_token::list_tokens_by_owner(&self.pool, pubkey).await + /// List all tokens for a (community, owner) pair (including revoked). + pub async fn list_tokens_by_owner( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await } - /// Revoke a single token by ID. + /// Revoke a single token by ID, scoped to (community, owner). pub async fn revoke_token( &self, + community_id: CommunityId, id: Uuid, owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { - api_token::revoke_token(&self.pool, id, owner_pubkey, revoked_by).await + api_token::revoke_token( + &self.pool, + *community_id.as_uuid(), + id, + owner_pubkey, + revoked_by, + ) + .await } - /// Revoke all active tokens for a pubkey. - pub async fn revoke_all_tokens(&self, owner_pubkey: &[u8], revoked_by: &[u8]) -> Result { - api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await + /// Revoke all active tokens for a (community, owner) pair. + pub async fn revoke_all_tokens( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + revoked_by: &[u8], + ) -> Result { + api_token::revoke_all_tokens( + &self.pool, + *community_id.as_uuid(), + owner_pubkey, + revoked_by, + ) + .await } /// Create a new workflow. pub async fn create_workflow( &self, + community_id: CommunityId, channel_id: Option, owner_pubkey: &[u8], name: &str, @@ -1096,6 +1532,7 @@ impl Db { ) -> Result { workflow::create_workflow( &self.pool, + community_id, channel_id, owner_pubkey, name, @@ -1105,27 +1542,33 @@ impl Db { .await } - /// Fetch a single workflow by ID. - pub async fn get_workflow(&self, id: Uuid) -> Result { - workflow::get_workflow(&self.pool, id).await + /// Fetch a single workflow by ID, scoped to its community. + pub async fn get_workflow( + &self, + community_id: CommunityId, + id: Uuid, + ) -> Result { + workflow::get_workflow(&self.pool, community_id, id).await } /// List workflows for a channel. pub async fn list_channel_workflows( &self, + community_id: CommunityId, channel_id: Uuid, limit: Option, offset: Option, ) -> Result> { - workflow::list_channel_workflows(&self.pool, channel_id, limit, offset).await + workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset).await } /// List active, enabled workflows for a channel. pub async fn list_enabled_channel_workflows( &self, + community_id: CommunityId, channel_id: Uuid, ) -> Result> { - workflow::list_enabled_channel_workflows(&self.pool, channel_id).await + workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await } /// List all active, enabled schedule-triggered workflows. @@ -1133,81 +1576,177 @@ impl Db { workflow::list_all_enabled_workflows(&self.pool).await } + /// Claim a scheduled workflow fire for an authoritative schedule instant. + /// + /// Returns `Some` only for the first pod to claim `(community_id, + /// workflow_id, scheduled_for)`; all other pods must skip creating a run. + /// `community_id` is server provenance (the workflow row's own community + /// from the scheduler scan), never client-supplied — `workflows` is keyed + /// `(community_id, id)`, so the claim must bind both to avoid fanning + /// across communities that share the workflow UUID. + pub async fn claim_scheduled_workflow_fire( + &self, + community_id: CommunityId, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + ) -> Result> { + workflow::claim_scheduled_workflow_fire( + &self.pool, + community_id, + workflow_id, + scheduled_for, + ) + .await + } + + /// Fetch the latest claimed schedule instant for interval trigger anchoring. + pub async fn latest_scheduled_workflow_fire( + &self, + community_id: CommunityId, + workflow_id: Uuid, + ) -> Result>> { + workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await + } + + /// Attach the workflow run id created from a won scheduled-fire claim. + pub async fn attach_scheduled_workflow_run( + &self, + community_id: CommunityId, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + workflow_run_id: Uuid, + ) -> Result { + workflow::attach_scheduled_workflow_run( + &self.pool, + community_id, + workflow_id, + scheduled_for, + workflow_run_id, + ) + .await + } + + /// Delete old scheduled workflow fire claims before a retention cutoff. + pub async fn prune_scheduled_workflow_fires_before( + &self, + older_than: chrono::DateTime, + ) -> Result { + workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await + } + /// Update a workflow's name, definition, and hash. pub async fn update_workflow( &self, + community_id: CommunityId, id: Uuid, name: &str, definition_json: &str, definition_hash: &[u8], ) -> Result<()> { - workflow::update_workflow(&self.pool, id, name, definition_json, definition_hash).await + workflow::update_workflow( + &self.pool, + community_id, + id, + name, + definition_json, + definition_hash, + ) + .await } /// Update a workflow's status. pub async fn update_workflow_status( &self, + community_id: CommunityId, id: Uuid, status: workflow::WorkflowStatus, ) -> Result<()> { - workflow::update_workflow_status(&self.pool, id, status).await + workflow::update_workflow_status(&self.pool, community_id, id, status).await } /// Enable or disable a workflow. - pub async fn set_workflow_enabled(&self, id: Uuid, enabled: bool) -> Result<()> { - workflow::set_workflow_enabled(&self.pool, id, enabled).await + pub async fn set_workflow_enabled( + &self, + community_id: CommunityId, + id: Uuid, + enabled: bool, + ) -> Result<()> { + workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await } /// Delete a workflow and all its runs/approvals. - pub async fn delete_workflow(&self, id: Uuid) -> Result<()> { - workflow::delete_workflow(&self.pool, id).await + pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { + workflow::delete_workflow(&self.pool, community_id, id).await } - /// Find a workflow by owner pubkey and name. Used for NIP-09 a-tag deletion - /// where the d-tag is the workflow name (not UUID). + /// Find a workflow by owner pubkey and name within a community. Used for + /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). pub async fn find_workflow_by_owner_and_name( &self, + community_id: CommunityId, owner_pubkey: &[u8], name: &str, ) -> Result> { - workflow::find_by_owner_and_name(&self.pool, owner_pubkey, name).await + workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await } /// Create a new workflow run. pub async fn create_workflow_run( &self, + community_id: CommunityId, workflow_id: Uuid, trigger_event_id: Option<&[u8]>, trigger_context: Option<&serde_json::Value>, ) -> Result { - workflow::create_workflow_run(&self.pool, workflow_id, trigger_event_id, trigger_context) - .await + workflow::create_workflow_run( + &self.pool, + community_id, + workflow_id, + trigger_event_id, + trigger_context, + ) + .await } - /// Fetch a single workflow run. - pub async fn get_workflow_run(&self, id: Uuid) -> Result { - workflow::get_workflow_run(&self.pool, id).await + /// Fetch a single workflow run, scoped to its community. + pub async fn get_workflow_run( + &self, + community_id: CommunityId, + id: Uuid, + ) -> Result { + workflow::get_workflow_run(&self.pool, community_id, id).await } /// List runs for a workflow. pub async fn list_workflow_runs( &self, + community_id: CommunityId, workflow_id: Uuid, limit: i64, ) -> Result> { - workflow::list_workflow_runs(&self.pool, workflow_id, limit).await + workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await } /// Update a workflow run's status. pub async fn update_workflow_run( &self, + community_id: CommunityId, id: Uuid, status: workflow::RunStatus, current_step: i32, trace: &serde_json::Value, error: Option<&str>, ) -> Result<()> { - workflow::update_workflow_run(&self.pool, id, status, current_step, trace, error).await + workflow::update_workflow_run( + &self.pool, + community_id, + id, + status, + current_step, + trace, + error, + ) + .await } /// Create an approval request. @@ -1216,41 +1755,57 @@ impl Db { } /// Fetch an approval by raw token. - pub async fn get_approval(&self, token: &str) -> Result { - workflow::get_approval(&self.pool, token).await + pub async fn get_approval( + &self, + community_id: CommunityId, + token: &str, + ) -> Result { + workflow::get_approval(&self.pool, community_id, token).await } /// Fetch an approval by its already-hashed token (no re-hashing). pub async fn get_approval_by_stored_hash( &self, + community_id: CommunityId, token_hash: &[u8], ) -> Result { - workflow::get_approval_by_stored_hash(&self.pool, token_hash).await + workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await } /// Fetch all approvals for a workflow run. pub async fn get_run_approvals( &self, + community_id: CommunityId, workflow_id: uuid::Uuid, run_id: uuid::Uuid, ) -> Result> { - workflow::get_run_approvals(&self.pool, workflow_id, run_id).await + workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await } /// Update an approval's status. pub async fn update_approval( &self, + community_id: CommunityId, token: &str, status: workflow::ApprovalStatus, approver_pubkey: Option<&[u8]>, note: Option<&str>, ) -> Result { - workflow::update_approval(&self.pool, token, status, approver_pubkey, note).await + workflow::update_approval( + &self.pool, + community_id, + token, + status, + approver_pubkey, + note, + ) + .await } /// Update an approval by its already-hashed token (no re-hashing). pub async fn update_approval_by_stored_hash( &self, + community_id: CommunityId, token_hash: &[u8], status: workflow::ApprovalStatus, approver_pubkey: Option<&[u8]>, @@ -1258,6 +1813,7 @@ impl Db { ) -> Result { workflow::update_approval_by_stored_hash( &self.pool, + community_id, token_hash, status, approver_pubkey, @@ -1290,36 +1846,43 @@ impl Db { Ok(result.rows_affected()) } - /// Check if a pubkey is in the allowlist. - pub async fn is_pubkey_allowed(&self, pubkey: &[u8]) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE pubkey = $1") - .bind(pubkey) - .fetch_one(&self.pool) - .await?; + /// Check if a pubkey is in the allowlist for `community`. + pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_one(&self.pool) + .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) } - /// Check if the allowlist has any entries (i.e. is enforcement active). - pub async fn has_allowlist_entries(&self) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist") - .fetch_one(&self.pool) - .await?; + /// Check if the community allowlist has any entries (i.e. is enforcement active). + pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { + let row = + sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") + .bind(community.as_uuid()) + .fetch_one(&self.pool) + .await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt > 0) } - /// Add a pubkey to the allowlist. + /// Add a pubkey to the community allowlist. pub async fn add_to_allowlist( &self, + community: CommunityId, pubkey: &[u8], added_by: &[u8], note: Option<&str>, ) -> Result { let result = sqlx::query( - "INSERT INTO pubkey_allowlist (pubkey, added_by, note) VALUES ($1, $2, $3) \ + "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ ON CONFLICT DO NOTHING", ) + .bind(community.as_uuid()) .bind(pubkey) .bind(added_by) .bind(note) @@ -1328,20 +1891,27 @@ impl Db { Ok(result.rows_affected() > 0) } - /// Remove a pubkey from the allowlist. - pub async fn remove_from_allowlist(&self, pubkey: &[u8]) -> Result { - let result = sqlx::query("DELETE FROM pubkey_allowlist WHERE pubkey = $1") - .bind(pubkey) - .execute(&self.pool) - .await?; + /// Remove a pubkey from the community allowlist. + pub async fn remove_from_allowlist( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + let result = + sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&self.pool) + .await?; Ok(result.rows_affected() > 0) } - /// List all pubkeys in the allowlist. - pub async fn list_allowlist(&self) -> Result> { + /// List all pubkeys in the community allowlist. + pub async fn list_allowlist(&self, community: CommunityId) -> Result> { let rows = sqlx::query( - "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist ORDER BY added_at DESC", + "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", ) + .bind(community.as_uuid()) .fetch_all(&self.pool) .await?; @@ -1357,81 +1927,98 @@ impl Db { Ok(out) } - /// Returns `true` if `pubkey` (64-char hex) is in the relay member list. - pub async fn is_relay_member(&self, pubkey: &str) -> Result { - relay_members::is_relay_member(&self.pool, pubkey).await + /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { + relay_members::is_relay_member(&self.pool, community, pubkey).await } - /// Returns the relay member record for `pubkey`, or `None` if not found. + /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. pub async fn get_relay_member( &self, + community: CommunityId, pubkey: &str, ) -> Result> { - relay_members::get_relay_member(&self.pool, pubkey).await + relay_members::get_relay_member(&self.pool, community, pubkey).await } - /// Returns all relay members ordered by `created_at` ascending. - pub async fn list_relay_members(&self) -> Result> { - relay_members::list_relay_members(&self.pool).await + /// Returns all relay members of `community` ordered by `created_at` ascending. + pub async fn list_relay_members( + &self, + community: CommunityId, + ) -> Result> { + relay_members::list_relay_members(&self.pool, community).await } - /// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent). - /// Adds a new relay member. + /// Adds a new relay member to `community`. /// /// Returns `true` if the row was actually inserted, `false` if the pubkey - /// already existed (idempotent — `ON CONFLICT DO NOTHING`). + /// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`). pub async fn add_relay_member( &self, + community: CommunityId, pubkey: &str, role: &str, added_by: Option<&str>, ) -> Result { - relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await + relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await } - /// Removes a relay member atomically, refusing to delete the owner. - pub async fn remove_relay_member(&self, pubkey: &str) -> Result { - relay_members::remove_relay_member(&self.pool, pubkey).await + /// Removes a relay member from `community` atomically, refusing to delete the owner. + pub async fn remove_relay_member( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result { + relay_members::remove_relay_member(&self.pool, community, pubkey).await } - /// Removes a relay member only if their current role matches `expected_role`. + /// Removes a relay member from `community` only if their current role matches `expected_role`. /// /// Atomic conditional delete — eliminates the TOCTOU race between a /// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`]. pub async fn remove_relay_member_if_role( &self, + community: CommunityId, pubkey: &str, expected_role: &str, ) -> Result { - relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await + relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role) + .await } - /// Updates the role of an existing relay member. Returns `true` if updated. - pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result { - relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await + /// Updates the role of an existing relay member in `community`. Returns `true` if updated. + pub async fn update_relay_member_role( + &self, + community: CommunityId, + pubkey: &str, + new_role: &str, + ) -> Result { + relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await } - /// Ensures the owner pubkey exists with role `"owner"`. Called at startup. - pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> { - relay_members::bootstrap_owner(&self.pool, owner_pubkey).await + /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. + pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await } - /// Migrates existing `pubkey_allowlist` entries into `relay_members`. + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. /// /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. - pub async fn backfill_from_allowlist(&self) -> Result { - relay_members::backfill_from_allowlist(&self.pool).await + pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { + relay_members::backfill_from_allowlist(&self.pool, community).await } - /// Returns `true` if `pubkey` (64-char hex) is currently archived. - pub async fn is_archived(&self, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, pubkey).await + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. + pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::is_archived(&self.pool, community_id, pubkey).await } - /// Archives an identity. Returns `true` if inserted, `false` if already archived. + /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. + #[allow(clippy::too_many_arguments)] pub async fn archive( &self, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -1441,6 +2028,7 @@ impl Db { ) -> Result { archived_identities::archive( &self.pool, + community_id, pubkey, consent_path, actor, @@ -1451,26 +2039,31 @@ impl Db { .await } - /// Unarchives an identity. Returns `true` if deleted, `false` if absent. - pub async fn unarchive(&self, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, pubkey).await + /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. + pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::unarchive(&self.pool, community_id, pubkey).await } - /// Returns all archived identities ordered by archive time ascending. - pub async fn list_archived(&self) -> Result> { - archived_identities::list_archived(&self.pool).await + /// Returns all identities archived in `community_id`, ordered by archive time ascending. + pub async fn list_archived( + &self, + community_id: CommunityId, + ) -> Result> { + archived_identities::list_archived(&self.pool, community_id).await } /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. pub async fn soft_delete_discovery_events( &self, + community_id: CommunityId, channel_id: Uuid, relay_pubkey: &[u8], ) -> Result { let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE channel_id = $1 AND pubkey = $2 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(relay_pubkey) .execute(&self.pool) @@ -1487,6 +2080,7 @@ impl Db { /// skip fan-out/dispatch when `was_inserted` is false. pub async fn replace_addressable_event( &self, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { @@ -1501,6 +2095,10 @@ impl Db { // Collisions cause extra serialization, not incorrect behavior. let lock_key = { let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis + for b in community_id.as_uuid().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } for b in kind_i32.to_le_bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); // FNV prime @@ -1531,11 +2129,12 @@ impl Db { // historical data where prior bugs may have left multiple live rows. let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( "SELECT created_at, id FROM events \ - WHERE kind = $1 AND pubkey = $2 \ - AND channel_id IS NOT DISTINCT FROM $3 \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(channel_id) @@ -1562,10 +2161,11 @@ impl Db { // Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety. sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE kind = $1 AND pubkey = $2 \ - AND channel_id IS NOT DISTINCT FROM $3 \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(channel_id) @@ -1579,10 +2179,11 @@ impl Db { let d_tag = crate::event::extract_d_tag(event); let insert_result = sqlx::query( - "INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ ON CONFLICT DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(event.id.as_bytes().as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -1611,7 +2212,7 @@ impl Db { // Mentions are a denormalized index — safe outside the transaction. // insert_event() normally handles this, but we inlined the INSERT above. - if let Err(e) = crate::insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -1640,6 +2241,7 @@ impl Db { /// this function instead, where the author's pubkey + d-tag is the natural key. pub async fn replace_parameterized_event( &self, + community_id: CommunityId, event: &nostr::Event, d_tag: &str, channel_id: Option, @@ -1654,6 +2256,10 @@ impl Db { // Same algorithm as replace_addressable_event — deterministic across processes. let lock_key = { let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis + for b in community_id.as_uuid().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } for b in kind_i32.to_le_bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); @@ -1679,9 +2285,10 @@ impl Db { // Check for existing event with same (kind, pubkey, d_tag). let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( "SELECT created_at, id FROM events \ - WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) @@ -1705,8 +2312,9 @@ impl Db { // Soft-delete the older event(s). sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) @@ -1720,10 +2328,11 @@ impl Db { let received_at = chrono::Utc::now(); let insert_result = sqlx::query( - "INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \ ON CONFLICT DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(event.id.as_bytes().as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -1750,7 +2359,7 @@ impl Db { tx.commit().await?; // Mentions are a denormalized index — safe outside the transaction. - if let Err(e) = crate::insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -1833,3 +2442,326 @@ fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { revoked_at: row.try_get("revoked_at")?, }) } + +#[cfg(test)] +mod tests { + //! Pin the load-bearing contract for `Db::communities_of_channels`: + //! a channel id that does NOT exist MUST be absent from the result + //! map, never mapped to a default. The relay-side read-row emitter + //! relies on this — a missing entry triggers `MissingLookup → + //! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this + //! helper ever started returning a default/zero entry for unknown + //! channels, that fail-closed chain would go blind. + use super::*; + use buzz_core::CommunityId; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_db() -> Db { + let pool = PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB"); + Db { pool } + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_community_by_host_matches_case_insensitive_host_index() { + let db = setup_db().await; + let id = Uuid::new_v4(); + let lower_host = format!("lookup-community-{}.example", id.simple()); + let stored_host = lower_host.to_uppercase(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&stored_host) + .execute(&db.pool) + .await + .expect("insert mixed-case community host"); + + let found = db + .lookup_community_by_host(&lower_host) + .await + .expect("lookup lower-case host") + .expect("community found by lower-case host"); + assert_eq!(found.id, CommunityId::from_uuid(id)); + assert_eq!(found.host, stored_host); + + let found = db + .lookup_community_by_host(&stored_host) + .await + .expect("lookup stored-case host") + .expect("community found by stored-case host"); + assert_eq!(found.id, CommunityId::from_uuid(id)); + } + + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { + let creator: Vec = vec![0u8; 32]; + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by) + VALUES + ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4) + "#, + ) + .bind(channel_id) + .bind(community_id) + .bind(format!("ch-{}", channel_id.simple())) + .bind(&creator) + .execute(pool) + .await + .expect("insert channel"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn allowlist_is_scoped_to_community() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let pubkey = [7u8; 32]; + let added_by = [9u8; 32]; + + assert!(db + .add_to_allowlist(community_a, &pubkey, &added_by, Some("a-only")) + .await + .expect("add allowlist row")); + assert!(!db + .add_to_allowlist(community_a, &pubkey, &added_by, Some("duplicate")) + .await + .expect("duplicate allowlist row is idempotent")); + + assert!( + db.is_pubkey_allowed(community_a, &pubkey) + .await + .expect("allowlist check A"), + "pubkey added to A must be allowed in A" + ); + assert!( + !db.is_pubkey_allowed(community_b, &pubkey) + .await + .expect("allowlist check B"), + "pubkey added only to A must not be allowed in B" + ); + assert!(db + .has_allowlist_entries(community_a) + .await + .expect("A has entries")); + assert!(!db + .has_allowlist_entries(community_b) + .await + .expect("B has no entries")); + + let listed = db + .list_allowlist(community_a) + .await + .expect("list A allowlist"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].pubkey, pubkey); + + assert!( + !db.remove_from_allowlist(community_b, &pubkey) + .await + .expect("remove from B is no-op"), + "removing from B must not delete A's row" + ); + assert!(db + .is_pubkey_allowed(community_a, &pubkey) + .await + .expect("A still allowed after B remove")); + assert!(db + .remove_from_allowlist(community_a, &pubkey) + .await + .expect("remove from A")); + assert!(!db + .is_pubkey_allowed(community_a, &pubkey) + .await + .expect("A not allowed after remove")); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn communities_of_channels_present_for_existing_absent_for_missing() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let existing = Uuid::new_v4(); + insert_channel(&db.pool, community, existing).await; + + // Channel that is NOT inserted — the load-bearing case. + let missing = Uuid::new_v4(); + + let result = db + .communities_of_channels(&[existing, missing]) + .await + .expect("communities_of_channels"); + + // (1) Existing channel → present with its true community. + assert_eq!( + result.get(&existing).copied(), + Some(CommunityId::from_uuid(community)), + "existing channel must map to its true community", + ); + + // (2) Missing channel → ABSENT from the map (never defaulted). + // This is the contract the relay-side `MissingLookup → ImplBug` + // fail-closed guard-rail depends on. If this assertion ever + // weakens to `result.get(&missing) != Some(community)`, the + // mutate-bite below stops biting. + assert!( + !result.contains_key(&missing), + "missing channel must be absent from the result map, got {:?}", + result.get(&missing), + ); + + // (3) Map size matches: exactly one entry, the existing one. + assert_eq!( + result.len(), + 1, + "result map must contain only existing channels" + ); + } + + /// BUG-5 regression: the `reactions` table is community-scoped + /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a + /// reaction added under community A must be invisible and unremovable from + /// community B — even for the *identical* `(event_id, pubkey, emoji)` shape. + /// Before the fix, `add_reaction` omitted `community_id` (NOT NULL → 500) and + /// every read/remove filtered `event_id` only (latent cross-tenant bleed). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reactions_are_scoped_to_community() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + + // Identical referenced-event shape across both tenants. + let event_id = [0xABu8; 32]; + let event_created_at = Utc::now(); + let pubkey = [7u8; 32]; + let emoji = "👍"; + + // (1) Add succeeds under A (this INSERT 500'd before the fix). + assert!( + db.add_reaction( + community_a, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("add reaction under A"), + "first reaction under A must be inserted" + ); + // Idempotent: re-adding the same active reaction is a no-op. + assert!( + !db.add_reaction( + community_a, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("duplicate reaction under A"), + "active duplicate under A must not re-insert" + ); + + // (2) Visible on A, invisible on B (grouped read path). + let groups_a = db + .get_reactions(community_a, &event_id, event_created_at, 100, None) + .await + .expect("get reactions A"); + assert_eq!(groups_a.len(), 1, "A must see its own reaction group"); + assert_eq!(groups_a[0].emoji, emoji); + assert_eq!(groups_a[0].count, 1); + + let groups_b = db + .get_reactions(community_b, &event_id, event_created_at, 100, None) + .await + .expect("get reactions B"); + assert!( + groups_b.is_empty(), + "B must NOT see A's reaction for the same event shape, got {groups_b:?}" + ); + + // (3) Active-record lookup is scoped: present on A, absent on B. + assert!( + db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record A") + .is_some(), + "A's active reaction record must be present" + ); + assert!( + db.get_active_reaction_record(community_b, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record B") + .is_none(), + "B must not find A's active reaction record" + ); + + // (4) B can add the identical shape independently (no PK collision). + assert!( + db.add_reaction( + community_b, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("add reaction under B"), + "B must be able to add the same shape as its own scoped row" + ); + + // (5) Removing from B does not touch A's row. + assert!( + db.remove_reaction(community_b, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("remove under B"), + "B remove must affect B's own row" + ); + assert!( + db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record A after B remove") + .is_some(), + "A's reaction must survive a B-side removal" + ); + + // (6) A remove affects only A; A's read now empty. + assert!( + db.remove_reaction(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("remove under A"), + "A remove must affect A's row" + ); + let groups_a_after = db + .get_reactions(community_a, &event_id, event_created_at, 100, None) + .await + .expect("get reactions A after remove"); + assert!( + groups_a_after.is_empty(), + "A's reaction must be gone after A removes it" + ); + } +} diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index f4f3c9ab3..401d067d8 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -1,9 +1,8 @@ //! Embedded SQLx migrations for Buzz. //! -//! Fresh deployments apply the checked-in SQL files under `migrations/`. -//! Existing pre-SQLx deployments are baselined when core Buzz tables already -//! exist but `_sqlx_migrations` does not, so startup will not try to replay the -//! initial schema over a live database. +//! Fresh deployments apply the checked-in SQL files under `migrations/`. The +//! multi-tenant rewrite owns a clean consolidated `0001`; legacy single-tenant +//! cutover/backfill is a separate operator script, not startup migration state. use sqlx::PgPool; @@ -11,154 +10,619 @@ use crate::Result; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); -#[cfg(test)] -static SCHEMA_SQL: &str = include_str!("../../../schema/schema.sql"); - -const BASELINE_MIGRATION_VERSIONS: &[i64] = &[1, 2]; - /// Run all pending Buzz database migrations. pub async fn run_migrations(pool: &PgPool) -> Result<()> { - baseline_existing_database(pool).await?; MIGRATOR.run(pool).await?; Ok(()) } -async fn baseline_existing_database(pool: &PgPool) -> Result<()> { - if migrations_table_exists(pool).await? || !pre_sqlx_schema_exists(pool).await? { - return Ok(()); +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ConstraintKind { + ForeignKey, + PrimaryKey, + Unique, } - ensure_migrations_table(pool).await?; + #[derive(Debug, Clone, PartialEq, Eq)] + struct ConstraintLint { + table: String, + kind: ConstraintKind, + description: String, + columns: Vec, + } - for version in BASELINE_MIGRATION_VERSIONS { - let migration = MIGRATOR + fn migration_sql() -> &'static str { + MIGRATOR .iter() - .find(|migration| migration.version == *version) - .expect("baseline migration version must exist in embedded migrator"); - - sqlx::query( - r#" - INSERT INTO _sqlx_migrations - (version, description, success, checksum, execution_time) - VALUES ($1, $2, TRUE, $3, 0) - ON CONFLICT (version) DO NOTHING - "#, - ) - .bind(migration.version) - .bind(&*migration.description) - .bind(&*migration.checksum) - .execute(pool) - .await?; + .find(|migration| migration.version == 1) + .expect("initial migration must exist") + .sql + .as_str() } - tracing::info!( - versions = ?BASELINE_MIGRATION_VERSIONS, - "Baselined existing Buzz database for SQLx migrations" - ); + fn strip_sql_comments(sql: &str) -> String { + sql.lines() + .map(|line| line.split_once("--").map_or(line, |(before, _)| before)) + .collect::>() + .join("\n") + } - Ok(()) -} + fn normalize_sql(sql: &str) -> String { + strip_sql_comments(sql) + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() + } -async fn migrations_table_exists(pool: &PgPool) -> Result { - let exists = sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = '_sqlx_migrations' - ) - "#, - ) - .fetch_one(pool) - .await?; + fn split_sql_statements(sql: &str) -> Vec { + let sql = strip_sql_comments(sql); + let bytes = sql.as_bytes(); + let mut statements = Vec::new(); + let mut start = 0usize; + let mut idx = 0usize; + let mut in_single_quote = false; + let mut in_dollar_quote = false; + + while idx < bytes.len() { + match bytes[idx] { + b'\'' if !in_dollar_quote => { + in_single_quote = !in_single_quote; + idx += 1; + } + b'$' if !in_single_quote && idx + 1 < bytes.len() && bytes[idx + 1] == b'$' => { + in_dollar_quote = !in_dollar_quote; + idx += 2; + } + b';' if !in_single_quote && !in_dollar_quote => { + let statement = sql[start..idx].trim(); + if !statement.is_empty() { + statements.push(statement.to_owned()); + } + start = idx + 1; + idx += 1; + } + _ => idx += 1, + } + } + + let tail = sql[start..].trim(); + if !tail.is_empty() { + statements.push(tail.to_owned()); + } + + statements + } - Ok(exists) -} + fn find_matching_paren(sql: &str, open: usize) -> Option { + let mut depth = 0usize; + for (offset, byte) in sql.as_bytes()[open..].iter().enumerate() { + match byte { + b'(' => depth += 1, + b')' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(open + offset); + } + } + _ => {} + } + } + None + } -async fn pre_sqlx_schema_exists(pool: &PgPool) -> Result { - let exists = sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'events' - ) AND EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'channels' - ) - "#, - ) - .fetch_one(pool) - .await?; + fn split_top_level_csv(input: &str) -> Vec { + let mut parts = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + for (idx, byte) in input.bytes().enumerate() { + match byte { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + parts.push(input[start..idx].trim().to_owned()); + start = idx + 1; + } + _ => {} + } + } + let tail = input[start..].trim(); + if !tail.is_empty() { + parts.push(tail.to_owned()); + } + parts + } - Ok(exists) -} + fn identifier_after_keyword(statement: &str, keyword: &str) -> Option { + let lower = statement.to_ascii_lowercase(); + let keyword_pos = lower.find(keyword)?; + let mut remainder = statement[keyword_pos + keyword.len()..].trim_start(); + for prefix in ["if not exists", "if exists", "only"] { + if remainder.to_ascii_lowercase().starts_with(prefix) { + remainder = remainder[prefix.len()..].trim_start(); + } + } + + let identifier = remainder + .split(|ch: char| ch.is_whitespace() || ch == '(') + .next()? + .trim_matches('"') + .rsplit('.') + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + (!identifier.is_empty()).then_some(identifier) + } -async fn ensure_migrations_table(pool: &PgPool) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS _sqlx_migrations ( - version BIGINT PRIMARY KEY, - description TEXT NOT NULL, - installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), - success BOOLEAN NOT NULL, - checksum BYTEA NOT NULL, - execution_time BIGINT NOT NULL - ) - "#, - ) - .execute(pool) - .await?; + fn first_parenthesized_columns(input: &str) -> Vec { + let Some(open) = input.find('(') else { + return Vec::new(); + }; + let Some(close) = find_matching_paren(input, open) else { + return Vec::new(); + }; + + split_top_level_csv(&input[open + 1..close]) + .into_iter() + .filter_map(|column| { + let name = column + .trim() + .trim_matches('"') + .split_whitespace() + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + (!name.is_empty()).then_some(name) + }) + .collect() + } - Ok(()) -} + fn column_definition_name(definition: &str) -> Option { + let trimmed = definition.trim(); + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("constraint ") + || lower.starts_with("primary key") + || lower.starts_with("foreign key") + || lower.starts_with("unique") + || lower.starts_with("check ") + || lower.starts_with("exclude ") + { + return None; + } + + let name = trimmed + .split_whitespace() + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + (!name.is_empty()).then_some(name) + } -#[cfg(test)] -mod tests { - use super::*; - use sqlx::PgPool; + fn create_table_body(statement: &str) -> Option<(String, Vec)> { + let table = identifier_after_keyword(statement, "create table")?; + let open = statement.find('(')?; + let close = find_matching_paren(statement, open)?; + Some((table, split_top_level_csv(&statement[open + 1..close]))) + } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + fn create_table_definitions(sql: &str) -> Vec<(String, Vec)> { + split_sql_statements(sql) + .into_iter() + .filter_map(|statement| { + let normalized = statement.trim_start().to_ascii_lowercase(); + if !normalized.starts_with("create table") || normalized.contains(" partition of ") + { + return None; + } + create_table_body(&statement) + }) + .collect() + } + + fn create_tables(sql: &str) -> BTreeSet { + create_table_definitions(sql) + .into_iter() + .map(|(table, _)| table) + .collect() + } + + fn table_has_not_null_community_id(definitions: &[String]) -> bool { + definitions.iter().any(|definition| { + column_definition_name(definition).as_deref() == Some("community_id") + && normalize_sql(definition).contains("not null") + }) + } + + fn operator_global_tables(sql: &str) -> BTreeSet { + let mut globals = BTreeSet::new(); + let normalized = normalize_sql(sql); + let Some(insert_pos) = normalized.find("insert into _operator_global_tables") else { + return globals; + }; + + for value in [ + "communities", + "rate_limit_violations", + "_operator_global_tables", + ] { + if normalized[insert_pos..].contains(&format!("'{value}'")) { + globals.insert(value.to_owned()); + } + } + + globals + } + + fn scoped_tables(sql: &str) -> BTreeSet { + let globals = operator_global_tables(sql); + create_tables(sql) + .into_iter() + .filter(|table| !globals.contains(table)) + .collect() + } + + fn constraint_lint_for_definition(table: &str, definition: &str) -> Option { + let normalized = normalize_sql(definition); + let definition_without_name = if normalized.starts_with("constraint ") { + let after_constraint = definition + .trim_start() + .splitn(3, char::is_whitespace) + .nth(2) + .unwrap_or(""); + normalize_sql(after_constraint) + } else { + normalized.clone() + }; + + if definition_without_name.starts_with("primary key") { + Some(ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::PrimaryKey, + description: definition.to_owned(), + columns: first_parenthesized_columns(&definition_without_name), + }) + } else if definition_without_name.starts_with("unique") { + Some(ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::Unique, + description: definition.to_owned(), + columns: first_parenthesized_columns(&definition_without_name), + }) + } else if definition_without_name.starts_with("foreign key") { + Some(ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::ForeignKey, + description: definition.to_owned(), + columns: first_parenthesized_columns(&definition_without_name), + }) + } else if normalized.contains(" primary key") { + column_definition_name(definition).map(|column| ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::PrimaryKey, + description: definition.to_owned(), + columns: vec![column], + }) + } else if normalized.contains(" references ") { + column_definition_name(definition).map(|column| ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::ForeignKey, + description: definition.to_owned(), + columns: vec![column], + }) + } else if normalized.contains(" unique") { + column_definition_name(definition).map(|column| ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::Unique, + description: definition.to_owned(), + columns: vec![column], + }) + } else { + None + } + } + + fn table_constraints(sql: &str, scoped_tables: &BTreeSet) -> Vec { + create_table_definitions(sql) + .into_iter() + .filter(|(table, _)| scoped_tables.contains(table)) + .flat_map(|(table, definitions)| { + definitions.into_iter().filter_map(move |definition| { + constraint_lint_for_definition(&table, &definition) + }) + }) + .collect() + } + + fn alter_table_constraints(sql: &str, scoped_tables: &BTreeSet) -> Vec { + split_sql_statements(sql) + .into_iter() + .filter_map(|statement| { + let normalized = normalize_sql(&statement); + if !normalized.starts_with("alter table") { + return None; + } + + let table = identifier_after_keyword(&statement, "alter table")?; + if !scoped_tables.contains(&table) { + return None; + } + + let add_pos = normalized.find(" add ")?; + let definition = normalized[add_pos + " add ".len()..].trim(); + constraint_lint_for_definition(&table, definition) + }) + .collect() + } + + fn unique_indexes(sql: &str, scoped_tables: &BTreeSet) -> Vec { + split_sql_statements(sql) + .into_iter() + .filter_map(|statement| { + let normalized = normalize_sql(&statement); + if !normalized.starts_with("create unique index") { + return None; + } + + let lower_statement = statement.to_ascii_lowercase(); + let on_pos = lower_statement.find(" on ")?; + let table = statement[on_pos + " on ".len()..] + .trim_start() + .split(|ch: char| ch.is_whitespace() || ch == '(') + .next()? + .trim_matches('"') + .rsplit('.') + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + + scoped_tables.contains(&table).then(|| ConstraintLint { + table, + kind: ConstraintKind::Unique, + description: statement.clone(), + columns: first_parenthesized_columns(&statement[on_pos + " on ".len()..]), + }) + }) + .collect() + } + + fn scoped_constraint_lints(sql: &str, scoped_tables: &BTreeSet) -> Vec { + let mut constraints = table_constraints(sql, scoped_tables); + constraints.extend(alter_table_constraints(sql, scoped_tables)); + constraints.extend(unique_indexes(sql, scoped_tables)); + constraints + } + + fn is_allowed_partition_primary_key_exception(constraint: &ConstraintLint) -> bool { + constraint.table == "delivery_log" + && constraint.kind == ConstraintKind::PrimaryKey + && constraint.columns == ["delivered_at", "id"] + } + + fn scoped_constraint_violations(sql: &str) -> Vec { + let scoped_tables = scoped_tables(sql); + scoped_constraint_lints(sql, &scoped_tables) + .into_iter() + .filter(|constraint| { + if is_allowed_partition_primary_key_exception(constraint) { + return false; + } + constraint.columns.first().map(String::as_str) != Some("community_id") + }) + .collect() + } + + fn has_channels_community_id_immutability_guard(sql: &str) -> bool { + let normalized = normalize_sql(sql); + normalized.contains("create trigger") + && normalized.contains("before update") + && normalized.contains(" on channels") + && normalized.contains("community_id") + && normalized.contains("old.community_id") + && normalized.contains("new.community_id") + && normalized.contains("raise exception") + } + + fn forbidden_channels_community_id_mutations(sql: &str) -> Vec { + split_sql_statements(sql) + .into_iter() + .filter(|statement| { + let normalized = normalize_sql(statement); + let updates_channels = + identifier_after_keyword(statement, "update").as_deref() == Some("channels"); + let mutates_with_update = updates_channels + && normalized.contains(" set ") + && normalized.contains("community_id"); + let alters_channels = identifier_after_keyword(statement, "alter table").as_deref() + == Some("channels"); + let drops_channels = identifier_after_keyword(statement, "drop table").as_deref() + == Some("channels"); + let drops_or_rewrites_column = alters_channels + && (normalized.contains("drop column community_id") + || normalized.contains("alter column community_id") + || normalized.contains("rename column community_id") + || normalized.contains("rename community_id") + || normalized.contains("drop trigger") + || normalized.contains("disable trigger")); + + mutates_with_update || drops_or_rewrites_column || drops_channels + }) + .collect() + } #[test] - fn embedded_migrator_contains_all_schema_migrations() { + fn embedded_migrator_contains_consolidated_initial_schema() { let migrations: Vec<_> = MIGRATOR.iter().collect(); - assert_eq!(migrations.len(), 3); + assert_eq!(migrations.len(), 1); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); - assert!( - migrations[0].sql.as_str().contains("CREATE TABLE channels"), - "initial schema migration should include Buzz core tables" + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE communities")); + assert!(migrations[0].sql.as_str().contains("CREATE TABLE channels")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE scheduled_workflow_fires")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE audit_log")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE _operator_global_tables")); + assert!(migrations[0] + .sql + .as_str() + .contains("search_tsv TSVECTOR GENERATED ALWAYS")); + } + + #[test] + fn migration_lint_detects_tables_missing_community_id_by_default() { + let sql = r#" + CREATE TABLE communities (id UUID PRIMARY KEY); + CREATE TABLE widgets (id UUID PRIMARY KEY); + CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL); + INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('communities', 'tenant registry'), + ('_operator_global_tables', 'registry'); + "#; + + let definitions = create_table_definitions(sql); + let scoped = scoped_tables(sql); + let missing = definitions + .into_iter() + .filter(|(table, _)| scoped.contains(table)) + .filter(|(_, definitions)| !table_has_not_null_community_id(definitions)) + .map(|(table, _)| table) + .collect::>(); + + assert_eq!(missing, vec!["widgets"]); + } + + #[test] + fn migration_lint_detects_scoped_key_constraints_not_led_by_community_id() { + let sql = r#" + CREATE TABLE widgets ( + community_id UUID NOT NULL, + id UUID PRIMARY KEY, + channel_id UUID REFERENCES channels(id), + slug TEXT, + CONSTRAINT widgets_name_unique UNIQUE (slug), + CONSTRAINT widgets_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id) + ); + CREATE UNIQUE INDEX idx_widgets_slug ON widgets (slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id); + CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL); + INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('_operator_global_tables', 'registry'); + "#; + + let violations = scoped_constraint_violations(sql); + + assert!(violations + .iter() + .any(|violation| violation.kind == ConstraintKind::PrimaryKey)); + assert_eq!( + violations + .iter() + .filter(|violation| violation.kind == ConstraintKind::ForeignKey) + .count(), + 3 + ); + assert_eq!( + violations + .iter() + .filter(|violation| violation.kind == ConstraintKind::Unique) + .count(), + 3 ); + } + + #[test] + fn migration_lint_accepts_scoped_key_constraints_led_by_community_id() { + let sql = r#" + CREATE TABLE widgets ( + community_id UUID NOT NULL, + id UUID NOT NULL, + channel_id UUID NOT NULL, + slug TEXT NOT NULL, + PRIMARY KEY (community_id, id), + UNIQUE (community_id, slug), + FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id) + ); + CREATE UNIQUE INDEX idx_widgets_slug ON widgets (community_id, slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (community_id, slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id); + CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL); + INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('_operator_global_tables', 'registry'); + "#; + + assert!(scoped_constraint_violations(sql).is_empty()); + } + + #[test] + fn all_non_operator_global_tables_have_not_null_community_id() { + let sql = migration_sql(); + let scoped = scoped_tables(sql); + let missing = create_table_definitions(sql) + .into_iter() + .filter(|(table, _)| scoped.contains(table)) + .filter(|(_, definitions)| !table_has_not_null_community_id(definitions)) + .map(|(table, _)| table) + .collect::>(); + assert!( - migrations[0] - .sql - .as_str() - .contains("CREATE TABLE IF NOT EXISTS relay_members"), - "initial schema migration should include relay_members" + missing.is_empty(), + "every table not listed in _operator_global_tables must carry NOT NULL community_id; missing: {}", + missing.join(", ") ); + } + + #[test] + fn scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id() { + let sql = migration_sql(); + let violations = scoped_constraint_violations(sql) + .into_iter() + .map(|constraint| { + format!( + "{}. {:?} constraint must lead with community_id: {}", + constraint.table, constraint.kind, constraint.description + ) + }) + .collect::>(); - assert_eq!(migrations[1].version, 2); - assert_eq!(&*migrations[1].description, "backfill d tag"); assert!( - migrations[1].sql.as_str().contains("UPDATE events"), - "second migration should backfill existing event rows" + violations.is_empty(), + "tenant-scoped tables are all tables not listed in _operator_global_tables; primary key, unique/FK constraints, and unique indexes on those tables must lead with community_id:\n{}", + violations.join("\n") ); + } + + #[test] + fn channels_community_id_is_immutable_after_insert() { + let sql = migration_sql(); + let forbidden_mutations = forbidden_channels_community_id_mutations(sql); - assert_eq!(migrations[2].version, 3); - assert_eq!(&*migrations[2].description, "event reminders"); assert!( - migrations[2] - .sql - .as_str() - .contains("ADD COLUMN not_before BIGINT") - && migrations[2].sql.as_str().contains("idx_events_not_before"), - "third migration should add the NIP-ER reminder columns and index" + forbidden_mutations.is_empty(), + "channels.community_id must not be re-tenanted after insert; forbidden migration statements:\n{}", + forbidden_mutations.join("\n---\n") + ); + assert!( + has_channels_community_id_immutability_guard(sql), + "migrations define channels.community_id but no BEFORE UPDATE trigger/function guard that rejects OLD.community_id <> NEW.community_id was found" ); } @@ -192,88 +656,35 @@ mod tests { .expect("read applied migrations") } - /// Returns `schema/schema.sql` with the NIP-ER reminder DDL removed, so it - /// models a pre-stack deployment whose `events` table lacks the reminder - /// columns and index. The strip is asserted: if the snapshot text drifts so - /// these fragments no longer match, the test fails loudly rather than - /// silently loading a snapshot that already carries the reminder columns - /// (which would make migration 0003 collide on re-add). - fn pre_reminder_schema_snapshot() -> String { - const REMINDER_COLUMNS: &str = " not_before BIGINT,\n delivered_at BIGINT,\n"; - const REMINDER_INDEX: &str = "CREATE INDEX idx_events_not_before ON events (not_before)\n WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;\n"; - - assert!( - SCHEMA_SQL.contains(REMINDER_COLUMNS) && SCHEMA_SQL.contains(REMINDER_INDEX), - "schema.sql reminder DDL drifted; update pre_reminder_schema_snapshot to match" - ); - - SCHEMA_SQL - .replace(REMINDER_COLUMNS, "") - .replace(REMINDER_INDEX, "") - } - #[tokio::test] #[ignore = "requires Postgres"] - async fn run_migrations_applies_embedded_versions_on_fresh_database() { + async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { let pool = connect_test_pool().await; reset_public_schema(&pool).await; run_migrations(&pool).await.expect("run migrations"); - assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); - let events_exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'events')", - ) - .fetch_one(&pool) - .await - .expect("check events table"); - assert!(events_exists); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn run_migrations_baselines_existing_schema_and_preserves_allowlist_backfill_path() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - // Load a pre-stack snapshot (without the NIP-ER reminder DDL) so the - // events table matches a real pre-SQLx deployment, which never had the - // reminder columns. Migration 0003 must then add them — proving the - // genuine prod-upgrade path, not a snapshot that already carries them. - sqlx::raw_sql(sqlx::AssertSqlSafe(pre_reminder_schema_snapshot())) - .execute(&pool) - .await - .expect("load pre-SQLx schema snapshot"); - sqlx::query( - "INSERT INTO pubkey_allowlist (pubkey, added_at) VALUES (decode($1, 'hex'), now())", - ) - .bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .execute(&pool) - .await - .expect("seed legacy allowlist row"); - - run_migrations(&pool).await.expect("baseline migrations"); - - assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); - let allowlist_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pubkey_allowlist") + assert_eq!(applied_versions(&pool).await, vec![1]); + let tables = create_tables(migration_sql()); + for table in [ + "communities", + "events", + "channels", + "scheduled_workflow_fires", + "audit_log", + ] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) .fetch_one(&pool) .await - .expect("count allowlist rows"); - assert_eq!( - allowlist_count, 1, - "baseline must not drop legacy allowlist rows before relay startup backfills them" - ); - - let inserted = crate::relay_members::backfill_from_allowlist(&pool) - .await - .expect("backfill legacy allowlist rows"); - assert_eq!(inserted, 1); - let relay_member_count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM relay_members WHERE pubkey = $1 AND role = 'member'", - ) - .bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .fetch_one(&pool) - .await - .expect("count backfilled relay member"); - assert_eq!(relay_member_count, 1); + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!( + tables.contains(table), + "migration parser should see {table}" + ); + assert!(exists, "migration should create {table}"); + } } } diff --git a/crates/buzz-db/src/partition.rs b/crates/buzz-db/src/partition.rs index 480da3b55..b3803f1b3 100644 --- a/crates/buzz-db/src/partition.rs +++ b/crates/buzz-db/src/partition.rs @@ -100,7 +100,7 @@ async fn ensure_partition( ))); } - let partition_name = format!("{table_name}_{suffix}"); + let partition_name = format!("{table_name}_p{suffix}"); let row = sqlx::query( r#" @@ -127,10 +127,26 @@ async fn ensure_partition( FOR VALUES FROM ('{start_date_str}') TO ('{end_date_str}')" ); - sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await?; - info!("added partition {partition_name}"); - - Ok(()) + match sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await { + Ok(_) => { + info!("added partition {partition_name}"); + Ok(()) + } + Err(sqlx::Error::Database(db_err)) + if db_err.code().as_deref() == Some("42P17") + && db_err.message().contains("would overlap partition") => + { + // Fresh schemas include a right-edge catch-all partition (`*_p_future`). + // If it already covers this month, the table is still safe for writes; + // treat the overlap as "ensured" rather than failing startup. + info!( + partition_name, + "partition range already covered by an existing partition" + ); + Ok(()) + } + Err(e) => Err(e.into()), + } } #[cfg(test)] diff --git a/crates/buzz-db/src/reaction.rs b/crates/buzz-db/src/reaction.rs index f35344833..3fd1caab7 100644 --- a/crates/buzz-db/src/reaction.rs +++ b/crates/buzz-db/src/reaction.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use crate::error::Result; +use crate::CommunityId; // -- Public structs ----------------------------------------------------------- @@ -70,6 +71,7 @@ pub struct ActiveReactionRecord { /// two concurrent adds both see no existing row and then race to INSERT. pub async fn add_reaction( pool: &PgPool, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], @@ -78,15 +80,16 @@ pub async fn add_reaction( ) -> Result { let result = sqlx::query( r#" - INSERT INTO reactions (event_created_at, event_id, pubkey, emoji, reaction_event_id) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (event_created_at, event_id, pubkey, emoji) DO UPDATE SET + INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET created_at = NOW(), removed_at = NULL, reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id) WHERE reactions.removed_at IS NOT NULL "#, ) + .bind(community.as_uuid()) .bind(event_created_at) .bind(event_id) .bind(pubkey) @@ -109,6 +112,7 @@ pub async fn add_reaction( /// Returns `true` if a row was updated, `false` if not found or already removed. pub async fn remove_reaction( pool: &PgPool, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], @@ -118,13 +122,15 @@ pub async fn remove_reaction( r#" UPDATE reactions SET removed_at = NOW() - WHERE event_created_at = $1 - AND event_id = $2 - AND pubkey = $3 - AND emoji = $4 + WHERE community_id = $1 + AND event_created_at = $2 + AND event_id = $3 + AND pubkey = $4 + AND emoji = $5 AND removed_at IS NULL "#, ) + .bind(community.as_uuid()) .bind(event_created_at) .bind(event_id) .bind(pubkey) @@ -140,16 +146,19 @@ pub async fn remove_reaction( /// Returns `true` if a row was updated, `false` if not found or already removed. pub async fn remove_reaction_by_source_event_id( pool: &PgPool, + community: CommunityId, reaction_event_id: &[u8], ) -> Result { let result = sqlx::query( r#" UPDATE reactions SET removed_at = NOW() - WHERE reaction_event_id = $1 + WHERE community_id = $1 + AND reaction_event_id = $2 AND removed_at IS NULL "#, ) + .bind(community.as_uuid()) .bind(reaction_event_id) .execute(pool) .await?; @@ -160,6 +169,7 @@ pub async fn remove_reaction_by_source_event_id( /// Look up the active reaction row for one actor + emoji + target tuple. pub async fn get_active_reaction_record( pool: &PgPool, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], @@ -169,14 +179,16 @@ pub async fn get_active_reaction_record( r#" SELECT reaction_event_id FROM reactions - WHERE event_id = $1 - AND event_created_at = $2 - AND pubkey = $3 - AND emoji = $4 + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND pubkey = $4 + AND emoji = $5 AND removed_at IS NULL LIMIT 1 "#, ) + .bind(community.as_uuid()) .bind(event_id) .bind(event_created_at) .bind(pubkey) @@ -198,6 +210,7 @@ pub async fn get_active_reaction_record( /// reaction row to its source event. Returns `true` if the row was updated. pub async fn set_reaction_event_id( pool: &PgPool, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, pubkey: &[u8], @@ -208,14 +221,16 @@ pub async fn set_reaction_event_id( r#" UPDATE reactions SET reaction_event_id = $1 - WHERE event_created_at = $2 - AND event_id = $3 - AND pubkey = $4 - AND emoji = $5 + WHERE community_id = $2 + AND event_created_at = $3 + AND event_id = $4 + AND pubkey = $5 + AND emoji = $6 AND removed_at IS NULL "#, ) .bind(reaction_event_id) + .bind(community.as_uuid()) .bind(event_created_at) .bind(event_id) .bind(pubkey) @@ -232,11 +247,12 @@ pub async fn set_reaction_event_id( /// /// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting /// user pubkeys. Display names are NOT resolved here -- callers should enrich via -/// `get_users_bulk` if needed. +/// scoped user lookups if needed. /// /// `cursor` is reserved for future keyset pagination (currently unused). pub async fn get_reactions( pool: &PgPool, + community: CommunityId, event_id: &[u8], event_created_at: DateTime, limit: u32, @@ -253,18 +269,21 @@ pub async fn get_reactions( INNER JOIN ( SELECT DISTINCT emoji FROM reactions - WHERE event_id = $1 - AND event_created_at = $2 + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 AND removed_at IS NULL ORDER BY emoji - LIMIT $3 + LIMIT $4 ) g ON g.emoji = r.emoji - WHERE r.event_id = $1 - AND r.event_created_at = $2 + WHERE r.community_id = $1 + AND r.event_id = $2 + AND r.event_created_at = $3 AND r.removed_at IS NULL ORDER BY r.emoji, r.created_at "#, ) + .bind(community.as_uuid()) .bind(event_id) .bind(event_created_at) .bind(limit as i64) @@ -319,6 +338,7 @@ pub async fn get_reactions( /// active reaction. Pairs with no reactions are omitted. pub async fn get_reactions_bulk( pool: &PgPool, + community: CommunityId, event_ids: &[(&[u8], DateTime)], ) -> Result> { if event_ids.is_empty() { @@ -335,13 +355,15 @@ pub async fn get_reactions_bulk( r#" SELECT emoji, COUNT(*) AS count FROM reactions - WHERE event_id = $1 - AND event_created_at = $2 + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 AND removed_at IS NULL GROUP BY emoji ORDER BY emoji "#, ) + .bind(community.as_uuid()) .bind(*event_id) .bind(event_created_at) .fetch_all(pool) diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 8a7dc6d82..8a71d8609 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -1,12 +1,16 @@ //! Relay-level membership persistence (NIP-43). //! -//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata. -//! All pubkey values are 64-char lowercase hex strings. +//! The `relay_members` table is community-scoped: its primary key is +//! `(community_id, pubkey)`. Every read, write, and list is bound to a single +//! `community_id` so that admitting a pubkey to community A never admits it to +//! community B (NIP-43 admission confinement). `pubkey` values are 64-char +//! lowercase hex strings. use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; +use crate::CommunityId; /// A single relay member record. #[derive(Debug, Clone)] @@ -23,21 +27,27 @@ pub struct RelayMember { pub updated_at: DateTime, } -/// Returns `true` if `pubkey` (64-char hex) is in the relay member list. -pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1") +/// Returns `true` if `pubkey` (64-char hex) is a member of `community`. +pub async fn is_relay_member(pool: &PgPool, community: CommunityId, pubkey: &str) -> Result { + let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; Ok(row.is_some()) } -/// Returns the relay member record for `pubkey`, or `None` if not found. -pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result> { +/// Returns the relay member record for `pubkey` in `community`, or `None`. +pub async fn get_relay_member( + pool: &PgPool, + community: CommunityId, + pubkey: &str, +) -> Result> { let row = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ - FROM relay_members WHERE pubkey = $1", + FROM relay_members WHERE community_id = $1 AND pubkey = $2", ) + .bind(community.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; @@ -55,12 +65,13 @@ pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result Result> { +/// Returns all relay members of `community` ordered by `created_at` ascending. +pub async fn list_relay_members(pool: &PgPool, community: CommunityId) -> Result> { let rows = sqlx::query( "SELECT pubkey, role, added_by, created_at, updated_at \ - FROM relay_members ORDER BY created_at ASC", + FROM relay_members WHERE community_id = $1 ORDER BY created_at ASC", ) + .bind(community.as_uuid()) .fetch_all(pool) .await?; @@ -78,20 +89,23 @@ pub async fn list_relay_members(pool: &PgPool) -> Result> { .map_err(crate::error::DbError::from) } -/// Adds a new relay member. +/// Adds a new relay member to `community`. /// /// Returns `true` if the row was actually inserted, `false` if the pubkey -/// already existed (idempotent — `ON CONFLICT DO NOTHING`). +/// already existed in this community (idempotent — `ON CONFLICT DO NOTHING` on +/// the `(community_id, pubkey)` primary key). pub async fn add_relay_member( pool: &PgPool, + community: CommunityId, pubkey: &str, role: &str, added_by: Option<&str>, ) -> Result { let result = sqlx::query( - "INSERT INTO relay_members (pubkey, role, added_by) \ - VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING", + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community.as_uuid()) .bind(pubkey) .bind(role) .bind(added_by) @@ -118,11 +132,19 @@ pub enum RemoveResult { /// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the /// owner-protection check and the deletion are one atomic operation — /// no TOCTOU race between a separate read and delete. -pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result { - let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'") - .bind(pubkey) - .execute(pool) - .await?; +pub async fn remove_relay_member( + pool: &PgPool, + community: CommunityId, + pubkey: &str, +) -> Result { + let result = sqlx::query( + "DELETE FROM relay_members \ + WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; if result.rows_affected() > 0 { return Ok(RemoveResult::Removed); @@ -130,7 +152,8 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result Result Result { - let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2") - .bind(pubkey) - .bind(expected_role) - .execute(pool) - .await?; + let result = sqlx::query( + "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expected_role) + .execute(pool) + .await?; if result.rows_affected() > 0 { return Ok(RemoveResult::Removed); @@ -172,7 +199,8 @@ pub async fn remove_relay_member_if_role( // rows_affected == 0: either not found or role changed. One cheap read to // distinguish the cases so callers can return the right error message. - let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1") + let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; @@ -193,42 +221,58 @@ pub async fn remove_relay_member_if_role( } } -/// Updates the role of an existing relay member. Returns `true` if updated. -pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result { +/// Updates the role of an existing relay member in `community`. Returns `true` +/// if updated. +pub async fn update_relay_member_role( + pool: &PgPool, + community: CommunityId, + pubkey: &str, + new_role: &str, +) -> Result { let result = sqlx::query( - "UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'", + "UPDATE relay_members SET role = $1, updated_at = now() \ + WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", ) .bind(new_role) + .bind(community.as_uuid()) .bind(pubkey) .execute(pool) .await?; Ok(result.rows_affected() > 0) } -/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes -/// any other owners to `"admin"`. This handles owner rotation: if -/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted. +/// Ensures the configured owner pubkey holds the `"owner"` role *in +/// `community`*, and demotes any other owners in that community to `"admin"`. +/// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner +/// is automatically demoted. Scoped to one community — an owner of community A +/// is never bootstrapped into community B. /// /// Runs in a single transaction. Safe to call at every startup — idempotent. -pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> { +pub async fn bootstrap_owner( + pool: &PgPool, + community: CommunityId, + owner_pubkey: &str, +) -> Result<()> { let pubkey = owner_pubkey.to_ascii_lowercase(); let mut tx = pool.begin().await?; - // 1. Upsert the configured owner. + // 1. Upsert the configured owner for this community. sqlx::query( - "INSERT INTO relay_members (pubkey, role, added_by) \ - VALUES ($1, 'owner', NULL) \ - ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()", + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'owner', NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET role = 'owner', updated_at = now()", ) + .bind(community.as_uuid()) .bind(&pubkey) .execute(&mut *tx) .await?; - // 2. Demote any other owners to admin. + // 2. Demote any other owners in this community to admin. sqlx::query( "UPDATE relay_members SET role = 'admin', updated_at = now() \ - WHERE role = 'owner' AND pubkey <> $1", + WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2", ) + .bind(community.as_uuid()) .bind(&pubkey) .execute(&mut *tx) .await?; @@ -237,16 +281,18 @@ pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> { Ok(()) } -/// Migrates existing `pubkey_allowlist` entries into `relay_members`. +/// Migrates existing `pubkey_allowlist` entries into `relay_members` for +/// `community` (the deployment's default community). /// -/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members. -/// Returns the number of rows inserted, or 0 if: +/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members of +/// `community`. Returns the number of rows inserted, or 0 if: /// - the `pubkey_allowlist` table doesn't exist, or -/// - `relay_members` already has rows (migration ran in a prior startup). +/// - `relay_members` already has rows for this community (migration ran in a +/// prior startup). /// /// The empty-table guard prevents re-adding members that were intentionally /// removed by an admin after the initial backfill. -pub async fn backfill_from_allowlist(pool: &PgPool) -> Result { +pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result { // Check if pubkey_allowlist table exists. let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ @@ -259,25 +305,155 @@ pub async fn backfill_from_allowlist(pool: &PgPool) -> Result { return Ok(0); } - // Only backfill if relay_members is empty — once the table has rows - // (from a previous backfill or manual admin commands), we must not + // Only backfill if this community's relay_members is empty — once it has + // rows (from a previous backfill or manual admin commands), we must not // re-add members that were intentionally removed. - let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)") - .fetch_one(pool) - .await?; + let has_members: bool = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") + .bind(community.as_uuid()) + .fetch_one(pool) + .await?; if has_members { return Ok(0); } let result = sqlx::query( - "INSERT INTO relay_members (pubkey, role, added_by, created_at) \ - SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \ + "INSERT INTO relay_members (community_id, pubkey, role, added_by, created_at) \ + SELECT $1, encode(pubkey, 'hex'), 'member', NULL, added_at \ FROM pubkey_allowlist \ - ON CONFLICT (pubkey) DO NOTHING", + WHERE community_id = $1 \ + ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community.as_uuid()) .execute(pool) .await?; Ok(result.rows_affected()) } + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("relay-members-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + /// NIP-43 admission confinement: a pubkey admitted to community A is *not* + /// admitted to community B. This is the exact mutation #1285 targets — a + /// `WHERE pubkey = $1` membership check (no community predicate) would let an + /// A-member authenticate against B. We add the pubkey only to A and assert + /// every read path (`is_relay_member`, `get_relay_member`, `list_relay_members`) + /// confines it to A. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn membership_is_confined_to_its_community() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + // 64-char lowercase hex, unique per run so reruns don't collide. + let pubkey = format!("{:064x}", Uuid::new_v4().as_u128()); + + let inserted = add_relay_member(&pool, community_a, &pubkey, "member", None) + .await + .expect("add member to community A"); + assert!(inserted, "first insert into A should report inserted"); + + // is_relay_member: member of A, NOT of B. + assert!( + is_relay_member(&pool, community_a, &pubkey) + .await + .expect("is_relay_member A"), + "pubkey must be a member of community A" + ); + assert!( + !is_relay_member(&pool, community_b, &pubkey) + .await + .expect("is_relay_member B"), + "pubkey admitted to A must NOT be a member of B (admission confinement)" + ); + + // get_relay_member (used by the NIP-OA owner check + admin role lookups): + // resolves in A, absent in B. + assert!( + get_relay_member(&pool, community_a, &pubkey) + .await + .expect("get_relay_member A") + .is_some(), + "get_relay_member must resolve in community A" + ); + assert!( + get_relay_member(&pool, community_b, &pubkey) + .await + .expect("get_relay_member B") + .is_none(), + "get_relay_member must not resolve the A pubkey in community B" + ); + + // list_relay_members: B's list never contains A's member. + let list_a = list_relay_members(&pool, community_a) + .await + .expect("list A"); + assert!( + list_a.iter().any(|m| m.pubkey == pubkey), + "community A list must contain the admitted pubkey" + ); + let list_b = list_relay_members(&pool, community_b) + .await + .expect("list B"); + assert!( + list_b.iter().all(|m| m.pubkey != pubkey), + "community B list must not contain A's member" + ); + } + + /// Owner bootstrap is community-scoped: bootstrapping the owner in A does not + /// make that pubkey an owner (or member) of B. Guards against a global + /// `INSERT ... (pubkey, role)` bootstrap leaking the owner across tenants. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_bootstrap_is_confined_to_its_community() { + let pool = setup_pool().await; + let community_a = make_test_community(&pool).await; + let community_b = make_test_community(&pool).await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + bootstrap_owner(&pool, community_a, &owner) + .await + .expect("bootstrap owner in A"); + + let in_a = get_relay_member(&pool, community_a, &owner) + .await + .expect("get owner A") + .expect("owner exists in A"); + assert_eq!(in_a.role, "owner", "bootstrapped pubkey must be owner in A"); + + assert!( + !is_relay_member(&pool, community_b, &owner) + .await + .expect("is_relay_member B"), + "owner bootstrapped in A must NOT be a member of B" + ); + } +} diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 4ccd8a248..8281ed9db 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -9,6 +9,8 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use uuid::Uuid; +use buzz_core::CommunityId; + use crate::{error::Result, event::row_to_stored_event}; // -- Structs ------------------------------------------------------------------ @@ -110,6 +112,7 @@ pub struct ThreadMetadataRecord { #[allow(clippy::too_many_arguments)] pub async fn insert_thread_metadata( pool: &PgPool, + community_id: CommunityId, event_id: &[u8], event_created_at: DateTime, channel_id: Uuid, @@ -125,14 +128,15 @@ pub async fn insert_thread_metadata( let result = sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(event_created_at) .bind(event_id) .bind(channel_id) @@ -156,14 +160,15 @@ pub async fn insert_thread_metadata( sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false) + VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(parent_ts) .bind(pid) .bind(channel_id) @@ -185,6 +190,7 @@ pub async fn insert_thread_metadata( ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(root_ts) .bind(root_id) .bind(channel_id) @@ -199,9 +205,10 @@ pub async fn insert_thread_metadata( UPDATE thread_metadata SET reply_count = reply_count + 1, last_reply_at = NOW() - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(pid) .execute(&mut *tx) .await?; @@ -212,9 +219,10 @@ pub async fn insert_thread_metadata( r#" UPDATE thread_metadata SET descendant_count = descendant_count + 1 - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(&mut *tx) .await?; @@ -239,6 +247,7 @@ pub async fn insert_thread_metadata( #[allow(dead_code)] pub async fn increment_reply_count( pool: &PgPool, + community_id: CommunityId, parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { @@ -248,9 +257,10 @@ pub async fn increment_reply_count( UPDATE thread_metadata SET reply_count = reply_count + 1, last_reply_at = NOW() - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(parent_event_id) .execute(pool) .await?; @@ -261,9 +271,10 @@ pub async fn increment_reply_count( r#" UPDATE thread_metadata SET descendant_count = descendant_count + 1 - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(pool) .await?; @@ -277,6 +288,7 @@ pub async fn increment_reply_count( /// root -- even when root == parent. Mirrors the increment logic exactly. pub async fn decrement_reply_count( pool: &PgPool, + community_id: CommunityId, parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { @@ -285,9 +297,10 @@ pub async fn decrement_reply_count( r#" UPDATE thread_metadata SET reply_count = GREATEST(reply_count - 1, 0) - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(parent_event_id) .execute(pool) .await?; @@ -298,9 +311,10 @@ pub async fn decrement_reply_count( r#" UPDATE thread_metadata SET descendant_count = GREATEST(descendant_count - 1, 0) - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(pool) .await?; @@ -320,6 +334,7 @@ pub async fn decrement_reply_count( /// - `limit` -- maximum rows returned (caller should cap this). pub async fn get_thread_replies( pool: &PgPool, + community_id: CommunityId, root_event_id: &[u8], depth_limit: Option, limit: u32, @@ -336,7 +351,7 @@ pub async fn get_thread_replies( // Build the query dynamically based on optional filters. // Track the next positional parameter index. - let mut param_idx = 2u32; // $1 is root_event_id + let mut param_idx = 3u32; // $1 is community_id, $2 is root_event_id let mut sql = String::from( r#" SELECT @@ -357,9 +372,11 @@ pub async fn get_thread_replies( tm.broadcast FROM thread_metadata tm JOIN events e - ON e.created_at = tm.event_created_at + ON e.community_id = tm.community_id + AND e.created_at = tm.event_created_at AND e.id = tm.event_id - WHERE tm.root_event_id = $1 + WHERE tm.community_id = $1 + AND tm.root_event_id = $2 AND e.deleted_at IS NULL "#, ); @@ -377,7 +394,9 @@ pub async fn get_thread_replies( " ORDER BY tm.event_created_at ASC LIMIT ${param_idx}" )); - let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(root_event_id); + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(community_id.as_uuid()) + .bind(root_event_id); if let Some(dl) = depth_limit { q = q.bind(dl as i32); @@ -428,15 +447,20 @@ pub async fn get_thread_replies( } /// Fetch aggregated thread stats for a single event, plus up to 10 participant pubkeys. -pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result> { +pub async fn get_thread_summary( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], +) -> Result> { let row = sqlx::query( r#" SELECT reply_count, descendant_count, last_reply_at FROM thread_metadata - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(event_id) .fetch_optional(pool) .await?; @@ -457,9 +481,11 @@ pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result