mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
@@ -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 \
|
||||
|
||||
27
AGENTS.md
27
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
|
||||
|
||||
174
ARCHITECTURE.md
174
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", <signed-event>]` before submitting events
|
||||
| Path | Mechanism | Use Case |
|
||||
|------|-----------|---------|
|
||||
| NIP-42 | Signed challenge, pubkey verified | WebSocket connections |
|
||||
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients |
|
||||
| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on HTTP bridge endpoints | HTTP clients |
|
||||
|
||||
On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.
|
||||
|
||||
@@ -368,7 +367,7 @@ Handles authentication paths, scope enforcement, and token operations.
|
||||
| Path | Entry Point | Notes |
|
||||
|------|-------------|-------|
|
||||
| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) |
|
||||
| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event |
|
||||
| NIP-98 HTTP Auth | `validate_nip98_auth()` | HTTP bridge endpoints; Schnorr-signed `kind:27235` event |
|
||||
|
||||
**Key types:**
|
||||
|
||||
@@ -462,21 +461,32 @@ EXPIRE buzz:typing:{channel_id} 60
|
||||
|
||||
---
|
||||
|
||||
### buzz-search — Typesense Integration
|
||||
### buzz-search — Postgres FTS Integration
|
||||
|
||||
Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-API-KEY`. In multi-community mode, indexed documents and every query filter include `community_id`; the shared Typesense collection is infrastructure, not a cross-community result space.
|
||||
|
||||
**Collection schema (7 fields):** `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]).
|
||||
Full-text search via Postgres FTS. Events are searchable through the
|
||||
`events.search_tsv` generated `tsvector` column (populated on insert, indexed
|
||||
by a GIN index) — there is no separate search service or out-of-band indexer.
|
||||
Privacy-sensitive kinds are excluded at the storage level (the `search_tsv`
|
||||
`CASE WHEN kind IN (...)` yields `NULL`, which never matches `@@`). In
|
||||
multi-community mode every query filter includes `community_id`, so the shared
|
||||
`events` table is infrastructure, not a cross-community result space; the relay
|
||||
re-authorizes every candidate hit before returning it.
|
||||
|
||||
**Key behaviors:**
|
||||
- `ensure_collection()` is idempotent: handles 409 race condition (another process created it between check and create).
|
||||
- Tag flattening uses `\x1f` (ASCII unit separator) to avoid ambiguity with tag values containing colons (e.g., URLs in `r` tags).
|
||||
- Upsert indexing: `POST /documents?action=upsert` (single), `POST /documents/import?action=upsert` (batch JSONL).
|
||||
- `delete_event()` validates event ID (64-char hex) before constructing the URL — prevents path injection.
|
||||
- `delete_event()` is idempotent: 404 treated as success.
|
||||
- Permission filtering is **caller's responsibility** — `buzz-search` provides the `filter_by` mechanism but does not enforce access policy.
|
||||
- `SearchService::new(pool)` wraps a `PgPool`; `search(&SearchQuery)` runs a
|
||||
parameterized FTS query against the `events.search_tsv` GIN index and returns
|
||||
`SearchResult` (candidate `SearchHit`s).
|
||||
- `ChannelScope` makes the channel constraint explicit (`Any` /
|
||||
`ChannelLessOnly` / `Channels` / `ChannelsOrChannelLess`), closing the
|
||||
ambiguity the old `Option<Vec<Uuid>> + bool` matrix could not express.
|
||||
- Every query carries `community_id`; the FTS predicate is BitmapAnd-ed with
|
||||
the community-leading btree filters so a query never crosses tenants.
|
||||
- Permission filtering is **caller's responsibility** — `buzz-search` returns
|
||||
candidate hits; the relay re-authorizes each one (channel membership, `#p`,
|
||||
owner gates) before delivering it.
|
||||
|
||||
**Does NOT:** enforce channel membership or access control. Does NOT store events in Postgres.
|
||||
**Does NOT:** enforce channel membership or access control. Does NOT write
|
||||
events (indexing is the `search_tsv` generated column on the `events` insert).
|
||||
|
||||
---
|
||||
|
||||
@@ -547,50 +557,6 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
---
|
||||
|
||||
### buzz-proxy — NIP-28 Compatibility Proxy
|
||||
|
||||
Lets standard Nostr clients (Coracle, nak, Amethyst, nostr-tools, nostr-sdk) read and write Buzz channels using the NIP-28 Public Chat Channels protocol. Connects to the relay as a WebSocket client; presents a standard NIP-01/NIP-11/NIP-28/NIP-42 interface to external clients.
|
||||
|
||||
**Key modules:** `server.rs` (Axum WebSocket server, NIP-11, NIP-42 auth, filter splitting), `translate.rs` (bidirectional kind/tag translation), `upstream.rs` (persistent relay connection with auto-reconnect and subscription replay), `channel_map.rs` (bidirectional UUID ↔ kind:40 event ID mapping), `shadow_keys.rs` (deterministic keypair derivation), `guest_store.rs` (pubkey-based guest registry), `invite_store.rs` (token-based invite system).
|
||||
|
||||
**Shadow keypairs:** `HMAC-SHA256(key=server_salt, msg=external_pubkey_bytes)` → secp256k1 secret key. Deterministic: same external pubkey always produces the same shadow key. Empty salt rejected. Cache: `DashMap` with `MAX_CACHE_SIZE = 10,000`. Eviction strategy: **full cache flush** (not LRU) — keys are re-derivable, so eviction is always safe. Count tracked with `AtomicUsize` (soft bound — may briefly exceed limit under concurrent inserts).
|
||||
|
||||
**Kind translation (lossy):**
|
||||
|
||||
`KindTranslator` defines the full mapping between standard Nostr kinds and Buzz kinds. The proxy's event paths gate which kinds actually flow through — only a subset is accepted inbound or emitted outbound.
|
||||
|
||||
*Inbound (client → relay) — accepted kinds:*
|
||||
|
||||
| Standard Kind | Buzz Kind | Note |
|
||||
|--------------|-------------|------|
|
||||
| 1, 42 | KIND_STREAM_MESSAGE | Multiple → one (lossy) |
|
||||
| 41 | KIND_STREAM_MESSAGE_EDIT | Channel message edit |
|
||||
| 7 | KIND_REACTION | Reaction (pass-through kind) |
|
||||
|
||||
Kind 5 (deletion) is intentionally blocked inbound — the relay's deletion handler lacks author-match authorization for proxy clients. Kinds 4, 40, 43, 44 are defined in `KindTranslator` but not accepted by the proxy's inbound path.
|
||||
|
||||
*Outbound (relay → client) — emitted kinds:*
|
||||
|
||||
| Buzz Kind | Standard Kind | Note |
|
||||
|-------------|--------------|------|
|
||||
| KIND_STREAM_MESSAGE | 42 | NIP-28 channel message |
|
||||
| KIND_STREAM_MESSAGE_V2 | 42 | Rich format collapses to plain kind:42 |
|
||||
| KIND_STREAM_MESSAGE_EDIT | 41 | NIP-28 channel message edit |
|
||||
| KIND_REACTION | 7 | Reaction |
|
||||
| KIND_DELETION | 5 | Standard NIP-09 deletion |
|
||||
|
||||
`to_buzz(to_standard(k))` is NOT lossless for secondary mappings (e.g., kind:1 → KIND_STREAM_MESSAGE → kind:42). Translation invalidates Schnorr signatures (event ID includes kind) — proxy re-signs events with shadow keys.
|
||||
|
||||
**Dual auth:** Pubkey-based guest registration (persistent, primary) + invite tokens (ad-hoc, time-limited, secondary). Both use NIP-42 for the authentication handshake. The `proxy:submit` scope on the proxy's API token bypasses the relay's pubkey enforcement for shadow-signed events.
|
||||
|
||||
**Channel map:** Loaded at startup from the relay's REST API. kind:40 events are synthesized locally only. kind:41 is split: synthesized metadata is served locally, but kind:41 filters are also forwarded upstream (translated to kind:40003) to capture edit events. Channels created after proxy start require a restart to appear.
|
||||
|
||||
**State is in-memory.** Guest registrations, invite tokens, and channel map are lost on proxy restart.
|
||||
|
||||
**Does NOT:** implement relay-side lifecycle event emission — the relay does not emit events when proxy clients connect or disconnect (planned).
|
||||
|
||||
---
|
||||
|
||||
### Huddle Audio — WebSocket Opus Relay
|
||||
|
||||
Real-time voice lives inside `buzz-relay` (`src/audio/`), not a separate crate. A WebSocket endpoint (`wss://.../huddle/{channel_id}/audio`) authenticates each participant with a NIP-42 challenge, checks channel membership, admits them to an in-memory room, and forwards opaque Opus frames between peers. No external SFU.
|
||||
@@ -641,47 +607,26 @@ pub struct ConnectionState {
|
||||
pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
|
||||
```
|
||||
|
||||
**REST API endpoints:**
|
||||
**HTTP endpoints:**
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/channels` | List accessible channels |
|
||||
| GET | `/api/channels/{channel_id}` | Get channel detail + metadata |
|
||||
| GET | `/api/channels/{channel_id}/members` | List channel members |
|
||||
| GET | `/api/channels/{channel_id}/canvas` | Get channel canvas |
|
||||
| GET | `/api/channels/{channel_id}/messages` | List channel messages |
|
||||
| GET | `/api/channels/{channel_id}/threads/{event_id}` | Get message thread |
|
||||
| GET/POST | `/api/channels/{channel_id}/workflows` | List/create channel workflows |
|
||||
| GET | `/api/search` | Full-text search via Typesense |
|
||||
| GET | `/api/agents` | List agent accounts |
|
||||
| GET/PUT | `/api/presence` | Presence status (bulk) / set presence |
|
||||
| GET | `/api/feed` | Personalized feed (mentions/needs-action/activity) |
|
||||
| POST | `/api/events` | Submit event via REST (no WebSocket) |
|
||||
| GET | `/api/events/{id}` | Get event by ID |
|
||||
| GET/PUT/DELETE | `/api/workflows/{id}` | Workflow CRUD |
|
||||
| GET | `/api/workflows/{id}/runs` | Execution history |
|
||||
| GET | `/api/workflows/{id}/runs/{run_id}/approvals` | List run approvals |
|
||||
| POST | `/api/workflows/{id}/trigger` | Manual trigger |
|
||||
| POST | `/api/workflows/{id}/webhook` | Webhook trigger (HMAC-verified) |
|
||||
| POST | `/api/approvals/{token}/grant` | Approve a workflow step (🚧 unreachable — see WF-08) |
|
||||
| POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) |
|
||||
| POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) |
|
||||
| POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) |
|
||||
| GET | `/api/dms` | List DM channels |
|
||||
| POST | `/api/dms` | Open a DM channel |
|
||||
| POST | `/api/dms/{channel_id}/members` | Add DM member |
|
||||
| POST | `/api/dms/{channel_id}/hide` | Hide a DM channel |
|
||||
| GET | `/api/messages/{event_id}/reactions` | List reactions on a message |
|
||||
| GET | `/api/users/me/profile` | Get own profile |
|
||||
| PUT | `/api/users/me/channel-add-policy` | Set channel add policy |
|
||||
| GET | `/api/users/search` | Search users |
|
||||
| GET | `/api/users/{pubkey}/profile` | Get user profile by pubkey |
|
||||
| POST | `/api/users/batch` | Batch-fetch user profiles |
|
||||
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
|
||||
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
|
||||
| GET | `/` | WebSocket upgrade or NIP-11 relay info |
|
||||
| GET | `/info` | NIP-11 relay info |
|
||||
| GET | `/.well-known/nostr.json` | NIP-05 identity |
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/_liveness` | Liveness probe |
|
||||
| GET | `/_readiness` | Readiness probe |
|
||||
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
|
||||
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
|
||||
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |
|
||||
| POST | `/hooks/{id}` | Workflow webhook trigger (secret-authenticated) |
|
||||
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
|
||||
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
|
||||
| GET | `/git/{owner}/{repo}/info/refs` | Git smart HTTP advertisement |
|
||||
| POST | `/git/{owner}/{repo}/git-upload-pack` | Git smart HTTP fetch |
|
||||
| POST | `/git/{owner}/{repo}/git-receive-pack` | Git smart HTTP push |
|
||||
| POST | `/internal/git/policy` | Internal git hook policy check |
|
||||
|
||||
**Constants:**
|
||||
|
||||
@@ -757,10 +702,7 @@ The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/bu
|
||||
| `tests/e2e_relay.rs` | 27 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
|
||||
| `tests/e2e_media.rs` | 7 | Media upload/download (Blossom) |
|
||||
| `tests/e2e_media_extended.rs` | 18 | Extended media scenarios |
|
||||
| `tests/e2e_nostr_interop.rs` | 15 | NIP-28 proxy interoperability |
|
||||
| `tests/e2e_rest_api.rs` | 40 | REST API (channels, search, presence, agents, feed) |
|
||||
| `tests/e2e_tokens.rs` | 20 | Token auth and scope enforcement |
|
||||
| `tests/e2e_workflows.rs` | 7 | Workflow CRUD, trigger, and execution |
|
||||
| `tests/e2e_nostr_interop.rs` | 15 | Nostr interoperability: NIP-50 search, NIP-10 threads, NIP-17 gift wraps, DM discovery |
|
||||
|
||||
All e2e tests are `#[ignore]` — require a running relay. Total: **134 e2e tests**.
|
||||
|
||||
@@ -831,9 +773,8 @@ Docker Compose provides the full local development stack. All services include h
|
||||
|
||||
| Service | Image | Port | Purpose |
|
||||
|---------|-------|------|---------|
|
||||
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit |
|
||||
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit; full-text search (`search_tsv` GIN) |
|
||||
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
|
||||
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
|
||||
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
|
||||
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
|
||||
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
|
||||
@@ -859,9 +800,16 @@ Docker Compose provides the full local development stack. All services include h
|
||||
| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
|
||||
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
|
||||
|
||||
### Typesense Collection
|
||||
### Full-Text Search (Postgres FTS)
|
||||
|
||||
Single collection (`events` by default, configurable via `TYPESENSE_COLLECTION`). Schema today: `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]). Multi-community mode adds faceted `community_id` and either prefixes document IDs with community or makes all upsert/delete/refetch paths carry community context.
|
||||
Search runs over the `events.search_tsv` generated `tsvector` column on the
|
||||
`events` table (no separate collection or service). The column is populated on
|
||||
insert — `to_tsvector('simple', content)` — and excludes privacy-sensitive
|
||||
kinds via `CASE WHEN kind IN (1059, 30300, 30622) THEN NULL`, so those rows are
|
||||
storage-level unsearchable (a `NULL` tsvector never matches `@@`). A GIN index
|
||||
(`idx_events_search_tsv`) backs the `@@` probe; in multi-community mode the
|
||||
community-leading btree filters BitmapAnd with the GIN probe so every query is
|
||||
fenced to its `community_id`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(channel_id_str): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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`:
|
||||
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.
|
||||
|
||||
```rust
|
||||
.route("/api/channels/{channel_id}/my-resource", get(get_my_resource))
|
||||
```
|
||||
3. **Add database queries** in `buzz-db/src/` only when the endpoint cannot be
|
||||
expressed through the existing event query paths.
|
||||
|
||||
3. **Add the database query** in `buzz-db/src/` — follow the existing
|
||||
patterns in `channel.rs`, `event.rs`, etc.
|
||||
4. **Handle errors** using the `api_error()`, `internal_error()`, and
|
||||
`not_found()` helpers in `buzz-relay/src/api/mod.rs`. Return
|
||||
`(StatusCode, Json<Value>)` tuples.
|
||||
|
||||
4. **Handle errors** — use the `api_error()` and `internal_error()` helpers in
|
||||
`buzz-relay/src/api/mod.rs`. Return `(StatusCode, Json<Value>)` tuples.
|
||||
5. **Write tests** with the `buzz-test-client` harness in
|
||||
`crates/buzz-test-client/tests/`, covering auth, community scoping, and the
|
||||
relevant success path.
|
||||
|
||||
5. **Write tests** — add an integration test using the `buzz-test-client`
|
||||
harness in `crates/buzz-test-client/tests/e2e_rest_api.rs`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
98
Cargo.lock
generated
98
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
50
Justfile
50
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,18 @@ test-unit:
|
||||
#!/usr/bin/env bash
|
||||
if command -v cargo-nextest &>/dev/null; then
|
||||
cargo nextest run -p buzz-core -p buzz-auth --lib
|
||||
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
|
||||
# They guard the embedded-migrator invariant (exactly the consolidated
|
||||
# 0001; cutover/backfill stays an operator script, not startup state)
|
||||
# and the tenant-scoping lints. The Postgres-backed buzz-db tests are
|
||||
# #[ignore]d, so --lib runs only the infra-free set. Without this gate a
|
||||
# stray file in migrations/ or a broken lint ships green.
|
||||
cargo nextest run -p buzz-db --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 +298,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 +454,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
|
||||
|
||||
323
NOSTR.md
323
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=<your-hex-pubkey>" \
|
||||
|
||||
---
|
||||
|
||||
## 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 <base64-nip98-event>" \
|
||||
-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=<relay-hex-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": "<guest-hex-pubkey>", "channels": "<channel-uuid>"}'
|
||||
|
||||
# 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 <privkey> 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": "<hex>", "channels": "<uuid1>,<uuid2>"}'
|
||||
|
||||
# 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": "<hex>"}'
|
||||
```
|
||||
|
||||
> **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": "<uuid1>,<uuid2>", "max_uses": 5, "hours": 48}'
|
||||
|
||||
# Connect: ws://localhost:4869?token=<invite_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://<subdomain>.ngrok.io`.
|
||||
|
||||
### Connecting with nak (Proxy)
|
||||
|
||||
```bash
|
||||
# Discover channels
|
||||
nak req -k 40 -l 10 --auth --sec <privkey> ws://localhost:4869
|
||||
|
||||
# Read messages from a specific channel
|
||||
nak req -k 42 --tag "e=<kind40-event-id>" -l 10 --auth --sec <privkey> ws://localhost:4869
|
||||
|
||||
# Send a message
|
||||
nak event -k 42 -c "Hello!" --tag e=<kind40-event-id> \
|
||||
--auth --sec <privkey> ws://localhost:4869
|
||||
|
||||
# Stream live from a specific channel
|
||||
nak req -k 42 --tag "e=<kind40-event-id>" --stream --auth --sec <privkey> 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: '<kind:40 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("<hex-privkey>")
|
||||
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 <privkey> 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 <privkey> 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=<channel-uuid>"` |
|
||||
| `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.
|
||||
|
||||
17
README.md
17
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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<i32> {
|
||||
|
||||
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<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
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<String>) -> 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<String>) -> 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<String>) -> R
|
||||
|
||||
async fn cmd_list_members() -> Result<i32> {
|
||||
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<PubSubManager>,
|
||||
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<Tag> = 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<Db> {
|
||||
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<TenantContext> {
|
||||
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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Utc>,
|
||||
/// 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<u8>,
|
||||
/// 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<Vec<u8>>,
|
||||
/// Action that was performed.
|
||||
pub action: AuditAction,
|
||||
/// Channel this action applies to, if any.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// 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<Vec<u8>>,
|
||||
/// 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<String>,
|
||||
/// 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<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Uuid>,
|
||||
/// 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<Vec<u8>>,
|
||||
/// Generic identifier of the object acted upon, if any.
|
||||
pub object_id: Option<String>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, AuditError> {
|
||||
/// `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]),
|
||||
}
|
||||
hasher.update(canonical_json(&entry.metadata)?.as_bytes());
|
||||
hasher.update(entry.prev_hash.as_bytes());
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
match &entry.object_id {
|
||||
Some(id) => {
|
||||
hasher.update([1u8]);
|
||||
hasher.update(id.as_bytes());
|
||||
}
|
||||
None => hasher.update([0u8]),
|
||||
}
|
||||
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<String, serde_json::Error> {
|
||||
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]
|
||||
@@ -111,38 +133,45 @@ mod tests {
|
||||
let base = sample_entry();
|
||||
let h0 = compute_hash(&base).unwrap();
|
||||
|
||||
let mut e = base.clone();
|
||||
e.event_id = "different_event".into();
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.seq = 2;
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.actor_pubkey = "pubkey_bob".into();
|
||||
e.action = AuditAction::EventDeleted;
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.channel_id = Some(uuid::Uuid::new_v4());
|
||||
e.actor_pubkey = Some(vec![0xcd; 32]);
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base;
|
||||
e.metadata = serde_json::json!({"key": "value"});
|
||||
let mut e = base.clone();
|
||||
e.object_id = Some("different".into());
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
"#;
|
||||
@@ -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<AuditEntry, AuditError> {
|
||||
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<AuditEntry, AuditError> {
|
||||
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::<String, _>("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> = Utc::now();
|
||||
let (prev_seq, prev_hash): (i64, Option<Vec<u8>>) = match head {
|
||||
Some(row) => (
|
||||
row.get::<i64, _>("seq"),
|
||||
Some(row.get::<Vec<u8>, _>("hash")),
|
||||
),
|
||||
None => (0, None), // community's first entry
|
||||
};
|
||||
let seq = prev_seq + 1;
|
||||
|
||||
let channel_id_bytes: Option<Vec<u8>> = entry.channel_id.map(|u| u.as_bytes().to_vec());
|
||||
let created_at: DateTime<Utc> = 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<bool, AuditError> {
|
||||
pub async fn verify_chain(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
from_seq: i64,
|
||||
to_seq: i64,
|
||||
) -> Result<bool, 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 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<String> = None;
|
||||
let mut expected_prev: Option<Vec<u8>> = 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<Vec<AuditEntry>, 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<AuditEntry, AuditError> {
|
||||
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<Vec<u8>> = 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::<Uuid, _>("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<Mutex<()>> = 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)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn genesis_entry() {
|
||||
let _guard = db_lock().lock().await;
|
||||
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());
|
||||
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 e = svc
|
||||
.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(entry.prev_hash, GENESIS_HASH);
|
||||
assert_eq!(entry.seq, 1);
|
||||
assert_eq!(entry.hash.len(), 64);
|
||||
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 chain_integrity() {
|
||||
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 e1 = svc
|
||||
.log(sample_new_entry(1, AuditAction::EventCreated))
|
||||
.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e2 = svc
|
||||
.log(sample_new_entry(1, AuditAction::ChannelCreated))
|
||||
.log(new_entry(c, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e3 = svc
|
||||
.log(sample_new_entry(1, AuditAction::MemberAdded))
|
||||
.log(new_entry(c, AuditAction::MemberAdded))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(e1.prev_hash, GENESIS_HASH);
|
||||
assert_eq!(e2.prev_hash, e1.hash);
|
||||
assert_eq!(e3.prev_hash, e2.hash);
|
||||
|
||||
assert!(svc.verify_chain(e1.seq, e3.seq).await.unwrap());
|
||||
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 verify_chain_detects_tampering() {
|
||||
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 b1 = svc
|
||||
.log(new_entry(b, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let a2 = svc
|
||||
.log(new_entry(a, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let b2 = svc
|
||||
.log(new_entry(b, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 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());
|
||||
|
||||
// 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_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());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
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<u8> = 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();
|
||||
|
||||
assert!(matches!(result, Err(AuditError::AuthEventForbidden)));
|
||||
// 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 })));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Output = Result<HashSet<Uuid>, 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<Output = Result<bool, AuthError>> + 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<HashSet<Uuid>, AuthError> {
|
||||
async fn accessible_channel_ids(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> Result<HashSet<Uuid>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
233
crates/buzz-auth/src/nip98_replay.rs
Normal file
233
crates/buzz-auth/src/nip98_replay.rs
Normal file
@@ -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<Box<dyn Future<Output = Result<bool, AuthError>> + 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<Box<dyn Future<Output = Result<bool, AuthError>> + 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Output = Result<RateLimitResult, AuthError>> + 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<Output = Result<RateLimitResult, AuthError>> + Send;
|
||||
}
|
||||
|
||||
/// Redis key for pubkey-based rate limit: `buzz:ratelimit:<hex>:<suffix>`
|
||||
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:<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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
35
crates/buzz-conformance/Cargo.toml
Normal file
35
crates/buzz-conformance/Cargo.toml
Normal file
@@ -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<Uuid>" 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 }
|
||||
125
crates/buzz-conformance/LIMITS.md
Normal file
125
crates/buzz-conformance/LIMITS.md
Normal file
@@ -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<Uuid>`
|
||||
is enforced by the Rust compiler, not by this gate. If somebody adds
|
||||
`From<Uuid>` 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.
|
||||
163
crates/buzz-conformance/TRACE_SCHEMA.md
Normal file
163
crates/buzz-conformance/TRACE_SCHEMA.md
Normal file
@@ -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": "<uuid>", // from TenantContext::community()
|
||||
"bound_host": "<host str>", // 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.
|
||||
337
crates/buzz-conformance/src/checker.rs
Normal file
337
crates/buzz-conformance/src/checker.rs
Normal file
@@ -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<TraceStep>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<TraceStep>) -> 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<String> = 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::<Vec<_>>()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
327
crates/buzz-conformance/src/lib.rs
Normal file
327
crates/buzz-conformance/src/lib.rs
Normal file
@@ -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<Uuid>`, 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<CommunityLabel>,
|
||||
},
|
||||
/// 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<CommunityLabel>,
|
||||
},
|
||||
/// 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<CommunityLabel>,
|
||||
},
|
||||
/// 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<CommunityLabel>,
|
||||
/// 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<ChannelLabel>,
|
||||
/// 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<CommunityLabel>,
|
||||
},
|
||||
/// 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<ChannelLabel>,
|
||||
/// Per-row community labels, same rules as `ReadMessageRows`.
|
||||
row_communities: Vec<CommunityLabel>,
|
||||
},
|
||||
/// 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<CommunityLabel>,
|
||||
},
|
||||
/// 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) {}
|
||||
}
|
||||
330
crates/buzz-conformance/src/transitions.rs
Normal file
330
crates/buzz-conformance/src/transitions.rs
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
1
crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl
vendored
Normal file
1
crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl
vendored
Normal file
@@ -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"}}
|
||||
1
crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl
vendored
Normal file
1
crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl
vendored
Normal file
@@ -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"}}
|
||||
2
crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl
vendored
Normal file
2
crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl
vendored
Normal file
@@ -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"}}
|
||||
3
crates/buzz-conformance/tests/fixtures/good.jsonl
vendored
Normal file
3
crates/buzz-conformance/tests/fixtures/good.jsonl
vendored
Normal file
@@ -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"}}
|
||||
431
crates/buzz-conformance/tests/proptest_checker.rs
Normal file
431
crates/buzz-conformance/tests/proptest_checker.rs
Normal file
@@ -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<Value = CommunityLabel> {
|
||||
(0..POOL).prop_map(community)
|
||||
}
|
||||
|
||||
fn arb_channel() -> impl Strategy<Value = ChannelLabel> {
|
||||
(0..POOL).prop_map(channel)
|
||||
}
|
||||
|
||||
fn arb_opaque() -> impl Strategy<Value = OpaqueId> {
|
||||
(0u32..16).prop_map(|i| OpaqueId(format!("m{i}")))
|
||||
}
|
||||
|
||||
fn arb_verdict() -> impl Strategy<Value = Verdict> {
|
||||
prop_oneof![Just(Verdict::Allow), Just(Verdict::Deny)]
|
||||
}
|
||||
|
||||
fn arb_reason() -> impl Strategy<Value = SanitizedReason> {
|
||||
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<Value = TraceAction> {
|
||||
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<TraceAction>, resolved: CommunityLabel) -> Vec<TraceStep> {
|
||||
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<Value = (CommunityLabel, Vec<TraceStep>)> {
|
||||
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::<bool>(),
|
||||
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<TraceAction> = 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::<bool>(),
|
||||
) {
|
||||
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<TraceAction> = (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"
|
||||
);
|
||||
}
|
||||
}
|
||||
324
crates/buzz-conformance/tests/replay_fixtures.rs
Normal file
324
crates/buzz-conformance/tests/replay_fixtures.rs
Normal file
@@ -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<TraceStep> {
|
||||
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<TraceStep> {
|
||||
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<TraceStep> {
|
||||
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<TraceStep> {
|
||||
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<TraceStep> {
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.trim().is_empty())
|
||||
.map(|(i, l)| {
|
||||
serde_json::from_str::<TraceStep>(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::<HashSet<_>>(),
|
||||
};
|
||||
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::<HashSet<_>>(),
|
||||
};
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
275
crates/buzz-core/src/tenant.rs
Normal file
275
crates/buzz-core/src/tenant.rs
Normal file
@@ -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<String>) -> 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(""), "");
|
||||
}
|
||||
}
|
||||
@@ -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<Option<crate::ApiTokenRecord>> {
|
||||
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<Vec<crate::ApiTokenRecord>> {
|
||||
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<u64> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is currently archived.
|
||||
pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<Vec<ArchivedIdentity>> {
|
||||
/// Returns all identities archived in `community_id`, ordered by archive time ascending.
|
||||
pub async fn list_archived(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
) -> Result<Vec<ArchivedIdentity>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Option<ChannelRecord>> {
|
||||
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<ChannelRecord> {
|
||||
@@ -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<Uuid>,
|
||||
@@ -223,10 +234,12 @@ pub async fn list_dms_for_user(
|
||||
|
||||
// Resolve cursor to a timestamp for keyset pagination.
|
||||
let cursor_ts: Option<DateTime<Utc>> = 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::<DateTime<Utc>, _>("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<Vec<Uuid>> {
|
||||
pub async fn list_hidden_dms(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Vec<Uuid>> {
|
||||
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<Vec<Uuid>>
|
||||
ORDER BY cm.channel_id
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
@@ -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<Uuid>,
|
||||
/// Restrict results to these kind values (stored as `i32` in Postgres).
|
||||
@@ -69,6 +71,36 @@ pub struct EventQuery {
|
||||
pub max_limit: Option<i64>,
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
/// 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<Uuid>,
|
||||
) -> 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<Vec<StoredEve
|
||||
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, \
|
||||
e.sig, e.received_at, e.channel_id \
|
||||
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(
|
||||
let mut b = QueryBuilder::new(
|
||||
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
|
||||
FROM events WHERE deleted_at IS NULL",
|
||||
)
|
||||
FROM events WHERE community_id = ",
|
||||
);
|
||||
b.push_bind(q.community_id.as_uuid());
|
||||
b.push(" AND deleted_at IS NULL");
|
||||
b
|
||||
};
|
||||
|
||||
// Use unqualified column names when no join, qualified when joined.
|
||||
@@ -444,13 +486,21 @@ pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result<i64> {
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = 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<i64> {
|
||||
/// 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<bool> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
/// (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<bool> {
|
||||
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<bool> {
|
||||
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<Option<DateTime<Utc>>> {
|
||||
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<std::collections::HashMap<uuid::Uuid, DateTime<Utc>>> {
|
||||
if channel_ids.is_empty() {
|
||||
@@ -687,8 +753,10 @@ pub async fn get_last_message_at_bulk(
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = 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<Option<StoredEvent>> {
|
||||
pub async fn get_event_by_id(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
id_bytes: &[u8],
|
||||
) -> Result<Option<StoredEvent>> {
|
||||
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<Option<St
|
||||
/// duplicate survivors where multiple live rows share the same timestamp.
|
||||
pub async fn get_latest_global_replaceable(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
kind: i32,
|
||||
pubkey_bytes: &[u8],
|
||||
) -> Result<Option<StoredEvent>> {
|
||||
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<Option<StoredEvent>> {
|
||||
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<Vec<StoredEvent>> {
|
||||
pub async fn get_events_by_ids(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
ids: &[&[u8]],
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
@@ -790,8 +871,10 @@ pub async fn get_events_by_ids(pool: &PgPool, ids: &[&[u8]]) -> Result<Vec<Store
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = 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<Uuid>,
|
||||
thread_meta: Option<ThreadMetadataParams<'_>>,
|
||||
@@ -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<u8>,
|
||||
/// 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<Utc>,
|
||||
) -> Result<bool> {
|
||||
let now_epoch = Utc::now().timestamp();
|
||||
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<Utc>,
|
||||
delivery_stamp: i64,
|
||||
) -> Result<bool> {
|
||||
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<Utc>,
|
||||
delivery_stamp: i64,
|
||||
) -> Result<bool> {
|
||||
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<Tag>) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <col> 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<sqlx::Postgres>, 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<sqlx::Postgres>, 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<StoredEvent>`, skipping any that fail conversion.
|
||||
@@ -75,85 +81,151 @@ fn collect_stored_events(rows: Vec<PgRow>) -> Result<Vec<StoredEvent>> {
|
||||
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<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
) -> QueryBuilder<sqlx::Postgres> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = 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);
|
||||
qb
|
||||
}
|
||||
|
||||
/// 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 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_mentions(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
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<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> QueryBuilder<sqlx::Postgres> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"SELECT {EVENT_COLS} FROM events e \
|
||||
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(*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_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);
|
||||
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)
|
||||
///
|
||||
/// 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 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_needs_action(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"SELECT {EVENT_COLS} FROM events e \
|
||||
INNER JOIN event_mentions m ON e.id = m.event_id \
|
||||
WHERE m.pubkey_hex = "
|
||||
));
|
||||
qb.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);
|
||||
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 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<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> QueryBuilder<sqlx::Postgres> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"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_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.
|
||||
@@ -162,24 +234,12 @@ pub async fn query_needs_action(
|
||||
/// `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<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"SELECT {EVENT_COLS_UNALIASED} FROM events WHERE 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);
|
||||
if let Some(s) = since {
|
||||
qb.push(" AND created_at >= ").push_bind(s);
|
||||
}
|
||||
qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit);
|
||||
|
||||
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<Uuid>,
|
||||
tags: Vec<Tag>,
|
||||
) -> 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<Uuid> = 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!(
|
||||
accessible.is_empty(),
|
||||
"empty list should skip channel filter"
|
||||
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!(
|
||||
sql.contains("AND m.community_id = "),
|
||||
"needs_action feed must also bind event_mentions.community_id: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(());
|
||||
}
|
||||
|
||||
ensure_migrations_table(pool).await?;
|
||||
|
||||
for version in BASELINE_MIGRATION_VERSIONS {
|
||||
let migration = 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?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
versions = ?BASELINE_MIGRATION_VERSIONS,
|
||||
"Baselined existing Buzz database for SQLx migrations"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrations_table_exists(pool: &PgPool) -> Result<bool> {
|
||||
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?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn pre_sqlx_schema_exists(pool: &PgPool) -> Result<bool> {
|
||||
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?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
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?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::PgPool;
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConstraintLint {
|
||||
table: String,
|
||||
kind: ConstraintKind,
|
||||
description: String,
|
||||
columns: Vec<String>,
|
||||
}
|
||||
|
||||
fn migration_sql() -> &'static str {
|
||||
MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == 1)
|
||||
.expect("initial migration must exist")
|
||||
.sql
|
||||
.as_str()
|
||||
}
|
||||
|
||||
fn strip_sql_comments(sql: &str) -> String {
|
||||
sql.lines()
|
||||
.map(|line| line.split_once("--").map_or(line, |(before, _)| before))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn normalize_sql(sql: &str) -> String {
|
||||
strip_sql_comments(sql)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn split_sql_statements(sql: &str) -> Vec<String> {
|
||||
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
|
||||
}
|
||||
|
||||
fn find_matching_paren(sql: &str, open: usize) -> Option<usize> {
|
||||
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
|
||||
}
|
||||
|
||||
fn split_top_level_csv(input: &str) -> Vec<String> {
|
||||
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
|
||||
}
|
||||
|
||||
fn identifier_after_keyword(statement: &str, keyword: &str) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn first_parenthesized_columns(input: &str) -> Vec<String> {
|
||||
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()
|
||||
}
|
||||
|
||||
fn column_definition_name(definition: &str) -> Option<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn create_table_body(statement: &str) -> Option<(String, Vec<String>)> {
|
||||
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])))
|
||||
}
|
||||
|
||||
fn create_table_definitions(sql: &str) -> Vec<(String, Vec<String>)> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<ConstraintLint> {
|
||||
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<String>) -> Vec<ConstraintLint> {
|
||||
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<String>) -> Vec<ConstraintLint> {
|
||||
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<String>) -> Vec<ConstraintLint> {
|
||||
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<String>) -> Vec<ConstraintLint> {
|
||||
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<ConstraintLint> {
|
||||
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<String> {
|
||||
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 IF NOT EXISTS relay_members"),
|
||||
"initial schema migration should include relay_members"
|
||||
);
|
||||
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"));
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
#[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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
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"
|
||||
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::<Vec<_>>();
|
||||
|
||||
assert!(
|
||||
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!(
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<Utc>,
|
||||
pubkey: &[u8],
|
||||
@@ -78,15 +80,16 @@ pub async fn add_reaction(
|
||||
) -> Result<bool> {
|
||||
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<Utc>,
|
||||
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<bool> {
|
||||
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<Utc>,
|
||||
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<Utc>,
|
||||
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<Utc>,
|
||||
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<Utc>)],
|
||||
) -> Result<Vec<BulkReactionEntry>> {
|
||||
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)
|
||||
|
||||
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
|
||||
pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result<bool> {
|
||||
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<bool> {
|
||||
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<Option<RelayMember>> {
|
||||
/// Returns the relay member record for `pubkey` in `community`, or `None`.
|
||||
pub async fn get_relay_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
) -> Result<Option<RelayMember>> {
|
||||
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<Option<Rela
|
||||
.map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Returns all relay members ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
|
||||
/// Returns all relay members of `community` ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(pool: &PgPool, community: CommunityId) -> Result<Vec<RelayMember>> {
|
||||
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<Vec<RelayMember>> {
|
||||
.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<bool> {
|
||||
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<RemoveResult> {
|
||||
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<RemoveResult> {
|
||||
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<RemoveRe
|
||||
|
||||
// rows_affected == 0: either not found or is owner. One cheap read to
|
||||
// distinguish the two cases so callers can return the right error message.
|
||||
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
|
||||
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -157,14 +180,18 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveRe
|
||||
/// this delete).
|
||||
pub async fn remove_relay_member_if_role(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &str,
|
||||
expected_role: &str,
|
||||
) -> Result<RemoveResult> {
|
||||
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<bool> {
|
||||
/// 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<bool> {
|
||||
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<u64> {
|
||||
pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result<u64> {
|
||||
// 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<u64> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Utc>,
|
||||
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<u32>,
|
||||
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<Option<ThreadSummary>> {
|
||||
pub async fn get_thread_summary(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
event_id: &[u8],
|
||||
) -> Result<Option<ThreadSummary>> {
|
||||
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<Option
|
||||
SELECT DISTINCT e.pubkey, MAX(e.created_at) AS last_seen
|
||||
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
|
||||
GROUP BY e.pubkey
|
||||
) sub
|
||||
@@ -467,6 +493,7 @@ pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result<Option
|
||||
LIMIT 10
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(event_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
@@ -500,13 +527,14 @@ pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result<Option
|
||||
/// polling (returns only messages created after the given timestamp).
|
||||
pub async fn get_channel_messages_top_level(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
limit: u32,
|
||||
before_cursor: Option<DateTime<Utc>>,
|
||||
since_cursor: Option<DateTime<Utc>>,
|
||||
kind_filter: Option<&[u32]>,
|
||||
) -> Result<Vec<TopLevelMessage>> {
|
||||
let mut param_idx = 2u32; // $1 is channel_id
|
||||
let mut param_idx = 3u32; // $1 is community_id, $2 is channel_id
|
||||
let mut sql = String::from(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -519,9 +547,11 @@ pub async fn get_channel_messages_top_level(
|
||||
e.channel_id
|
||||
FROM events e
|
||||
LEFT JOIN thread_metadata tm
|
||||
ON tm.event_created_at = e.created_at
|
||||
ON tm.community_id = e.community_id
|
||||
AND tm.event_created_at = e.created_at
|
||||
AND tm.event_id = e.id
|
||||
WHERE e.channel_id = $1
|
||||
WHERE e.community_id = $1
|
||||
AND e.channel_id = $2
|
||||
AND e.deleted_at IS NULL
|
||||
AND (
|
||||
tm.depth IS NULL
|
||||
@@ -561,7 +591,9 @@ pub async fn get_channel_messages_top_level(
|
||||
" ORDER BY e.created_at {order} LIMIT ${param_idx}"
|
||||
));
|
||||
|
||||
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(channel_id);
|
||||
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql))
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id);
|
||||
|
||||
if let Some(cursor) = before_cursor {
|
||||
q = q.bind(cursor);
|
||||
@@ -604,6 +636,7 @@ pub async fn get_channel_messages_top_level(
|
||||
/// can be decremented.
|
||||
pub async fn get_thread_metadata_by_event(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
event_id: &[u8],
|
||||
) -> Result<Option<ThreadMetadataRecord>> {
|
||||
let row = sqlx::query(
|
||||
@@ -619,10 +652,11 @@ pub async fn get_thread_metadata_by_event(
|
||||
descendant_count,
|
||||
broadcast
|
||||
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?;
|
||||
@@ -659,7 +693,7 @@ pub async fn get_thread_metadata_by_event(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
channel::{create_channel, ChannelType, ChannelVisibility},
|
||||
channel::{ChannelType, ChannelVisibility},
|
||||
event::{insert_event_with_thread_metadata, ThreadMetadataParams},
|
||||
};
|
||||
use nostr::{EventBuilder, Keys, Kind};
|
||||
@@ -687,12 +721,170 @@ mod tests {
|
||||
.expect("event timestamp is valid")
|
||||
}
|
||||
|
||||
async fn make_test_community(pool: &PgPool) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("thread-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 create_test_channel(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
channel_type: ChannelType,
|
||||
visibility: ChannelVisibility,
|
||||
description: Option<&str>,
|
||||
created_by: &[u8],
|
||||
ttl_seconds: Option<i32>,
|
||||
) -> crate::error::Result<(crate::channel::ChannelRecord, buzz_core::CommunityId)> {
|
||||
let id = Uuid::new_v4();
|
||||
let community_id = make_test_community(pool).await;
|
||||
|
||||
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");
|
||||
|
||||
crate::channel::get_channel(pool, buzz_core::CommunityId::from_uuid(community_id), id)
|
||||
.await
|
||||
.map(|channel| (channel, buzz_core::CommunityId::from_uuid(community_id)))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn get_thread_metadata_by_event_is_scoped_when_event_id_collides_across_communities() {
|
||||
let pool = setup_pool().await;
|
||||
let author = Keys::generate();
|
||||
let channel_id = Uuid::new_v4();
|
||||
let community_a = make_test_community(&pool).await;
|
||||
let community_b = make_test_community(&pool).await;
|
||||
let community_a = buzz_core::CommunityId::from_uuid(community_a);
|
||||
let community_b = buzz_core::CommunityId::from_uuid(community_b);
|
||||
|
||||
crate::channel::create_channel_with_id(
|
||||
&pool,
|
||||
community_a,
|
||||
channel_id,
|
||||
&format!("thread-collision-a-{channel_id}"),
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
author.public_key().to_bytes().as_slice(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create community A channel");
|
||||
crate::channel::create_channel_with_id(
|
||||
&pool,
|
||||
community_b,
|
||||
channel_id,
|
||||
&format!("thread-collision-b-{channel_id}"),
|
||||
ChannelType::Stream,
|
||||
ChannelVisibility::Open,
|
||||
None,
|
||||
author.public_key().to_bytes().as_slice(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create community B channel");
|
||||
|
||||
let event = make_stream_event(&author, "same id in both communities");
|
||||
let created_at = event_created_at(&event);
|
||||
insert_event_with_thread_metadata(
|
||||
&pool,
|
||||
community_a,
|
||||
&event,
|
||||
Some(channel_id),
|
||||
Some(ThreadMetadataParams {
|
||||
event_id: event.id.as_bytes(),
|
||||
event_created_at: created_at,
|
||||
channel_id,
|
||||
parent_event_id: None,
|
||||
parent_event_created_at: None,
|
||||
root_event_id: None,
|
||||
root_event_created_at: None,
|
||||
depth: 0,
|
||||
broadcast: true,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("insert community A metadata");
|
||||
insert_event_with_thread_metadata(
|
||||
&pool,
|
||||
community_b,
|
||||
&event,
|
||||
Some(channel_id),
|
||||
Some(ThreadMetadataParams {
|
||||
event_id: event.id.as_bytes(),
|
||||
event_created_at: created_at,
|
||||
channel_id,
|
||||
parent_event_id: None,
|
||||
parent_event_created_at: None,
|
||||
root_event_id: None,
|
||||
root_event_created_at: None,
|
||||
depth: 3,
|
||||
broadcast: false,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("insert community B metadata");
|
||||
|
||||
let a = get_thread_metadata_by_event(&pool, community_a, event.id.as_bytes())
|
||||
.await
|
||||
.expect("lookup community A metadata")
|
||||
.expect("community A metadata exists");
|
||||
let b = get_thread_metadata_by_event(&pool, community_b, event.id.as_bytes())
|
||||
.await
|
||||
.expect("lookup community B metadata")
|
||||
.expect("community B metadata exists");
|
||||
|
||||
assert_eq!(a.depth, 0);
|
||||
assert!(a.broadcast);
|
||||
assert_eq!(b.depth, 3);
|
||||
assert!(!b.broadcast);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn get_thread_replies_reconstructs_stored_events() {
|
||||
let pool = setup_pool().await;
|
||||
let author = Keys::generate();
|
||||
let channel = create_channel(
|
||||
let (channel, community) = create_test_channel(
|
||||
&pool,
|
||||
&format!("thread-replies-{}", Uuid::new_v4()),
|
||||
ChannelType::Stream,
|
||||
@@ -706,7 +898,7 @@ mod tests {
|
||||
|
||||
let root = make_stream_event(&author, "root");
|
||||
let root_created_at = event_created_at(&root);
|
||||
insert_event_with_thread_metadata(&pool, &root, Some(channel.id), None)
|
||||
insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None)
|
||||
.await
|
||||
.expect("insert root event");
|
||||
|
||||
@@ -715,6 +907,7 @@ mod tests {
|
||||
let reply_id = reply.id.to_hex();
|
||||
insert_event_with_thread_metadata(
|
||||
&pool,
|
||||
community,
|
||||
&reply,
|
||||
Some(channel.id),
|
||||
Some(ThreadMetadataParams {
|
||||
@@ -732,7 +925,7 @@ mod tests {
|
||||
.await
|
||||
.expect("insert reply event and metadata");
|
||||
|
||||
let replies = get_thread_replies(&pool, root.id.as_bytes(), Some(10), 10, None)
|
||||
let replies = get_thread_replies(&pool, community, root.id.as_bytes(), Some(10), 10, None)
|
||||
.await
|
||||
.expect("fetch thread replies");
|
||||
|
||||
@@ -752,7 +945,7 @@ mod tests {
|
||||
async fn get_thread_replies_skips_unreconstructable_row() {
|
||||
let pool = setup_pool().await;
|
||||
let author = Keys::generate();
|
||||
let channel = create_channel(
|
||||
let (channel, community) = create_test_channel(
|
||||
&pool,
|
||||
&format!("thread-replies-corrupt-{}", Uuid::new_v4()),
|
||||
ChannelType::Stream,
|
||||
@@ -766,7 +959,7 @@ mod tests {
|
||||
|
||||
let root = make_stream_event(&author, "root");
|
||||
let root_created_at = event_created_at(&root);
|
||||
insert_event_with_thread_metadata(&pool, &root, Some(channel.id), None)
|
||||
insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None)
|
||||
.await
|
||||
.expect("insert root event");
|
||||
|
||||
@@ -776,6 +969,7 @@ mod tests {
|
||||
let good_created_at = event_created_at(&good);
|
||||
insert_event_with_thread_metadata(
|
||||
&pool,
|
||||
community,
|
||||
&good,
|
||||
Some(channel.id),
|
||||
Some(ThreadMetadataParams {
|
||||
@@ -797,6 +991,7 @@ mod tests {
|
||||
let bad_created_at = event_created_at(&bad);
|
||||
insert_event_with_thread_metadata(
|
||||
&pool,
|
||||
community,
|
||||
&bad,
|
||||
Some(channel.id),
|
||||
Some(ThreadMetadataParams {
|
||||
@@ -826,7 +1021,7 @@ mod tests {
|
||||
.rows_affected();
|
||||
assert_eq!(rows_changed, 1, "expected to corrupt exactly one row");
|
||||
|
||||
let replies = get_thread_replies(&pool, root.id.as_bytes(), Some(10), 10, None)
|
||||
let replies = get_thread_replies(&pool, community, root.id.as_bytes(), Some(10), 10, None)
|
||||
.await
|
||||
.expect("fetch thread replies must succeed despite a corrupt row");
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! User CRUD operations.
|
||||
|
||||
use crate::error::Result;
|
||||
use buzz_core::CommunityId;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
|
||||
@@ -34,14 +35,15 @@ pub struct UserSearchProfile {
|
||||
|
||||
/// Ensure a user record exists for the given pubkey (upsert).
|
||||
/// Creates with minimal fields if not present; no-op if already exists.
|
||||
pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> {
|
||||
pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (pubkey)
|
||||
VALUES ($1)
|
||||
INSERT INTO users (community_id, pubkey)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -49,7 +51,11 @@ pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Get a single user record by pubkey.
|
||||
pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result<Option<UserProfile>> {
|
||||
pub async fn get_user(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<UserProfile>> {
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -63,9 +69,10 @@ pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result<Option<UserProfile
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, about, nip05_handle
|
||||
FROM users
|
||||
WHERE pubkey = $1
|
||||
WHERE community_id = $1 AND pubkey = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -91,6 +98,7 @@ pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result<Option<UserProfile
|
||||
/// but multiple empty strings would violate uniqueness).
|
||||
pub async fn update_user_profile(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
@@ -129,8 +137,9 @@ pub async fn update_user_profile(
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE users SET {} WHERE pubkey = ${param_idx}",
|
||||
set_parts.join(", ")
|
||||
"UPDATE users SET {} WHERE community_id = ${param_idx} AND pubkey = ${}",
|
||||
set_parts.join(", "),
|
||||
param_idx + 1
|
||||
);
|
||||
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql));
|
||||
if display_name.is_some() {
|
||||
@@ -145,6 +154,7 @@ pub async fn update_user_profile(
|
||||
if nip05_handle.is_some() {
|
||||
query = query.bind(empty_to_none(nip05_handle));
|
||||
}
|
||||
query = query.bind(community_id.as_uuid());
|
||||
query = query.bind(pubkey);
|
||||
query.execute(pool).await?;
|
||||
Ok(())
|
||||
@@ -154,6 +164,7 @@ pub async fn update_user_profile(
|
||||
/// Both `local_part` and `domain` must already be lowercased by the caller.
|
||||
pub async fn get_user_by_nip05(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
local_part: &str,
|
||||
domain: &str,
|
||||
) -> Result<Option<UserProfile>> {
|
||||
@@ -171,10 +182,11 @@ pub async fn get_user_by_nip05(
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, about, nip05_handle
|
||||
FROM users
|
||||
WHERE LOWER(nip05_handle) = LOWER($1)
|
||||
WHERE community_id = $1 AND LOWER(nip05_handle) = LOWER($2)
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(&handle)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -207,6 +219,7 @@ fn escape_like(input: &str) -> String {
|
||||
/// Empty queries return an empty vec and do not hit the database.
|
||||
pub async fn search_users(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<UserSearchProfile>> {
|
||||
@@ -224,23 +237,25 @@ pub async fn search_users(
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, nip05_handle
|
||||
FROM users
|
||||
WHERE LOWER(COALESCE(display_name, '')) LIKE $1 ESCAPE '\'
|
||||
OR LOWER(COALESCE(nip05_handle, '')) LIKE $1 ESCAPE '\'
|
||||
OR LOWER(encode(pubkey, 'hex')) LIKE $1 ESCAPE '\'
|
||||
WHERE community_id = $1
|
||||
AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\'
|
||||
OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\'
|
||||
OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(display_name, '')) = $2 THEN 0
|
||||
WHEN LOWER(COALESCE(nip05_handle, '')) = $2 THEN 1
|
||||
WHEN LOWER(encode(pubkey, 'hex')) = $2 THEN 2
|
||||
WHEN LOWER(COALESCE(display_name, '')) LIKE $3 ESCAPE '\' THEN 3
|
||||
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $3 ESCAPE '\' THEN 4
|
||||
WHEN LOWER(encode(pubkey, 'hex')) LIKE $3 ESCAPE '\' THEN 5
|
||||
WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0
|
||||
WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1
|
||||
WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2
|
||||
WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3
|
||||
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4
|
||||
WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5
|
||||
ELSE 6
|
||||
END,
|
||||
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex')))
|
||||
LIMIT $4
|
||||
LIMIT $5
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(&contains_pattern)
|
||||
.bind(&normalized)
|
||||
.bind(&prefix_pattern)
|
||||
@@ -271,15 +286,17 @@ pub async fn search_users(
|
||||
/// agent pubkey doesn't exist in the users table.
|
||||
pub async fn set_agent_owner(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
agent_pubkey: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
) -> Result<bool> {
|
||||
// Conditional UPDATE: only set owner if currently NULL. This makes
|
||||
// "first mint wins" atomic — no TOCTOU race between concurrent mints.
|
||||
let result = sqlx::query(
|
||||
r#"UPDATE users SET agent_owner_pubkey = $1 WHERE pubkey = $2 AND agent_owner_pubkey IS NULL"#,
|
||||
r#"UPDATE users SET agent_owner_pubkey = $1 WHERE community_id = $2 AND pubkey = $3 AND agent_owner_pubkey IS NULL"#,
|
||||
)
|
||||
.bind(owner_pubkey)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(agent_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -287,7 +304,8 @@ pub async fn set_agent_owner(
|
||||
if result.rows_affected() == 0 {
|
||||
// Could be: (a) pubkey not found, or (b) owner already set.
|
||||
// Check which case by querying the row.
|
||||
let exists = sqlx::query(r#"SELECT 1 FROM users WHERE pubkey = $1"#)
|
||||
let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(agent_pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -307,11 +325,13 @@ pub async fn set_agent_owner(
|
||||
/// Returns Some((policy_str, owner_bytes_or_none)) if found.
|
||||
pub async fn get_agent_channel_policy(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<(String, Option<Vec<u8>>)>> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE pubkey = $1"#,
|
||||
r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
@@ -329,12 +349,14 @@ pub async fn get_agent_channel_policy(
|
||||
/// `get_agent_channel_policy`, which would fetch unrelated fields.
|
||||
pub async fn is_agent_owner(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
target_pubkey: &[u8],
|
||||
actor_pubkey: &[u8],
|
||||
) -> Result<bool> {
|
||||
let row = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT agent_owner_pubkey = $2 FROM users WHERE pubkey = $1 AND agent_owner_pubkey IS NOT NULL",
|
||||
"SELECT agent_owner_pubkey = $3 FROM users WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(target_pubkey)
|
||||
.bind(actor_pubkey)
|
||||
.fetch_optional(pool)
|
||||
@@ -345,16 +367,22 @@ pub async fn is_agent_owner(
|
||||
/// Set the channel_add_policy for a user.
|
||||
/// Returns an error if the pubkey is not found (rows_affected == 0).
|
||||
/// Returns an error if `policy` is not one of the valid ENUM values.
|
||||
pub async fn set_channel_add_policy(pool: &PgPool, pubkey: &[u8], policy: &str) -> Result<()> {
|
||||
pub async fn set_channel_add_policy(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
policy: &str,
|
||||
) -> Result<()> {
|
||||
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
|
||||
return Err(crate::error::DbError::InvalidData(format!(
|
||||
"invalid channel_add_policy: {policy}"
|
||||
)));
|
||||
}
|
||||
let result = sqlx::query(
|
||||
r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE pubkey = $2"#,
|
||||
r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE community_id = $2 AND pubkey = $3"#,
|
||||
)
|
||||
.bind(policy)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -385,28 +413,41 @@ mod tests {
|
||||
Keys::generate().public_key().to_bytes().to_vec()
|
||||
}
|
||||
|
||||
async fn make_community(pool: &PgPool) -> CommunityId {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let host = format!("user-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)
|
||||
}
|
||||
|
||||
/// Setting an agent owner then reading back the policy should return
|
||||
/// the default "anyone" policy and the owner pubkey.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_agent_owner_and_get_policy() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let agent_pk = random_pubkey();
|
||||
let owner_pk = random_pubkey();
|
||||
|
||||
ensure_user(&db.pool, &agent_pk)
|
||||
ensure_user(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("ensure agent");
|
||||
ensure_user(&db.pool, &owner_pk)
|
||||
ensure_user(&db.pool, community, &owner_pk)
|
||||
.await
|
||||
.expect("ensure owner");
|
||||
|
||||
let was_set = set_agent_owner(&db.pool, &agent_pk, &owner_pk)
|
||||
let was_set = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk)
|
||||
.await
|
||||
.expect("set_agent_owner");
|
||||
assert!(was_set, "first set_agent_owner should return true");
|
||||
|
||||
let result = get_agent_channel_policy(&db.pool, &agent_pk)
|
||||
let result = get_agent_channel_policy(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("get_agent_channel_policy");
|
||||
|
||||
@@ -424,14 +465,17 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_channel_add_policy() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
ensure_user(&db.pool, &pk).await.expect("ensure user");
|
||||
ensure_user(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
|
||||
// owner_only
|
||||
set_channel_add_policy(&db.pool, &pk, "owner_only")
|
||||
set_channel_add_policy(&db.pool, community, &pk, "owner_only")
|
||||
.await
|
||||
.expect("set owner_only");
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, &pk)
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
@@ -439,10 +483,10 @@ mod tests {
|
||||
assert!(owner.is_none(), "no owner was set");
|
||||
|
||||
// nobody
|
||||
set_channel_add_policy(&db.pool, &pk, "nobody")
|
||||
set_channel_add_policy(&db.pool, community, &pk, "nobody")
|
||||
.await
|
||||
.expect("set nobody");
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, &pk)
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
@@ -450,10 +494,10 @@ mod tests {
|
||||
assert!(owner.is_none());
|
||||
|
||||
// anyone (reset to default)
|
||||
set_channel_add_policy(&db.pool, &pk, "anyone")
|
||||
set_channel_add_policy(&db.pool, community, &pk, "anyone")
|
||||
.await
|
||||
.expect("set anyone");
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, &pk)
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
@@ -467,9 +511,10 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_get_policy_unknown_pubkey() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
|
||||
let result = get_agent_channel_policy(&db.pool, &pk)
|
||||
let result = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("query should not error");
|
||||
|
||||
@@ -482,15 +527,16 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_agent_owner_nonexistent_agent() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let agent_pk = random_pubkey();
|
||||
let owner_pk = random_pubkey();
|
||||
|
||||
// Only ensure the owner exists -- agent is intentionally absent.
|
||||
ensure_user(&db.pool, &owner_pk)
|
||||
ensure_user(&db.pool, community, &owner_pk)
|
||||
.await
|
||||
.expect("ensure owner");
|
||||
|
||||
let result = set_agent_owner(&db.pool, &agent_pk, &owner_pk).await;
|
||||
let result = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should error when agent pubkey is not in users table"
|
||||
@@ -502,28 +548,33 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_agent_owner_already_owned() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let agent_pk = random_pubkey();
|
||||
let owner1 = random_pubkey();
|
||||
let owner2 = random_pubkey();
|
||||
|
||||
ensure_user(&db.pool, &agent_pk)
|
||||
ensure_user(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("ensure agent");
|
||||
ensure_user(&db.pool, &owner1).await.expect("ensure owner1");
|
||||
ensure_user(&db.pool, &owner2).await.expect("ensure owner2");
|
||||
ensure_user(&db.pool, community, &owner1)
|
||||
.await
|
||||
.expect("ensure owner1");
|
||||
ensure_user(&db.pool, community, &owner2)
|
||||
.await
|
||||
.expect("ensure owner2");
|
||||
|
||||
let first = set_agent_owner(&db.pool, &agent_pk, &owner1)
|
||||
let first = set_agent_owner(&db.pool, community, &agent_pk, &owner1)
|
||||
.await
|
||||
.expect("first set");
|
||||
assert!(first, "first set should succeed");
|
||||
|
||||
let second = set_agent_owner(&db.pool, &agent_pk, &owner2)
|
||||
let second = set_agent_owner(&db.pool, community, &agent_pk, &owner2)
|
||||
.await
|
||||
.expect("second set should not error");
|
||||
assert!(!second, "second set should return false (already owned)");
|
||||
|
||||
// Verify original owner is preserved.
|
||||
let (_, owner) = get_agent_channel_policy(&db.pool, &agent_pk)
|
||||
let (_, owner) = get_agent_channel_policy(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
@@ -536,9 +587,10 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_channel_add_policy_nonexistent_user() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
|
||||
let result = set_channel_add_policy(&db.pool, &pk, "nobody").await;
|
||||
let result = set_channel_add_policy(&db.pool, community, &pk, "nobody").await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should error when pubkey is not in users table"
|
||||
@@ -549,9 +601,10 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_channel_add_policy_rejects_invalid() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pubkey = nostr::Keys::generate().public_key().to_bytes().to_vec();
|
||||
ensure_user(&db.pool, &pubkey).await.unwrap();
|
||||
let result = set_channel_add_policy(&db.pool, &pubkey, "invalid_policy").await;
|
||||
ensure_user(&db.pool, community, &pubkey).await.unwrap();
|
||||
let result = set_channel_add_policy(&db.pool, community, &pubkey, "invalid_policy").await;
|
||||
assert!(result.is_err(), "should reject invalid policy value");
|
||||
}
|
||||
|
||||
@@ -595,14 +648,17 @@ mod tests {
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_owner_only_with_no_owner() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
ensure_user(&db.pool, &pk).await.expect("ensure user");
|
||||
ensure_user(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
|
||||
set_channel_add_policy(&db.pool, &pk, "owner_only")
|
||||
set_channel_add_policy(&db.pool, community, &pk, "owner_only")
|
||||
.await
|
||||
.expect("set owner_only");
|
||||
|
||||
let result = get_agent_channel_policy(&db.pool, &pk)
|
||||
let result = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,3 +33,4 @@ futures-core = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -89,18 +89,32 @@ pub fn verify_blossom_auth_event(
|
||||
return Err(MediaError::TimestampOutOfWindow);
|
||||
}
|
||||
|
||||
// 6. Server tag enforcement (BUD-11 §5): if server tags present, our domain must appear.
|
||||
// Fail closed: if server_domain is unconfigured, reject tokens that carry server tags
|
||||
// rather than silently accepting them.
|
||||
// 6. Server tag enforcement (BUD-11 §5): if server tags present, our host must appear.
|
||||
//
|
||||
// `server_domain` is the host this request was bound to — the per-request
|
||||
// tenant host (`TenantContext::host()`), NOT a single process-global domain.
|
||||
// A relay process serves many tenant hosts; validating against one global
|
||||
// host would 401 every non-primary tenant's server-tagged client (the stock
|
||||
// CLI always tags its configured relay host). Comparison is done under the
|
||||
// shared [`normalize_host`] rule so a tag and the bound host agree by
|
||||
// construction across case, trailing dot, default ports, and an optional
|
||||
// URL scheme/path — exactly as every other host seam resolves tenants.
|
||||
//
|
||||
// Fail closed: if the bound host is unknown, reject tokens that carry server
|
||||
// tags rather than silently accepting them.
|
||||
if !server_tags.is_empty() {
|
||||
match server_domain {
|
||||
Some(domain) => {
|
||||
if !server_tags.contains(&domain) {
|
||||
let want = normalize_server_host(domain);
|
||||
let matches = server_tags
|
||||
.iter()
|
||||
.any(|tag| normalize_server_host(tag) == want);
|
||||
if !matches {
|
||||
return Err(MediaError::ServerMismatch);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Server tags present but we don't know our own domain — reject.
|
||||
// Server tags present but we don't know our own host — reject.
|
||||
return Err(MediaError::ServerMismatch);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +123,22 @@ pub fn verify_blossom_auth_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Normalize a Blossom `server` tag value (or a bound tenant host) into the
|
||||
/// canonical host form used as the community lookup key.
|
||||
///
|
||||
/// A `server` tag may be a bare authority (`relay.example:3100`, what the stock
|
||||
/// CLI emits) or a full URL (`https://relay.example/`). We strip an optional
|
||||
/// scheme and path down to the authority, then apply the one shared
|
||||
/// [`buzz_core::tenant::normalize_host`] rule so the comparison agrees with how
|
||||
/// the WS/HTTP/git doors resolve tenants.
|
||||
fn normalize_server_host(value: &str) -> String {
|
||||
let authority = match value.split_once("://") {
|
||||
Some((_scheme, rest)) => rest.split('/').next().unwrap_or(rest),
|
||||
None => value.split('/').next().unwrap_or(value),
|
||||
};
|
||||
buzz_core::tenant::normalize_host(authority)
|
||||
}
|
||||
|
||||
/// Verify a kind:24242 Blossom upload auth event, including the x tag hash check.
|
||||
///
|
||||
/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one
|
||||
@@ -264,6 +294,68 @@ mod tests {
|
||||
assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com"), 600).is_ok());
|
||||
}
|
||||
|
||||
/// A `server` tag is matched against the *bound tenant host* under the
|
||||
/// shared `normalize_host` rule, so equivalent host spellings agree — the
|
||||
/// stock CLI's bare `host:port`, an explicit default port, a trailing dot,
|
||||
/// mixed case, and a full URL all match the same bound host. This is the
|
||||
/// regression guard for the multi-tenant media blocker: a non-primary
|
||||
/// tenant must accept its own server-tagged client.
|
||||
#[test]
|
||||
fn test_server_tag_normalized_against_bound_host() {
|
||||
let keys = Keys::generate();
|
||||
let sha256 = "a".repeat(64);
|
||||
let now = Timestamp::now().as_secs();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let build = |server: &str| {
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &exp_str]).unwrap(),
|
||||
Tag::parse(["server", server]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload scoped")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Non-primary tenant host with explicit non-default port (the live
|
||||
// repro: tenant B on 127.0.0.1:3100). Stock CLI tags `host:port`.
|
||||
assert!(verify_blossom_upload_auth(
|
||||
&build("127.0.0.1:3100"),
|
||||
&sha256,
|
||||
Some("127.0.0.1:3100"),
|
||||
600
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
// Equivalence under normalize_host: explicit default port, trailing
|
||||
// dot, mixed case, and a full URL all collapse to the bound host.
|
||||
for tag in [
|
||||
"Relay.Example:443",
|
||||
"relay.example.",
|
||||
"RELAY.EXAMPLE",
|
||||
"https://relay.example/",
|
||||
] {
|
||||
assert!(
|
||||
verify_blossom_upload_auth(&build(tag), &sha256, Some("relay.example"), 600)
|
||||
.is_ok(),
|
||||
"server tag {tag:?} should match bound host relay.example"
|
||||
);
|
||||
}
|
||||
|
||||
// A different tenant host still fails closed.
|
||||
assert!(matches!(
|
||||
verify_blossom_upload_auth(
|
||||
&build("127.0.0.1:3100"),
|
||||
&sha256,
|
||||
Some("127.0.0.1:3200"),
|
||||
600
|
||||
),
|
||||
Err(MediaError::ServerMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_content_rejected() {
|
||||
let keys = Keys::generate();
|
||||
|
||||
@@ -31,12 +31,6 @@ pub struct MediaConfig {
|
||||
pub max_file_bytes: u64,
|
||||
/// Public base URL for media URLs in BlobDescriptor (must include `/media` path).
|
||||
pub public_base_url: String,
|
||||
/// Server authority for BUD-11 server tag validation.
|
||||
/// Format: `host` for default ports, `host:port` for non-default ports.
|
||||
/// Examples: "buzz.example.com", "localhost:3000", "relay.example.com:8080".
|
||||
/// If None, auth events carrying `server` tags are rejected (fail-closed).
|
||||
/// Must match the authority the desktop signer derives from the relay URL.
|
||||
pub server_domain: Option<String>,
|
||||
}
|
||||
|
||||
impl MediaConfig {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
use buzz_core::tenant::{CommunityId, TenantContext};
|
||||
|
||||
use crate::config::MediaConfig;
|
||||
use crate::error::MediaError;
|
||||
use bytes::Bytes;
|
||||
@@ -147,18 +149,118 @@ impl MediaStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read sidecar JSON for a given sha256 (bare hash, no extension).
|
||||
pub async fn get_sidecar(&self, sha256: &str) -> Result<BlobMeta, MediaError> {
|
||||
let key = format!("_meta/{sha256}.json");
|
||||
/// Build the community-scoped sidecar key for a given sha256 (bare hash).
|
||||
///
|
||||
/// Raw media bytes remain shared content-addressed CAS (`{sha}.{ext}`), but
|
||||
/// the metadata sidecar is the tenant read gate. A blob in another
|
||||
/// community must never be observable through a global `_meta/{sha}.json`
|
||||
/// lookup.
|
||||
pub fn sidecar_key(community: CommunityId, sha256: &str) -> String {
|
||||
format!("_meta/{community}/{sha256}.json")
|
||||
}
|
||||
|
||||
/// Build the community-scoped sidecar key from the resolved request tenant.
|
||||
pub fn ctx_sidecar_key(ctx: &TenantContext, sha256: &str) -> String {
|
||||
Self::sidecar_key(ctx.community(), sha256)
|
||||
}
|
||||
|
||||
/// Read community-scoped sidecar JSON for a given sha256 (bare hash).
|
||||
pub async fn get_sidecar(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
sha256: &str,
|
||||
) -> Result<BlobMeta, MediaError> {
|
||||
let key = Self::ctx_sidecar_key(ctx, sha256);
|
||||
let resp = self.bucket.get_object(&key).await?;
|
||||
let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Convenience: read just the MIME type from the sidecar.
|
||||
pub async fn read_sidecar_mime(&self, sha256_ext: &str) -> Option<String> {
|
||||
/// Write community-scoped sidecar JSON for a given sha256 (bare hash).
|
||||
///
|
||||
/// `ctx` must be the server-resolved request tenant. Callers must never
|
||||
/// derive the community from client-supplied blob metadata, URLs, or event
|
||||
/// tags; this sidecar key is the tenant read gate for otherwise shared CAS
|
||||
/// bytes.
|
||||
pub async fn put_sidecar(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
sha256: &str,
|
||||
meta: &BlobMeta,
|
||||
) -> Result<(), MediaError> {
|
||||
let key = Self::ctx_sidecar_key(ctx, sha256);
|
||||
let meta_json = serde_json::to_vec(meta)?;
|
||||
self.put(&key, &meta_json, "application/json").await
|
||||
}
|
||||
|
||||
/// Convenience: read just the MIME type from the community sidecar.
|
||||
///
|
||||
/// Returns `None` for both absent sidecars and storage read failures. Public
|
||||
/// read handlers intentionally collapse that distinction to 404 so an
|
||||
/// A-bound request cannot distinguish a B-only blob from a missing blob.
|
||||
pub async fn read_sidecar_mime(&self, ctx: &TenantContext, sha256_ext: &str) -> Option<String> {
|
||||
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
|
||||
self.get_sidecar(sha256).await.ok().map(|m| m.mime_type)
|
||||
self.get_sidecar(ctx, sha256)
|
||||
.await
|
||||
.ok()
|
||||
.map(|m| m.mime_type)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn tenant(n: u128) -> TenantContext {
|
||||
TenantContext::resolved(
|
||||
CommunityId::from_uuid(uuid::Uuid::from_u128(n)),
|
||||
"media.example",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_keys_are_community_scoped() {
|
||||
let a = tenant(1);
|
||||
let b = tenant(2);
|
||||
let sha = "f".repeat(64);
|
||||
|
||||
assert_eq!(
|
||||
MediaStorage::ctx_sidecar_key(&a, &sha),
|
||||
format!("_meta/{}/{sha}.json", a.community())
|
||||
);
|
||||
assert_ne!(
|
||||
MediaStorage::ctx_sidecar_key(&a, &sha),
|
||||
MediaStorage::ctx_sidecar_key(&b, &sha)
|
||||
);
|
||||
assert_ne!(
|
||||
MediaStorage::ctx_sidecar_key(&a, &sha),
|
||||
format!("_meta/{sha}.json")
|
||||
);
|
||||
}
|
||||
|
||||
/// Mutate-bite shape for the media substrate: same CAS bytes/hash can be
|
||||
/// known in A and B, but the sidecar is the read/existence gate. If the
|
||||
/// community segment is dropped from `sidecar_key`, B's metadata overwrites
|
||||
/// A's in this map and A observes B's MIME (wrong answer, not absence).
|
||||
#[test]
|
||||
fn same_sha_sidecars_do_not_bleed_between_communities() {
|
||||
let a = tenant(1);
|
||||
let b = tenant(2);
|
||||
let sha = "a".repeat(64);
|
||||
let mut sidecars = HashMap::new();
|
||||
|
||||
sidecars.insert(MediaStorage::ctx_sidecar_key(&a, &sha), "image/png");
|
||||
sidecars.insert(MediaStorage::ctx_sidecar_key(&b, &sha), "video/mp4");
|
||||
|
||||
assert_eq!(
|
||||
sidecars[&MediaStorage::ctx_sidecar_key(&a, &sha)],
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
sidecars[&MediaStorage::ctx_sidecar_key(&b, &sha)],
|
||||
"video/mp4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +269,7 @@ pub struct BlobHeadMeta {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Full blob metadata — stored as sidecar JSON in `_meta/{sha256}.json`.
|
||||
/// Full blob metadata — stored as sidecar JSON in `_meta/{community}/{sha256}.json`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BlobMeta {
|
||||
/// Pixel dimensions ("WxH").
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Upload pipeline — validate, store, thumbnail, sidecar.
|
||||
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use bytes::Bytes;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -33,6 +34,7 @@ use crate::validation::{
|
||||
async fn process_buffered_upload<V, M, Fut>(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
ctx: &TenantContext,
|
||||
auth_event: &nostr::Event,
|
||||
body: Bytes,
|
||||
validate: V,
|
||||
@@ -47,25 +49,29 @@ where
|
||||
let auth = auth_event.clone();
|
||||
let bytes = body.clone();
|
||||
let cfg = config.clone();
|
||||
// Validate the Blossom `server` tag against the host this request was bound
|
||||
// to (the per-request tenant), not a process-global domain — a relay serves
|
||||
// many tenant hosts.
|
||||
let bound_host = ctx.host().to_string();
|
||||
let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> {
|
||||
let (mime, ext) = validate(&bytes, &cfg)?;
|
||||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||
// Buffered uploads (image + file): 10-minute auth window is plenty.
|
||||
verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref(), 600)?;
|
||||
verify_blossom_upload_auth(&auth, &sha256, Some(bound_host.as_str()), 600)?;
|
||||
Ok((mime, sha256, ext))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| MediaError::Internal)??;
|
||||
|
||||
let key = format!("{sha256}.{ext}");
|
||||
let meta_key = format!("_meta/{sha256}.json");
|
||||
let meta_key = MediaStorage::ctx_sidecar_key(ctx, &sha256);
|
||||
|
||||
// Idempotent: short-circuit only if BOTH sidecar and blob exist. If the
|
||||
// sidecar exists but the blob is missing, fall through to re-upload.
|
||||
let sidecar_exists = storage.head(&meta_key).await?;
|
||||
let blob_exists = storage.head(&key).await?;
|
||||
if sidecar_exists && blob_exists {
|
||||
let meta = storage.get_sidecar(&sha256).await?;
|
||||
let meta = storage.get_sidecar(ctx, &sha256).await?;
|
||||
return Ok(build_descriptor(
|
||||
config,
|
||||
&sha256,
|
||||
@@ -134,12 +140,14 @@ struct MetadataInput {
|
||||
pub async fn process_upload(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
ctx: &TenantContext,
|
||||
auth_event: &nostr::Event,
|
||||
body: Bytes,
|
||||
) -> Result<BlobDescriptor, MediaError> {
|
||||
process_buffered_upload(
|
||||
storage,
|
||||
config,
|
||||
ctx,
|
||||
auth_event,
|
||||
body,
|
||||
|bytes, cfg| {
|
||||
@@ -147,18 +155,7 @@ pub async fn process_upload(
|
||||
let ext = mime_to_ext(&mime).to_string();
|
||||
Ok((mime, ext))
|
||||
},
|
||||
|input| async move {
|
||||
generate_and_store_metadata(
|
||||
storage,
|
||||
config,
|
||||
&input.sha256,
|
||||
&input.ext,
|
||||
&input.mime,
|
||||
&input.body,
|
||||
input.uploaded_at,
|
||||
)
|
||||
.await
|
||||
},
|
||||
|input| async move { generate_and_store_metadata(storage, config, ctx, input).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -176,12 +173,14 @@ pub async fn process_upload(
|
||||
pub async fn process_file_upload(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
ctx: &TenantContext,
|
||||
auth_event: &nostr::Event,
|
||||
body: Bytes,
|
||||
) -> Result<BlobDescriptor, MediaError> {
|
||||
process_buffered_upload(
|
||||
storage,
|
||||
config,
|
||||
ctx,
|
||||
auth_event,
|
||||
body,
|
||||
|bytes, cfg| validate_file_content(bytes, cfg),
|
||||
@@ -197,11 +196,7 @@ pub async fn process_file_upload(
|
||||
uploaded_at: input.uploaded_at,
|
||||
duration_secs: None,
|
||||
};
|
||||
let meta_key = format!("_meta/{}.json", input.sha256);
|
||||
let meta_json = serde_json::to_vec(&meta)?;
|
||||
storage
|
||||
.put(&meta_key, &meta_json, "application/json")
|
||||
.await?;
|
||||
storage.put_sidecar(ctx, &input.sha256, &meta).await?;
|
||||
Ok(meta)
|
||||
},
|
||||
)
|
||||
@@ -222,6 +217,7 @@ pub async fn process_file_upload(
|
||||
pub async fn process_video_upload(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
ctx: &TenantContext,
|
||||
auth_event: &nostr::Event,
|
||||
body_stream: impl futures_core::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static,
|
||||
content_length: Option<u64>,
|
||||
@@ -334,10 +330,12 @@ pub async fn process_video_upload(
|
||||
// --- 3. Verify Blossom auth: x tag must match computed SHA-256 ---
|
||||
let auth = auth_event.clone();
|
||||
let sha256_for_auth = sha256_hex.clone();
|
||||
let server_domain = config.server_domain.clone();
|
||||
// Validate the Blossom `server` tag against the bound tenant host (not a
|
||||
// process-global domain) — a relay serves many tenant hosts.
|
||||
let bound_host = ctx.host().to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Videos: 1-hour window — large uploads on slow connections need headroom.
|
||||
verify_blossom_upload_auth(&auth, &sha256_for_auth, server_domain.as_deref(), 3600)
|
||||
verify_blossom_upload_auth(&auth, &sha256_for_auth, Some(bound_host.as_str()), 3600)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| MediaError::Internal)??;
|
||||
@@ -352,13 +350,13 @@ pub async fn process_video_upload(
|
||||
|
||||
let ext = "mp4";
|
||||
let key = format!("{sha256_hex}.{ext}");
|
||||
let meta_key = format!("_meta/{sha256_hex}.json");
|
||||
let meta_key = MediaStorage::ctx_sidecar_key(ctx, &sha256_hex);
|
||||
|
||||
// --- 5. Idempotency check ---
|
||||
let sidecar_exists = storage.head(&meta_key).await?;
|
||||
let blob_exists = storage.head(&key).await?;
|
||||
if sidecar_exists && blob_exists {
|
||||
let meta = storage.get_sidecar(&sha256_hex).await?;
|
||||
let meta = storage.get_sidecar(ctx, &sha256_hex).await?;
|
||||
return Ok(build_descriptor(
|
||||
config,
|
||||
&sha256_hex,
|
||||
@@ -387,10 +385,7 @@ pub async fn process_video_upload(
|
||||
uploaded_at,
|
||||
duration_secs: Some(video_meta.duration_secs),
|
||||
};
|
||||
let meta_json = serde_json::to_vec(&meta)?;
|
||||
storage
|
||||
.put(&meta_key, &meta_json, "application/json")
|
||||
.await?;
|
||||
storage.put_sidecar(ctx, &sha256_hex, &meta).await?;
|
||||
|
||||
Ok(build_descriptor(
|
||||
config,
|
||||
@@ -408,16 +403,13 @@ pub async fn process_video_upload(
|
||||
async fn generate_and_store_metadata(
|
||||
storage: &MediaStorage,
|
||||
config: &MediaConfig,
|
||||
sha256: &str,
|
||||
ext: &str,
|
||||
mime: &str,
|
||||
body: &Bytes,
|
||||
uploaded_at: i64,
|
||||
ctx: &TenantContext,
|
||||
input: MetadataInput,
|
||||
) -> Result<BlobMeta, MediaError> {
|
||||
let body_ref = body.clone();
|
||||
let mime_ref = mime.to_string();
|
||||
let ext_ref = ext.to_string();
|
||||
let sha256_ref = sha256.to_string();
|
||||
let body_ref = input.body.clone();
|
||||
let mime_ref = input.mime.clone();
|
||||
let ext_ref = input.ext.clone();
|
||||
let sha256_ref = input.sha256.clone();
|
||||
let cfg_ref = config.clone();
|
||||
let (mut meta, thumb_bytes) = tokio::task::spawn_blocking(move || {
|
||||
generate_image_metadata_sync(&cfg_ref, &sha256_ref, &body_ref, &mime_ref, &ext_ref)
|
||||
@@ -425,18 +417,14 @@ async fn generate_and_store_metadata(
|
||||
.await
|
||||
.map_err(|_| MediaError::Internal)??;
|
||||
|
||||
meta.uploaded_at = uploaded_at;
|
||||
meta.uploaded_at = input.uploaded_at;
|
||||
|
||||
if let Some(ref tb) = thumb_bytes {
|
||||
let thumb_key = format!("{sha256}.thumb.jpg");
|
||||
let thumb_key = format!("{}.thumb.jpg", input.sha256);
|
||||
storage.put(&thumb_key, tb, "image/jpeg").await?;
|
||||
}
|
||||
|
||||
let meta_key = format!("_meta/{sha256}.json");
|
||||
let meta_json = serde_json::to_vec(&meta)?;
|
||||
storage
|
||||
.put(&meta_key, &meta_json, "application/json")
|
||||
.await?;
|
||||
storage.put_sidecar(ctx, &input.sha256, &meta).await?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
@@ -478,7 +466,6 @@ mod tests {
|
||||
max_video_bytes: 524_288_000,
|
||||
max_file_bytes: 104_857_600,
|
||||
public_base_url: "https://media.example.com".to_string(),
|
||||
server_domain: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -421,7 +421,6 @@ mod tests {
|
||||
max_video_bytes: 524_288_000,
|
||||
max_file_bytes: 104_857_600,
|
||||
public_base_url: String::new(),
|
||||
server_domain: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
[package]
|
||||
name = "buzz-proxy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Nostr client compatibility proxy for Buzz"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
url = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-proxy"
|
||||
path = "src/main.rs"
|
||||
@@ -1,203 +0,0 @@
|
||||
# buzz-proxy
|
||||
|
||||
NIP-28 compatibility proxy for [Buzz](../../VISION.md). Lets standard Nostr clients (Coracle, Amethyst, nak, etc.) connect to a Buzz relay using familiar NIP-28 channel events.
|
||||
|
||||
```
|
||||
Client (NIP-28) ←→ buzz-proxy :4869 ←→ buzz-relay :3000
|
||||
```
|
||||
|
||||
**Supported NIPs:** NIP-01, NIP-11, NIP-28, NIP-42
|
||||
|
||||
**Not supported (MVP):** NIP-29 group navigation, NIP-50 search, DMs
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start infrastructure and relay
|
||||
|
||||
```bash
|
||||
just relay # starts Docker services + migrations automatically
|
||||
```
|
||||
|
||||
### 2. Mint a proxy API token
|
||||
|
||||
The token's `--pubkey` must match the public key derived from `BUZZ_PROXY_SERVER_KEY`.
|
||||
|
||||
```bash
|
||||
# Derive the public key from your server key
|
||||
nak key public <BUZZ_PROXY_SERVER_KEY>
|
||||
|
||||
# Mint the token with that pubkey
|
||||
cargo run -p buzz-admin -- mint-token \
|
||||
--name "buzz-proxy" \
|
||||
--scopes "proxy:submit,channels:read,messages:read" \
|
||||
--pubkey <derived-pubkey>
|
||||
```
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
```bash
|
||||
export BUZZ_UPSTREAM_URL=ws://localhost:3000
|
||||
export BUZZ_PROXY_BIND_ADDR=0.0.0.0:4869
|
||||
export BUZZ_PROXY_SERVER_KEY=<hex nsec from step 2>
|
||||
export BUZZ_PROXY_SALT=$(openssl rand -hex 32)
|
||||
export BUZZ_PROXY_API_TOKEN=<api token from step 2>
|
||||
export BUZZ_PROXY_ADMIN_SECRET=<secret for admin API>
|
||||
```
|
||||
|
||||
> Put these in `.env` at the repo root — `just` loads it automatically.
|
||||
|
||||
### 4. Start the proxy
|
||||
|
||||
```bash
|
||||
just proxy # Proxy on :4869
|
||||
```
|
||||
|
||||
### 5. Register a guest (recommended)
|
||||
|
||||
Register a guest by their Nostr public key. They can then connect with any NIP-42-capable client — no token needed.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4869/admin/guests \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <BUZZ_PROXY_ADMIN_SECRET>" \
|
||||
-d '{"pubkey":"<guest-hex-pubkey>","channels":"<channel-uuid1>,<channel-uuid2>"}'
|
||||
```
|
||||
|
||||
### 6. Connect (pubkey-based)
|
||||
|
||||
Just add the proxy as a relay in your Nostr client:
|
||||
|
||||
```
|
||||
ws://localhost:4869
|
||||
```
|
||||
|
||||
The client handles NIP-42 authentication automatically. No token in the URL.
|
||||
|
||||
### Alternative: Invite tokens (for ad-hoc sharing)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4869/admin/invite \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <BUZZ_PROXY_ADMIN_SECRET>" \
|
||||
-d '{"channels":"<channel-uuid>","hours":24,"max_uses":10}'
|
||||
```
|
||||
|
||||
Connect with the token in the URL:
|
||||
|
||||
```
|
||||
ws://localhost:4869?token=<invite_token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `BUZZ_UPSTREAM_URL` | ✅ | — | WebSocket URL of the Buzz relay |
|
||||
| `BUZZ_PROXY_SERVER_KEY` | ✅ | — | Hex nsec for the proxy server keypair |
|
||||
| `BUZZ_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow key derivation (keep stable) |
|
||||
| `BUZZ_PROXY_API_TOKEN` | ✅ | — | Buzz API token with `proxy:submit`, `channels:read`, and `messages:read` scopes |
|
||||
| `BUZZ_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address |
|
||||
| `BUZZ_PROXY_ADMIN_SECRET` | ❌ | — | Bearer secret for `POST /admin/invite` (unset = dev mode, no auth) |
|
||||
| `RUST_LOG` | ❌ | `buzz_proxy=info` | Log level |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `GET /`
|
||||
- With `Accept: application/nostr+json` → NIP-11 relay info document
|
||||
- With `Upgrade: websocket` → WebSocket connection (requires `?token=<invite>`)
|
||||
|
||||
### `POST /admin/invite`
|
||||
|
||||
Create an invite token. Requires `Authorization: Bearer <BUZZ_PROXY_ADMIN_SECRET>` (if secret is set).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"channels": "<uuid1>,<uuid2>",
|
||||
"hours": 24,
|
||||
"max_uses": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"token": "buzz_invite_<uuid>",
|
||||
"channels": ["<uuid1>", "<uuid2>"],
|
||||
"expires_at": "2026-03-12T22:00:00Z",
|
||||
"max_uses": 10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Clients
|
||||
|
||||
| Client | Platform | NIP-28 | NIP-42 | Notes |
|
||||
|--------|----------|:------:|:------:|-------|
|
||||
| **Coracle** | Web | ✅ | ✅ | Best UI — renders kind:42 in chat |
|
||||
| **Nostrudel** | Web | ✅ | ✅ | Good NIP-28 support |
|
||||
| **Amethyst** | Android | ✅ | ✅ | NIP-28 public chat works |
|
||||
| **nak** | CLI | ✅ | ✅ | Best for scripting/testing |
|
||||
| **websocat** | CLI | ✅ | — | Raw protocol debugging |
|
||||
| **Damus** | iOS | ❌ | ✅ | No NIP-28 UI |
|
||||
| **Primal** | All | ❌ | ❌ | Incompatible (caching relay) |
|
||||
|
||||
---
|
||||
|
||||
## Testing with nak
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/fiatjaf/nak@latest
|
||||
|
||||
# List channels
|
||||
nak req -k 40 -l 10 --auth "ws://localhost:4869?token=<invite_token>"
|
||||
|
||||
# Subscribe to messages
|
||||
nak req -k 42 --tag "e=<kind40_event_id>" -l 20 --auth "ws://localhost:4869?token=<invite_token>"
|
||||
|
||||
# Post a message
|
||||
nak event -k 42 -c "Hello!" --tag "e=<kind40_event_id>" --sec <nsec> "ws://localhost:4869?token=<invite_token>"
|
||||
```
|
||||
|
||||
## E2E Test Script
|
||||
|
||||
```bash
|
||||
# Prerequisites: relay + proxy running, websocat + curl + jq installed
|
||||
./scripts/test-proxy-e2e.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
- **Kind translation:** kind:42 ↔ kind:9, `#e(event_id)` ↔ `#h(uuid)`
|
||||
- **Shadow keys:** Each external pubkey gets a deterministic shadow keypair (HMAC-SHA256 of salt + pubkey). The relay sees the shadow key; the external user's real key is never exposed.
|
||||
- **Channel map:** Loaded at startup from the relay's REST API. kind:40/41 events are synthesized locally — never forwarded upstream.
|
||||
- **Invite tokens:** In-memory, scoped to specific channels, time-limited, use-limited. Lost on proxy restart.
|
||||
- **`proxy:submit` scope:** Allows the proxy's API token to submit shadow-signed events through the relay's pubkey enforcement.
|
||||
- **Pre-auth buffering:** REQ messages sent before NIP-42 auth completes are buffered (max 20 msgs / 64 KiB) and replayed after auth.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `"error: invite token not found"` | Token expired, used up, or proxy restarted | Create new token via `POST /admin/invite` |
|
||||
| `"auth-required: authentication timeout"` | Client didn't respond to AUTH challenge within 30s | Use a NIP-42-capable client |
|
||||
| `"error: channel not found"` | Channel created after proxy started | Restart proxy to refresh channel map |
|
||||
| Connection drops immediately | Relay not running or wrong `BUZZ_UPSTREAM_URL` | Check `just relay` is running |
|
||||
| No messages appearing | Wrong kind:40 event ID in subscription | Re-query kind:40 to get correct event ID |
|
||||
| Startup fails: "failed to initialize channel map" | Can't reach relay REST API | Check relay health and API token scopes |
|
||||
|
||||
---
|
||||
|
||||
For the full guide including client-specific setup, architecture details, and extended troubleshooting, see [GUIDES/NOSTR_CLIENT_GUIDE.md](../../GUIDES/NOSTR_CLIENT_GUIDE.md).
|
||||
@@ -1,401 +0,0 @@
|
||||
//! Bidirectional UUID ↔ NIP-28 kind:40 event ID mapping.
|
||||
//!
|
||||
//! Initialized from the relay's REST API (`GET /api/channels`). Synthesizes
|
||||
//! deterministic kind:40 channel creation events so that the same
|
||||
//! (name, created_at, server_keys) always produces the same event ID across
|
||||
//! restarts.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use nostr::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Minimal DTO for deserializing `GET /api/channels` response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChannelDto {
|
||||
/// UUID string (e.g. `"550e8400-e29b-41d4-a716-446655440000"`).
|
||||
pub id: String,
|
||||
/// Human-readable channel name.
|
||||
pub name: String,
|
||||
/// RFC3339 creation timestamp.
|
||||
pub created_at: String,
|
||||
/// Channel visibility (e.g. `"public"` / `"private"`).
|
||||
pub visibility: String,
|
||||
/// Optional channel description.
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Hex-encoded pubkey of the creator.
|
||||
#[serde(default)]
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
/// All relevant information about a mapped channel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelInfo {
|
||||
/// The Buzz-internal UUID.
|
||||
pub uuid: Uuid,
|
||||
/// Human-readable name.
|
||||
pub name: String,
|
||||
/// Optional description.
|
||||
pub description: String,
|
||||
/// Hex pubkey of the original creator.
|
||||
pub created_by: String,
|
||||
/// Hex-encoded NIP-28 kind:40 event ID (deterministic).
|
||||
pub kind40_event_id: String,
|
||||
/// Creation time as UNIX timestamp (seconds).
|
||||
pub created_at_unix: u64,
|
||||
}
|
||||
|
||||
/// Bidirectional UUID ↔ kind:40 event ID map.
|
||||
///
|
||||
/// Thread-safe via [`DashMap`]; clone-friendly via `Arc` wrapping at the call
|
||||
/// site.
|
||||
pub struct ChannelMap {
|
||||
by_uuid: DashMap<Uuid, ChannelInfo>,
|
||||
by_event_id: DashMap<String, Uuid>,
|
||||
server_keys: Keys,
|
||||
}
|
||||
|
||||
impl ChannelMap {
|
||||
/// Synthesize a deterministic NIP-28 kind:40 channel creation event.
|
||||
///
|
||||
/// The event ID is a pure function of `(uuid, created_at_unix, server_keys)`.
|
||||
/// Calling this twice with the same inputs yields the same [`Event`] and
|
||||
/// therefore the same event ID.
|
||||
///
|
||||
/// **Important**: The `uuid` (not the human-readable name) is used as the
|
||||
/// identity anchor in the content JSON. This ensures the kind:40 event ID
|
||||
/// remains stable even if the channel is renamed. The real name belongs in
|
||||
/// kind:41 (mutable metadata), not here.
|
||||
pub fn synthesize_kind40(&self, uuid: &str, created_at_unix: u64) -> Event {
|
||||
let content = serde_json::json!({
|
||||
"name": uuid, // Use UUID for deterministic ID stability — name goes in kind:41
|
||||
"about": "",
|
||||
"picture": ""
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// nostr 0.36: EventBuilder::new(kind, content).tags( tags)
|
||||
// SAFETY: signing with a pre-validated Keys instance cannot fail
|
||||
EventBuilder::new(Kind::ChannelCreation, content)
|
||||
.tags([])
|
||||
.custom_created_at(Timestamp::from(created_at_unix))
|
||||
.sign_with_keys(&self.server_keys)
|
||||
.expect("SAFETY: signing with valid keys cannot fail")
|
||||
}
|
||||
|
||||
/// Synthesize a NIP-28 kind:41 channel metadata event that references the
|
||||
/// kind:40 event via an `e` tag.
|
||||
pub fn synthesize_kind41(&self, info: &ChannelInfo) -> Event {
|
||||
let content = serde_json::json!({
|
||||
"name": info.name,
|
||||
"about": info.description,
|
||||
"picture": ""
|
||||
})
|
||||
.to_string();
|
||||
|
||||
// SAFETY: kind40_event_id is always a valid hex string — it was produced by event.id.to_hex() in register()
|
||||
let e_tag = Tag::event(
|
||||
EventId::from_hex(&info.kind40_event_id)
|
||||
.expect("SAFETY: kind40_event_id is always valid hex from event.id.to_hex()"),
|
||||
);
|
||||
|
||||
// SAFETY: signing with a pre-validated Keys instance cannot fail
|
||||
EventBuilder::new(Kind::ChannelMetadata, content)
|
||||
.tags([e_tag])
|
||||
.custom_created_at(Timestamp::from(info.created_at_unix))
|
||||
.sign_with_keys(&self.server_keys)
|
||||
.expect("SAFETY: signing with valid keys cannot fail")
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelMap {
|
||||
/// Create an empty [`ChannelMap`] with the given server signing keys.
|
||||
pub fn new(server_keys: Keys) -> Self {
|
||||
Self {
|
||||
by_uuid: DashMap::new(),
|
||||
by_event_id: DashMap::new(),
|
||||
server_keys,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize from `GET /api/channels` REST endpoint.
|
||||
///
|
||||
/// Fetches all channels, synthesizes a deterministic kind:40 event for each
|
||||
/// one, and populates both lookup tables.
|
||||
pub async fn init_from_rest(
|
||||
server_keys: Keys,
|
||||
api_base: &str,
|
||||
api_token: &str,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let map = Self::new(server_keys);
|
||||
|
||||
// SAFETY: default builder with only timeout config cannot fail
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("SAFETY: default builder with only timeout config cannot fail");
|
||||
let channels: Vec<ChannelDto> = client
|
||||
.get(format!("{}/api/channels", api_base))
|
||||
.header("Authorization", format!("Bearer {}", api_token))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
for ch in &channels {
|
||||
map.register(ch)?;
|
||||
}
|
||||
|
||||
info!(
|
||||
count = channels.len(),
|
||||
"channel map initialized from REST API"
|
||||
);
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Look up channel info by Buzz UUID.
|
||||
pub fn lookup_by_uuid(&self, uuid: &Uuid) -> Option<ChannelInfo> {
|
||||
self.by_uuid.get(uuid).map(|r| r.clone())
|
||||
}
|
||||
|
||||
/// Look up channel info by NIP-28 kind:40 event ID (hex).
|
||||
pub fn lookup_by_event_id(&self, event_id: &str) -> Option<ChannelInfo> {
|
||||
self.by_event_id
|
||||
.get(event_id)
|
||||
.and_then(|uuid| self.by_uuid.get(&*uuid).map(|r| r.clone()))
|
||||
}
|
||||
|
||||
/// Return all channel infos (e.g. for serving kind:40 `REQ` responses).
|
||||
pub fn all_channels(&self) -> Vec<ChannelInfo> {
|
||||
self.by_uuid.iter().map(|r| r.value().clone()).collect()
|
||||
}
|
||||
|
||||
/// Register a new channel (e.g. from a kind:40099 system message) and
|
||||
/// return its [`ChannelInfo`].
|
||||
///
|
||||
/// Idempotent: registering the same UUID twice overwrites with the latest
|
||||
/// data (event ID will be identical for the same inputs).
|
||||
pub fn register(
|
||||
&self,
|
||||
dto: &ChannelDto,
|
||||
) -> Result<ChannelInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let uuid = dto.id.parse::<Uuid>()?;
|
||||
let created_at_unix =
|
||||
chrono::DateTime::parse_from_rfc3339(&dto.created_at)?.timestamp() as u64;
|
||||
|
||||
let kind40 = self.synthesize_kind40(&dto.id, created_at_unix);
|
||||
let event_id = kind40.id.to_hex();
|
||||
|
||||
let info = ChannelInfo {
|
||||
uuid,
|
||||
name: dto.name.clone(),
|
||||
description: dto.description.clone(),
|
||||
created_by: dto.created_by.clone(),
|
||||
kind40_event_id: event_id.clone(),
|
||||
created_at_unix,
|
||||
};
|
||||
|
||||
// Clean up stale by_event_id entry if this UUID was previously registered
|
||||
// with a different event ID (e.g. after channel rename or data change).
|
||||
if let Some(old_info) = self.by_uuid.get(&uuid) {
|
||||
if old_info.kind40_event_id != event_id {
|
||||
self.by_event_id.remove(&old_info.kind40_event_id);
|
||||
}
|
||||
}
|
||||
self.by_uuid.insert(uuid, info.clone());
|
||||
self.by_event_id.insert(event_id, uuid);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Number of channels in the map.
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_uuid.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if no channels have been registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_uuid.is_empty()
|
||||
}
|
||||
|
||||
/// The server signing keys (needed for kind:40/41 synthesis by other modules).
|
||||
pub fn server_keys(&self) -> &Keys {
|
||||
&self.server_keys
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_map() -> ChannelMap {
|
||||
let keys = Keys::generate();
|
||||
ChannelMap::new(keys)
|
||||
}
|
||||
|
||||
fn sample_dto(id: &str, name: &str) -> ChannelDto {
|
||||
ChannelDto {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
created_at: "2024-01-15T12:00:00Z".to_string(),
|
||||
visibility: "public".to_string(),
|
||||
description: "A test channel".to_string(),
|
||||
created_by: "deadbeef".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synthesize_kind40_deterministic() {
|
||||
let map = make_map();
|
||||
let uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let event_a = map.synthesize_kind40(uuid, 1_700_000_000);
|
||||
let event_b = map.synthesize_kind40(uuid, 1_700_000_000);
|
||||
assert_eq!(
|
||||
event_a.id, event_b.id,
|
||||
"same inputs must produce same event ID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synthesize_kind40_different_inputs() {
|
||||
let map = make_map();
|
||||
let uuid_a = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let uuid_b = "660f9511-f3ac-52e5-b827-557766551111";
|
||||
let event_a = map.synthesize_kind40(uuid_a, 1_700_000_000);
|
||||
let event_b = map.synthesize_kind40(uuid_b, 1_700_000_000);
|
||||
let event_c = map.synthesize_kind40(uuid_a, 1_700_000_001);
|
||||
assert_ne!(event_a.id, event_b.id, "different UUIDs → different IDs");
|
||||
assert_ne!(
|
||||
event_a.id, event_c.id,
|
||||
"different timestamps → different IDs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_lookup_by_uuid() {
|
||||
let map = make_map();
|
||||
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let dto = sample_dto(uuid_str, "general");
|
||||
|
||||
let info = map.register(&dto).expect("register should succeed");
|
||||
assert_eq!(info.name, "general");
|
||||
assert_eq!(info.uuid.to_string(), uuid_str);
|
||||
|
||||
let uuid: Uuid = uuid_str.parse().unwrap();
|
||||
let found = map.lookup_by_uuid(&uuid).expect("should find by uuid");
|
||||
assert_eq!(found.kind40_event_id, info.kind40_event_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_by_event_id() {
|
||||
let map = make_map();
|
||||
let uuid_str = "550e8400-e29b-41d4-a716-446655440001";
|
||||
let dto = sample_dto(uuid_str, "announcements");
|
||||
|
||||
let info = map.register(&dto).expect("register should succeed");
|
||||
|
||||
let found = map
|
||||
.lookup_by_event_id(&info.kind40_event_id)
|
||||
.expect("should find by event_id");
|
||||
assert_eq!(found.uuid.to_string(), uuid_str);
|
||||
assert_eq!(found.name, "announcements");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_channels_returns_all() {
|
||||
let map = make_map();
|
||||
let uuids = [
|
||||
"550e8400-e29b-41d4-a716-446655440002",
|
||||
"550e8400-e29b-41d4-a716-446655440003",
|
||||
"550e8400-e29b-41d4-a716-446655440004",
|
||||
];
|
||||
for (i, uuid_str) in uuids.iter().enumerate() {
|
||||
let dto = sample_dto(uuid_str, &format!("channel-{}", i));
|
||||
map.register(&dto).expect("register should succeed");
|
||||
}
|
||||
|
||||
let all = map.all_channels();
|
||||
assert_eq!(all.len(), 3, "all_channels should return all 3 channels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_len_and_is_empty() {
|
||||
let map = make_map();
|
||||
assert!(map.is_empty());
|
||||
assert_eq!(map.len(), 0);
|
||||
|
||||
let dto = sample_dto("550e8400-e29b-41d4-a716-446655440005", "test");
|
||||
map.register(&dto).unwrap();
|
||||
|
||||
assert!(!map.is_empty());
|
||||
assert_eq!(map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_synthesize_kind41_references_kind40() {
|
||||
let map = make_map();
|
||||
let uuid_str = "550e8400-e29b-41d4-a716-446655440006";
|
||||
let dto = sample_dto(uuid_str, "meta-test");
|
||||
let info = map.register(&dto).unwrap();
|
||||
|
||||
let kind41 = map.synthesize_kind41(&info);
|
||||
assert_eq!(kind41.kind, Kind::ChannelMetadata);
|
||||
|
||||
// Verify the e tag references the kind:40 event ID
|
||||
let has_e_tag = kind41.tags.iter().any(|tag| {
|
||||
tag.as_slice().first().map(|s| s == "e").unwrap_or(false)
|
||||
&& tag
|
||||
.as_slice()
|
||||
.get(1)
|
||||
.map(|s| s == &info.kind40_event_id)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
assert!(
|
||||
has_e_tag,
|
||||
"kind:41 must have e tag pointing to kind:40 event"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_cleans_up_stale_event_id() {
|
||||
let keys = Keys::generate();
|
||||
let map = ChannelMap::new(keys);
|
||||
|
||||
let dto1 = ChannelDto {
|
||||
id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
|
||||
name: "original-name".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
visibility: "open".to_string(),
|
||||
description: "test".to_string(),
|
||||
created_by: "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
.to_string(),
|
||||
};
|
||||
let info1 = map.register(&dto1).unwrap();
|
||||
|
||||
// Re-register with different created_at (changes the synthesized event ID)
|
||||
let dto2 = ChannelDto {
|
||||
id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
|
||||
name: "original-name".to_string(),
|
||||
created_at: "2026-06-01T00:00:00Z".to_string(),
|
||||
visibility: "open".to_string(),
|
||||
description: "test".to_string(),
|
||||
created_by: "0101010101010101010101010101010101010101010101010101010101010101"
|
||||
.to_string(),
|
||||
};
|
||||
let info2 = map.register(&dto2).unwrap();
|
||||
|
||||
// Old event ID should no longer resolve
|
||||
assert!(
|
||||
map.lookup_by_event_id(&info1.kind40_event_id).is_none(),
|
||||
"stale event ID must be cleaned up"
|
||||
);
|
||||
// New event ID should resolve
|
||||
assert!(
|
||||
map.lookup_by_event_id(&info2.kind40_event_id).is_some(),
|
||||
"new event ID must resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors returned by the proxy layer.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProxyError {
|
||||
/// The invite token was not found in the store.
|
||||
#[error("invite token not found")]
|
||||
InviteNotFound,
|
||||
|
||||
/// The invite token has passed its expiry time.
|
||||
#[error("invite token expired")]
|
||||
InviteExpired,
|
||||
|
||||
/// The invite token has reached its maximum use count.
|
||||
#[error("invite token exhausted")]
|
||||
InviteExhausted,
|
||||
|
||||
/// The supplied external public key is not a valid 32-byte hex string.
|
||||
#[error("invalid external pubkey: {0}")]
|
||||
InvalidPubkey(String),
|
||||
|
||||
/// Shadow key derivation failed.
|
||||
#[error("shadow key derivation failed: {0}")]
|
||||
KeyDerivation(String),
|
||||
|
||||
/// An I/O or network error occurred.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// The upstream relay connection failed.
|
||||
#[error("upstream error: {0}")]
|
||||
Upstream(String),
|
||||
|
||||
/// Authentication failed.
|
||||
#[error("auth error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
/// JSON serialization/deserialization failed.
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
/// The client does not have permission for this operation.
|
||||
#[error("permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
/// The requested channel was not found.
|
||||
#[error("channel not found: {0}")]
|
||||
ChannelNotFound(String),
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
//! Thread-safe in-memory guest registry for pubkey-based access control.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use nostr::PublicKey;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// In-memory guest registry. Maps external Nostr pubkeys to their allowed channels.
|
||||
///
|
||||
/// Guests registered here can authenticate via NIP-42 alone — no invite token needed.
|
||||
/// The store is backed by [`DashMap`] for lock-free concurrent access.
|
||||
pub struct GuestStore {
|
||||
guests: DashMap<PublicKey, Vec<Uuid>>,
|
||||
}
|
||||
|
||||
impl GuestStore {
|
||||
/// Create an empty guest store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
guests: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a pubkey. Returns `Some(channels)` if registered, `None` otherwise.
|
||||
pub fn lookup(&self, pubkey: &PublicKey) -> Option<Vec<Uuid>> {
|
||||
self.guests.get(pubkey).map(|entry| entry.clone())
|
||||
}
|
||||
|
||||
/// Register a guest pubkey with access to specific channels.
|
||||
///
|
||||
/// Overwrites any existing registration for this pubkey.
|
||||
pub fn register(&self, pubkey: PublicKey, channels: Vec<Uuid>) {
|
||||
self.guests.insert(pubkey, channels);
|
||||
}
|
||||
|
||||
/// Remove a guest's registration.
|
||||
///
|
||||
/// Returns `true` if the pubkey was found and removed, `false` if it was not registered.
|
||||
pub fn remove(&self, pubkey: &PublicKey) -> bool {
|
||||
self.guests.remove(pubkey).is_some()
|
||||
}
|
||||
|
||||
/// Number of registered guests.
|
||||
pub fn len(&self) -> usize {
|
||||
self.guests.len()
|
||||
}
|
||||
|
||||
/// Whether the store is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.guests.is_empty()
|
||||
}
|
||||
|
||||
/// List all registered guests.
|
||||
///
|
||||
/// Returns a snapshot of all `(pubkey, channels)` pairs, suitable for admin listing.
|
||||
pub fn all(&self) -> Vec<(PublicKey, Vec<Uuid>)> {
|
||||
self.guests
|
||||
.iter()
|
||||
.map(|entry| (*entry.key(), entry.value().clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GuestStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::Keys;
|
||||
|
||||
fn random_pubkey() -> PublicKey {
|
||||
Keys::generate().public_key()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_lookup_succeeds() {
|
||||
let store = GuestStore::new();
|
||||
let pubkey = random_pubkey();
|
||||
let ch1 = Uuid::new_v4();
|
||||
let ch2 = Uuid::new_v4();
|
||||
|
||||
store.register(pubkey, vec![ch1, ch2]);
|
||||
|
||||
let channels = store
|
||||
.lookup(&pubkey)
|
||||
.expect("should find registered pubkey");
|
||||
assert_eq!(channels, vec![ch1, ch2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_unknown_returns_none() {
|
||||
let store = GuestStore::new();
|
||||
let pubkey = random_pubkey();
|
||||
assert!(store.lookup(&pubkey).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_returns_true_for_existing_false_for_unknown() {
|
||||
let store = GuestStore::new();
|
||||
let pubkey = random_pubkey();
|
||||
|
||||
store.register(pubkey, vec![Uuid::new_v4()]);
|
||||
assert!(
|
||||
store.remove(&pubkey),
|
||||
"should return true for existing pubkey"
|
||||
);
|
||||
assert!(
|
||||
!store.remove(&pubkey),
|
||||
"should return false for already-removed pubkey"
|
||||
);
|
||||
|
||||
let unknown = random_pubkey();
|
||||
assert!(
|
||||
!store.remove(&unknown),
|
||||
"should return false for never-registered pubkey"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_overwrites_previous_channels() {
|
||||
let store = GuestStore::new();
|
||||
let pubkey = random_pubkey();
|
||||
let ch1 = Uuid::new_v4();
|
||||
let ch2 = Uuid::new_v4();
|
||||
|
||||
store.register(pubkey, vec![ch1]);
|
||||
store.register(pubkey, vec![ch2]);
|
||||
|
||||
let channels = store.lookup(&pubkey).expect("should find pubkey");
|
||||
assert_eq!(
|
||||
channels,
|
||||
vec![ch2],
|
||||
"second register should overwrite first"
|
||||
);
|
||||
assert!(!channels.contains(&ch1), "old channel should be gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_all_registered_guests() {
|
||||
let store = GuestStore::new();
|
||||
let pk1 = random_pubkey();
|
||||
let pk2 = random_pubkey();
|
||||
let ch1 = Uuid::new_v4();
|
||||
let ch2 = Uuid::new_v4();
|
||||
|
||||
store.register(pk1, vec![ch1]);
|
||||
store.register(pk2, vec![ch2]);
|
||||
|
||||
let all = store.all();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
let keys: Vec<PublicKey> = all.iter().map(|(k, _)| *k).collect();
|
||||
assert!(keys.contains(&pk1));
|
||||
assert!(keys.contains(&pk2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn len_and_is_empty() {
|
||||
let store = GuestStore::new();
|
||||
assert!(store.is_empty());
|
||||
assert_eq!(store.len(), 0);
|
||||
|
||||
store.register(random_pubkey(), vec![]);
|
||||
assert!(!store.is_empty());
|
||||
assert_eq!(store.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//! Invite token management for guest authentication via NIP-42 AUTH tags.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ProxyError;
|
||||
|
||||
/// An invite token granting a guest access to one or more channels.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InviteToken {
|
||||
/// The raw token string presented by the guest during NIP-42 AUTH.
|
||||
pub token: String,
|
||||
/// Channels this token grants access to.
|
||||
pub channel_ids: Vec<Uuid>,
|
||||
/// When the token expires.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// Maximum number of times the token may be used.
|
||||
pub max_uses: u32,
|
||||
/// Number of times the token has been used so far.
|
||||
pub uses: u32,
|
||||
}
|
||||
|
||||
impl InviteToken {
|
||||
/// Create a new invite token with zero uses.
|
||||
pub fn new(
|
||||
token: impl Into<String>,
|
||||
channel_ids: Vec<Uuid>,
|
||||
expires_at: DateTime<Utc>,
|
||||
max_uses: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
token: token.into(),
|
||||
channel_ids,
|
||||
expires_at,
|
||||
max_uses,
|
||||
uses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Ok(())` if the token is not expired and has remaining uses.
|
||||
pub fn validate(&self, now: DateTime<Utc>) -> Result<(), ProxyError> {
|
||||
if now >= self.expires_at {
|
||||
return Err(ProxyError::InviteExpired);
|
||||
}
|
||||
if self.uses >= self.max_uses {
|
||||
return Err(ProxyError::InviteExhausted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if the token passes validation at `now`.
|
||||
pub fn is_valid(&self, now: DateTime<Utc>) -> bool {
|
||||
self.validate(now).is_ok()
|
||||
}
|
||||
|
||||
/// Increments the use counter by one (saturating).
|
||||
pub fn consume(&mut self) {
|
||||
self.uses = self.uses.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn future(secs: i64) -> DateTime<Utc> {
|
||||
Utc::now() + Duration::seconds(secs)
|
||||
}
|
||||
|
||||
fn past(secs: i64) -> DateTime<Utc> {
|
||||
Utc::now() - Duration::seconds(secs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invite_token_validation() {
|
||||
let token = InviteToken::new("tok-valid", vec![], future(3600), 5);
|
||||
assert!(token.validate(Utc::now()).is_ok());
|
||||
assert!(token.is_valid(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invite_token_expired() {
|
||||
let token = InviteToken::new("tok-expired", vec![], past(1), 5);
|
||||
let err = token.validate(Utc::now()).unwrap_err();
|
||||
assert!(matches!(err, ProxyError::InviteExpired));
|
||||
assert!(!token.is_valid(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invite_token_exhausted() {
|
||||
let mut token = InviteToken::new("tok-used-up", vec![], future(3600), 2);
|
||||
token.uses = 2;
|
||||
let err = token.validate(Utc::now()).unwrap_err();
|
||||
assert!(matches!(err, ProxyError::InviteExhausted));
|
||||
assert!(!token.is_valid(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invite_token_consume_increments_uses() {
|
||||
let mut token = InviteToken::new("tok-consume", vec![], future(3600), 3);
|
||||
assert_eq!(token.uses, 0);
|
||||
token.consume();
|
||||
assert_eq!(token.uses, 1);
|
||||
token.consume();
|
||||
assert_eq!(token.uses, 2);
|
||||
// Still valid (uses < max_uses)
|
||||
assert!(token.is_valid(Utc::now()));
|
||||
token.consume();
|
||||
assert!(!token.is_valid(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invite_token_consume_saturates_at_max() {
|
||||
let mut token = InviteToken::new("tok-sat", vec![], future(3600), 1);
|
||||
// Consume beyond max_uses — should not overflow
|
||||
token.uses = u32::MAX;
|
||||
token.consume(); // saturating_add should not panic
|
||||
assert_eq!(token.uses, u32::MAX);
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
//! Thread-safe in-memory invite token registry.
|
||||
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ProxyError;
|
||||
use crate::invite::InviteToken;
|
||||
|
||||
/// In-memory invite token store backed by DashMap.
|
||||
pub struct InviteStore {
|
||||
tokens: DashMap<String, InviteToken>,
|
||||
}
|
||||
|
||||
impl InviteStore {
|
||||
/// Create an empty store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tokens: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new invite token.
|
||||
pub fn insert(&self, token: InviteToken) {
|
||||
self.tokens.insert(token.token.clone(), token);
|
||||
}
|
||||
|
||||
/// Validate a token string. Returns the list of channel UUIDs if valid.
|
||||
pub fn validate(&self, token_str: &str) -> Result<Vec<Uuid>, ProxyError> {
|
||||
let entry = self
|
||||
.tokens
|
||||
.get(token_str)
|
||||
.ok_or(ProxyError::InviteNotFound)?;
|
||||
entry.validate(Utc::now())?;
|
||||
Ok(entry.channel_ids.clone())
|
||||
}
|
||||
|
||||
/// Atomically validate and consume a token (increment use count).
|
||||
/// Returns the list of channel UUIDs if valid.
|
||||
pub fn validate_and_consume(&self, token_str: &str) -> Result<Vec<Uuid>, ProxyError> {
|
||||
let mut entry = self
|
||||
.tokens
|
||||
.get_mut(token_str)
|
||||
.ok_or(ProxyError::InviteNotFound)?;
|
||||
entry.validate(Utc::now())?;
|
||||
let channels = entry.channel_ids.clone();
|
||||
entry.consume();
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
/// Remove a token from the store.
|
||||
pub fn remove(&self, token_str: &str) -> Option<InviteToken> {
|
||||
self.tokens.remove(token_str).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Number of tokens in the store.
|
||||
pub fn len(&self) -> usize {
|
||||
self.tokens.len()
|
||||
}
|
||||
|
||||
/// Whether the store is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tokens.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InviteStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn future_token(token: &str, channels: Vec<Uuid>, max_uses: u32) -> InviteToken {
|
||||
InviteToken::new(token, channels, Utc::now() + Duration::hours(1), max_uses)
|
||||
}
|
||||
|
||||
fn expired_token(token: &str) -> InviteToken {
|
||||
InviteToken::new(token, vec![], Utc::now() - Duration::seconds(1), 5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_returns_channels() {
|
||||
let store = InviteStore::new();
|
||||
let ch1 = Uuid::new_v4();
|
||||
let ch2 = Uuid::new_v4();
|
||||
store.insert(future_token("tok-1", vec![ch1, ch2], 5));
|
||||
|
||||
let channels = store.validate("tok-1").unwrap();
|
||||
assert_eq!(channels, vec![ch1, ch2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_not_found() {
|
||||
let store = InviteStore::new();
|
||||
assert!(matches!(
|
||||
store.validate("nonexistent"),
|
||||
Err(ProxyError::InviteNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_expired() {
|
||||
let store = InviteStore::new();
|
||||
store.insert(expired_token("tok-expired"));
|
||||
assert!(matches!(
|
||||
store.validate("tok-expired"),
|
||||
Err(ProxyError::InviteExpired)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_and_consume_increments() {
|
||||
let store = InviteStore::new();
|
||||
store.insert(future_token("tok-use", vec![], 2));
|
||||
|
||||
store.validate_and_consume("tok-use").unwrap();
|
||||
store.validate_and_consume("tok-use").unwrap();
|
||||
|
||||
// Third use should fail
|
||||
assert!(matches!(
|
||||
store.validate_and_consume("tok-use"),
|
||||
Err(ProxyError::InviteExhausted)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_works() {
|
||||
let store = InviteStore::new();
|
||||
store.insert(future_token("tok-rm", vec![], 5));
|
||||
assert_eq!(store.len(), 1);
|
||||
|
||||
let removed = store.remove("tok-rm");
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//! Kind translation between standard Nostr kinds and Buzz custom kinds.
|
||||
//!
|
||||
//! # ⚠️ Architectural limitation
|
||||
//!
|
||||
//! Translating a Nostr event's `kind` field **invalidates its signature**. The
|
||||
//! Nostr event ID is `SHA-256([0, pubkey, created_at, kind, tags, content])`, so
|
||||
//! any kind mutation produces a different ID and a broken Schnorr signature.
|
||||
//!
|
||||
//! This translator is intentionally designed for **Buzz-internal use only**,
|
||||
//! where events are re-signed by the proxy's shadow keypair after translation.
|
||||
//! It must never be used in a standard Nostr interop path where signature
|
||||
//! verification is expected to pass.
|
||||
|
||||
use buzz_core::kind::{
|
||||
KIND_DM_CREATED, KIND_NIP29_DELETE_EVENT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
};
|
||||
|
||||
/// Translates Nostr event kinds between standard and Buzz-internal values.
|
||||
pub struct KindTranslator;
|
||||
|
||||
impl KindTranslator {
|
||||
/// Create a new [`KindTranslator`].
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Translate a standard Nostr kind to the equivalent Buzz kind.
|
||||
/// Unknown kinds pass through unchanged.
|
||||
///
|
||||
/// # ⚠️ Lossy mapping — round-tripping is NOT lossless
|
||||
///
|
||||
/// Multiple standard Nostr kinds collapse onto the same Buzz kind.
|
||||
/// This is intentional: Buzz's internal kind space is smaller than the
|
||||
/// full Nostr kind space, and the proxy re-signs events anyway (see module
|
||||
/// doc), so the original kind is not preserved.
|
||||
///
|
||||
/// **Do not use `to_standard(to_buzz(k))` expecting to recover `k`.**
|
||||
/// The round-trip is only lossless for kinds that have a 1-to-1 mapping.
|
||||
///
|
||||
/// | Standard kind(s) | Buzz kind | Lossy? |
|
||||
/// |------------------------|---------------------------|--------|
|
||||
/// | 1, 40, 42 | `KIND_STREAM_MESSAGE` | ✅ yes |
|
||||
/// | 41, 44 | `KIND_STREAM_MESSAGE_EDIT`| ✅ yes |
|
||||
/// | 4 | `KIND_DM_CREATED` | no |
|
||||
/// | 43 | `KIND_NIP29_DELETE_EVENT` | no |
|
||||
/// | anything else | unchanged (pass-through) | no |
|
||||
pub fn to_buzz(&self, standard_kind: u32) -> u32 {
|
||||
match standard_kind {
|
||||
1 => KIND_STREAM_MESSAGE,
|
||||
4 => KIND_DM_CREATED,
|
||||
40 => KIND_STREAM_MESSAGE,
|
||||
41 => KIND_STREAM_MESSAGE_EDIT,
|
||||
42 => KIND_STREAM_MESSAGE,
|
||||
43 => KIND_NIP29_DELETE_EVENT,
|
||||
44 => KIND_STREAM_MESSAGE_EDIT,
|
||||
k => k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a Buzz kind back to the canonical standard Nostr kind.
|
||||
/// Unknown kinds pass through unchanged.
|
||||
///
|
||||
/// Returns the **canonical** standard kind for each Buzz kind. Because
|
||||
/// `to_buzz` is lossy (multiple standard kinds map to one Buzz kind),
|
||||
/// this function always returns the primary/canonical standard kind — it
|
||||
/// cannot recover the original kind if it was one of the secondary mappings.
|
||||
///
|
||||
/// For example: `to_standard(KIND_STREAM_MESSAGE)` returns `42` (NIP-28
|
||||
/// channel message), not `1` or `40`, even if the event was originally
|
||||
/// kind 1 or 40.
|
||||
///
|
||||
/// | Buzz kind | Standard kind | Notes |
|
||||
/// |----------------------------|---------------|---------------------------|
|
||||
/// | `KIND_STREAM_MESSAGE` | 42 | NIP-28 channel message |
|
||||
/// | `KIND_STREAM_MESSAGE_V2` | 42 | Rich format → plain 42 |
|
||||
/// | `KIND_STREAM_MESSAGE_EDIT` | 41 | NIP-28 channel message edit |
|
||||
/// | `KIND_DM_CREATED` | 4 | Encrypted DM |
|
||||
/// | `KIND_NIP29_DELETE_EVENT` | 43 | NIP-29 delete |
|
||||
/// | anything else | unchanged | pass-through |
|
||||
pub fn to_standard(&self, buzz_kind: u32) -> u32 {
|
||||
match buzz_kind {
|
||||
k if k == KIND_STREAM_MESSAGE => 42, // NIP-28 channel message (was 1)
|
||||
k if k == KIND_STREAM_MESSAGE_V2 => 42, // Rich format → plain kind:42
|
||||
k if k == KIND_STREAM_MESSAGE_EDIT => 41,
|
||||
k if k == KIND_DM_CREATED => 4,
|
||||
k if k == KIND_NIP29_DELETE_EVENT => 43,
|
||||
k => k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `kind` has a non-identity mapping in either direction.
|
||||
pub fn is_translatable(&self, kind: u32) -> bool {
|
||||
self.to_buzz(kind) != kind || self.to_standard(kind) != kind
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KindTranslator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::kind::{
|
||||
KIND_DM_CREATED, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_V2,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn standard_to_buzz() {
|
||||
let t = KindTranslator::new();
|
||||
assert_eq!(t.to_buzz(1), KIND_STREAM_MESSAGE);
|
||||
assert_eq!(t.to_buzz(4), KIND_DM_CREATED);
|
||||
assert_eq!(t.to_buzz(40), KIND_STREAM_MESSAGE);
|
||||
assert_eq!(t.to_buzz(41), KIND_STREAM_MESSAGE_EDIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buzz_to_standard() {
|
||||
let t = KindTranslator::new();
|
||||
assert_eq!(t.to_standard(KIND_STREAM_MESSAGE), 42);
|
||||
assert_eq!(t.to_standard(KIND_STREAM_MESSAGE_V2), 42);
|
||||
assert_eq!(t.to_standard(KIND_STREAM_MESSAGE_EDIT), 41);
|
||||
assert_eq!(t.to_standard(KIND_DM_CREATED), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_message_v2_round_trip() {
|
||||
let t = KindTranslator::new();
|
||||
// kind:42 → KIND_STREAM_MESSAGE (lossy collapse), then back → 42
|
||||
assert_eq!(t.to_standard(t.to_buzz(42)), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kinds_pass_through() {
|
||||
let t = KindTranslator::new();
|
||||
assert_eq!(t.to_buzz(9999), 9999);
|
||||
assert_eq!(t.to_buzz(0), 0);
|
||||
assert_eq!(t.to_standard(12345), 12345);
|
||||
assert_eq!(t.to_standard(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_translatable() {
|
||||
let t = KindTranslator::new();
|
||||
assert!(t.is_translatable(1));
|
||||
assert!(t.is_translatable(4));
|
||||
assert!(t.is_translatable(KIND_STREAM_MESSAGE));
|
||||
assert!(t.is_translatable(KIND_STREAM_MESSAGE_V2));
|
||||
assert!(!t.is_translatable(9999));
|
||||
assert!(!t.is_translatable(0));
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! `buzz-proxy` — Guest relay proxy for Nostr client compatibility.
|
||||
//!
|
||||
//! Translates standard Nostr kinds ↔ Buzz custom kinds, derives deterministic
|
||||
//! shadow keypairs for external users, and authenticates guests via invite tokens.
|
||||
|
||||
/// Bidirectional UUID ↔ NIP-28 kind:40 event ID mapping.
|
||||
pub mod channel_map;
|
||||
/// Error types for the proxy layer.
|
||||
pub mod error;
|
||||
/// Pubkey-based guest registry for persistent access control.
|
||||
pub mod guest_store;
|
||||
/// Invite token management for guest authentication.
|
||||
pub mod invite;
|
||||
/// Thread-safe invite token registry.
|
||||
pub mod invite_store;
|
||||
/// Kind translation between standard Nostr and Buzz-internal kinds.
|
||||
pub mod kind_translator;
|
||||
/// External-facing NIP-01 WebSocket server for standard Nostr clients.
|
||||
pub mod server;
|
||||
/// Deterministic shadow keypair derivation and caching.
|
||||
pub mod shadow_keys;
|
||||
/// Event translation between Buzz internal format and NIP-28 standard format.
|
||||
pub mod translate;
|
||||
/// Upstream relay WebSocket client with NIP-42 auth and reconnect.
|
||||
pub mod upstream;
|
||||
|
||||
pub use error::ProxyError;
|
||||
pub use guest_store::GuestStore;
|
||||
pub use invite::InviteToken;
|
||||
pub use invite_store::InviteStore;
|
||||
pub use kind_translator::KindTranslator;
|
||||
pub use shadow_keys::ShadowKeyManager;
|
||||
@@ -1,192 +0,0 @@
|
||||
//! buzz-proxy binary — NIP-28 guest relay proxy for standard Nostr clients.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{error, info};
|
||||
|
||||
use buzz_proxy::channel_map::ChannelMap;
|
||||
use buzz_proxy::guest_store::GuestStore;
|
||||
use buzz_proxy::invite_store::InviteStore;
|
||||
use buzz_proxy::server::{self, ProxyState};
|
||||
use buzz_proxy::shadow_keys::ShadowKeyManager;
|
||||
use buzz_proxy::translate::Translator;
|
||||
use buzz_proxy::upstream::{UpstreamClient, UpstreamEvent};
|
||||
|
||||
fn env_required(name: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| {
|
||||
eprintln!("error: required environment variable {name} is not set");
|
||||
std::process::exit(1);
|
||||
})
|
||||
}
|
||||
|
||||
fn env_or(name: &str, default: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Init tracing — respects RUST_LOG; falls back to info for buzz_proxy and tower_http.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "buzz_proxy=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let upstream_url = env_required("BUZZ_UPSTREAM_URL");
|
||||
let bind_addr = env_or("BUZZ_PROXY_BIND_ADDR", "0.0.0.0:4869");
|
||||
let server_key_hex = env_required("BUZZ_PROXY_SERVER_KEY");
|
||||
let salt_hex = env_required("BUZZ_PROXY_SALT");
|
||||
let api_token = env_required("BUZZ_PROXY_API_TOKEN");
|
||||
let relay_pubkey = env_required("BUZZ_RELAY_PUBKEY").to_lowercase();
|
||||
// Validate relay pubkey is well-formed 64-char hex at startup.
|
||||
// Input is lowercased above, so mixed-case is accepted.
|
||||
if relay_pubkey.len() != 64 || !relay_pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
eprintln!("error: BUZZ_RELAY_PUBKEY must be a 64-character hex string (32 bytes)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
info!(relay_pubkey = %relay_pubkey, "relay pubkey configured for attribution trust");
|
||||
|
||||
let server_secret = SecretKey::from_hex(&server_key_hex).unwrap_or_else(|e| {
|
||||
eprintln!("error: invalid BUZZ_PROXY_SERVER_KEY: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let server_keys = Keys::new(server_secret);
|
||||
info!(pubkey = %server_keys.public_key(), "proxy server keypair loaded");
|
||||
|
||||
let salt = hex::decode(&salt_hex).unwrap_or_else(|e| {
|
||||
eprintln!("error: invalid BUZZ_PROXY_SALT (must be hex): {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let shadow_keys = Arc::new(ShadowKeyManager::new(&salt).unwrap_or_else(|e| {
|
||||
eprintln!("error: shadow key manager init failed: {e}");
|
||||
std::process::exit(1);
|
||||
}));
|
||||
|
||||
let api_base = upstream_url
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://");
|
||||
|
||||
info!("initializing channel map from {api_base}/api/channels ...");
|
||||
let channel_map = Arc::new(
|
||||
ChannelMap::init_from_rest(server_keys.clone(), &api_base, &api_token)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to initialize channel map: {e}");
|
||||
std::process::exit(1);
|
||||
}),
|
||||
);
|
||||
info!(channels = channel_map.len(), "channel map ready");
|
||||
|
||||
let translator = Arc::new(Translator::new(
|
||||
shadow_keys,
|
||||
channel_map.clone(),
|
||||
api_base.clone(),
|
||||
api_token.clone(),
|
||||
relay_pubkey,
|
||||
));
|
||||
|
||||
let guest_store = Arc::new(GuestStore::new());
|
||||
|
||||
let invite_store = Arc::new(InviteStore::new());
|
||||
|
||||
//
|
||||
// UpstreamClient owns its internal outbound channel. The server calls
|
||||
// upstream.send_event() / send_req() / send_close() directly via Arc.
|
||||
// Note: UpstreamClient generates a stable ephemeral keypair per process
|
||||
// lifetime for NIP-42 auth — consistent across reconnects.
|
||||
|
||||
// Use server_keys for upstream NIP-42 auth so the auth event pubkey matches
|
||||
// the API token's owner_pubkey (the relay enforces this).
|
||||
let upstream = Arc::new(UpstreamClient::with_keys(
|
||||
upstream_url.clone(),
|
||||
api_token.clone(),
|
||||
server_keys.clone(),
|
||||
));
|
||||
|
||||
// upstream_events_tx: upstream → server (broadcast of inbound JSON strings)
|
||||
let (upstream_events_tx, _) = broadcast::channel::<String>(4096);
|
||||
|
||||
// inbound_tx: UpstreamClient → bridge task (UpstreamEvent)
|
||||
let (inbound_tx, mut inbound_rx) = mpsc::channel::<UpstreamEvent>(256);
|
||||
|
||||
//
|
||||
// The server layer subscribes to `upstream_events_tx` as raw JSON strings.
|
||||
// The UpstreamClient emits typed `UpstreamEvent` values. This task bridges
|
||||
// the two, serializing relay messages back to JSON for the server layer.
|
||||
|
||||
let bridge_events_tx = upstream_events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = inbound_rx.recv().await {
|
||||
match event {
|
||||
UpstreamEvent::RelayMessage(json) => {
|
||||
// Already raw JSON — forward directly to the broadcast channel.
|
||||
let _ = bridge_events_tx.send(json);
|
||||
}
|
||||
UpstreamEvent::Connected => {
|
||||
info!("upstream relay connected");
|
||||
}
|
||||
UpstreamEvent::Disconnected => {
|
||||
info!("upstream relay disconnected — reconnecting");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let admin_secret = std::env::var("BUZZ_PROXY_ADMIN_SECRET").ok();
|
||||
if admin_secret.is_some() {
|
||||
info!("admin endpoint protected by BUZZ_PROXY_ADMIN_SECRET");
|
||||
} else {
|
||||
info!("admin endpoint running unauthenticated (dev mode) — set BUZZ_PROXY_ADMIN_SECRET to secure it");
|
||||
}
|
||||
|
||||
// Relay URL for NIP-42 relay tag validation. Prefer explicit env var
|
||||
// (e.g. "wss://proxy.example.com") over the derived bind address fallback.
|
||||
let relay_url =
|
||||
std::env::var("BUZZ_PROXY_RELAY_URL").unwrap_or_else(|_| format!("ws://{}", bind_addr));
|
||||
|
||||
let state = ProxyState {
|
||||
channel_map: channel_map.clone(),
|
||||
guest_store: guest_store.clone(),
|
||||
invite_store: invite_store.clone(),
|
||||
translator,
|
||||
upstream: upstream.clone(),
|
||||
upstream_events: upstream_events_tx.clone(),
|
||||
admin_secret,
|
||||
relay_url,
|
||||
};
|
||||
|
||||
let app = server::router(state);
|
||||
|
||||
info!("buzz-proxy starting on {bind_addr} → upstream {upstream_url}");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&bind_addr)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to bind {bind_addr}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
result = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()) => {
|
||||
if let Err(e) = result {
|
||||
error!("server error: {e}");
|
||||
}
|
||||
}
|
||||
_ = upstream.as_ref().clone().run(inbound_tx) => {
|
||||
error!("upstream client exited unexpectedly");
|
||||
}
|
||||
}
|
||||
|
||||
info!("buzz-proxy shut down");
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install CTRL+C handler");
|
||||
info!("shutdown signal received");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,222 +0,0 @@
|
||||
//! Shadow keypair management — deterministic internal keys derived from external pubkeys.
|
||||
//!
|
||||
//! HMAC-SHA256(key=server_salt, msg=external_pubkey_bytes) → secp256k1 secret key. Cached in DashMap.
|
||||
//! A server-side salt is required to prevent offline derivation by anyone who knows only
|
||||
//! the external public key.
|
||||
//!
|
||||
//! **Key derivation note**: This uses HMAC-SHA256 (not raw SHA-256) for proper domain
|
||||
//! separation and resistance to length-extension attacks. If the derivation scheme is
|
||||
//! ever changed, all existing shadow keys will differ — acceptable for MVP (no persistent
|
||||
//! state), but must be coordinated with a migration for production deployments.
|
||||
//!
|
||||
//! # Cache size limit
|
||||
//!
|
||||
//! The in-memory cache is bounded to `MAX_CACHE_SIZE` entries. When the limit
|
||||
//! is reached the entire cache is cleared before inserting the new entry. This
|
||||
//! is a simple "flush on full" strategy: it trades a brief cold-cache period
|
||||
//! for zero dependency on an external LRU crate. Because shadow keys are
|
||||
//! deterministically re-derivable from the salt and the public key, eviction
|
||||
//! is always safe — the next lookup simply re-derives and re-caches the key.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use hex;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use nostr::{Keys, SecretKey};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::error::ProxyError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Maximum number of shadow keys held in the in-memory cache at one time.
|
||||
/// Exceeding this limit triggers a full cache flush before the new entry is
|
||||
/// inserted, bounding worst-case memory use to roughly
|
||||
/// `MAX_CACHE_SIZE × ~200 bytes` ≈ 2 MB at the default.
|
||||
pub const MAX_CACHE_SIZE: usize = 10_000;
|
||||
|
||||
/// Manages deterministic shadow keypairs derived from external Nostr public keys.
|
||||
pub struct ShadowKeyManager {
|
||||
salt: Vec<u8>,
|
||||
cache: DashMap<String, Keys>,
|
||||
/// Approximate entry count. May briefly exceed `MAX_CACHE_SIZE` under
|
||||
/// concurrent inserts; the bound is soft but close in practice.
|
||||
cache_len: AtomicUsize,
|
||||
}
|
||||
|
||||
impl ShadowKeyManager {
|
||||
/// Create a new [`ShadowKeyManager`] with the given server-side salt.
|
||||
///
|
||||
/// Returns an error if `salt` is empty.
|
||||
pub fn new(salt: &[u8]) -> Result<Self, ProxyError> {
|
||||
if salt.is_empty() {
|
||||
return Err(ProxyError::KeyDerivation(
|
||||
"shadow key salt must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
salt: salt.to_vec(),
|
||||
cache: DashMap::new(),
|
||||
cache_len: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the shadow [`Keys`] for `external_pubkey`, deriving and caching them if needed.
|
||||
pub fn get_or_create(&self, external_pubkey: &str) -> Result<Keys, ProxyError> {
|
||||
if let Some(entry) = self.cache.get(external_pubkey) {
|
||||
return Ok(entry.clone());
|
||||
}
|
||||
|
||||
let keys = self.derive(external_pubkey)?;
|
||||
self.insert_bounded(external_pubkey.to_string(), keys.clone());
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Return cached shadow keys for `external_pubkey` without deriving new ones.
|
||||
pub fn lookup(&self, external_pubkey: &str) -> Option<Keys> {
|
||||
self.cache.get(external_pubkey).map(|e| e.clone())
|
||||
}
|
||||
|
||||
/// Returns the current number of cached entries.
|
||||
pub fn cache_len(&self) -> usize {
|
||||
self.cache_len.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Insert a key, evicting the entire cache first if it is at capacity.
|
||||
fn insert_bounded(&self, pubkey: String, keys: Keys) {
|
||||
if self.cache_len.load(Ordering::Relaxed) >= MAX_CACHE_SIZE {
|
||||
self.cache.clear();
|
||||
self.cache_len.store(0, Ordering::Relaxed);
|
||||
}
|
||||
self.cache.insert(pubkey, keys);
|
||||
self.cache_len.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn derive(&self, external_pubkey: &str) -> Result<Keys, ProxyError> {
|
||||
let pubkey_bytes = hex::decode(external_pubkey)
|
||||
.map_err(|e| ProxyError::InvalidPubkey(format!("hex decode failed: {e}")))?;
|
||||
|
||||
if pubkey_bytes.len() != 32 {
|
||||
return Err(ProxyError::InvalidPubkey(format!(
|
||||
"expected 32 bytes, got {}",
|
||||
pubkey_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// HMAC-SHA256(key=salt, msg=pubkey_bytes) — provides proper domain separation
|
||||
// and resistance to length-extension attacks vs. raw SHA-256(salt || pubkey).
|
||||
let mut mac = HmacSha256::new_from_slice(&self.salt)
|
||||
.map_err(|e| ProxyError::KeyDerivation(format!("HMAC init: {e}")))?;
|
||||
mac.update(&pubkey_bytes);
|
||||
let secret_bytes: [u8; 32] = mac.finalize().into_bytes().into();
|
||||
let secret_key = SecretKey::from_slice(&secret_bytes)
|
||||
.map_err(|e| ProxyError::KeyDerivation(e.to_string()))?;
|
||||
|
||||
Ok(Keys::new(secret_key))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PUBKEY_A: &str = "0101010101010101010101010101010101010101010101010101010101010101";
|
||||
const PUBKEY_B: &str = "0202020202020202020202020202020202020202020202020202020202020202";
|
||||
const TEST_SALT: &[u8] = b"test-server-salt-do-not-use-in-production";
|
||||
|
||||
fn mgr() -> ShadowKeyManager {
|
||||
ShadowKeyManager::new(TEST_SALT).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_salt_returns_error() {
|
||||
assert!(matches!(
|
||||
ShadowKeyManager::new(b""),
|
||||
Err(ProxyError::KeyDerivation(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_same_pubkey() {
|
||||
let m = mgr();
|
||||
let k1 = m.get_or_create(PUBKEY_A).unwrap();
|
||||
let k2 = m.get_or_create(PUBKEY_A).unwrap();
|
||||
assert_eq!(k1.public_key().to_hex(), k2.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_pubkeys_produce_different_shadows() {
|
||||
let m = mgr();
|
||||
let ka = m.get_or_create(PUBKEY_A).unwrap();
|
||||
let kb = m.get_or_create(PUBKEY_B).unwrap();
|
||||
assert_ne!(ka.public_key().to_hex(), kb.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pubkey_hex_rejected() {
|
||||
let m = mgr();
|
||||
assert!(matches!(
|
||||
m.get_or_create("not-hex!"),
|
||||
Err(ProxyError::InvalidPubkey(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_length_pubkey_rejected() {
|
||||
let m = mgr();
|
||||
assert!(matches!(
|
||||
m.get_or_create("01020304050607080910111213141516"),
|
||||
Err(ProxyError::InvalidPubkey(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_across_manager_instances() {
|
||||
let k1 = ShadowKeyManager::new(TEST_SALT)
|
||||
.unwrap()
|
||||
.get_or_create(PUBKEY_A)
|
||||
.unwrap();
|
||||
let k2 = ShadowKeyManager::new(TEST_SALT)
|
||||
.unwrap()
|
||||
.get_or_create(PUBKEY_A)
|
||||
.unwrap();
|
||||
assert_eq!(k1.public_key().to_hex(), k2.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_salts_produce_different_keys() {
|
||||
let k1 = ShadowKeyManager::new(b"salt-1")
|
||||
.unwrap()
|
||||
.get_or_create(PUBKEY_A)
|
||||
.unwrap();
|
||||
let k2 = ShadowKeyManager::new(b"salt-2")
|
||||
.unwrap()
|
||||
.get_or_create(PUBKEY_A)
|
||||
.unwrap();
|
||||
assert_ne!(k1.public_key().to_hex(), k2.public_key().to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_is_bounded_and_evicts_on_overflow() {
|
||||
// Use a tiny limit to exercise the eviction path without inserting 10k entries.
|
||||
// We test the logic by directly calling insert_bounded in a loop.
|
||||
let m = mgr();
|
||||
|
||||
// Fill up to MAX_CACHE_SIZE - 1 using synthetic keys (we bypass derive to
|
||||
// keep the test fast; we just need to verify the counter and eviction).
|
||||
// Instead, insert PUBKEY_A and PUBKEY_B repeatedly to verify that after
|
||||
// eviction the key is still derivable (deterministic re-derive).
|
||||
let k_before = m.get_or_create(PUBKEY_A).unwrap();
|
||||
assert_eq!(m.cache_len(), 1);
|
||||
|
||||
let k_after = m.get_or_create(PUBKEY_A).unwrap();
|
||||
assert_eq!(
|
||||
k_before.public_key().to_hex(),
|
||||
k_after.public_key().to_hex()
|
||||
);
|
||||
|
||||
// Verify cache_len never goes negative after a clear.
|
||||
assert!(m.cache_len() <= MAX_CACHE_SIZE);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,576 +0,0 @@
|
||||
//! Upstream relay client — single persistent WebSocket connection to the Buzz relay.
|
||||
//!
|
||||
//! Maintains one authenticated connection to the upstream Buzz relay, authenticated
|
||||
//! via a `proxy:submit` API token. Handles NIP-42 auth automatically and reconnects
|
||||
//! with exponential backoff on disconnect.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr::prelude::*;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Messages forwarded from the upstream relay to the server layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UpstreamEvent {
|
||||
/// A relay message to route to a downstream client (raw JSON text).
|
||||
RelayMessage(String),
|
||||
/// The upstream connection was lost (reconnect in progress).
|
||||
Disconnected,
|
||||
/// The upstream connection was (re)established and authenticated.
|
||||
Connected,
|
||||
}
|
||||
|
||||
/// Inner state shared across the `Arc`. Kept separate so `Arc<Inner>` can be
|
||||
/// moved into `'static` spawned tasks without capturing `&self`.
|
||||
struct Inner {
|
||||
relay_url: String,
|
||||
api_token: String,
|
||||
/// Keypair used to sign NIP-42 auth events. Generated once at construction
|
||||
/// so the auth pubkey is stable across reconnects within a process lifetime.
|
||||
auth_keys: Keys,
|
||||
/// Whether we're currently connected and authenticated.
|
||||
connected: RwLock<bool>,
|
||||
/// The EventId of the most recently sent auth event. Used to correlate
|
||||
/// OK responses: only an OK for this specific event ID marks us as authenticated.
|
||||
auth_event_id: tokio::sync::Mutex<Option<EventId>>,
|
||||
/// Active subscriptions: maps subscription_id → original REQ JSON.
|
||||
/// Replayed after reconnect so clients don't silently lose their subscriptions.
|
||||
active_subs: DashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Manages a single persistent, authenticated WebSocket connection to the upstream
|
||||
/// Buzz relay.
|
||||
///
|
||||
/// Clone-friendly: all state is behind an `Arc`.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use buzz_proxy::upstream::UpstreamClient;
|
||||
/// # use tokio::sync::mpsc;
|
||||
/// # async fn example() {
|
||||
/// let (inbound_tx, mut inbound_rx) = mpsc::channel(256);
|
||||
/// let client = UpstreamClient::new("ws://localhost:3000", "buzz_mytoken123");
|
||||
///
|
||||
/// // Spawn the connection loop in the background.
|
||||
/// let client2 = client.clone();
|
||||
/// tokio::spawn(async move { client2.run(inbound_tx).await });
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamClient {
|
||||
inner: Arc<Inner>,
|
||||
/// Sender for outbound messages (JSON strings) TO the relay.
|
||||
/// Cloning the client shares the same outbound channel.
|
||||
outbound_tx: mpsc::Sender<String>,
|
||||
/// Receiver end — wrapped in `Arc<Mutex>` so the run loop can take it
|
||||
/// without needing `&mut self`.
|
||||
outbound_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<String>>>,
|
||||
}
|
||||
|
||||
impl UpstreamClient {
|
||||
/// Create a new [`UpstreamClient`].
|
||||
///
|
||||
/// `relay_url` — WebSocket URL of the upstream Buzz relay (e.g. `ws://localhost:3000`).
|
||||
/// `api_token` — A `buzz_*` API token with the `proxy:submit` scope.
|
||||
pub fn new(relay_url: impl Into<String>, api_token: impl Into<String>) -> Self {
|
||||
Self::with_keys(relay_url, api_token, Keys::generate())
|
||||
}
|
||||
|
||||
/// Create a new upstream client with explicit auth keys.
|
||||
///
|
||||
/// Use this when the API token's `owner_pubkey` must match a specific keypair
|
||||
/// (e.g. the proxy's server key). The relay verifies that the NIP-42 auth
|
||||
/// event is signed by the token owner.
|
||||
pub fn with_keys(
|
||||
relay_url: impl Into<String>,
|
||||
api_token: impl Into<String>,
|
||||
auth_keys: Keys,
|
||||
) -> Self {
|
||||
let (outbound_tx, outbound_rx) = mpsc::channel::<String>(128);
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
relay_url: relay_url.into(),
|
||||
api_token: api_token.into(),
|
||||
auth_keys,
|
||||
connected: RwLock::new(false),
|
||||
auth_event_id: tokio::sync::Mutex::new(None),
|
||||
active_subs: DashMap::new(),
|
||||
}),
|
||||
outbound_tx,
|
||||
outbound_rx: Arc::new(tokio::sync::Mutex::new(outbound_rx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an event to the upstream relay.
|
||||
pub async fn send_event(&self, event: Event) -> Result<(), crate::ProxyError> {
|
||||
let msg = ClientMessage::event(event).as_json();
|
||||
self.outbound_tx
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|_| crate::ProxyError::Upstream("outbound channel closed".into()))
|
||||
}
|
||||
|
||||
/// Send a REQ subscription to the upstream relay.
|
||||
/// Also stores the subscription so it can be replayed on reconnect.
|
||||
pub async fn send_req(
|
||||
&self,
|
||||
sub_id: SubscriptionId,
|
||||
filters: Vec<Filter>,
|
||||
) -> Result<(), crate::ProxyError> {
|
||||
let msg = ClientMessage::req(sub_id.clone(), filters).as_json();
|
||||
self.inner
|
||||
.active_subs
|
||||
.insert(sub_id.to_string(), msg.clone());
|
||||
self.outbound_tx
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|_| crate::ProxyError::Upstream("outbound channel closed".into()))
|
||||
}
|
||||
|
||||
/// Send a CLOSE to the upstream relay.
|
||||
/// Also removes the subscription from the active set.
|
||||
pub async fn send_close(&self, sub_id: SubscriptionId) -> Result<(), crate::ProxyError> {
|
||||
self.inner.active_subs.remove(&sub_id.to_string());
|
||||
let msg = ClientMessage::close(sub_id).as_json();
|
||||
self.outbound_tx
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|_| crate::ProxyError::Upstream("outbound channel closed".into()))
|
||||
}
|
||||
|
||||
/// Returns `true` if the upstream connection is currently established and authenticated.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.inner.connected.try_read().map(|v| *v).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Run the upstream connection loop. Reconnects on disconnect with exponential
|
||||
/// backoff (1 → 2 → 4 → … → 30 seconds).
|
||||
///
|
||||
/// This method runs forever; spawn it in a background task.
|
||||
pub async fn run(self, inbound_tx: mpsc::Sender<UpstreamEvent>) {
|
||||
let mut backoff_secs: u64 = 1;
|
||||
|
||||
loop {
|
||||
match self.connect_once(&inbound_tx).await {
|
||||
Ok(()) => {
|
||||
info!("upstream connection closed cleanly");
|
||||
backoff_secs = 1;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("upstream connection error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = inbound_tx.send(UpstreamEvent::Disconnected).await;
|
||||
*self.inner.connected.write().await = false;
|
||||
|
||||
let delay = backoff_secs.min(30);
|
||||
warn!(delay_secs = delay, "reconnecting to upstream relay");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish one WebSocket connection, authenticate, and pump messages until
|
||||
/// the socket closes or an error occurs.
|
||||
async fn connect_once(
|
||||
&self,
|
||||
inbound_tx: &mpsc::Sender<UpstreamEvent>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!(url = %self.inner.relay_url, "connecting to upstream relay");
|
||||
|
||||
let (ws_stream, _response) =
|
||||
tokio_tungstenite::connect_async(self.inner.relay_url.as_str()).await?;
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
// Per-connection channel: the write loop owns the sink and drains this channel.
|
||||
let (write_tx, mut write_rx) = mpsc::channel::<String>(128);
|
||||
|
||||
// Spawn the WebSocket write loop. It owns `sink` and `write_rx`.
|
||||
let write_task = tokio::spawn(async move {
|
||||
while let Some(msg) = write_rx.recv().await {
|
||||
debug!("→ upstream: {msg}");
|
||||
if let Err(e) = sink.send(WsMessage::Text(msg.into())).await {
|
||||
error!("upstream write error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = sink.close().await;
|
||||
});
|
||||
|
||||
// Notify used to gate the bridge task until authentication succeeds.
|
||||
// This prevents outbound messages from being forwarded before the connection
|
||||
// is authenticated.
|
||||
let connected_notify = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
// Bridge task: forward from the shared outbound_rx to the per-connection write_tx.
|
||||
// Waits for the connected_notify signal before starting to forward, ensuring
|
||||
// no messages are sent before authentication completes.
|
||||
let outbound_rx_arc = Arc::clone(&self.outbound_rx);
|
||||
let bridge_write_tx = write_tx.clone();
|
||||
let bridge_notify = Arc::clone(&connected_notify);
|
||||
let bridge_task = tokio::spawn(async move {
|
||||
// Wait until authentication succeeds before forwarding any messages.
|
||||
bridge_notify.notified().await;
|
||||
let mut rx = outbound_rx_arc.lock().await;
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if bridge_write_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Read loop: relay → inbound_tx.
|
||||
let result = read_loop(
|
||||
&mut stream,
|
||||
&write_tx,
|
||||
inbound_tx,
|
||||
Arc::clone(&self.inner),
|
||||
connected_notify,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Clean up tasks.
|
||||
write_task.abort();
|
||||
bridge_task.abort();
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Extracted as a free function so it does not capture `&self` — it receives
|
||||
// only the `Arc<Inner>` it needs, which is `'static`.
|
||||
|
||||
async fn read_loop<S>(
|
||||
stream: &mut S,
|
||||
write_tx: &mpsc::Sender<String>,
|
||||
inbound_tx: &mpsc::Sender<UpstreamEvent>,
|
||||
inner: Arc<Inner>,
|
||||
connected_notify: Arc<tokio::sync::Notify>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
where
|
||||
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
let mut authenticated = false;
|
||||
|
||||
while let Some(frame) = stream.next().await {
|
||||
match frame? {
|
||||
WsMessage::Text(text) => {
|
||||
let text_str = text.as_str();
|
||||
debug!("← upstream: {text_str}");
|
||||
|
||||
let relay_msg = match RelayMessage::from_json(text_str) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("failed to parse relay message: {e} — raw: {text_str}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match relay_msg {
|
||||
RelayMessage::Auth { ref challenge } => {
|
||||
debug!("received AUTH challenge: {challenge}");
|
||||
match respond_to_auth_challenge(challenge, &inner, write_tx).await {
|
||||
Ok(()) => debug!("sent AUTH response"),
|
||||
Err(e) => {
|
||||
error!("failed to send AUTH response: {e}");
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RelayMessage::Ok {
|
||||
event_id,
|
||||
ref status,
|
||||
ref message,
|
||||
} if !authenticated => {
|
||||
// Only treat this OK as the auth response if the event_id
|
||||
// matches the auth event we sent. If it's for something else
|
||||
// (e.g. a queued event that got through), forward it downstream.
|
||||
let stored_auth_id = *inner.auth_event_id.lock().await;
|
||||
if stored_auth_id.as_ref() == Some(&event_id) {
|
||||
if *status {
|
||||
info!("upstream relay authenticated successfully");
|
||||
authenticated = true;
|
||||
*inner.connected.write().await = true;
|
||||
|
||||
// Replay any active subscriptions from before the reconnect.
|
||||
let subs: Vec<String> = inner
|
||||
.active_subs
|
||||
.iter()
|
||||
.map(|entry| entry.value().clone())
|
||||
.collect();
|
||||
if !subs.is_empty() {
|
||||
info!(
|
||||
count = subs.len(),
|
||||
"replaying active subscriptions after reconnect"
|
||||
);
|
||||
for req_json in subs {
|
||||
if let Err(e) = write_tx.send(req_json).await {
|
||||
error!("failed to replay subscription: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal the bridge task to start forwarding.
|
||||
connected_notify.notify_one();
|
||||
|
||||
let _ = inbound_tx.send(UpstreamEvent::Connected).await;
|
||||
} else {
|
||||
error!("upstream auth rejected: {message}");
|
||||
return Err(Box::new(crate::ProxyError::Auth(format!(
|
||||
"upstream auth rejected: {message}"
|
||||
))));
|
||||
}
|
||||
} else {
|
||||
// OK for a different event — forward downstream as normal.
|
||||
debug!(
|
||||
"received OK for non-auth event {} while not yet authenticated — forwarding",
|
||||
event_id.to_hex()
|
||||
);
|
||||
if inbound_tx
|
||||
.send(UpstreamEvent::RelayMessage(text_str.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
debug!("inbound_tx closed — stopping upstream read loop");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_other => {
|
||||
if inbound_tx
|
||||
.send(UpstreamEvent::RelayMessage(text_str.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
debug!("inbound_tx closed — stopping upstream read loop");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WsMessage::Ping(data) => {
|
||||
// tokio-tungstenite automatically responds to pings at the transport
|
||||
// layer when using connect_async, so we just log and continue.
|
||||
debug!(
|
||||
"received PING ({} bytes) — tungstenite auto-pongs",
|
||||
data.len()
|
||||
);
|
||||
}
|
||||
|
||||
WsMessage::Close(frame) => {
|
||||
info!("upstream relay closed connection: {:?}", frame);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WsMessage::Binary(_) | WsMessage::Pong(_) | WsMessage::Frame(_) => {
|
||||
// Ignore binary, pong, and raw frames.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build and send a NIP-42 kind:22242 auth event in response to a challenge.
|
||||
/// Stores the auth event's ID in `inner.auth_event_id` so the read loop can
|
||||
/// correlate the OK response to this specific event.
|
||||
async fn respond_to_auth_challenge(
|
||||
challenge: &str,
|
||||
inner: &Inner,
|
||||
write_tx: &mpsc::Sender<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let relay_tag = Tag::parse(["relay", &inner.relay_url])
|
||||
.map_err(|e| crate::ProxyError::Auth(format!("relay tag: {e}")))?;
|
||||
let challenge_tag = Tag::parse(["challenge", challenge])
|
||||
.map_err(|e| crate::ProxyError::Auth(format!("challenge tag: {e}")))?;
|
||||
let token_tag = Tag::parse(["auth_token", &inner.api_token])
|
||||
.map_err(|e| crate::ProxyError::Auth(format!("auth_token tag: {e}")))?;
|
||||
|
||||
let auth_event = EventBuilder::new(
|
||||
Kind::Authentication, // kind:22242
|
||||
"",
|
||||
)
|
||||
.tags([relay_tag, challenge_tag, token_tag])
|
||||
.sign_with_keys(&inner.auth_keys)
|
||||
.map_err(|e| crate::ProxyError::Auth(format!("sign auth event: {e}")))?;
|
||||
|
||||
// Store the auth event ID so the read loop can correlate the OK response.
|
||||
*inner.auth_event_id.lock().await = Some(auth_event.id);
|
||||
|
||||
let msg = ClientMessage::auth(auth_event).as_json();
|
||||
write_tx.send(msg).await.map_err(|_| {
|
||||
Box::new(crate::ProxyError::Upstream(
|
||||
"write channel closed during auth".into(),
|
||||
)) as Box<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_client_starts_disconnected() {
|
||||
let client = UpstreamClient::new("ws://localhost:3000", "buzz_test");
|
||||
assert!(!client.is_connected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_methods_queue_correctly_formatted_json() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let client = UpstreamClient::new("ws://localhost:3000", "buzz_test");
|
||||
|
||||
// Queue an EVENT message.
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "hello")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let event_id = event.id;
|
||||
client.send_event(event).await.unwrap();
|
||||
|
||||
// Queue a REQ message.
|
||||
let sub_id = SubscriptionId::new("test-sub");
|
||||
let filters = vec![Filter::new().kind(Kind::TextNote)];
|
||||
client.send_req(sub_id.clone(), filters).await.unwrap();
|
||||
|
||||
// Queue a CLOSE message.
|
||||
client.send_close(sub_id).await.unwrap();
|
||||
|
||||
// Drain the channel and verify.
|
||||
let mut rx = client.outbound_rx.lock().await;
|
||||
let event_msg = rx.recv().await.unwrap();
|
||||
let req_msg = rx.recv().await.unwrap();
|
||||
let close_msg = rx.recv().await.unwrap();
|
||||
|
||||
assert!(
|
||||
event_msg.contains("EVENT"),
|
||||
"expected EVENT in: {event_msg}"
|
||||
);
|
||||
assert!(
|
||||
event_msg.contains(&event_id.to_hex()),
|
||||
"expected event id in: {event_msg}"
|
||||
);
|
||||
assert!(req_msg.contains("REQ"), "expected REQ in: {req_msg}");
|
||||
assert!(
|
||||
req_msg.contains("test-sub"),
|
||||
"expected sub id in: {req_msg}"
|
||||
);
|
||||
assert!(
|
||||
close_msg.contains("CLOSE"),
|
||||
"expected CLOSE in: {close_msg}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_event_has_required_tags() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let inner = Inner {
|
||||
relay_url: "ws://localhost:3000".into(),
|
||||
api_token: "buzz_mytoken".into(),
|
||||
auth_keys: Keys::generate(),
|
||||
connected: RwLock::new(false),
|
||||
auth_event_id: tokio::sync::Mutex::new(None),
|
||||
active_subs: DashMap::new(),
|
||||
};
|
||||
let (write_tx, mut write_rx) = mpsc::channel::<String>(8);
|
||||
|
||||
respond_to_auth_challenge("test-challenge-abc", &inner, &write_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg = write_rx.recv().await.unwrap();
|
||||
// Should be ["AUTH", <event>]
|
||||
assert!(msg.contains("AUTH"), "expected AUTH in: {msg}");
|
||||
assert!(msg.contains("22242"), "expected kind 22242 in: {msg}");
|
||||
assert!(
|
||||
msg.contains("test-challenge-abc"),
|
||||
"expected challenge in: {msg}"
|
||||
);
|
||||
assert!(msg.contains("buzz_mytoken"), "expected token in: {msg}");
|
||||
assert!(
|
||||
msg.contains("ws://localhost:3000"),
|
||||
"expected relay url in: {msg}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_event_id_stored_after_challenge_response() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let inner = Inner {
|
||||
relay_url: "ws://localhost:3000".into(),
|
||||
api_token: "buzz_mytoken".into(),
|
||||
auth_keys: Keys::generate(),
|
||||
connected: RwLock::new(false),
|
||||
auth_event_id: tokio::sync::Mutex::new(None),
|
||||
active_subs: DashMap::new(),
|
||||
};
|
||||
let (write_tx, mut write_rx) = mpsc::channel::<String>(8);
|
||||
|
||||
// Before challenge: no auth_event_id stored.
|
||||
assert!(inner.auth_event_id.lock().await.is_none());
|
||||
|
||||
respond_to_auth_challenge("my-challenge", &inner, &write_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// After challenge: auth_event_id should be set.
|
||||
let stored_id = *inner.auth_event_id.lock().await;
|
||||
assert!(
|
||||
stored_id.is_some(),
|
||||
"auth_event_id should be set after challenge"
|
||||
);
|
||||
|
||||
// The AUTH message should contain the event ID.
|
||||
let msg = write_rx.recv().await.unwrap();
|
||||
let stored_hex = stored_id.unwrap().to_hex();
|
||||
assert!(
|
||||
msg.contains(&stored_hex),
|
||||
"AUTH message should contain the event ID {stored_hex}: {msg}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_req_tracks_subscription() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let client = UpstreamClient::new("ws://localhost:3000", "buzz_test");
|
||||
|
||||
assert_eq!(client.inner.active_subs.len(), 0);
|
||||
|
||||
let sub_id = SubscriptionId::new("tracked-sub");
|
||||
let filters = vec![Filter::new().kind(Kind::TextNote)];
|
||||
client.send_req(sub_id.clone(), filters).await.unwrap();
|
||||
|
||||
assert_eq!(client.inner.active_subs.len(), 1);
|
||||
assert!(
|
||||
client.inner.active_subs.contains_key("tracked-sub"),
|
||||
"subscription should be tracked"
|
||||
);
|
||||
|
||||
// CLOSE should remove it.
|
||||
client.send_close(sub_id).await.unwrap();
|
||||
assert_eq!(client.inner.active_subs.len(), 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,48 @@
|
||||
//! universal delivery-enforcement point, so dropping the stale key is
|
||||
//! sufficient: the next read re-fetches authoritative state from the DB.
|
||||
|
||||
use buzz_core::{CommunityId, TenantContext};
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Redis pub/sub channel for cache-invalidation messages. Distinct from the
|
||||
/// `buzz:channel:*` event topic so the two streams never interfere.
|
||||
pub const CACHE_INVALIDATION_CHANNEL: &str = "buzz:cache-invalidate";
|
||||
use crate::topic::BUZZ_PREFIX;
|
||||
|
||||
/// Tenant-local Redis pub/sub channel suffix for cache-invalidation messages.
|
||||
pub const CACHE_INVALIDATION_SUFFIX: &str = "cache-invalidate";
|
||||
|
||||
/// Pattern used by the subscriber to receive cache invalidations for all
|
||||
/// communities this pod may have cached locally.
|
||||
pub const CACHE_INVALIDATION_PATTERN: &str = "buzz:*:cache-invalidate";
|
||||
|
||||
/// Redis pub/sub channel for cache-invalidation messages under `ctx`.
|
||||
pub fn cache_invalidation_channel(ctx: &TenantContext) -> String {
|
||||
format!(
|
||||
"{BUZZ_PREFIX}:{}:{CACHE_INVALIDATION_SUFFIX}",
|
||||
ctx.community()
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a cache-invalidation Redis channel into its scoped community id.
|
||||
pub fn parse_cache_invalidation_channel(channel: &str) -> Option<CommunityId> {
|
||||
let mut parts = channel.split(':');
|
||||
if parts.next()? != BUZZ_PREFIX {
|
||||
return None;
|
||||
}
|
||||
let community_id = Uuid::parse_str(parts.next()?).ok()?;
|
||||
if parts.next()? != CACHE_INVALIDATION_SUFFIX {
|
||||
return None;
|
||||
}
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(CommunityId::from_uuid(community_id))
|
||||
}
|
||||
|
||||
/// A cache-key drop to apply on every pod. Each variant mirrors exactly one of
|
||||
/// the relay's local `invalidate_*` operations.
|
||||
/// the relay's local `invalidate_*` operations. The community is carried by
|
||||
/// [`ScopedCacheInvalidation`], not by the tenant-local operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "op")]
|
||||
pub enum CacheInvalidation {
|
||||
@@ -47,19 +78,28 @@ pub enum CacheInvalidation {
|
||||
ChannelDeleted,
|
||||
}
|
||||
|
||||
/// A cache invalidation received from a community-scoped Redis channel.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScopedCacheInvalidation {
|
||||
/// Community whose local cache key should be dropped.
|
||||
pub community_id: CommunityId,
|
||||
/// Tenant-local cache invalidation operation.
|
||||
pub invalidation: CacheInvalidation,
|
||||
}
|
||||
|
||||
/// Initial reconnect backoff (1 second).
|
||||
const BACKOFF_INITIAL_SECS: u64 = 1;
|
||||
/// Maximum reconnect backoff (30 seconds).
|
||||
const BACKOFF_MAX_SECS: u64 = 30;
|
||||
|
||||
/// Subscribes to `buzz:cache-invalidate` and forwards drops to the broadcast.
|
||||
/// Subscribes to `buzz:*:cache-invalidate` and forwards scoped drops to the broadcast.
|
||||
///
|
||||
/// Mirrors `subscriber::run_subscriber`: a reconnect loop with exponential
|
||||
/// backoff (1s → 2s → 4s → … → 30s max). Never returns — runs for the lifetime
|
||||
/// of the relay.
|
||||
pub async fn run_cache_invalidation_subscriber(
|
||||
redis_url: String,
|
||||
broadcast_tx: broadcast::Sender<CacheInvalidation>,
|
||||
broadcast_tx: broadcast::Sender<ScopedCacheInvalidation>,
|
||||
) {
|
||||
let mut backoff_secs = BACKOFF_INITIAL_SECS;
|
||||
|
||||
@@ -87,19 +127,25 @@ pub async fn run_cache_invalidation_subscriber(
|
||||
|
||||
async fn connect_and_subscribe(
|
||||
redis_url: &str,
|
||||
broadcast_tx: &broadcast::Sender<CacheInvalidation>,
|
||||
broadcast_tx: &broadcast::Sender<ScopedCacheInvalidation>,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let client = redis::Client::open(redis_url)?;
|
||||
let mut conn = client.get_async_pubsub().await?;
|
||||
|
||||
conn.subscribe(CACHE_INVALIDATION_CHANNEL).await?;
|
||||
conn.psubscribe(CACHE_INVALIDATION_PATTERN).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Redis cache-invalidation subscriber connected — listening on {CACHE_INVALIDATION_CHANNEL}"
|
||||
"Redis cache-invalidation subscriber connected — listening on {CACHE_INVALIDATION_PATTERN}"
|
||||
);
|
||||
|
||||
let mut stream = conn.on_message();
|
||||
while let Some(msg) = stream.next().await {
|
||||
let channel = msg.get_channel_name();
|
||||
let Some(community_id) = parse_cache_invalidation_channel(channel) else {
|
||||
tracing::warn!("Received cache-invalidation message on unexpected channel: {channel}");
|
||||
continue;
|
||||
};
|
||||
|
||||
let payload: String = match msg.get_payload() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
@@ -116,7 +162,12 @@ async fn connect_and_subscribe(
|
||||
}
|
||||
};
|
||||
|
||||
if broadcast_tx.send(invalidation).is_err() {
|
||||
let scoped = ScopedCacheInvalidation {
|
||||
community_id,
|
||||
invalidation,
|
||||
};
|
||||
|
||||
if broadcast_tx.send(scoped).is_err() {
|
||||
tracing::trace!("No cache-invalidation receivers — message dropped");
|
||||
}
|
||||
}
|
||||
@@ -128,6 +179,46 @@ async fn connect_and_subscribe(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx(id: u128, host: &str) -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_invalidation_channel_is_community_scoped() {
|
||||
let community_a = ctx(0xaaaa, "a.example");
|
||||
let community_b = ctx(0xbbbb, "b.example");
|
||||
|
||||
assert_eq!(
|
||||
cache_invalidation_channel(&community_a),
|
||||
format!("buzz:{}:cache-invalidate", community_a.community())
|
||||
);
|
||||
assert_ne!(
|
||||
cache_invalidation_channel(&community_a),
|
||||
cache_invalidation_channel(&community_b)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_cache_invalidation_channel() {
|
||||
let community_id = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
||||
let raw = format!("buzz:{community_id}:cache-invalidate");
|
||||
|
||||
assert_eq!(parse_cache_invalidation_channel(&raw), Some(community_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_cache_invalidation_channels() {
|
||||
for raw in [
|
||||
"buzz:cache-invalidate",
|
||||
"buzz:not-a-uuid:cache-invalidate",
|
||||
"not-buzz:00000000-0000-0000-0000-00000000aaaa:cache-invalidate",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa:cache-invalidate:extra",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa:channel:00000000-0000-0000-0000-00000000bbbb",
|
||||
] {
|
||||
assert_eq!(parse_cache_invalidation_channel(raw), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_roundtrips_through_json() {
|
||||
let msg = CacheInvalidation::Membership {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! ├── deadpool-redis pool → PUBLISH, SET, ZADD, etc.
|
||||
//! │
|
||||
//! └── dedicated redis::aio::PubSub connection (NOT from pool)
|
||||
//! └── PSUBSCRIBE buzz:channel:*
|
||||
//! └── dynamic SUBSCRIBE buzz:{community}:channel:{id} / buzz:{community}:global
|
||||
//! └── run_subscriber() → broadcast::channel(4096) → N WS receivers
|
||||
//! ```
|
||||
//!
|
||||
@@ -25,6 +25,9 @@
|
||||
pub mod cache_invalidation;
|
||||
/// Error types for pub/sub operations.
|
||||
pub mod error;
|
||||
/// Redis-backed NIP-98 replay seen-set.
|
||||
pub mod nip98_replay;
|
||||
pub use nip98_replay::RedisNip98ReplayGuard;
|
||||
/// Online/offline presence tracking in Redis.
|
||||
pub mod presence;
|
||||
/// Redis PUBLISH for channel event fan-out.
|
||||
@@ -33,23 +36,31 @@ pub mod publisher;
|
||||
pub mod rate_limiter;
|
||||
/// Redis SUBSCRIBE for channel event delivery.
|
||||
pub mod subscriber;
|
||||
/// Community-scoped Redis event topics.
|
||||
pub mod topic;
|
||||
/// Typing indicator tracking in Redis.
|
||||
pub use error::PubSubError;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::PublicKey;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||
|
||||
use crate::cache_invalidation::{CacheInvalidation, CACHE_INVALIDATION_CHANNEL};
|
||||
use crate::cache_invalidation::{
|
||||
cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation,
|
||||
};
|
||||
pub use crate::topic::{channel_key, global_key, EventTopic, EventTopicKey};
|
||||
|
||||
/// A Nostr event received on a specific channel, broadcast to local subscribers.
|
||||
/// A Nostr event received on a scoped Redis event topic, broadcast to local subscribers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelEvent {
|
||||
/// Channel the event belongs to.
|
||||
pub channel_id: Uuid,
|
||||
/// Server-resolved community that scoped the Redis topic.
|
||||
pub community_id: buzz_core::CommunityId,
|
||||
/// Tenant-local routing scope for this event.
|
||||
pub topic: EventTopic,
|
||||
/// The Nostr event payload.
|
||||
pub event: nostr::Event,
|
||||
}
|
||||
@@ -59,15 +70,27 @@ pub struct ChannelEvent {
|
||||
pub struct PubSubConfig {
|
||||
/// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
|
||||
pub redis_url: String,
|
||||
/// Delay before unsubscribing after the last local interest is released.
|
||||
pub unsubscribe_debounce: Duration,
|
||||
}
|
||||
|
||||
impl PubSubConfig {
|
||||
/// Default delay before unsubscribing after the last local interest is released.
|
||||
pub const DEFAULT_UNSUBSCRIBE_DEBOUNCE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Creates a new `PubSubConfig` with the given Redis URL.
|
||||
pub fn new(redis_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
redis_url: redis_url.into(),
|
||||
unsubscribe_debounce: Self::DEFAULT_UNSUBSCRIBE_DEBOUNCE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the unsubscribe debounce delay.
|
||||
pub fn with_unsubscribe_debounce(mut self, debounce: Duration) -> Self {
|
||||
self.unsubscribe_debounce = debounce;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Central pub/sub manager for a Buzz relay instance.
|
||||
@@ -75,19 +98,38 @@ pub struct PubSubManager {
|
||||
pool: deadpool_redis::Pool,
|
||||
/// Redis URL used by the reconnect loop to re-establish pub/sub connections.
|
||||
redis_url: String,
|
||||
/// Delay before unsubscribing after the last local interest is released.
|
||||
unsubscribe_debounce: Duration,
|
||||
/// Local desired topic refcounts; source of truth across Redis reconnects.
|
||||
desired_topics: subscriber::DesiredTopics,
|
||||
subscription_tx: mpsc::Sender<subscriber::SubscriptionCommand>,
|
||||
subscription_rx: Mutex<Option<mpsc::Receiver<subscriber::SubscriptionCommand>>>,
|
||||
broadcast_tx: broadcast::Sender<ChannelEvent>,
|
||||
cache_invalidation_tx: broadcast::Sender<CacheInvalidation>,
|
||||
cache_invalidation_tx: broadcast::Sender<ScopedCacheInvalidation>,
|
||||
}
|
||||
|
||||
impl PubSubManager {
|
||||
/// Creates a new `PubSubManager` connected to the given Redis URL.
|
||||
pub async fn new(redis_url: &str, pool: deadpool_redis::Pool) -> Result<Self, PubSubError> {
|
||||
Self::with_config(PubSubConfig::new(redis_url), pool).await
|
||||
}
|
||||
|
||||
/// Creates a new `PubSubManager` using explicit pub/sub configuration.
|
||||
pub async fn with_config(
|
||||
config: PubSubConfig,
|
||||
pool: deadpool_redis::Pool,
|
||||
) -> Result<Self, PubSubError> {
|
||||
let (broadcast_tx, _) = broadcast::channel(4096);
|
||||
let (cache_invalidation_tx, _) = broadcast::channel(4096);
|
||||
let (subscription_tx, subscription_rx) = mpsc::channel(4096);
|
||||
|
||||
Ok(Self {
|
||||
pool,
|
||||
redis_url: redis_url.to_string(),
|
||||
redis_url: config.redis_url,
|
||||
unsubscribe_debounce: config.unsubscribe_debounce,
|
||||
desired_topics: Arc::new(Mutex::new(HashMap::new())),
|
||||
subscription_tx,
|
||||
subscription_rx: Mutex::new(Some(subscription_rx)),
|
||||
broadcast_tx,
|
||||
cache_invalidation_tx,
|
||||
})
|
||||
@@ -98,7 +140,18 @@ impl PubSubManager {
|
||||
/// Runs forever — spawn this in a background task. The loop reconnects
|
||||
/// with exponential backoff on Redis disconnect (1s → 2s → 4s → … → 30s).
|
||||
pub async fn run_subscriber(self: Arc<Self>) {
|
||||
subscriber::run_subscriber(self.redis_url.clone(), self.broadcast_tx.clone()).await;
|
||||
let Some(subscription_rx) = self.subscription_rx.lock().await.take() else {
|
||||
tracing::error!("Redis pub/sub subscriber already started");
|
||||
return;
|
||||
};
|
||||
|
||||
subscriber::run_subscriber(
|
||||
self.redis_url.clone(),
|
||||
self.broadcast_tx.clone(),
|
||||
self.desired_topics.clone(),
|
||||
subscription_rx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Starts the cache-invalidation subscriber loop with automatic
|
||||
@@ -116,8 +169,78 @@ impl PubSubManager {
|
||||
self.broadcast_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Retain local interest in a scoped Redis event topic.
|
||||
///
|
||||
/// The first retain for a topic asks the subscriber task to `SUBSCRIBE`.
|
||||
/// Additional retains only increment the local desired refcount.
|
||||
pub async fn retain_topic(&self, ctx: &TenantContext, topic: EventTopic) {
|
||||
let topic_key = EventTopicKey::from_context(ctx, topic);
|
||||
let should_subscribe = {
|
||||
let mut desired = self.desired_topics.lock().await;
|
||||
let count = desired.entry(topic_key).or_insert(0);
|
||||
let was_zero = *count == 0;
|
||||
*count += 1;
|
||||
was_zero
|
||||
};
|
||||
|
||||
if should_subscribe {
|
||||
let _ = self
|
||||
.subscription_tx
|
||||
.send(subscriber::SubscriptionCommand::Subscribe(topic_key))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release local interest in a scoped Redis event topic.
|
||||
///
|
||||
/// When the last retain is released, unsubscribe is delayed by the configured
|
||||
/// debounce. If another retain arrives during that delay, the pending
|
||||
/// unsubscribe becomes a no-op.
|
||||
pub async fn release_topic(&self, ctx: &TenantContext, topic: EventTopic) {
|
||||
let topic_key = EventTopicKey::from_context(ctx, topic);
|
||||
let became_zero = {
|
||||
let mut desired = self.desired_topics.lock().await;
|
||||
let Some(count) = desired.get_mut(&topic_key) else {
|
||||
tracing::warn!(?topic_key, "release_topic called for unretained topic");
|
||||
return;
|
||||
};
|
||||
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
desired.remove(&topic_key);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if became_zero {
|
||||
let tx = self.subscription_tx.clone();
|
||||
let debounce = self.unsubscribe_debounce;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(debounce).await;
|
||||
let _ = tx
|
||||
.send(subscriber::SubscriptionCommand::UnsubscribeIfIdle(
|
||||
topic_key,
|
||||
))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Current local desired refcount for tests and metrics.
|
||||
pub async fn topic_refcount(&self, ctx: &TenantContext, topic: EventTopic) -> usize {
|
||||
let topic_key = EventTopicKey::from_context(ctx, topic);
|
||||
self.desired_topics
|
||||
.lock()
|
||||
.await
|
||||
.get(&topic_key)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Returns a new broadcast receiver for cross-pod cache-invalidation drops.
|
||||
pub fn subscribe_cache_invalidations(&self) -> broadcast::Receiver<CacheInvalidation> {
|
||||
pub fn subscribe_cache_invalidations(&self) -> broadcast::Receiver<ScopedCacheInvalidation> {
|
||||
self.cache_invalidation_tx.subscribe()
|
||||
}
|
||||
|
||||
@@ -127,12 +250,13 @@ impl PubSubManager {
|
||||
/// DB confirmation, so callers may spawn this without awaiting delivery.
|
||||
pub async fn publish_cache_invalidation(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
invalidation: &CacheInvalidation,
|
||||
) -> Result<i64, PubSubError> {
|
||||
let mut conn = self.pool.get().await?;
|
||||
let payload = serde_json::to_string(invalidation)?;
|
||||
let subscriber_count: i64 = redis::cmd("PUBLISH")
|
||||
.arg(CACHE_INVALIDATION_CHANNEL)
|
||||
.arg(cache_invalidation_channel(ctx))
|
||||
.arg(&payload)
|
||||
.query_async(&mut conn)
|
||||
.await?;
|
||||
@@ -142,9 +266,9 @@ impl PubSubManager {
|
||||
/// Publish an event to the Redis channel. Returns subscriber count.
|
||||
///
|
||||
/// Routing note (NIP-ER author-private reminders): events are keyed by
|
||||
/// `channel_id` (`buzz:channel:{id}`), and every relay node's subscriber
|
||||
/// `PSUBSCRIBE buzz:channel:*` — so the channel key is a routing label, not
|
||||
/// an isolation boundary; every node already receives every published event.
|
||||
/// `buzz:{community}:channel:{id}` / `buzz:{community}:global`, and
|
||||
/// relay nodes dynamically subscribe only to topics with local interest —
|
||||
/// so the topic key is a routing label, not an isolation boundary.
|
||||
/// Author-private reminders (kind:30300, stored under the nil channel
|
||||
/// sentinel) are therefore NOT protected by per-author Redis routing, and
|
||||
/// adding it would be pointless: the reminder's author may be connected to
|
||||
@@ -156,33 +280,48 @@ impl PubSubManager {
|
||||
/// domain; the ciphertext is NIP-44-encrypted to the author regardless.
|
||||
pub async fn publish_event(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
ctx: &TenantContext,
|
||||
topic: EventTopic,
|
||||
event: &nostr::Event,
|
||||
) -> Result<i64, PubSubError> {
|
||||
publisher::publish_event(&self.pool, channel_id, event).await
|
||||
publisher::publish_event(&self.pool, ctx, topic, event).await
|
||||
}
|
||||
|
||||
/// Set presence with 60s TTL. Call on connect and every 30s heartbeat.
|
||||
pub async fn set_presence(&self, pubkey: &PublicKey, status: &str) -> Result<(), PubSubError> {
|
||||
presence::set_presence(&self.pool, pubkey, status).await
|
||||
pub async fn set_presence(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
status: &str,
|
||||
) -> Result<(), PubSubError> {
|
||||
presence::set_presence(&self.pool, ctx, pubkey, status).await
|
||||
}
|
||||
|
||||
/// Remove presence for `pubkey`. Call on clean disconnect.
|
||||
pub async fn clear_presence(&self, pubkey: &PublicKey) -> Result<(), PubSubError> {
|
||||
presence::clear_presence(&self.pool, pubkey).await
|
||||
pub async fn clear_presence(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> Result<(), PubSubError> {
|
||||
presence::clear_presence(&self.pool, ctx, pubkey).await
|
||||
}
|
||||
|
||||
/// Returns the current presence status for `pubkey`, or `None` if not set.
|
||||
pub async fn get_presence(&self, pubkey: &PublicKey) -> Result<Option<String>, PubSubError> {
|
||||
presence::get_presence(&self.pool, pubkey).await
|
||||
pub async fn get_presence(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> Result<Option<String>, PubSubError> {
|
||||
presence::get_presence(&self.pool, ctx, pubkey).await
|
||||
}
|
||||
|
||||
/// Returns presence statuses for multiple pubkeys as a `pubkey_hex → status` map.
|
||||
pub async fn get_presence_bulk(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkeys: &[PublicKey],
|
||||
) -> Result<HashMap<String, String>, PubSubError> {
|
||||
presence::get_presence_bulk(&self.pool, pubkeys).await
|
||||
presence::get_presence_bulk(&self.pool, ctx, pubkeys).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +338,9 @@ pub(crate) mod test_util {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::make_test_pool;
|
||||
use buzz_core::{CommunityId, TenantContext};
|
||||
use nostr::{EventBuilder, Keys, Kind};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn make_manager() -> Arc<PubSubManager> {
|
||||
let pool = make_test_pool();
|
||||
@@ -210,6 +351,10 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn ctx(id: u128, host: &str) -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn test_publish_and_subscribe_roundtrip() {
|
||||
@@ -220,6 +365,7 @@ mod tests {
|
||||
tokio::spawn(async move { manager_clone.run_subscriber().await });
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
let channel_id = Uuid::new_v4();
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "hello pubsub")
|
||||
@@ -229,7 +375,11 @@ mod tests {
|
||||
let event_id = event.id;
|
||||
|
||||
manager
|
||||
.publish_event(channel_id, &event)
|
||||
.retain_topic(&ctx, EventTopic::Channel(channel_id))
|
||||
.await;
|
||||
|
||||
manager
|
||||
.publish_event(&ctx, EventTopic::Channel(channel_id), &event)
|
||||
.await
|
||||
.expect("publish failed");
|
||||
|
||||
@@ -238,7 +388,8 @@ mod tests {
|
||||
.expect("timeout")
|
||||
.expect("channel closed");
|
||||
|
||||
assert_eq!(received.channel_id, channel_id);
|
||||
assert_eq!(received.community_id, ctx.community());
|
||||
assert_eq!(received.topic, EventTopic::Channel(channel_id));
|
||||
assert_eq!(received.event.id, event_id);
|
||||
}
|
||||
|
||||
@@ -259,8 +410,10 @@ mod tests {
|
||||
pubkey: pubkey.clone(),
|
||||
};
|
||||
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
|
||||
manager
|
||||
.publish_cache_invalidation(&sent)
|
||||
.publish_cache_invalidation(&ctx, &sent)
|
||||
.await
|
||||
.expect("publish failed");
|
||||
|
||||
@@ -269,7 +422,13 @@ mod tests {
|
||||
.expect("timeout")
|
||||
.expect("channel closed");
|
||||
|
||||
assert_eq!(received, sent);
|
||||
assert_eq!(
|
||||
received,
|
||||
ScopedCacheInvalidation {
|
||||
community_id: ctx.community(),
|
||||
invalidation: sent,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -277,19 +436,20 @@ mod tests {
|
||||
async fn test_presence_set_and_get() {
|
||||
let pool = make_test_pool();
|
||||
let pubkey = Keys::generate().public_key();
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
|
||||
let status = presence::get_presence(&pool, &pubkey).await.unwrap();
|
||||
let status = presence::get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert!(status.is_none());
|
||||
|
||||
presence::set_presence(&pool, &pubkey, "online")
|
||||
presence::set_presence(&pool, &ctx, &pubkey, "online")
|
||||
.await
|
||||
.unwrap();
|
||||
let status = presence::get_presence(&pool, &pubkey).await.unwrap();
|
||||
let status = presence::get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert_eq!(status.as_deref(), Some("online"));
|
||||
|
||||
let mut conn = pool.get().await.unwrap();
|
||||
let ttl: i64 = redis::cmd("TTL")
|
||||
.arg(presence::presence_key(&pubkey))
|
||||
.arg(presence::presence_key(&ctx, &pubkey))
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -299,8 +459,130 @@ mod tests {
|
||||
presence::PRESENCE_TTL_SECS
|
||||
);
|
||||
|
||||
presence::clear_presence(&pool, &pubkey).await.unwrap();
|
||||
let status = presence::get_presence(&pool, &pubkey).await.unwrap();
|
||||
presence::clear_presence(&pool, &ctx, &pubkey)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = presence::get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert!(status.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn same_channel_id_in_two_communities_release_one_keeps_other_live() {
|
||||
let pool = make_test_pool();
|
||||
let manager = Arc::new(
|
||||
PubSubManager::with_config(
|
||||
PubSubConfig::new("redis://127.0.0.1:6379")
|
||||
.with_unsubscribe_debounce(Duration::from_millis(25)),
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create PubSubManager"),
|
||||
);
|
||||
let mut rx = manager.subscribe_local();
|
||||
|
||||
let manager_clone = manager.clone();
|
||||
tokio::spawn(async move { manager_clone.run_subscriber().await });
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
let ctx_a = ctx(0xaaaa, "a.example");
|
||||
let ctx_b = ctx(0xbbbb, "b.example");
|
||||
let channel_id = Uuid::from_u128(0xcccc);
|
||||
let topic = EventTopic::Channel(channel_id);
|
||||
|
||||
manager.retain_topic(&ctx_a, topic).await;
|
||||
manager.retain_topic(&ctx_b, topic).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(manager.topic_refcount(&ctx_a, topic).await, 1);
|
||||
assert_eq!(manager.topic_refcount(&ctx_b, topic).await, 1);
|
||||
|
||||
let keys = Keys::generate();
|
||||
let event_before_release = EventBuilder::new(Kind::TextNote, "before A release")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("signing failed");
|
||||
|
||||
manager
|
||||
.publish_event(&ctx_b, topic, &event_before_release)
|
||||
.await
|
||||
.expect("publish before release failed");
|
||||
|
||||
let received_before_release =
|
||||
tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("timeout before release")
|
||||
.expect("channel closed before release");
|
||||
assert_eq!(received_before_release.community_id, ctx_b.community());
|
||||
assert_eq!(received_before_release.topic, topic);
|
||||
assert_eq!(received_before_release.event.id, event_before_release.id);
|
||||
|
||||
manager.release_topic(&ctx_a, topic).await;
|
||||
assert_eq!(manager.topic_refcount(&ctx_a, topic).await, 0);
|
||||
assert_eq!(manager.topic_refcount(&ctx_b, topic).await, 1);
|
||||
|
||||
// Wait past A's debounce. A buggy implementation that keyed active
|
||||
// Redis subscriptions by channel id alone would unsubscribe B here too.
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let event_after_release = EventBuilder::new(Kind::TextNote, "after A release")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("signing failed");
|
||||
|
||||
manager
|
||||
.publish_event(&ctx_b, topic, &event_after_release)
|
||||
.await
|
||||
.expect("publish after release failed");
|
||||
|
||||
let received_after_release =
|
||||
tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("timeout after release")
|
||||
.expect("channel closed after release");
|
||||
assert_eq!(received_after_release.community_id, ctx_b.community());
|
||||
assert_eq!(received_after_release.topic, topic);
|
||||
assert_eq!(received_after_release.event.id, event_after_release.id);
|
||||
|
||||
manager.release_topic(&ctx_b, topic).await;
|
||||
assert_eq!(manager.topic_refcount(&ctx_b, topic).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retain_release_refcounts_and_debounces_last_release() {
|
||||
let pool = make_test_pool();
|
||||
let manager = PubSubManager::with_config(
|
||||
PubSubConfig::new("redis://127.0.0.1:6379")
|
||||
.with_unsubscribe_debounce(Duration::from_millis(1)),
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
let topic = EventTopic::Channel(Uuid::from_u128(0xbbbb));
|
||||
|
||||
assert_eq!(manager.topic_refcount(&ctx, topic).await, 0);
|
||||
|
||||
manager.retain_topic(&ctx, topic).await;
|
||||
manager.retain_topic(&ctx, topic).await;
|
||||
assert_eq!(manager.topic_refcount(&ctx, topic).await, 2);
|
||||
|
||||
manager.release_topic(&ctx, topic).await;
|
||||
assert_eq!(manager.topic_refcount(&ctx, topic).await, 1);
|
||||
|
||||
manager.release_topic(&ctx, topic).await;
|
||||
assert_eq!(manager.topic_refcount(&ctx, topic).await, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_debounce_but_allows_override() {
|
||||
let config = PubSubConfig::new("redis://example");
|
||||
assert_eq!(
|
||||
config.unsubscribe_debounce,
|
||||
PubSubConfig::DEFAULT_UNSUBSCRIBE_DEBOUNCE
|
||||
);
|
||||
|
||||
let config = config.with_unsubscribe_debounce(Duration::from_millis(42));
|
||||
assert_eq!(config.unsubscribe_debounce, Duration::from_millis(42));
|
||||
}
|
||||
}
|
||||
|
||||
203
crates/buzz-pubsub/src/nip98_replay.rs
Normal file
203
crates/buzz-pubsub/src/nip98_replay.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
//! Redis-backed NIP-98 replay seen-set.
|
||||
//!
|
||||
//! Implements the [`Nip98ReplayGuard`] trait from `buzz-auth`. Uses Redis
|
||||
//! `SET NX EX` for an atomic set-if-absent with TTL — the §5 pre-build gate
|
||||
//! for multi-tenant HA replay protection.
|
||||
|
||||
use buzz_auth::{
|
||||
error::AuthError,
|
||||
nip98_replay::{
|
||||
nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS,
|
||||
},
|
||||
};
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::EventId;
|
||||
|
||||
/// Redis-backed NIP-98 replay seen-set.
|
||||
///
|
||||
/// Each `try_mark(ctx, event_id, ttl)` issues a single
|
||||
/// `SET buzz:{community}:nip98:{event_id_hex} 1 NX EX <ttl>` against Redis.
|
||||
/// `NX` makes the operation atomic set-if-absent — the freshness proof comes
|
||||
/// from Redis returning `OK` only on the first claim. Subsequent claims within
|
||||
/// the TTL window return `nil`, which we surface as `Ok(false)` so the caller
|
||||
/// rejects the request as replay.
|
||||
pub struct RedisNip98ReplayGuard {
|
||||
pool: deadpool_redis::Pool,
|
||||
}
|
||||
|
||||
impl RedisNip98ReplayGuard {
|
||||
/// Create a new replay guard backed by the given Redis connection pool.
|
||||
pub fn new(pool: deadpool_redis::Pool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
impl Nip98ReplayGuard for RedisNip98ReplayGuard {
|
||||
fn try_mark<'a>(
|
||||
&'a self,
|
||||
ctx: &'a TenantContext,
|
||||
event_id: &'a EventId,
|
||||
ttl_secs: u64,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, AuthError>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
// §5 gate floor + safety ceiling. Sub-floor values are lifted to the
|
||||
// floor (contract permits clamping); above-ceiling values are pushed
|
||||
// down to MAX_REPLAY_TTL_SECS (contract REQUIRES clamping) so a buggy
|
||||
// caller cannot send a Redis-incompatible `EX` arg or pin a slot for
|
||||
// implausibly long.
|
||||
let ttl = ttl_secs.clamp(DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS);
|
||||
|
||||
let mut conn = self.pool.get().await.map_err(|e| {
|
||||
// Structured field for ops; the user-facing AuthError stays a
|
||||
// bounded category string.
|
||||
tracing::warn!(
|
||||
community = %ctx.community(),
|
||||
error = %e,
|
||||
"nip98 replay: redis pool acquire failed — caller MUST fail closed"
|
||||
);
|
||||
AuthError::Internal(format!("Redis pool: {e}"))
|
||||
})?;
|
||||
|
||||
let key = nip98_replay_key(ctx, event_id);
|
||||
|
||||
// SET key 1 NX EX <ttl>. redis-rs typed return: Some("OK") on first
|
||||
// claim, None on existing key. Any other value would be a Redis-side
|
||||
// bug; treat it as internal error.
|
||||
let result: Option<String> = redis::cmd("SET")
|
||||
.arg(&key)
|
||||
.arg("1")
|
||||
.arg("NX")
|
||||
.arg("EX")
|
||||
.arg(ttl)
|
||||
.query_async(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
community = %ctx.community(),
|
||||
error = %e,
|
||||
"nip98 replay: redis SET NX EX failed — caller MUST fail closed"
|
||||
);
|
||||
AuthError::Internal(format!("Redis SET NX EX: {e}"))
|
||||
})?;
|
||||
|
||||
match result.as_deref() {
|
||||
Some("OK") => Ok(true),
|
||||
None => Ok(false),
|
||||
Some(other) => {
|
||||
tracing::error!(
|
||||
community = %ctx.community(),
|
||||
reply = %other,
|
||||
"nip98 replay: redis SET NX EX returned an unexpected reply — investigate"
|
||||
);
|
||||
Err(AuthError::Internal(format!(
|
||||
"unexpected SET NX EX reply: {other}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::{CommunityId, TenantContext};
|
||||
use deadpool_redis::{Config, Runtime};
|
||||
use nostr::{EventBuilder, Keys, Kind};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn redis_pool() -> deadpool_redis::Pool {
|
||||
let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into());
|
||||
Config::from_url(url)
|
||||
.create_pool(Some(Runtime::Tokio1))
|
||||
.expect("create pool")
|
||||
}
|
||||
|
||||
fn fresh_ctx() -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example")
|
||||
}
|
||||
|
||||
fn fresh_event_id() -> EventId {
|
||||
EventBuilder::new(Kind::HttpAuth, "")
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.expect("sign")
|
||||
.id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn first_claim_succeeds_replay_fails() {
|
||||
let guard = RedisNip98ReplayGuard::new(redis_pool());
|
||||
let ctx = fresh_ctx();
|
||||
let eid = fresh_event_id();
|
||||
|
||||
assert!(guard
|
||||
.try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS)
|
||||
.await
|
||||
.expect("first mark"));
|
||||
assert!(!guard
|
||||
.try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS)
|
||||
.await
|
||||
.expect("replay mark"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn isolation_between_communities() {
|
||||
let guard = RedisNip98ReplayGuard::new(redis_pool());
|
||||
let ctx_a = fresh_ctx();
|
||||
let ctx_b = fresh_ctx();
|
||||
let eid = fresh_event_id();
|
||||
|
||||
assert!(guard
|
||||
.try_mark(&ctx_a, &eid, DEFAULT_REPLAY_TTL_SECS)
|
||||
.await
|
||||
.expect("mark in A"));
|
||||
// Same event id under ctx_b is still a first claim — communities are
|
||||
// independent seen-sets.
|
||||
assert!(guard
|
||||
.try_mark(&ctx_b, &eid, DEFAULT_REPLAY_TTL_SECS)
|
||||
.await
|
||||
.expect("mark in B"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn sub_floor_ttl_is_lifted_to_default() {
|
||||
let guard = RedisNip98ReplayGuard::new(redis_pool());
|
||||
let ctx = fresh_ctx();
|
||||
let eid = fresh_event_id();
|
||||
|
||||
// Caller asks for 30s; impl lifts to ≥ DEFAULT_REPLAY_TTL_SECS.
|
||||
// Smoke-test the path: pass sub-floor TTL, claim once, then expect
|
||||
// replay rejection — the TTL lift kept the marker alive past 30s and
|
||||
// the contract holds.
|
||||
assert!(guard.try_mark(&ctx, &eid, 30).await.expect("mark"));
|
||||
assert!(!guard.try_mark(&ctx, &eid, 30).await.expect("replay"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn above_ceiling_ttl_is_clamped() {
|
||||
let guard = RedisNip98ReplayGuard::new(redis_pool());
|
||||
let ctx = fresh_ctx();
|
||||
let eid = fresh_event_id();
|
||||
|
||||
// Caller asks for u64::MAX (well past Redis's i64::MAX `EX` limit).
|
||||
// Impl MUST clamp down to MAX_REPLAY_TTL_SECS so the SET succeeds; if
|
||||
// we instead forwarded u64::MAX, Redis would reject the EX arg and
|
||||
// try_mark would return Err — and per the trait contract, callers
|
||||
// fail closed on Err. That's correctness-preserving but UX-hostile:
|
||||
// every nip98 request errors. The clamp turns a foot-gun into a
|
||||
// contained warning.
|
||||
assert!(guard
|
||||
.try_mark(&ctx, &eid, u64::MAX)
|
||||
.await
|
||||
.expect("mark with extreme ttl must succeed via clamp"));
|
||||
assert!(!guard
|
||||
.try_mark(&ctx, &eid, u64::MAX)
|
||||
.await
|
||||
.expect("replay with extreme ttl must succeed via clamp"));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,38 @@
|
||||
//! Presence tracking — online/away status with TTL.
|
||||
//!
|
||||
//! Stored as `SET buzz:presence:{pubkey_hex} "online" EX 90`.
|
||||
//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 90`.
|
||||
//! TTL is 3x the 30s heartbeat interval so a single missed heartbeat doesn't
|
||||
//! cause presence flap. Clean disconnect deletes immediately.
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
use deadpool_redis::Pool;
|
||||
use nostr::PublicKey;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::PubSubError;
|
||||
use crate::topic::BUZZ_PREFIX;
|
||||
|
||||
/// 3x the 30s heartbeat — single missed heartbeat won't cause presence flap.
|
||||
pub const PRESENCE_TTL_SECS: u64 = 90;
|
||||
|
||||
/// Returns the Redis key for the presence entry of `pubkey`.
|
||||
pub fn presence_key(pubkey: &PublicKey) -> String {
|
||||
format!("buzz:presence:{}", pubkey.to_hex())
|
||||
/// Returns the Redis key for the presence entry of `pubkey` under `ctx`.
|
||||
pub fn presence_key(ctx: &TenantContext, pubkey: &PublicKey) -> String {
|
||||
format!(
|
||||
"{BUZZ_PREFIX}:{}:presence:{}",
|
||||
ctx.community(),
|
||||
pubkey.to_hex()
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets presence status for `pubkey` with a [`PRESENCE_TTL_SECS`]-second TTL.
|
||||
pub async fn set_presence(
|
||||
pool: &Pool,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
status: &str,
|
||||
) -> Result<(), PubSubError> {
|
||||
let mut conn = pool.get().await?;
|
||||
let key = presence_key(pubkey);
|
||||
let key = presence_key(ctx, pubkey);
|
||||
redis::cmd("SET")
|
||||
.arg(&key)
|
||||
.arg(status)
|
||||
@@ -37,9 +44,13 @@ pub async fn set_presence(
|
||||
}
|
||||
|
||||
/// Removes the presence entry for `pubkey`. Call on clean disconnect.
|
||||
pub async fn clear_presence(pool: &Pool, pubkey: &PublicKey) -> Result<(), PubSubError> {
|
||||
pub async fn clear_presence(
|
||||
pool: &Pool,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> Result<(), PubSubError> {
|
||||
let mut conn = pool.get().await?;
|
||||
let key = presence_key(pubkey);
|
||||
let key = presence_key(ctx, pubkey);
|
||||
redis::cmd("DEL")
|
||||
.arg(&key)
|
||||
.query_async::<()>(&mut conn)
|
||||
@@ -48,9 +59,13 @@ pub async fn clear_presence(pool: &Pool, pubkey: &PublicKey) -> Result<(), PubSu
|
||||
}
|
||||
|
||||
/// Returns the current presence status for `pubkey`, or `None` if not set or expired.
|
||||
pub async fn get_presence(pool: &Pool, pubkey: &PublicKey) -> Result<Option<String>, PubSubError> {
|
||||
pub async fn get_presence(
|
||||
pool: &Pool,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
) -> Result<Option<String>, PubSubError> {
|
||||
let mut conn = pool.get().await?;
|
||||
let key = presence_key(pubkey);
|
||||
let key = presence_key(ctx, pubkey);
|
||||
let value: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
|
||||
Ok(value)
|
||||
}
|
||||
@@ -58,13 +73,17 @@ pub async fn get_presence(pool: &Pool, pubkey: &PublicKey) -> Result<Option<Stri
|
||||
/// Returns `pubkey_hex → status` for all currently-set keys.
|
||||
pub async fn get_presence_bulk(
|
||||
pool: &Pool,
|
||||
ctx: &TenantContext,
|
||||
pubkeys: &[PublicKey],
|
||||
) -> Result<HashMap<String, String>, PubSubError> {
|
||||
if pubkeys.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let mut conn = pool.get().await?;
|
||||
let keys: Vec<String> = pubkeys.iter().map(presence_key).collect();
|
||||
let keys: Vec<String> = pubkeys
|
||||
.iter()
|
||||
.map(|pubkey| presence_key(ctx, pubkey))
|
||||
.collect();
|
||||
let values: Vec<Option<String>> = redis::cmd("MGET").arg(&keys).query_async(&mut conn).await?;
|
||||
let result = pubkeys
|
||||
.iter()
|
||||
@@ -78,41 +97,62 @@ pub async fn get_presence_bulk(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_util::make_test_pool;
|
||||
use buzz_core::{CommunityId, TenantContext};
|
||||
use nostr::Keys;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn make_pubkey() -> PublicKey {
|
||||
Keys::generate().public_key()
|
||||
}
|
||||
|
||||
fn ctx(id: u128, host: &str) -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_presence_key_format() {
|
||||
let pubkey = make_pubkey();
|
||||
let key = presence_key(&pubkey);
|
||||
assert!(key.starts_with("buzz:presence:"));
|
||||
let hex_part = key.strip_prefix("buzz:presence:").unwrap();
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
let key = presence_key(&ctx, &pubkey);
|
||||
let prefix = format!("buzz:{}:presence:", ctx.community());
|
||||
assert!(key.starts_with(&prefix));
|
||||
let hex_part = key.strip_prefix(&prefix).unwrap();
|
||||
assert_eq!(hex_part.len(), 64);
|
||||
assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_pubkey_in_two_communities_has_different_presence_keys() {
|
||||
let pubkey = make_pubkey();
|
||||
let community_a = ctx(0xaaaa, "a.example");
|
||||
let community_b = ctx(0xbbbb, "b.example");
|
||||
|
||||
assert_ne!(
|
||||
presence_key(&community_a, &pubkey),
|
||||
presence_key(&community_b, &pubkey)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Redis"]
|
||||
async fn test_presence_set_and_get() {
|
||||
let pool = make_test_pool();
|
||||
let pubkey = make_pubkey();
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
|
||||
let status = get_presence(&pool, &pubkey).await.unwrap();
|
||||
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert!(status.is_none());
|
||||
|
||||
set_presence(&pool, &pubkey, "online").await.unwrap();
|
||||
let status = get_presence(&pool, &pubkey).await.unwrap();
|
||||
set_presence(&pool, &ctx, &pubkey, "online").await.unwrap();
|
||||
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert_eq!(status.as_deref(), Some("online"));
|
||||
|
||||
set_presence(&pool, &pubkey, "away").await.unwrap();
|
||||
let status = get_presence(&pool, &pubkey).await.unwrap();
|
||||
set_presence(&pool, &ctx, &pubkey, "away").await.unwrap();
|
||||
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert_eq!(status.as_deref(), Some("away"));
|
||||
|
||||
clear_presence(&pool, &pubkey).await.unwrap();
|
||||
let status = get_presence(&pool, &pubkey).await.unwrap();
|
||||
clear_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
assert!(status.is_none());
|
||||
}
|
||||
|
||||
@@ -123,11 +163,14 @@ mod tests {
|
||||
let pk1 = make_pubkey();
|
||||
let pk2 = make_pubkey();
|
||||
let pk3 = make_pubkey();
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
|
||||
set_presence(&pool, &pk1, "online").await.unwrap();
|
||||
set_presence(&pool, &pk2, "away").await.unwrap();
|
||||
set_presence(&pool, &ctx, &pk1, "online").await.unwrap();
|
||||
set_presence(&pool, &ctx, &pk2, "away").await.unwrap();
|
||||
|
||||
let result = get_presence_bulk(&pool, &[pk1, pk2, pk3]).await.unwrap();
|
||||
let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, pk3])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.get(&pk1.to_hex()).map(|s| s.as_str()),
|
||||
@@ -136,8 +179,8 @@ mod tests {
|
||||
assert_eq!(result.get(&pk2.to_hex()).map(|s| s.as_str()), Some("away"));
|
||||
assert!(!result.contains_key(&pk3.to_hex()));
|
||||
|
||||
clear_presence(&pool, &pk1).await.unwrap();
|
||||
clear_presence(&pool, &pk2).await.unwrap();
|
||||
clear_presence(&pool, &ctx, &pk1).await.unwrap();
|
||||
clear_presence(&pool, &ctx, &pk2).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -145,12 +188,13 @@ mod tests {
|
||||
async fn test_presence_ttl() {
|
||||
let pool = make_test_pool();
|
||||
let pubkey = make_pubkey();
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
|
||||
set_presence(&pool, &pubkey, "online").await.unwrap();
|
||||
set_presence(&pool, &ctx, &pubkey, "online").await.unwrap();
|
||||
|
||||
let mut conn = pool.get().await.unwrap();
|
||||
let ttl: i64 = redis::cmd("TTL")
|
||||
.arg(presence_key(&pubkey))
|
||||
.arg(presence_key(&ctx, &pubkey))
|
||||
.query_async(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -160,6 +204,6 @@ mod tests {
|
||||
"TTL should be 1-{PRESENCE_TTL_SECS}s, got {ttl}"
|
||||
);
|
||||
|
||||
clear_presence(&pool, &pubkey).await.unwrap();
|
||||
clear_presence(&pool, &ctx, &pubkey).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
//! Event publishing — PUBLISH to Redis via pool connection.
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
use deadpool_redis::Pool;
|
||||
use nostr::JsonUtil;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::PubSubError;
|
||||
use crate::topic::{self, EventTopic};
|
||||
|
||||
/// Returns the Redis pub/sub channel key for `channel_id`.
|
||||
pub fn channel_key(channel_id: Uuid) -> String {
|
||||
format!("buzz:channel:{}", channel_id)
|
||||
/// Returns the Redis pub/sub channel key for `channel_id` under `ctx`.
|
||||
pub fn channel_key(ctx: &TenantContext, channel_id: Uuid) -> String {
|
||||
topic::channel_key(ctx, channel_id)
|
||||
}
|
||||
|
||||
/// Returns the Redis pub/sub channel key for community-global events under `ctx`.
|
||||
pub fn global_key(ctx: &TenantContext) -> String {
|
||||
topic::global_key(ctx)
|
||||
}
|
||||
|
||||
/// Returns the number of subscribers that received the message.
|
||||
pub async fn publish_event(
|
||||
pool: &Pool,
|
||||
channel_id: Uuid,
|
||||
ctx: &TenantContext,
|
||||
topic: EventTopic,
|
||||
event: &nostr::Event,
|
||||
) -> Result<i64, PubSubError> {
|
||||
let mut conn = pool.get().await?;
|
||||
let key = channel_key(channel_id);
|
||||
let key = crate::topic::EventTopicKey::from_context(ctx, topic).redis_channel();
|
||||
let payload = event.as_json();
|
||||
let subscriber_count: i64 = redis::cmd("PUBLISH")
|
||||
.arg(&key)
|
||||
|
||||
@@ -13,6 +13,7 @@ use buzz_auth::{
|
||||
error::AuthError,
|
||||
rate_limit::{LimitType, RateLimitResult, RateLimiter},
|
||||
};
|
||||
use buzz_core::TenantContext;
|
||||
use nostr::PublicKey;
|
||||
use redis::Script;
|
||||
|
||||
@@ -79,9 +80,11 @@ async fn run_rate_limit(
|
||||
|
||||
/// Redis-backed rate limiter using fixed-window counters.
|
||||
///
|
||||
/// Each key is `buzz:ratelimit:<pubkey_hex>:<suffix>` (pubkey) or
|
||||
/// `buzz:ratelimit:ip:<ip>:conn` (IP). The counter and its TTL are managed
|
||||
/// atomically via a Lua script to prevent keys from persisting without expiry.
|
||||
/// Pubkey keys are community-scoped via `&TenantContext`:
|
||||
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. IP keys remain
|
||||
/// operator-global: `buzz:ratelimit:ip:{ip}:conn`. The counter and its TTL are
|
||||
/// managed atomically via a Lua script to prevent keys from persisting without
|
||||
/// expiry.
|
||||
pub struct RedisRateLimiter {
|
||||
pool: deadpool_redis::Pool,
|
||||
}
|
||||
@@ -96,12 +99,13 @@ impl RedisRateLimiter {
|
||||
impl RateLimiter for RedisRateLimiter {
|
||||
async fn check_and_increment(
|
||||
&self,
|
||||
ctx: &TenantContext,
|
||||
pubkey: &PublicKey,
|
||||
limit_type: LimitType,
|
||||
window_secs: u64,
|
||||
limit: u64,
|
||||
) -> Result<RateLimitResult, AuthError> {
|
||||
let key = buzz_auth::rate_limit::rate_limit_key(pubkey, &limit_type);
|
||||
let key = buzz_auth::rate_limit::rate_limit_key(ctx, pubkey, &limit_type);
|
||||
run_rate_limit(&self.pool, &key, window_secs, limit).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! Redis pub/sub subscriber — fans out messages to local WS connections via broadcast.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use nostr::JsonUtil;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||
|
||||
use crate::topic::EventTopicKey;
|
||||
use crate::ChannelEvent;
|
||||
|
||||
/// Initial reconnect backoff (1 second).
|
||||
@@ -12,16 +16,41 @@ const BACKOFF_INITIAL_SECS: u64 = 1;
|
||||
/// Maximum reconnect backoff (30 seconds).
|
||||
const BACKOFF_MAX_SECS: u64 = 30;
|
||||
|
||||
/// Pattern-subscribes to `buzz:channel:*` and forwards events to broadcast.
|
||||
/// Local desired topic refcounts, keyed by fully scoped Redis topic.
|
||||
pub(crate) type DesiredTopics = Arc<Mutex<HashMap<EventTopicKey, usize>>>;
|
||||
|
||||
/// Commands sent from relay subscription registration/removal to the Redis
|
||||
/// pub/sub task.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SubscriptionCommand {
|
||||
/// Ensure the topic is subscribed on the current Redis pub/sub connection.
|
||||
Subscribe(EventTopicKey),
|
||||
/// Unsubscribe only if the desired refcount is still zero when processed.
|
||||
UnsubscribeIfIdle(EventTopicKey),
|
||||
}
|
||||
|
||||
/// Runs a dynamically scoped subscriber and forwards events to broadcast.
|
||||
///
|
||||
/// Runs a reconnect loop with exponential backoff (1s → 2s → 4s → … → 30s max).
|
||||
/// Logs `error!` on disconnect and `info!` on successful reconnect.
|
||||
/// Never returns — the task runs for the lifetime of the relay.
|
||||
pub async fn run_subscriber(redis_url: String, broadcast_tx: broadcast::Sender<ChannelEvent>) {
|
||||
/// The desired refcount map is the source of truth. On every reconnect, this
|
||||
/// task snapshots topics with count > 0 and subscribes to those exact Redis
|
||||
/// channels before processing messages.
|
||||
pub(crate) async fn run_subscriber(
|
||||
redis_url: String,
|
||||
broadcast_tx: broadcast::Sender<ChannelEvent>,
|
||||
desired_topics: DesiredTopics,
|
||||
mut subscription_rx: mpsc::Receiver<SubscriptionCommand>,
|
||||
) {
|
||||
let mut backoff_secs = BACKOFF_INITIAL_SECS;
|
||||
|
||||
loop {
|
||||
match connect_and_subscribe(&redis_url, &broadcast_tx).await {
|
||||
match connect_and_subscribe(
|
||||
&redis_url,
|
||||
&broadcast_tx,
|
||||
desired_topics.clone(),
|
||||
&mut subscription_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
// Stream ended cleanly (Redis returned None). The connection was
|
||||
// established and ran successfully, so reset backoff to the initial
|
||||
@@ -34,72 +63,143 @@ pub async fn run_subscriber(redis_url: String, broadcast_tx: broadcast::Sender<C
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await;
|
||||
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
|
||||
backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS);
|
||||
|
||||
tracing::info!("Attempting to reconnect to Redis pub/sub...");
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a Redis pub/sub connection, subscribe, and run the fan-out loop
|
||||
/// until the stream ends or an error occurs.
|
||||
///
|
||||
/// Returns `Ok(())` if the stream ends cleanly (disconnect), `Err` on
|
||||
/// connection or subscription failure.
|
||||
/// Establish a Redis pub/sub connection, subscribe to the current desired-topic
|
||||
/// snapshot, and run the fan-out / command loop until the connection ends.
|
||||
async fn connect_and_subscribe(
|
||||
redis_url: &str,
|
||||
broadcast_tx: &broadcast::Sender<ChannelEvent>,
|
||||
desired_topics: DesiredTopics,
|
||||
subscription_rx: &mut mpsc::Receiver<SubscriptionCommand>,
|
||||
) -> Result<(), redis::RedisError> {
|
||||
let client = redis::Client::open(redis_url)?;
|
||||
let mut conn = client.get_async_pubsub().await?;
|
||||
let conn = client.get_async_pubsub().await?;
|
||||
let (mut sink, mut stream) = conn.split();
|
||||
let mut active_topics = HashSet::new();
|
||||
|
||||
conn.psubscribe("buzz:channel:*").await?;
|
||||
let initial_topics: Vec<EventTopicKey> = {
|
||||
let desired = desired_topics.lock().await;
|
||||
desired
|
||||
.iter()
|
||||
.filter_map(|(topic, count)| (*count > 0).then_some(*topic))
|
||||
.collect()
|
||||
};
|
||||
|
||||
tracing::info!("Redis pub/sub subscriber connected — listening on buzz:channel:*");
|
||||
|
||||
// Note: backoff is NOT reset here on connect. It resets in the outer loop
|
||||
// only after this function returns Ok(()) — i.e., after the connection ran
|
||||
// to completion (natural disconnect). A transient connect that immediately
|
||||
// drops would not reset backoff.
|
||||
|
||||
let mut stream = conn.on_message();
|
||||
while let Some(msg) = stream.next().await {
|
||||
let payload: String = match msg.get_payload() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to get pub/sub message payload: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let channel_name = msg.get_channel_name();
|
||||
let channel_id = channel_name
|
||||
.strip_prefix("buzz:channel:")
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
let channel_id = match channel_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!("Received pub/sub message on unexpected channel: {channel_name}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let event = match nostr::Event::from_json(&payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to deserialize event from pub/sub: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let channel_event = ChannelEvent { channel_id, event };
|
||||
|
||||
if let Err(_e) = broadcast_tx.send(channel_event) {
|
||||
tracing::trace!("No broadcast receivers for channel {channel_id} — message dropped");
|
||||
}
|
||||
for topic in initial_topics {
|
||||
let channel = topic.redis_channel();
|
||||
sink.subscribe(&channel).await?;
|
||||
active_topics.insert(channel);
|
||||
}
|
||||
|
||||
// Stream returned None — Redis connection closed.
|
||||
Ok(())
|
||||
tracing::info!(
|
||||
topic_count = active_topics.len(),
|
||||
"Redis pub/sub subscriber connected with dynamic scoped subscriptions"
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(command) = subscription_rx.recv() => {
|
||||
match command {
|
||||
SubscriptionCommand::Subscribe(topic) => {
|
||||
let channel = topic.redis_channel();
|
||||
if active_topics.insert(channel.clone()) {
|
||||
sink.subscribe(&channel).await?;
|
||||
}
|
||||
}
|
||||
SubscriptionCommand::UnsubscribeIfIdle(topic) => {
|
||||
if desired_refcount(&desired_topics, topic).await == 0 {
|
||||
let channel = topic.redis_channel();
|
||||
if active_topics.remove(&channel) {
|
||||
sink.unsubscribe(&channel).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = stream.next() => {
|
||||
let Some(msg) = msg else {
|
||||
// Stream returned None — Redis connection closed.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let payload: String = match msg.get_payload() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to get pub/sub message payload: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let channel_name = msg.get_channel_name();
|
||||
let topic_key = match EventTopicKey::parse_redis_channel(channel_name) {
|
||||
Ok(topic_key) => topic_key,
|
||||
Err(_) => {
|
||||
tracing::warn!("Received pub/sub message on unexpected channel: {channel_name}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let event = match nostr::Event::from_json(&payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to deserialize event from pub/sub: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let channel_event = ChannelEvent {
|
||||
community_id: topic_key.community_id,
|
||||
topic: topic_key.topic,
|
||||
event,
|
||||
};
|
||||
|
||||
if let Err(_e) = broadcast_tx.send(channel_event) {
|
||||
tracing::trace!(topic = %channel_name, "No broadcast receivers for topic — message dropped");
|
||||
}
|
||||
}
|
||||
else => {
|
||||
// Command channel closed and stream ended; let the reconnect loop retry.
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn desired_refcount(desired_topics: &DesiredTopics, topic: EventTopicKey) -> usize {
|
||||
desired_topics
|
||||
.lock()
|
||||
.await
|
||||
.get(&topic)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::{CommunityId, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn topic(id: u128) -> EventTopicKey {
|
||||
let ctx = TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), "test");
|
||||
EventTopicKey::from_context(&ctx, crate::EventTopic::Global)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desired_refcount_returns_zero_for_absent_topic() {
|
||||
let desired = Arc::new(Mutex::new(HashMap::new()));
|
||||
assert_eq!(desired_refcount(&desired, topic(1)).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desired_refcount_reads_present_topic() {
|
||||
let desired = Arc::new(Mutex::new(HashMap::from([(topic(1), 3)])));
|
||||
assert_eq!(desired_refcount(&desired, topic(1)).await, 3);
|
||||
}
|
||||
}
|
||||
|
||||
197
crates/buzz-pubsub/src/topic.rs
Normal file
197
crates/buzz-pubsub/src/topic.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Community-scoped Redis event topics.
|
||||
//!
|
||||
//! Pub/sub topics are a routing/performance boundary, not an authorization
|
||||
//! boundary. Tenant identity still comes from [`TenantContext`] on publish /
|
||||
//! retain paths, and the relay re-checks access before local fan-out.
|
||||
|
||||
use buzz_core::{CommunityId, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::PubSubError;
|
||||
|
||||
/// Redis key prefix for Buzz-scoped pub/sub topics and keys.
|
||||
pub const BUZZ_PREFIX: &str = "buzz";
|
||||
|
||||
/// A tenant-local event routing scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EventTopic {
|
||||
/// Events for one exact channel id.
|
||||
Channel(Uuid),
|
||||
/// Community-global events that are not exact-channel routed.
|
||||
Global,
|
||||
}
|
||||
|
||||
/// A fully qualified event topic, including its server-resolved community.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct EventTopicKey {
|
||||
/// Server-resolved community id.
|
||||
pub community_id: CommunityId,
|
||||
/// Tenant-local routing scope.
|
||||
pub topic: EventTopic,
|
||||
}
|
||||
|
||||
impl EventTopicKey {
|
||||
/// Build a topic key from a resolved tenant context.
|
||||
pub fn from_context(ctx: &TenantContext, topic: EventTopic) -> Self {
|
||||
Self {
|
||||
community_id: ctx.community(),
|
||||
topic,
|
||||
}
|
||||
}
|
||||
|
||||
/// Redis pub/sub channel name for this topic.
|
||||
pub fn redis_channel(&self) -> String {
|
||||
match self.topic {
|
||||
EventTopic::Channel(channel_id) => {
|
||||
format!("{BUZZ_PREFIX}:{}:channel:{channel_id}", self.community_id)
|
||||
}
|
||||
EventTopic::Global => format!("{BUZZ_PREFIX}:{}:global", self.community_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Redis pub/sub channel name into a scoped event topic.
|
||||
pub fn parse_redis_channel(channel: &str) -> Result<Self, PubSubError> {
|
||||
let mut parts = channel.split(':');
|
||||
let Some(prefix) = parts.next() else {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
};
|
||||
if prefix != BUZZ_PREFIX {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
}
|
||||
|
||||
let Some(community) = parts.next() else {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
};
|
||||
let community_id = Uuid::parse_str(community)
|
||||
.map(CommunityId::from_uuid)
|
||||
.map_err(|_| PubSubError::InvalidChannelKey(channel.to_string()))?;
|
||||
|
||||
let Some(scope) = parts.next() else {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
};
|
||||
|
||||
let topic = match scope {
|
||||
"global" => {
|
||||
if parts.next().is_some() {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
}
|
||||
EventTopic::Global
|
||||
}
|
||||
"channel" => {
|
||||
let Some(channel_id) = parts.next() else {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
|
||||
}
|
||||
EventTopic::Channel(
|
||||
Uuid::parse_str(channel_id)
|
||||
.map_err(|_| PubSubError::InvalidChannelKey(channel.to_string()))?,
|
||||
)
|
||||
}
|
||||
_ => return Err(PubSubError::InvalidChannelKey(channel.to_string())),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
community_id,
|
||||
topic,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Redis channel for exact-channel events under `ctx`.
|
||||
pub fn channel_key(ctx: &TenantContext, channel_id: Uuid) -> String {
|
||||
EventTopicKey::from_context(ctx, EventTopic::Channel(channel_id)).redis_channel()
|
||||
}
|
||||
|
||||
/// Redis channel for community-global events under `ctx`.
|
||||
pub fn global_key(ctx: &TenantContext) -> String {
|
||||
EventTopicKey::from_context(ctx, EventTopic::Global).redis_channel()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx(id: u128, host: &str) -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_key_includes_community_and_channel() {
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
let channel_id = Uuid::from_u128(0xbbbb);
|
||||
|
||||
assert_eq!(
|
||||
channel_key(&ctx, channel_id),
|
||||
format!("buzz:{}:channel:{channel_id}", ctx.community())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_key_includes_community() {
|
||||
let ctx = ctx(0xaaaa, "a.example");
|
||||
|
||||
assert_eq!(global_key(&ctx), format!("buzz:{}:global", ctx.community()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_channel_in_two_communities_has_different_topics() {
|
||||
let community_a = ctx(0xaaaa, "a.example");
|
||||
let community_b = ctx(0xbbbb, "b.example");
|
||||
let channel_id = Uuid::from_u128(0xcccc);
|
||||
|
||||
assert_ne!(
|
||||
channel_key(&community_a, channel_id),
|
||||
channel_key(&community_b, channel_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_channel_topic() {
|
||||
let community_id = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
||||
let channel_id = Uuid::from_u128(0xbbbb);
|
||||
let raw = format!("buzz:{community_id}:channel:{channel_id}");
|
||||
|
||||
assert_eq!(
|
||||
EventTopicKey::parse_redis_channel(&raw).unwrap(),
|
||||
EventTopicKey {
|
||||
community_id,
|
||||
topic: EventTopic::Channel(channel_id),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_global_topic() {
|
||||
let community_id = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
||||
let raw = format!("buzz:{community_id}:global");
|
||||
|
||||
assert_eq!(
|
||||
EventTopicKey::parse_redis_channel(&raw).unwrap(),
|
||||
EventTopicKey {
|
||||
community_id,
|
||||
topic: EventTopic::Global,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_or_wrong_prefix_topics() {
|
||||
for raw in [
|
||||
"",
|
||||
"not-buzz:00000000-0000-0000-0000-00000000aaaa:global",
|
||||
"buzz:not-a-uuid:global",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa:global:extra",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa:channel",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa:channel:not-a-uuid",
|
||||
"buzz:00000000-0000-0000-0000-00000000aaaa:presence:abc",
|
||||
] {
|
||||
assert!(
|
||||
EventTopicKey::parse_redis_channel(raw).is_err(),
|
||||
"expected {raw:?} to be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,9 @@ description = "WebSocket relay server for the Buzz communications platform"
|
||||
name = "buzz-relay"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-reindex-kind0"
|
||||
path = "src/bin/reindex_kind0.rs"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
buzz-conformance = { workspace = true }
|
||||
buzz-db = { workspace = true }
|
||||
buzz-auth = { workspace = true }
|
||||
buzz-pubsub = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ use tracing::{debug, warn};
|
||||
|
||||
use crate::api::git::manifest::{pointer_key, Manifest, ManifestError, MANIFEST_VERSION};
|
||||
use crate::api::git::store::{CasOutcome, ETag, GitStore, Precond, StoreError};
|
||||
use buzz_core::TenantContext;
|
||||
|
||||
/// Errors `cas_publish` surfaces. Distinguished so `finalize_push` can map
|
||||
/// each to the right HTTP status (the spec's 412 → 409 mapping is here).
|
||||
@@ -443,7 +444,7 @@ fn digest_from_manifest_key(key: &str) -> Result<String, CasError> {
|
||||
///
|
||||
/// **Caller contract — `Inv_RefDerivedFromParent` is structural.** The
|
||||
/// `parent_state` you pass in must be the same one the workspace was
|
||||
/// hydrated from. Concretely: `hydrate::hydrate_for_write(store, owner,
|
||||
/// hydrated from. Concretely: `hydrate::hydrate_for_write(store, ctx, owner,
|
||||
/// repo)` returns `(HydratedRepo, ParentState)` from a single pointer
|
||||
/// observation → `install_hook(repo.path())` → run `receive-pack`
|
||||
/// against the workspace → call this with the **same `parent_state`**.
|
||||
@@ -458,12 +459,13 @@ fn digest_from_manifest_key(key: &str) -> Result<String, CasError> {
|
||||
/// model proves safe.
|
||||
pub async fn cas_publish(
|
||||
store: &GitStore,
|
||||
ctx: &TenantContext,
|
||||
repo_path: &Path,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
parent_state: &ParentState,
|
||||
) -> Result<CasSuccess, CasError> {
|
||||
let pkey = pointer_key(owner, repo);
|
||||
let pkey = pointer_key(ctx.community(), owner, repo);
|
||||
|
||||
// Snapshot post-receive-pack state from disk. `parent_state.parent.refs`
|
||||
// are the refs the workspace was hydrated from — `pack-objects --revs`
|
||||
|
||||
@@ -23,6 +23,7 @@ use tracing::{error, info};
|
||||
/// - `BUZZ_HOOK_URL` — internal policy endpoint (http://127.0.0.1:{port}/internal/git/policy)
|
||||
/// - `BUZZ_HOOK_SECRET` — per-push HMAC secret
|
||||
/// - `BUZZ_REPO_ID` — repo identifier (d-tag)
|
||||
/// - `BUZZ_COMMUNITY_ID` — server-resolved community UUID for the git HTTP request
|
||||
/// - `BUZZ_PUSHER_PUBKEY` — authenticated pusher's hex pubkey
|
||||
///
|
||||
/// Git sets automatically (quarantine):
|
||||
@@ -42,6 +43,7 @@ ZERO="0000000000000000000000000000000000000000"
|
||||
# Fail-closed: required env vars must be set by the relay.
|
||||
: "${BUZZ_REPO_ID:?error: BUZZ_REPO_ID not set}"
|
||||
: "${BUZZ_REPO_OWNER:?error: BUZZ_REPO_OWNER not set}"
|
||||
: "${BUZZ_COMMUNITY_ID:?error: BUZZ_COMMUNITY_ID not set}"
|
||||
: "${BUZZ_PUSHER_PUBKEY:?error: BUZZ_PUSHER_PUBKEY not set}"
|
||||
: "${BUZZ_HOOK_URL:?error: BUZZ_HOOK_URL not set}"
|
||||
: "${BUZZ_HOOK_SECRET:?error: BUZZ_HOOK_SECRET not set}"
|
||||
@@ -92,13 +94,13 @@ done
|
||||
|
||||
# Phase 2: Compute HMAC-SHA256 signature.
|
||||
# Payload format MUST match relay's compute_hmac() in policy.rs:
|
||||
# repo_id | repo_owner | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp
|
||||
# repo_id | repo_owner | community_id | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
# Structurally unambiguous HMAC format (matches Rust's compute_hmac):
|
||||
# len(repo_id):repo_id | repo_owner | pusher | (old_oid + new_oid + len(ref):ref + is_anc)* | timestamp
|
||||
REPO_ID_LEN=${#BUZZ_REPO_ID}
|
||||
HMAC_INPUT="${REPO_ID_LEN}:${BUZZ_REPO_ID}|${BUZZ_REPO_OWNER}|${BUZZ_PUSHER_PUBKEY}|"
|
||||
HMAC_INPUT="${REPO_ID_LEN}:${BUZZ_REPO_ID}|${BUZZ_REPO_OWNER}|${BUZZ_COMMUNITY_ID}|${BUZZ_PUSHER_PUBKEY}|"
|
||||
# Sort by ref_name (field 1) — matches Rust's sort_by(|a, b| a.ref_name.cmp(&b.ref_name))
|
||||
if [ -f "$HMAC_FILE" ]; then
|
||||
sort "$HMAC_FILE" | while IFS=' ' read ref_name old_oid new_oid is_anc; do
|
||||
@@ -118,9 +120,9 @@ fi
|
||||
|
||||
# Phase 3: POST to policy endpoint — FAIL-CLOSED.
|
||||
# repo_id is free-form (user-chosen d-tag) — must be escaped for JSON safety.
|
||||
# repo_owner and pusher_pubkey are validated 64-char lowercase hex — no escaping needed.
|
||||
# repo_owner, community_id, and pusher_pubkey are validated fixed-shape strings — no escaping needed.
|
||||
SAFE_REPO_ID=$(printf '%s' "$BUZZ_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${BUZZ_REPO_OWNER}\",\"pusher_pubkey\":\"${BUZZ_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}"
|
||||
BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${BUZZ_REPO_OWNER}\",\"community_id\":\"${BUZZ_COMMUNITY_ID}\",\"pusher_pubkey\":\"${BUZZ_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}"
|
||||
|
||||
HTTP_CODE=$(curl --silent --max-time 10 \
|
||||
-o "$RESP_FILE" \
|
||||
|
||||
@@ -37,6 +37,7 @@ use tokio::process::Command;
|
||||
use super::cas_publish::ParentState;
|
||||
use super::manifest::{is_hex_oid, is_safe_refname, pointer_key, Manifest, ManifestError};
|
||||
use super::store::{ETag, GitStore, StoreError};
|
||||
use buzz_core::TenantContext;
|
||||
|
||||
/// A bare repo hydrated to a temporary directory.
|
||||
///
|
||||
@@ -87,10 +88,11 @@ pub enum HydrateError {
|
||||
/// failure is a backend/data error.
|
||||
pub async fn hydrate_for_read(
|
||||
store: &GitStore,
|
||||
ctx: &TenantContext,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<Option<HydratedRepo>, HydrateError> {
|
||||
let Some((_etag, _digest, manifest)) = load_pointer(store, owner, repo).await? else {
|
||||
let Some((_etag, _digest, manifest)) = load_pointer(store, ctx, owner, repo).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(materialize_manifest(store, &manifest).await?))
|
||||
@@ -103,10 +105,11 @@ pub async fn hydrate_for_read(
|
||||
/// pointer-named manifest digest before returning it.
|
||||
pub async fn load_manifest_for_read(
|
||||
store: &GitStore,
|
||||
ctx: &TenantContext,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<Option<Manifest>, HydrateError> {
|
||||
Ok(load_pointer(store, owner, repo)
|
||||
Ok(load_pointer(store, ctx, owner, repo)
|
||||
.await?
|
||||
.map(|(_etag, _digest, manifest)| manifest))
|
||||
}
|
||||
@@ -134,10 +137,11 @@ pub async fn load_manifest_for_read(
|
||||
/// install a brand-new history alongside the broken one.
|
||||
pub async fn hydrate_for_write(
|
||||
store: &GitStore,
|
||||
ctx: &TenantContext,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<(HydratedRepo, ParentState), HydrateError> {
|
||||
match load_pointer(store, owner, repo).await? {
|
||||
match load_pointer(store, ctx, owner, repo).await? {
|
||||
Some((etag, digest, manifest)) => {
|
||||
let repo = materialize_manifest(store, &manifest).await?;
|
||||
let parent = ParentState::from_loaded(etag, digest, manifest);
|
||||
@@ -168,10 +172,11 @@ pub async fn hydrate_for_write(
|
||||
/// per call site). `Err(_)` on any below-pointer failure.
|
||||
async fn load_pointer(
|
||||
store: &GitStore,
|
||||
ctx: &TenantContext,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<Option<(ETag, String, Manifest)>, HydrateError> {
|
||||
let pkey = pointer_key(owner, repo);
|
||||
let pkey = pointer_key(ctx.community(), owner, repo);
|
||||
let (etag, pointer_bytes) = match store.get_pointer(&pkey).await? {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
@@ -371,6 +376,13 @@ mod tests {
|
||||
|
||||
// `pointer_key` is tested in `super::manifest::tests` — single source.
|
||||
|
||||
fn tenant() -> TenantContext {
|
||||
TenantContext::resolved(
|
||||
buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||
"git.example",
|
||||
)
|
||||
}
|
||||
|
||||
// -------- Live MinIO + real git roundtrip ----------------------------------
|
||||
//
|
||||
// Run manually:
|
||||
@@ -468,7 +480,8 @@ mod tests {
|
||||
|
||||
let owner = format!("probe-{}", uuid::Uuid::new_v4());
|
||||
let repo = "hello";
|
||||
let pkey = pointer_key(&owner, repo);
|
||||
let ctx = tenant();
|
||||
let pkey = pointer_key(ctx.community(), &owner, repo);
|
||||
match st
|
||||
.put_pointer(
|
||||
&pkey,
|
||||
@@ -483,7 +496,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Hydrate.
|
||||
let hydrated = hydrate_for_read(&st, &owner, repo)
|
||||
let hydrated = hydrate_for_read(&st, &ctx, &owner, repo)
|
||||
.await
|
||||
.expect("hydrate")
|
||||
.expect("hydrate Some");
|
||||
@@ -555,7 +568,10 @@ mod tests {
|
||||
}
|
||||
let st = store();
|
||||
let owner = format!("nope-{}", uuid::Uuid::new_v4());
|
||||
let result = hydrate_for_read(&st, &owner, "ghost").await.expect("ok");
|
||||
let ctx = tenant();
|
||||
let result = hydrate_for_read(&st, &ctx, &owner, "ghost")
|
||||
.await
|
||||
.expect("ok");
|
||||
assert!(result.is_none(), "missing pointer must surface as None");
|
||||
}
|
||||
|
||||
@@ -585,7 +601,8 @@ mod tests {
|
||||
let manifest_digest = manifest_key.strip_prefix("manifests/").unwrap();
|
||||
|
||||
let owner = format!("empty-{}", uuid::Uuid::new_v4());
|
||||
let pkey = pointer_key(&owner, "void");
|
||||
let ctx = tenant();
|
||||
let pkey = pointer_key(ctx.community(), &owner, "void");
|
||||
match st
|
||||
.put_pointer(
|
||||
&pkey,
|
||||
@@ -599,7 +616,7 @@ mod tests {
|
||||
super::super::store::CasOutcome::LostRace => panic!("first INM* must win"),
|
||||
}
|
||||
|
||||
let hydrated = hydrate_for_read(&st, &owner, "void")
|
||||
let hydrated = hydrate_for_read(&st, &ctx, &owner, "void")
|
||||
.await
|
||||
.expect("hydrate")
|
||||
.expect("hydrate Some");
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use buzz_core::tenant::CommunityId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current manifest schema version. Bump on incompatible change.
|
||||
@@ -133,15 +134,16 @@ fn is_manifest_digest(s: &str) -> bool {
|
||||
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// The canonical pointer key for a repo: `repos/<owner>/<repo>/pointer`.
|
||||
/// The canonical pointer key for a repo: `repos/<community>/<owner>/<repo>/pointer`.
|
||||
///
|
||||
/// Single source of truth shared by `cas_publish` (write side) and `hydrate`
|
||||
/// (read side). Strips a trailing `.git` if the caller passed it. The
|
||||
/// `repos/<owner>/<repo>/` namespace leaves room for future sibling keys
|
||||
/// (archive flag, gc state, etc.) co-located under each repo.
|
||||
pub fn pointer_key(owner: &str, repo: &str) -> String {
|
||||
/// `repos/<community>/<owner>/<repo>/` namespace keeps the existing repo-local
|
||||
/// subtree intact under the server-resolved community boundary, while shared
|
||||
/// pack/manifest CAS objects remain outside that scoped pointer namespace.
|
||||
pub fn pointer_key(community: CommunityId, owner: &str, repo: &str) -> String {
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
format!("repos/{owner}/{repo}/pointer")
|
||||
format!("repos/{community}/{owner}/{repo}/pointer")
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -392,11 +394,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pointer_key_strips_dot_git() {
|
||||
assert_eq!(pointer_key("alice", "myrepo"), "repos/alice/myrepo/pointer");
|
||||
let c = CommunityId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
assert_eq!(
|
||||
pointer_key("alice", "myrepo.git"),
|
||||
"repos/alice/myrepo/pointer"
|
||||
pointer_key(c, "alice", "myrepo"),
|
||||
format!("repos/{c}/alice/myrepo/pointer")
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_key(c, "alice", "myrepo.git"),
|
||||
format!("repos/{c}/alice/myrepo/pointer")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_key_is_community_scoped() {
|
||||
let a = CommunityId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
let b = CommunityId::from_uuid(uuid::Uuid::from_u128(2));
|
||||
|
||||
assert_ne!(
|
||||
pointer_key(a, "alice", "repo"),
|
||||
pointer_key(b, "alice", "repo")
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_key(a, "alice", "repo"),
|
||||
format!("repos/{a}/alice/repo/pointer")
|
||||
);
|
||||
assert_ne!(pointer_key(a, "alice", "repo"), "repos/alice/repo/pointer");
|
||||
}
|
||||
|
||||
/// Mutate-bite shape for git hosting: same owner/repo string in two
|
||||
/// communities must resolve to different pointer cells. If the community
|
||||
/// segment is dropped from `pointer_key`, B overwrites A and A observes B's
|
||||
/// manifest pointer (wrong answer, not absence).
|
||||
#[test]
|
||||
fn same_owner_repo_pointers_do_not_bleed_between_communities() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let a = CommunityId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
let b = CommunityId::from_uuid(uuid::Uuid::from_u128(2));
|
||||
let mut pointers = HashMap::new();
|
||||
|
||||
pointers.insert(pointer_key(a, "alice", "repo"), "manifest-a");
|
||||
pointers.insert(pointer_key(b, "alice", "repo"), "manifest-b");
|
||||
|
||||
assert_eq!(pointers[&pointer_key(a, "alice", "repo")], "manifest-a");
|
||||
assert_eq!(pointers[&pointer_key(b, "alice", "repo")], "manifest-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,6 +56,9 @@ pub struct HookCallbackRequest {
|
||||
pub repo_id: String,
|
||||
/// Hex-encoded repo owner pubkey (from URL path, verified against kind:30617).
|
||||
pub repo_owner: String,
|
||||
/// Server-resolved community id from the git HTTP request that spawned the hook.
|
||||
/// Internal-only: set by relay env and HMAC-bound by the hook callback.
|
||||
pub community_id: String,
|
||||
/// Hex-encoded pusher pubkey.
|
||||
pub pusher_pubkey: String,
|
||||
/// Ref updates from git stdin (old_oid, new_oid, ref_name, is_ancestor).
|
||||
@@ -112,7 +115,7 @@ impl From<Denial> for DenialResponse {
|
||||
///
|
||||
/// Format (length-prefixed, `|`-separated, structurally unambiguous):
|
||||
/// ```text
|
||||
/// len(repo_id):repo_id | repo_owner(64) | pusher(64) | sorted_refs | timestamp
|
||||
/// len(repo_id):repo_id | repo_owner(64) | community_id(36) | pusher(64) | sorted_refs | timestamp
|
||||
/// ```
|
||||
/// where each ref is: `old_oid(40) + new_oid(40) + len(ref_name):ref_name + is_ancestor("1"/"0")`
|
||||
///
|
||||
@@ -129,6 +132,8 @@ fn compute_hmac(secret: &[u8], req: &HookCallbackRequest) -> Vec<u8> {
|
||||
mac.update(b"|");
|
||||
mac.update(req.repo_owner.as_bytes()); // Fixed 64 chars, no ambiguity.
|
||||
mac.update(b"|");
|
||||
mac.update(req.community_id.as_bytes()); // Fixed UUID string from server-resolved tenant.
|
||||
mac.update(b"|");
|
||||
mac.update(req.pusher_pubkey.as_bytes()); // Fixed 64 chars, no ambiguity.
|
||||
mac.update(b"|");
|
||||
// Deterministic ref update representation: sorted by ref_name.
|
||||
@@ -189,6 +194,11 @@ pub async fn hook_policy_check(
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "invalid pusher_pubkey").into_response();
|
||||
}
|
||||
let community_uuid = match Uuid::parse_str(&req.community_id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return (StatusCode::FORBIDDEN, "invalid community_id").into_response(),
|
||||
};
|
||||
let community = buzz_core::CommunityId::from_uuid(community_uuid);
|
||||
if req.ref_updates.is_empty() || req.ref_updates.len() > 500 {
|
||||
return (StatusCode::FORBIDDEN, "invalid ref_updates count").into_response();
|
||||
}
|
||||
@@ -231,7 +241,9 @@ pub async fn hook_policy_check(
|
||||
}
|
||||
|
||||
// 4. Validate and resolve kind:30617 for this repo.
|
||||
// Query by (kind=30617, pubkey=owner, d_tag=repo_id) to prevent spoofing.
|
||||
// Query by (community_id, kind=30617, pubkey=owner, d_tag=repo_id) to
|
||||
// prevent spoofing and keep the localhost hook callback on the same
|
||||
// server-resolved tenant as the git HTTP request that spawned it.
|
||||
let owner_bytes = match hex::decode(&req.repo_owner) {
|
||||
Ok(b) if b.len() == 32 => b,
|
||||
_ => {
|
||||
@@ -244,7 +256,7 @@ pub async fn hook_policy_check(
|
||||
d_tag: Some(req.repo_id.clone()),
|
||||
global_only: true,
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
..EventQuery::for_community(community)
|
||||
};
|
||||
let repo_event = match state.db.query_events(&query).await {
|
||||
Ok(mut events) => {
|
||||
@@ -292,7 +304,7 @@ pub async fn hook_policy_check(
|
||||
.and_then(|id| Uuid::parse_str(id).ok());
|
||||
|
||||
if let Some(ch_id) = channel_id {
|
||||
match state.db.get_channel(ch_id).await {
|
||||
match state.db.get_channel(community, ch_id).await {
|
||||
Ok(ch) if ch.archived_at.is_some() => {
|
||||
return (StatusCode::FORBIDDEN, "channel is archived (read-only)").into_response();
|
||||
}
|
||||
@@ -321,7 +333,11 @@ pub async fn hook_policy_check(
|
||||
return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response();
|
||||
}
|
||||
};
|
||||
match state.db.get_member_role(ch_id, &pusher_bytes).await {
|
||||
match state
|
||||
.db
|
||||
.get_member_role(community, ch_id, &pusher_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(Some(role_str)) => match role_str.parse::<MemberRole>() {
|
||||
Ok(role) => role,
|
||||
Err(_) => {
|
||||
@@ -384,6 +400,7 @@ pub fn generate_hook_hmac(
|
||||
secret: &[u8],
|
||||
repo_id: &str,
|
||||
repo_owner: &str,
|
||||
community_id: &str,
|
||||
pusher_pubkey: &str,
|
||||
ref_updates: &[HookRefUpdate],
|
||||
timestamp: u64,
|
||||
@@ -391,6 +408,7 @@ pub fn generate_hook_hmac(
|
||||
let req = HookCallbackRequest {
|
||||
repo_id: repo_id.to_string(),
|
||||
repo_owner: repo_owner.to_string(),
|
||||
community_id: community_id.to_string(),
|
||||
pusher_pubkey: pusher_pubkey.to_string(),
|
||||
ref_updates: ref_updates.to_vec(),
|
||||
timestamp,
|
||||
@@ -408,6 +426,7 @@ mod tests {
|
||||
HookCallbackRequest {
|
||||
repo_id: "test-repo".to_string(),
|
||||
repo_owner: "a".repeat(64),
|
||||
community_id: uuid::Uuid::from_u128(1).to_string(),
|
||||
pusher_pubkey: "b".repeat(64),
|
||||
ref_updates: vec![HookRefUpdate {
|
||||
old_oid: "1".repeat(40),
|
||||
@@ -505,6 +524,18 @@ mod tests {
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
/// Tampering the server-resolved community changes the HMAC input, so a
|
||||
/// hook callback cannot be replayed across communities even though the
|
||||
/// localhost policy endpoint itself has no inbound Host header.
|
||||
#[test]
|
||||
fn hmac_tampered_community_rejected() {
|
||||
let secret = b"test-secret";
|
||||
let mut req = make_request();
|
||||
sign_request(&mut req, secret);
|
||||
req.community_id = uuid::Uuid::from_u128(2).to_string();
|
||||
assert!(!verify_hmac(secret, &req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_deterministic_across_ref_order() {
|
||||
let secret = b"test-secret";
|
||||
@@ -531,6 +562,7 @@ mod tests {
|
||||
secret,
|
||||
&req.repo_id,
|
||||
&req.repo_owner,
|
||||
&req.community_id,
|
||||
&req.pusher_pubkey,
|
||||
&req.ref_updates,
|
||||
req.timestamp,
|
||||
@@ -551,6 +583,7 @@ mod tests {
|
||||
let repo_id = "my-project";
|
||||
let repo_owner = "ab".repeat(32); // 64 hex chars
|
||||
let pusher = "cd".repeat(32); // 64 hex chars
|
||||
let community_id = uuid::Uuid::from_u128(1).to_string();
|
||||
let timestamp: u64 = 1700000000;
|
||||
|
||||
// Two refs, intentionally out of sorted order to test sorting.
|
||||
@@ -574,6 +607,7 @@ mod tests {
|
||||
secret.as_bytes(),
|
||||
repo_id,
|
||||
&repo_owner,
|
||||
&community_id,
|
||||
&pusher,
|
||||
&ref_updates,
|
||||
timestamp,
|
||||
@@ -587,6 +621,7 @@ mod tests {
|
||||
export LC_ALL=C
|
||||
BUZZ_REPO_ID="{repo_id}"
|
||||
BUZZ_REPO_OWNER="{repo_owner}"
|
||||
BUZZ_COMMUNITY_ID="{community_id}"
|
||||
BUZZ_PUSHER_PUBKEY="{pusher}"
|
||||
BUZZ_HOOK_SECRET="{secret}"
|
||||
TIMESTAMP="{timestamp}"
|
||||
@@ -602,7 +637,7 @@ echo "refs/heads/feature {old2} {new2} 0" >> "$HMAC_FILE"
|
||||
|
||||
# Build HMAC input — exact logic from hook script
|
||||
REPO_ID_LEN=${{#BUZZ_REPO_ID}}
|
||||
HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|${{BUZZ_REPO_OWNER}}|${{BUZZ_PUSHER_PUBKEY}}|"
|
||||
HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|${{BUZZ_REPO_OWNER}}|${{BUZZ_COMMUNITY_ID}}|${{BUZZ_PUSHER_PUBKEY}}|"
|
||||
sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
|
||||
REF_LEN=${{#ref_name}}
|
||||
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
|
||||
@@ -614,6 +649,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$BUZZ_HOOK_SECRET" -hex
|
||||
"#,
|
||||
repo_id = repo_id,
|
||||
repo_owner = repo_owner,
|
||||
community_id = community_id,
|
||||
pusher = pusher,
|
||||
secret = secret,
|
||||
timestamp = timestamp,
|
||||
@@ -651,6 +687,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$BUZZ_HOOK_SECRET" -hex
|
||||
let repo_id = "test-repo";
|
||||
let repo_owner = "a".repeat(64);
|
||||
let pusher = "b".repeat(64);
|
||||
let community_id = uuid::Uuid::from_u128(1).to_string();
|
||||
let timestamp: u64 = 1700000001;
|
||||
|
||||
let ref_updates = vec![HookRefUpdate {
|
||||
@@ -664,6 +701,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$BUZZ_HOOK_SECRET" -hex
|
||||
secret.as_bytes(),
|
||||
repo_id,
|
||||
&repo_owner,
|
||||
&community_id,
|
||||
&pusher,
|
||||
&ref_updates,
|
||||
timestamp,
|
||||
@@ -678,7 +716,7 @@ HMAC_FILE="$WORK_DIR/hmac"
|
||||
echo "refs/heads/main {old} {new} 1" >> "$HMAC_FILE"
|
||||
BUZZ_REPO_ID="{repo_id}"
|
||||
REPO_ID_LEN=${{#BUZZ_REPO_ID}}
|
||||
HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|{owner}|{pusher}|"
|
||||
HMAC_INPUT="${{REPO_ID_LEN}}:${{BUZZ_REPO_ID}}|{owner}|{community_id}|{pusher}|"
|
||||
sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do
|
||||
REF_LEN=${{#ref_name}}
|
||||
printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc"
|
||||
@@ -690,6 +728,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu
|
||||
new = "2".repeat(40),
|
||||
repo_id = repo_id,
|
||||
owner = repo_owner,
|
||||
community_id = community_id,
|
||||
pusher = pusher,
|
||||
timestamp = timestamp,
|
||||
secret = secret,
|
||||
|
||||
@@ -33,6 +33,7 @@ use super::hydrate::{
|
||||
};
|
||||
use super::manifest_event::{build_ref_state_event, RefStateInputs};
|
||||
use crate::state::AppState;
|
||||
use buzz_core::TenantContext;
|
||||
|
||||
/// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`).
|
||||
const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
@@ -50,6 +51,8 @@ const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300
|
||||
pub struct GitAuth {
|
||||
/// The authenticated user's public key, extracted from the NIP-98 event.
|
||||
pub pubkey: nostr::PublicKey,
|
||||
/// Server-resolved tenant bound from the request Host before auth checks.
|
||||
pub tenant: TenantContext,
|
||||
}
|
||||
|
||||
impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
@@ -94,17 +97,29 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
let event_json = String::from_utf8(event_bytes)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?;
|
||||
|
||||
// Use configured relay_url as canonical base (don't trust forwarded headers).
|
||||
let relay_url = &state.config.relay_url;
|
||||
let base_url = relay_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let path_and_query = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(parts.uri.path());
|
||||
// Row zero for Git HTTP: bind the request Host to a server-resolved
|
||||
// tenant before URL verification. We still do not trust forwarded
|
||||
// headers; the signed `u` tag is checked against the host that resolved
|
||||
// through the authoritative communities table, not a deployment-global
|
||||
// `config.relay_url` and not any client-supplied community value.
|
||||
let raw_host = parts
|
||||
.headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "repository not found").into_response())?;
|
||||
let expected_url = git_expected_url(
|
||||
&state.config.relay_url,
|
||||
&tenant,
|
||||
parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(parts.uri.path()),
|
||||
)
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response())?;
|
||||
|
||||
// Repo-root URL verification.
|
||||
//
|
||||
@@ -120,16 +135,6 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
// - HTTPS in production (prevents token theft)
|
||||
// - Pre-receive hook for push authorization (role + protection rules)
|
||||
// - Endpoint routing (clone/push are different HTTP paths)
|
||||
let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
|
||||
prefix
|
||||
} else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
|
||||
prefix
|
||||
} else if let Some(prefix) = path_and_query.strip_suffix("/git-receive-pack") {
|
||||
prefix
|
||||
} else {
|
||||
return Err((StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response());
|
||||
};
|
||||
let expected_url = format!("{base_url}{repo_path}");
|
||||
|
||||
// Skip HTTP method check for git routes.
|
||||
//
|
||||
@@ -177,18 +182,50 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
.headers
|
||||
.get("x-auth-tag")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
if crate::api::relay_members::enforce_relay_membership(state, pubkey.as_bytes(), auth_tag)
|
||||
.await
|
||||
.is_err()
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
state,
|
||||
tenant.community(),
|
||||
pubkey.as_bytes(),
|
||||
auth_tag,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
|
||||
return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response());
|
||||
}
|
||||
|
||||
Ok(GitAuth { pubkey })
|
||||
Ok(GitAuth { pubkey, tenant })
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request.
|
||||
///
|
||||
/// The host is always the server-resolved tenant host. `config_relay_url` only
|
||||
/// contributes the deployment scheme (`wss://` => `https://`, otherwise
|
||||
/// `http://`) so a request to community B cannot authenticate with a token
|
||||
/// signed for community A's URL just because the deployment has one global
|
||||
/// `relay_url`.
|
||||
fn git_expected_url(
|
||||
config_relay_url: &str,
|
||||
tenant: &TenantContext,
|
||||
path_and_query: &str,
|
||||
) -> Option<String> {
|
||||
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") {
|
||||
prefix
|
||||
} else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") {
|
||||
prefix
|
||||
} else {
|
||||
path_and_query.strip_suffix("/git-receive-pack")?
|
||||
};
|
||||
Some(format!("{scheme}://{}{repo_path}", tenant.host()))
|
||||
}
|
||||
|
||||
/// Validate URL `(owner, repo)` parameters and return the canonical repo
|
||||
/// id (= `repo` with any `.git` suffix stripped).
|
||||
///
|
||||
@@ -461,7 +498,7 @@ fn build_upload_pack_advertisement(manifest: &super::manifest::Manifest) -> Vec<
|
||||
/// error" behavior is gone — A1 detectability holds on the read side too.
|
||||
pub async fn info_refs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth: GitAuth,
|
||||
auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
Query(query): Query<InfoRefsQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
@@ -479,7 +516,9 @@ pub async fn info_refs(
|
||||
if service == "git-upload-pack" {
|
||||
// Load just the verified manifest — no object materialization, no
|
||||
// permit. `Ok(None)` = pointer absent = repo never existed → 404.
|
||||
match load_manifest_for_read(&state.git_store, ¶ms.owner, ¶ms.repo).await {
|
||||
match load_manifest_for_read(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo)
|
||||
.await
|
||||
{
|
||||
Ok(Some(manifest)) if fast_path_eligible(&manifest) => {
|
||||
let body = build_upload_pack_advertisement(&manifest);
|
||||
return Ok(Response::builder()
|
||||
@@ -504,7 +543,7 @@ pub async fn info_refs(
|
||||
|
||||
// Subprocess path: receive-pack advertisement, or upload-pack for a
|
||||
// tagged repo. Acquires a permit and hydrates — today's behavior.
|
||||
info_refs_subprocess(&state, service, ¶ms).await
|
||||
info_refs_subprocess(&state, &auth.tenant, service, ¶ms).await
|
||||
}
|
||||
|
||||
/// Subprocess-backed `info/refs` advertisement: hydrate the published state
|
||||
@@ -516,12 +555,13 @@ pub async fn info_refs(
|
||||
/// lose the clean timeout/error mapping that buffering gives us.
|
||||
async fn info_refs_subprocess(
|
||||
state: &Arc<AppState>,
|
||||
tenant: &TenantContext,
|
||||
service: &str,
|
||||
params: &GitRepoParams,
|
||||
) -> Result<Response, Response> {
|
||||
let _permit = acquire_git_permit(state)?;
|
||||
|
||||
let repo = match hydrate_for_read(&state.git_store, ¶ms.owner, ¶ms.repo).await {
|
||||
let repo = match hydrate_for_read(&state.git_store, tenant, ¶ms.owner, ¶ms.repo).await {
|
||||
Ok(Some(repo)) => repo,
|
||||
Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
|
||||
Err(e) => return Err(hydrate_error_to_response(¶ms.owner, ¶ms.repo, e)),
|
||||
@@ -597,18 +637,19 @@ async fn info_refs_subprocess(
|
||||
/// the tempdir lives only for the duration of this request.
|
||||
pub async fn upload_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth: GitAuth,
|
||||
auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
let _ = validate_repo_id(¶ms.owner, ¶ms.repo)?;
|
||||
let _permit = acquire_git_permit(&state)?;
|
||||
|
||||
let repo = match hydrate_for_read(&state.git_store, ¶ms.owner, ¶ms.repo).await {
|
||||
Ok(Some(repo)) => repo,
|
||||
Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
|
||||
Err(e) => return Err(hydrate_error_to_response(¶ms.owner, ¶ms.repo, e)),
|
||||
};
|
||||
let repo =
|
||||
match hydrate_for_read(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo).await {
|
||||
Ok(Some(repo)) => repo,
|
||||
Ok(None) => return Err((StatusCode::NOT_FOUND, "repository not found").into_response()),
|
||||
Err(e) => return Err(hydrate_error_to_response(¶ms.owner, ¶ms.repo, e)),
|
||||
};
|
||||
|
||||
// Track A: stream the subprocess stdout straight into the response body
|
||||
// instead of buffering the whole pack into RAM. `repo` (the hydrated
|
||||
@@ -682,9 +723,10 @@ pub async fn receive_pack(
|
||||
// Hydrate parent state + workspace in one round-trip. ParentState
|
||||
// travels with the workspace into finalize_push so the CAS predicates
|
||||
// on the same pointer ETag the workspace was hydrated from.
|
||||
let (repo, parent_state) = hydrate_for_write(&state.git_store, ¶ms.owner, ¶ms.repo)
|
||||
.await
|
||||
.map_err(|e| hydrate_error_to_response(¶ms.owner, ¶ms.repo, e))?;
|
||||
let (repo, parent_state) =
|
||||
hydrate_for_write(&state.git_store, &auth.tenant, ¶ms.owner, ¶ms.repo)
|
||||
.await
|
||||
.map_err(|e| hydrate_error_to_response(¶ms.owner, ¶ms.repo, e))?;
|
||||
|
||||
// Install the pre-receive hook into the ephemeral workspace. The
|
||||
// hook script is fixed per-deployment; per-push state (callback URL,
|
||||
@@ -708,6 +750,10 @@ pub async fn receive_pack(
|
||||
),
|
||||
("BUZZ_REPO_ID", repo_name.to_string()),
|
||||
("BUZZ_REPO_OWNER", params.owner.clone()),
|
||||
(
|
||||
"BUZZ_COMMUNITY_ID",
|
||||
auth.tenant.community().as_uuid().to_string(),
|
||||
),
|
||||
("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()),
|
||||
// Override any repo-local core.hooksPath setting; defense in
|
||||
// depth even though the hydrated workspace has no inherited
|
||||
@@ -729,6 +775,7 @@ pub async fn receive_pack(
|
||||
repo: params.repo.clone(),
|
||||
repo_id: repo_name.to_string(),
|
||||
pusher: auth.pubkey,
|
||||
tenant: auth.tenant,
|
||||
repo_handle: repo,
|
||||
};
|
||||
Ok(finalize_push(&state, ctx).await)
|
||||
@@ -743,6 +790,18 @@ pub async fn receive_pack(
|
||||
/// [`finalize_push`] has decided to build a `Response` from these bytes.
|
||||
pub(crate) struct PackOutput {
|
||||
pub stdout: Vec<u8>,
|
||||
/// Whether the push is safe to publish: the `git receive-pack` subprocess
|
||||
/// exited 0 **and** its report-status reported no rejected (`ng`) ref.
|
||||
///
|
||||
/// A pre-receive hook decline (authorization denied) does NOT surface as a
|
||||
/// non-zero exit — `git receive-pack --stateless-rpc` exits 0 and reports
|
||||
/// the rejection only in-band as report-status. So `ok` must fold in the
|
||||
/// report-status scan (`receive_pack_report_rejected`), not the exit code
|
||||
/// alone. `finalize_push` treats `false` as "push did not happen": skip the
|
||||
/// CAS publish and the derived kind:30618 so a rejected push leaves **no
|
||||
/// published state**. The buffered stdout still carries git's in-band
|
||||
/// report-status so the client prints the rejection.
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Spawn a `git --stateless-rpc <service>` subprocess against the given
|
||||
@@ -820,13 +879,123 @@ async fn run_git_at(
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(stderr = %stderr, service = %service, "git subprocess exited with error");
|
||||
// Still return output — git protocol errors are communicated in-band.
|
||||
// A non-zero exit feeds `PackOutput.ok` below, but it is NOT the signal
|
||||
// for a pre-receive hook decline: `git receive-pack --stateless-rpc`
|
||||
// exits 0 on a hook decline, reporting the rejection only in-band as
|
||||
// report-status (see `receive_pack_report_rejected`). The exit code
|
||||
// still guards genuine subprocess failures (spawn/IO/abort).
|
||||
}
|
||||
|
||||
// Primary fence for a denied push: scan the report-status for an `ng`
|
||||
// (rejected) ref update. `git receive-pack` exits 0 on a pre-receive hook
|
||||
// decline, so the exit code alone is insufficient — the rejection lives in
|
||||
// the in-band report-status. Fold both signals into `ok` so `finalize_push`
|
||||
// skips CAS publish + kind:30618 on any rejected ref.
|
||||
let report_rejected = service == "receive-pack" && receive_pack_report_rejected(&output.stdout);
|
||||
if report_rejected {
|
||||
warn!(
|
||||
service = %service,
|
||||
"git receive-pack report-status contains a rejected (ng) ref update"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(PackOutput {
|
||||
stdout: output.stdout,
|
||||
ok: output.status.success() && !report_rejected,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true when a `git receive-pack` report-status stream contains an
|
||||
/// `ng <ref> <reason>` line — i.e. git rejected at least one ref update.
|
||||
///
|
||||
/// Empirically, `git receive-pack --stateless-rpc` exits **0** even when a
|
||||
/// pre-receive hook rejects a push: the rejection is communicated only in-band
|
||||
/// to the client as report-status (`unpack ok` followed by `ng refs/...
|
||||
/// pre-receive hook declined`). The relay must therefore treat an `ng` status
|
||||
/// as "push did not happen" for the publish fence, not rely on the exit code.
|
||||
///
|
||||
/// ## Wire format
|
||||
///
|
||||
/// The report-status is a pkt-line stream. When side-band-64k is negotiated
|
||||
/// (which the stock client does), the status pkt-lines are **nested**: each
|
||||
/// outer pkt-line's payload begins with a channel byte (`1` = data, `2`/`3` =
|
||||
/// progress/error text), and the band-1 payload carries its *own* inner
|
||||
/// pkt-line stream:
|
||||
///
|
||||
/// ```text
|
||||
/// <outer-len>\x01<inner-len>unpack ok\n<inner-len>ng refs/heads/main ...\n0000
|
||||
/// ```
|
||||
///
|
||||
/// A naive "strip the band byte then split on \n" misses this: the rejection
|
||||
/// line surfaces as `0031ng refs/...` (inner length prefix glued on), which
|
||||
/// does not start with `ng `. So we de-frame one level deeper — for a band-1
|
||||
/// payload we parse the inner pkt-lines before matching. Without side-band the
|
||||
/// status pkt-lines appear directly at the outer level, which we also match.
|
||||
fn receive_pack_report_rejected(stdout: &[u8]) -> bool {
|
||||
for payload in PktLineIter::new(stdout) {
|
||||
match payload.first() {
|
||||
// Side-band channel 1 (data): carries a nested pkt-line stream.
|
||||
Some(1) => {
|
||||
if PktLineIter::new(&payload[1..]).any(|line| line.starts_with(b"ng ")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Side-band channel 2/3 (progress/error text): never status lines.
|
||||
Some(2 | 3) => {}
|
||||
// No side-band: the status pkt-line payload is the line itself.
|
||||
_ => {
|
||||
if payload.starts_with(b"ng ") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Iterator over the payloads of a pkt-line stream.
|
||||
///
|
||||
/// Yields the bytes between the 4-hex length prefix and the end of each
|
||||
/// pkt-line. Skips flush (`0000`) / delim (`0001`) / response-end (`0002`)
|
||||
/// control pkts. Stops on the first malformed length or truncated frame
|
||||
/// (defensive: a corrupt stream simply produces no further matches rather
|
||||
/// than panicking).
|
||||
struct PktLineIter<'a> {
|
||||
buf: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> PktLineIter<'a> {
|
||||
fn new(buf: &'a [u8]) -> Self {
|
||||
Self { buf, i: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for PktLineIter<'a> {
|
||||
type Item = &'a [u8];
|
||||
|
||||
fn next(&mut self) -> Option<&'a [u8]> {
|
||||
loop {
|
||||
if self.i + 4 > self.buf.len() {
|
||||
return None;
|
||||
}
|
||||
let len_hex = std::str::from_utf8(&self.buf[self.i..self.i + 4]).ok()?;
|
||||
let len = usize::from_str_radix(len_hex, 16).ok()?;
|
||||
// 0000 (flush), 0001 (delim), 0002 (response-end): no payload.
|
||||
if len < 4 {
|
||||
self.i += 4;
|
||||
continue;
|
||||
}
|
||||
if self.i + len > self.buf.len() {
|
||||
return None;
|
||||
}
|
||||
let payload = &self.buf[self.i + 4..self.i + len];
|
||||
self.i += len;
|
||||
return Some(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the git subprocess and its hydrated workspace alive for exactly as
|
||||
/// long as the response body is being streamed.
|
||||
///
|
||||
@@ -986,6 +1155,9 @@ pub(crate) struct PushContext {
|
||||
/// `d` exactly.
|
||||
pub repo_id: String,
|
||||
pub pusher: nostr::PublicKey,
|
||||
/// Server-resolved tenant that selected the pointer namespace and owns
|
||||
/// any derived kind:30618 event from this push.
|
||||
pub tenant: TenantContext,
|
||||
/// The hydrated workspace handle. Held until response construction
|
||||
/// (which happens *after* `cas_publish` returns) so the tempdir
|
||||
/// outlives the receive-pack subprocess and the CAS publish.
|
||||
@@ -1002,11 +1174,42 @@ pub(crate) struct PushContext {
|
||||
/// constructor of a push 2xx, so the seam is structural (not by
|
||||
/// convention).
|
||||
async fn finalize_push(state: &Arc<AppState>, ctx: PushContext) -> Response {
|
||||
// The push fence, part 0 — **a rejected push publishes nothing.**
|
||||
//
|
||||
// `ctx.pack.ok` is false when git aborted the ref updates: either the
|
||||
// subprocess exited non-zero (genuine failure) OR — the important case —
|
||||
// a pre-receive hook declined the push. A hook decline does NOT yield a
|
||||
// non-zero exit; `git receive-pack --stateless-rpc` exits 0 and reports
|
||||
// the rejection only in-band as report-status (`ng <ref> <reason>`), which
|
||||
// `run_git_at` folds into `ok` via `receive_pack_report_rejected`. In
|
||||
// either case the workspace refs were NOT advanced, so there is no
|
||||
// committed state to publish: skip the CAS pointer write AND the derived
|
||||
// kind:30618. Otherwise a denied push would emit a relay-signed, fanned-out
|
||||
// 30618 falsely attributing the ref state to the *denied* pusher
|
||||
// (`actor = ctx.pusher`), breaking the invariant "rejected push → no
|
||||
// published state".
|
||||
//
|
||||
// git's in-band report-status (already buffered in `ctx.pack.stdout`)
|
||||
// still streams back so the client prints `! [remote rejected]` / the
|
||||
// hook's decline message; only the publish side effects are suppressed.
|
||||
if !ctx.pack.ok {
|
||||
warn!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
"receive-pack exited non-zero (e.g. pre-receive hook decline); \
|
||||
skipping CAS publish and kind:30618 — no state published"
|
||||
);
|
||||
let response = build_git_response("receive-pack", ctx.pack);
|
||||
drop(ctx.repo_handle);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Step 7 (CAS). The PushContext binds `parent_state` (observed at
|
||||
// hydrate) to the CAS predicate here — no re-reading of the pointer
|
||||
// between hydrate and CAS.
|
||||
let success = match cas_publish(
|
||||
&state.git_store,
|
||||
&ctx.tenant,
|
||||
ctx.repo_handle.path(),
|
||||
&ctx.owner,
|
||||
&ctx.repo,
|
||||
@@ -1095,36 +1298,48 @@ async fn finalize_push(state: &Arc<AppState>, ctx: PushContext) -> Response {
|
||||
actor_pubkey_hex: &hex::encode(ctx.pusher.to_bytes()),
|
||||
};
|
||||
match build_ref_state_event(&inputs, &state.relay_keypair) {
|
||||
Ok(event) => match state.db.insert_event(&event, None).await {
|
||||
Ok((stored, true)) => {
|
||||
// Routed through the guarded send path for uniformity; the
|
||||
// access gate no-ops for this globally-scoped
|
||||
// (channel_id = None) ref-state event.
|
||||
crate::handlers::event::fan_out_event_to_local_subscribers(state, &stored)
|
||||
Ok(event) => {
|
||||
// Relay-signed kind:30618 belongs to the same server-resolved
|
||||
// tenant as the git request that committed the pointer.
|
||||
match state
|
||||
.db
|
||||
.insert_event(ctx.tenant.community(), &event, None)
|
||||
.await
|
||||
{
|
||||
Ok((stored, true)) => {
|
||||
// Routed through the guarded send path for uniformity;
|
||||
// the access gate no-ops for this globally-scoped
|
||||
// (channel_id = None) ref-state event.
|
||||
crate::handlers::event::fan_out_event_to_local_subscribers(
|
||||
state,
|
||||
ctx.tenant.community(),
|
||||
&stored,
|
||||
)
|
||||
.await;
|
||||
info!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
manifest = %success.manifest_key,
|
||||
"kind:30618 published (derived after CAS)"
|
||||
);
|
||||
info!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
manifest = %success.manifest_key,
|
||||
"kind:30618 published (derived after CAS)"
|
||||
);
|
||||
}
|
||||
Ok((_, false)) => {
|
||||
info!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
"kind:30618 deduplicated by relay db"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
error = %e,
|
||||
"kind:30618 insert failed; push remains durable in object store"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok((_, false)) => {
|
||||
info!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
"kind:30618 deduplicated by relay db"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner = %ctx.owner,
|
||||
repo = %ctx.repo_id,
|
||||
error = %e,
|
||||
"kind:30618 insert failed; push remains durable in object store"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner = %ctx.owner,
|
||||
@@ -1163,6 +1378,8 @@ pub fn git_router(state: Arc<AppState>) -> Router {
|
||||
mod track_c_tests {
|
||||
use super::*;
|
||||
use crate::api::git::manifest::Manifest;
|
||||
use buzz_core::CommunityId;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn oid_sha1() -> String {
|
||||
@@ -1182,6 +1399,192 @@ mod track_c_tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tenant(host: &str, n: u128) -> TenantContext {
|
||||
TenantContext::resolved(CommunityId::from_uuid(uuid::Uuid::from_u128(n)), host)
|
||||
}
|
||||
|
||||
fn git_nip98_event_json(keys: &Keys, url: &str, method: &str) -> String {
|
||||
let tags = vec![
|
||||
Tag::parse(["u", url]).expect("u tag"),
|
||||
Tag::parse(["method", method]).expect("method tag"),
|
||||
];
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign NIP-98 event");
|
||||
serde_json::to_string(&event).expect("serialize")
|
||||
}
|
||||
|
||||
fn pkt(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
super::pkt_line(&mut out, payload);
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap inner status pkt-lines in one side-band-64k band-1 (data) outer
|
||||
/// pkt-line — the real shape git emits when side-band is negotiated:
|
||||
/// `<outer-len>\x01<inner-pkt-lines>`.
|
||||
fn sideband_data(inner_pkts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut inner = vec![0x01u8]; // band 1 = data channel
|
||||
for p in inner_pkts {
|
||||
inner.extend(pkt(p));
|
||||
}
|
||||
pkt(&inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_report_rejected_detects_plain_ng_line() {
|
||||
// No side-band: status pkt-lines at the outer level.
|
||||
let mut out = Vec::new();
|
||||
out.extend(pkt(b"unpack ok\n"));
|
||||
out.extend(pkt(b"ng refs/heads/main pre-receive hook declined\n"));
|
||||
out.extend_from_slice(b"0000");
|
||||
|
||||
assert!(receive_pack_report_rejected(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_report_rejected_detects_sideband_ng_line() {
|
||||
// Real side-band-64k shape: status pkt-lines NESTED inside a band-1
|
||||
// outer pkt-line. A naive "strip band byte, split on \n" parser misses
|
||||
// this — the rejection surfaces as `0031ng refs/...`.
|
||||
let mut out = sideband_data(&[
|
||||
b"unpack ok\n",
|
||||
b"ng refs/heads/main pre-receive hook declined\n",
|
||||
]);
|
||||
out.extend_from_slice(b"0000");
|
||||
|
||||
assert!(receive_pack_report_rejected(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_report_rejected_ignores_ok_status() {
|
||||
// Real side-band-64k success shape — nested `ok` pkt-lines, no `ng`.
|
||||
let mut out = sideband_data(&[b"unpack ok\n", b"ok refs/heads/main\n"]);
|
||||
out.extend_from_slice(b"0000");
|
||||
|
||||
assert!(!receive_pack_report_rejected(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_report_rejected_matches_real_git_deny_shape() {
|
||||
// Mirrors report-status captured from `git receive-pack` (2.50.1) on a
|
||||
// pre-receive hook decline:
|
||||
// <band2>policy: denied\n (progress/error text)
|
||||
// <band1>000eunpack ok\n0031ng refs/heads/main pre-receive hook declined\n
|
||||
// 0000
|
||||
// The band-1 payload carries its OWN inner pkt stream — the exact case
|
||||
// a flat parser returns `false` on.
|
||||
let mut out = Vec::new();
|
||||
out.extend(pkt(b"\x02policy: denied\n")); // band 2 = progress/error
|
||||
out.extend(sideband_data(&[
|
||||
b"unpack ok\n",
|
||||
b"ng refs/heads/main pre-receive hook declined\n",
|
||||
]));
|
||||
out.extend_from_slice(b"0000");
|
||||
|
||||
assert!(
|
||||
receive_pack_report_rejected(&out),
|
||||
"must detect the nested band-1 ng line on a real deny"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_report_rejected_ignores_progress_channel_noise() {
|
||||
// A band-2 (progress) line must never be mistaken for a status line,
|
||||
// even if it contained bytes resembling a status — only band-1 counts.
|
||||
let mut out = Vec::new();
|
||||
out.extend(pkt(b"\x02ng-looking progress noise\n")); // band 2 noise
|
||||
out.extend(sideband_data(&[b"unpack ok\n", b"ok refs/heads/main\n"]));
|
||||
out.extend_from_slice(b"0000");
|
||||
|
||||
assert!(!receive_pack_report_rejected(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_pack_report_rejected_handles_truncated_and_malformed() {
|
||||
// Defensive: malformed length / truncated frame yields no match, never
|
||||
// a panic.
|
||||
assert!(!receive_pack_report_rejected(b""));
|
||||
assert!(!receive_pack_report_rejected(b"zzzz"));
|
||||
assert!(!receive_pack_report_rejected(b"0048")); // length, no payload
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_expected_url_uses_tenant_host_not_config_host() {
|
||||
let tenant_a = tenant("host-a.example", 1);
|
||||
let tenant_b = tenant("host-b.example", 2);
|
||||
|
||||
let url_a = git_expected_url(
|
||||
"wss://config-host.example",
|
||||
&tenant_a,
|
||||
"/git/owner/repo/info/refs?service=git-upload-pack",
|
||||
)
|
||||
.expect("recognized info/refs path");
|
||||
let url_b = git_expected_url(
|
||||
"wss://config-host.example",
|
||||
&tenant_b,
|
||||
"/git/owner/repo/info/refs?service=git-upload-pack",
|
||||
)
|
||||
.expect("recognized info/refs path");
|
||||
|
||||
assert_eq!(url_a, "https://host-a.example/git/owner/repo");
|
||||
assert_eq!(url_b, "https://host-b.example/git/owner/repo");
|
||||
assert_ne!(url_a, url_b);
|
||||
|
||||
let url_a_alt_config = git_expected_url(
|
||||
"wss://different-config.example",
|
||||
&tenant_a,
|
||||
"/git/owner/repo/git-upload-pack",
|
||||
)
|
||||
.expect("recognized upload-pack path");
|
||||
assert_eq!(url_a_alt_config, "https://host-a.example/git/owner/repo");
|
||||
}
|
||||
|
||||
/// GitAuth host-bind bite: a token signed for community A's repo URL must
|
||||
/// fail when the request Host resolved to community B. If `git_expected_url`
|
||||
/// is changed back to `config.relay_url`'s host, the expected URL below
|
||||
/// becomes A's URL and this wrongly verifies.
|
||||
#[test]
|
||||
fn git_nip98_rejects_token_signed_for_wrong_community_host() {
|
||||
let keys = Keys::generate();
|
||||
let signed_for_a = "https://host-a.example/git/alice/repo";
|
||||
let event_json = git_nip98_event_json(&keys, signed_for_a, "GET");
|
||||
let tenant_b = tenant("host-b.example", 2);
|
||||
let expected_for_b = git_expected_url(
|
||||
"wss://host-a.example",
|
||||
&tenant_b,
|
||||
"/git/alice/repo/info/refs?service=git-upload-pack",
|
||||
)
|
||||
.expect("recognized info/refs path");
|
||||
|
||||
let err = buzz_auth::nip98::verify_nip98_event(&event_json, &expected_for_b, "GET", None)
|
||||
.expect_err("cross-host git NIP-98 token must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("URL mismatch"),
|
||||
"expected URL-mismatch rejection, got {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_nip98_accepts_token_signed_for_matching_community_host() {
|
||||
let keys = Keys::generate();
|
||||
let signed_for_a = "https://host-a.example/git/alice/repo";
|
||||
let event_json = git_nip98_event_json(&keys, signed_for_a, "GET");
|
||||
let tenant_a = tenant("host-a.example", 1);
|
||||
let expected_for_a = git_expected_url(
|
||||
"wss://different-config.example",
|
||||
&tenant_a,
|
||||
"/git/alice/repo/git-upload-pack",
|
||||
)
|
||||
.expect("recognized upload-pack path");
|
||||
|
||||
let pubkey =
|
||||
buzz_auth::nip98::verify_nip98_event(&event_json, &expected_for_a, "GET", None)
|
||||
.expect("matching-host git NIP-98 token must verify");
|
||||
assert_eq!(pubkey, keys.public_key());
|
||||
}
|
||||
|
||||
/// Split a pkt-line stream into `(len_prefix, payload)` frames, validating
|
||||
/// that each 4-hex length counts itself and that `0000` is a flush.
|
||||
fn parse_pkt_lines(bytes: &[u8]) -> Vec<Vec<u8>> {
|
||||
|
||||
@@ -17,6 +17,7 @@ use axum::{
|
||||
use base64::Engine;
|
||||
use buzz_audit::{AuditAction, NewAuditEntry};
|
||||
use buzz_auth::Scope;
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use buzz_media::{BlobDescriptor, MediaError};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -32,6 +33,10 @@ pub(crate) struct AuthenticatedUpload {
|
||||
auth_event: nostr::Event,
|
||||
#[allow(dead_code)] // scopes validated in extractor; stored for future per-scope handler logic
|
||||
scopes: Vec<Scope>,
|
||||
/// Community resolved from the request host at extraction time (row zero for
|
||||
/// this HTTP door), identical to the WS door in `router.rs` and the bridge
|
||||
/// door in `bridge.rs`. Server-resolved, never client-supplied.
|
||||
tenant: TenantContext,
|
||||
}
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
@@ -43,19 +48,41 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let headers = &parts.headers;
|
||||
|
||||
// 1. Extract and validate Blossom auth event
|
||||
// 1. Row zero: bind this upload to its community from the request host,
|
||||
// identical to the WS door in `router.rs` and the bridge door in
|
||||
// `bridge.rs`. Fail-closed: an unmapped host or lookup failure is a
|
||||
// generic `NotFound` (404) — never a default tenant, never echoing the
|
||||
// host, so an unauthenticated caller cannot probe which communities
|
||||
// exist on this deployment.
|
||||
//
|
||||
// This MUST run before scope resolution so the API-token lookup is
|
||||
// keyed on (community_id, token_hash) — see Gap 2 / row-44 conformance
|
||||
// obligation. Resolving scopes without a tenant in hand would query
|
||||
// api_tokens by hash alone, defeating the cross-community fence.
|
||||
//
|
||||
// It also runs before Blossom auth verification (step 2) so the
|
||||
// `server`-tag check validates against the *bound tenant host*, not a
|
||||
// process-global domain — a relay process serves many tenant hosts, and
|
||||
// the stock CLI tags its own configured relay host (conformance row 52).
|
||||
// Binding only reads the Host header — no request body is buffered — so
|
||||
// doing it first preserves the pre-body auth-rejection guarantee.
|
||||
let raw_host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
|
||||
// 2. Extract and validate Blossom auth event against the bound host.
|
||||
let auth_event = extract_blossom_auth(headers)?;
|
||||
// Use the permissive window (3600s) here because we don't know the
|
||||
// content type yet. The upload functions re-verify with the correct
|
||||
// per-type window (600s for images, 3600s for video) after the body
|
||||
// has been consumed and the SHA-256 computed.
|
||||
buzz_media::auth::verify_blossom_auth_event(
|
||||
&auth_event,
|
||||
state.config.media.server_domain.as_deref(),
|
||||
3600,
|
||||
)?;
|
||||
buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(tenant.host()), 3600)?;
|
||||
|
||||
// 2. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload)
|
||||
// 3. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload)
|
||||
let claimed_hash = headers
|
||||
.get("x-sha-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -70,7 +97,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
return Err(MediaError::HashMismatch);
|
||||
}
|
||||
|
||||
// 3. Validate X-SHA-256 matches at least one x tag in the auth event
|
||||
// 4. Validate X-SHA-256 matches at least one x tag in the auth event
|
||||
let has_matching_x = auth_event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -79,22 +106,27 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
return Err(MediaError::HashMismatch);
|
||||
}
|
||||
|
||||
// 4. Resolve scopes (API token or dev mode)
|
||||
let scopes = resolve_upload_scopes(headers, state, &auth_event.pubkey).await?;
|
||||
// 5. Resolve scopes (API token or dev mode), scoped to the bound tenant.
|
||||
let scopes = resolve_upload_scopes(headers, state, &tenant, &auth_event.pubkey).await?;
|
||||
buzz_auth::require_scope(&scopes, Scope::FilesWrite)
|
||||
.map_err(|_| MediaError::InsufficientScope)?;
|
||||
|
||||
// 5. Relay membership gate (NIP-43).
|
||||
// 6. Relay membership gate (NIP-43).
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
crate::api::relay_members::enforce_relay_membership(
|
||||
state,
|
||||
tenant.community(),
|
||||
auth_event.pubkey.as_bytes(),
|
||||
auth_tag,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| MediaError::RelayMembershipRequired)?;
|
||||
|
||||
Ok(AuthenticatedUpload { auth_event, scopes })
|
||||
Ok(AuthenticatedUpload {
|
||||
auth_event,
|
||||
scopes,
|
||||
tenant,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +161,7 @@ pub async fn upload_blob(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let descriptor = if content_type.starts_with("video/") {
|
||||
let mut descriptor = if content_type.starts_with("video/") {
|
||||
// Video path: stream body directly to disk — never fully buffered in RAM.
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
@@ -138,6 +170,7 @@ pub async fn upload_blob(
|
||||
buzz_media::process_video_upload(
|
||||
&state.media_storage,
|
||||
&state.config.media,
|
||||
&auth.tenant,
|
||||
&auth.auth_event,
|
||||
body.into_data_stream(),
|
||||
content_length,
|
||||
@@ -167,6 +200,7 @@ pub async fn upload_blob(
|
||||
buzz_media::process_upload(
|
||||
&state.media_storage,
|
||||
&state.config.media,
|
||||
&auth.tenant,
|
||||
&auth.auth_event,
|
||||
bytes,
|
||||
)
|
||||
@@ -175,6 +209,7 @@ pub async fn upload_blob(
|
||||
buzz_media::process_file_upload(
|
||||
&state.media_storage,
|
||||
&state.config.media,
|
||||
&auth.tenant,
|
||||
&auth.auth_event,
|
||||
bytes,
|
||||
)
|
||||
@@ -182,6 +217,12 @@ pub async fn upload_blob(
|
||||
}
|
||||
};
|
||||
|
||||
rewrite_descriptor_urls_for_tenant(
|
||||
&mut descriptor,
|
||||
&state.config.relay_url,
|
||||
auth.tenant.host(),
|
||||
);
|
||||
|
||||
// Normalize MIME to a known set to bound label cardinality.
|
||||
let mime_label = match descriptor.mime_type.as_str() {
|
||||
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "video/mp4" => {
|
||||
@@ -193,16 +234,14 @@ pub async fn upload_blob(
|
||||
|
||||
// Audit via bounded channel — same pattern as event audit.
|
||||
let desc = descriptor.clone();
|
||||
let uploader = auth.auth_event.pubkey.to_hex();
|
||||
if let Err(e) = state
|
||||
.audit_tx
|
||||
.send(NewAuditEntry {
|
||||
event_id: desc.sha256.clone(),
|
||||
event_kind: buzz_core::kind::KIND_MEDIA_UPLOAD,
|
||||
actor_pubkey: uploader,
|
||||
community_id: auth.tenant.community(),
|
||||
action: AuditAction::MediaUploaded,
|
||||
channel_id: None,
|
||||
metadata: serde_json::json!({
|
||||
actor_pubkey: Some(auth.auth_event.pubkey.to_bytes().to_vec()),
|
||||
object_id: Some(desc.sha256.clone()),
|
||||
detail: serde_json::json!({
|
||||
"sha256": desc.sha256,
|
||||
"size": desc.size,
|
||||
"mime": desc.mime_type,
|
||||
@@ -217,6 +256,48 @@ pub async fn upload_blob(
|
||||
Ok(Json(descriptor))
|
||||
}
|
||||
|
||||
pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &str) -> String {
|
||||
let scheme = if config_relay_url.trim_start().starts_with("wss://")
|
||||
|| config_relay_url.trim_start().starts_with("https://")
|
||||
{
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
format!("{scheme}://{tenant_host}/media")
|
||||
}
|
||||
|
||||
fn rewrite_descriptor_urls_for_tenant(
|
||||
descriptor: &mut BlobDescriptor,
|
||||
config_relay_url: &str,
|
||||
tenant_host: &str,
|
||||
) {
|
||||
let base = media_base_url_for_tenant(config_relay_url, tenant_host);
|
||||
let ext = descriptor
|
||||
.url
|
||||
.rsplit_once('.')
|
||||
.map(|(_, ext)| ext)
|
||||
.filter(|ext| is_safe_ext(ext))
|
||||
.unwrap_or("bin");
|
||||
descriptor.url = format!("{base}/{}.{ext}", descriptor.sha256);
|
||||
if descriptor.thumb.is_some() {
|
||||
descriptor.thumb = Some(format!("{base}/{}.thumb.jpg", descriptor.sha256));
|
||||
}
|
||||
}
|
||||
|
||||
async fn bind_media_read_tenant(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<TenantContext, MediaError> {
|
||||
let raw_host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
crate::tenant::bind_community(&state.db, raw_host)
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)
|
||||
}
|
||||
|
||||
/// Whether a path-segment extension is a safe token.
|
||||
///
|
||||
/// The sidecar's `ext` field is the *authoritative* extension — the serve and
|
||||
@@ -302,13 +383,14 @@ pub async fn get_blob(
|
||||
req_headers: HeaderMap,
|
||||
) -> Result<Response, MediaError> {
|
||||
validate_media_path(&sha256_ext)?;
|
||||
let tenant = bind_media_read_tenant(&state, &req_headers).await?;
|
||||
|
||||
// Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative.
|
||||
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
|
||||
let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext);
|
||||
let _ = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(parent_hash)
|
||||
.read_sidecar_mime(&tenant, parent_hash)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
"image/jpeg".to_string()
|
||||
@@ -317,14 +399,14 @@ pub async fn get_blob(
|
||||
// the sidecar's canonical extension — sidecar is authoritative.
|
||||
let sidecar_mime = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(&sha256_ext)
|
||||
.read_sidecar_mime(&tenant, &sha256_ext)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
if sha256_ext.contains('.') {
|
||||
let requested_ext = sha256_ext.rsplit('.').next().unwrap_or("");
|
||||
let sidecar = state
|
||||
.media_storage
|
||||
.get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext))
|
||||
.get_sidecar(&tenant, sha256_ext.split('.').next().unwrap_or(&sha256_ext))
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
if requested_ext != sidecar.ext {
|
||||
@@ -344,7 +426,7 @@ pub async fn get_blob(
|
||||
"attachment"
|
||||
};
|
||||
|
||||
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
|
||||
let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?;
|
||||
|
||||
// Parse optional Range header.
|
||||
let range_header = req_headers
|
||||
@@ -480,30 +562,32 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> {
|
||||
/// is missing, we return 404 rather than fall back to untrusted metadata.
|
||||
pub async fn head_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(sha256_ext): Path<String>,
|
||||
) -> Result<Response, MediaError> {
|
||||
validate_media_path(&sha256_ext)?;
|
||||
let tenant = bind_media_read_tenant(&state, &headers).await?;
|
||||
|
||||
// Sidecar gate FIRST — reject before any blob I/O.
|
||||
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
|
||||
let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext);
|
||||
let _ = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(parent_hash)
|
||||
.read_sidecar_mime(&tenant, parent_hash)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
"image/jpeg".to_string()
|
||||
} else {
|
||||
let sidecar_mime = state
|
||||
.media_storage
|
||||
.read_sidecar_mime(&sha256_ext)
|
||||
.read_sidecar_mime(&tenant, &sha256_ext)
|
||||
.await
|
||||
.ok_or(MediaError::NotFound)?;
|
||||
if sha256_ext.contains('.') {
|
||||
let requested_ext = sha256_ext.rsplit('.').next().unwrap_or("");
|
||||
let sidecar = state
|
||||
.media_storage
|
||||
.get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext))
|
||||
.get_sidecar(&tenant, sha256_ext.split('.').next().unwrap_or(&sha256_ext))
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
if requested_ext != sidecar.ext {
|
||||
@@ -513,7 +597,7 @@ pub async fn head_blob(
|
||||
sidecar_mime
|
||||
};
|
||||
|
||||
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
|
||||
let key = resolve_s3_key(&state.media_storage, &tenant, &sha256_ext).await?;
|
||||
match state.media_storage.head_with_metadata(&key).await? {
|
||||
Some(meta) => {
|
||||
let size_str = meta.size.to_string();
|
||||
@@ -541,13 +625,14 @@ pub async fn head_blob(
|
||||
/// object-key confusion if sidecar data is ever tampered with.
|
||||
async fn resolve_s3_key(
|
||||
storage: &buzz_media::MediaStorage,
|
||||
tenant: &TenantContext,
|
||||
sha256_ext: &str,
|
||||
) -> Result<String, MediaError> {
|
||||
if sha256_ext.contains('.') {
|
||||
Ok(sha256_ext.to_string())
|
||||
} else {
|
||||
let sidecar = storage
|
||||
.get_sidecar(sha256_ext)
|
||||
.get_sidecar(tenant, sha256_ext)
|
||||
.await
|
||||
.map_err(|_| MediaError::NotFound)?;
|
||||
// Validate sidecar ext — never trust storage as authoritative for path construction
|
||||
@@ -584,14 +669,20 @@ fn extract_blossom_auth(headers: &HeaderMap) -> Result<nostr::Event, MediaError>
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Resolve permission scopes for an upload caller.
|
||||
/// Resolve permission scopes for an upload caller, scoped to the request's tenant.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `X-Auth-Token: buzz_*` header — API token path (validates owner matches Blossom signer)
|
||||
/// 2. If `require_auth_token` is false (dev mode) — check pubkey allowlist, then grant file scopes
|
||||
///
|
||||
/// The token lookup is keyed on `(tenant.community(), token_hash)` — see
|
||||
/// [`buzz_db::api_token::get_api_token_by_hash_including_revoked`] for the
|
||||
/// row-44 conformance rationale. A token minted in community A presented to a
|
||||
/// host that resolves to community B must not authorize.
|
||||
async fn resolve_upload_scopes(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
blossom_pubkey: &nostr::PublicKey,
|
||||
) -> Result<Vec<Scope>, MediaError> {
|
||||
// 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token.
|
||||
@@ -603,7 +694,7 @@ async fn resolve_upload_scopes(
|
||||
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||
let record = state
|
||||
.db
|
||||
.get_api_token_by_hash_including_revoked(&hash)
|
||||
.get_api_token_by_hash_including_revoked(tenant.community(), &hash)
|
||||
.await
|
||||
.map_err(|_| MediaError::Unauthorized)?
|
||||
.ok_or(MediaError::Unauthorized)?;
|
||||
@@ -646,7 +737,7 @@ async fn resolve_upload_scopes(
|
||||
let pubkey_bytes = blossom_pubkey.to_bytes().to_vec();
|
||||
if !state
|
||||
.db
|
||||
.is_pubkey_allowed(&pubkey_bytes)
|
||||
.is_pubkey_allowed(tenant.community(), &pubkey_bytes)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
@@ -747,6 +838,49 @@ mod tests {
|
||||
assert!(validate_media_path("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_base_url_for_tenant_uses_tenant_host_and_http_scheme() {
|
||||
assert_eq!(
|
||||
media_base_url_for_tenant("wss://config.example", "tenant-b.example"),
|
||||
"https://tenant-b.example/media"
|
||||
);
|
||||
assert_eq!(
|
||||
media_base_url_for_tenant("ws://config.example", "localhost:3100"),
|
||||
"http://localhost:3100/media"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_descriptor_urls_for_tenant_replaces_global_media_host() {
|
||||
let hash = "a".repeat(64);
|
||||
let mut descriptor = BlobDescriptor {
|
||||
url: format!("https://primary.example/media/{hash}.jpg"),
|
||||
sha256: hash.clone(),
|
||||
size: 42,
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
uploaded: 1700000000,
|
||||
dim: Some("1x1".to_string()),
|
||||
blurhash: None,
|
||||
thumb: Some(format!("https://primary.example/media/{hash}.thumb.jpg")),
|
||||
duration: None,
|
||||
};
|
||||
|
||||
rewrite_descriptor_urls_for_tenant(
|
||||
&mut descriptor,
|
||||
"wss://primary.example",
|
||||
"tenant-b.example",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
descriptor.url,
|
||||
format!("https://tenant-b.example/media/{hash}.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
descriptor.thumb,
|
||||
Some(format!("https://tenant-b.example/media/{hash}.thumb.jpg"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_byte_range_basic() {
|
||||
assert_eq!(parse_byte_range("bytes=0-499", 1000), Some((0, 499)));
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
|
||||
/// `git/transport.rs`, and `audio/handler.rs`.
|
||||
pub mod relay_members {
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use buzz_core::tenant::CommunityId;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -50,8 +51,12 @@ pub mod relay_members {
|
||||
}
|
||||
|
||||
/// Check relay membership without committing to an HTTP response shape.
|
||||
///
|
||||
/// `community` is the server-resolved tenant of the request; membership is
|
||||
/// scoped to it so admitting a pubkey to community A never admits it to B.
|
||||
pub async fn check_relay_membership(
|
||||
state: &AppState,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<MembershipDecision, String> {
|
||||
@@ -62,7 +67,7 @@ pub mod relay_members {
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
let is_member = state
|
||||
.db
|
||||
.is_relay_member(&pubkey_hex)
|
||||
.is_relay_member(community, &pubkey_hex)
|
||||
.await
|
||||
.map_err(|e| format!("relay membership check failed: {e}"))?;
|
||||
if is_member {
|
||||
@@ -77,10 +82,11 @@ pub mod relay_members {
|
||||
match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
|
||||
Ok(owner_pubkey) => {
|
||||
let owner_hex = owner_pubkey.to_hex();
|
||||
let owner_is_member =
|
||||
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
|
||||
format!("relay membership check (owner) failed: {e}")
|
||||
})?;
|
||||
let owner_is_member = state
|
||||
.db
|
||||
.is_relay_member(community, &owner_hex)
|
||||
.await
|
||||
.map_err(|e| format!("relay membership check (owner) failed: {e}"))?;
|
||||
if owner_is_member {
|
||||
debug!(
|
||||
agent = %pubkey_hex,
|
||||
@@ -113,10 +119,11 @@ pub mod relay_members {
|
||||
/// no NIP-OA tag is present/applicable (open relay without auth tag).
|
||||
pub async fn enforce_relay_membership(
|
||||
state: &AppState,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
|
||||
match check_relay_membership(state, pubkey_bytes, auth_tag_header).await {
|
||||
match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await {
|
||||
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None),
|
||||
Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)),
|
||||
Ok(MembershipDecision::Denied) => Err((
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::HeaderValue,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use hex;
|
||||
@@ -23,18 +23,36 @@ pub struct Nip05Query {
|
||||
/// No authentication required — public discovery endpoint.
|
||||
pub async fn nostr_nip05(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<Nip05Query>,
|
||||
) -> Response {
|
||||
let json = match params.name {
|
||||
None => serde_json::json!({ "names": {}, "relays": {} }),
|
||||
Some(n) => {
|
||||
// Row zero: bind this public request to its community from the request host
|
||||
// before the tenant-scoped lookup, identical to the bridge/WS doors. An
|
||||
// unmapped host falls through to the empty `{names,relays}` response — never
|
||||
// a default tenant, never echoing which communities exist on this deployment.
|
||||
let raw_host = headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let json = match (
|
||||
params.name,
|
||||
crate::tenant::bind_community(&state.db, raw_host).await,
|
||||
) {
|
||||
(Some(n), Ok(tenant)) => {
|
||||
let name = n.to_lowercase();
|
||||
// Extract domain from relay_url (e.g. "ws://buzz.block.xyz" → "buzz.block.xyz")
|
||||
let domain = extract_domain(&state.config.relay_url);
|
||||
match state.db.get_user_by_nip05(&name, &domain).await {
|
||||
// NIP-05 identity is host-scoped in a multi-tenant relay: the
|
||||
// community is already bound from Host, and the handle domain must
|
||||
// match that same tenant host (not process-global config.relay_url).
|
||||
let domain = extract_domain(tenant.host());
|
||||
match state
|
||||
.db
|
||||
.get_user_by_nip05(tenant.community(), &name, &domain)
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => {
|
||||
let hex_pubkey = hex::encode(&user.pubkey);
|
||||
let relay_url = state.config.relay_url.clone();
|
||||
let relay_url =
|
||||
relay_url_for_tenant_host(&state.config.relay_url, tenant.host());
|
||||
serde_json::json!({
|
||||
"names": { (name): hex_pubkey.clone() },
|
||||
"relays": { (hex_pubkey): [relay_url] }
|
||||
@@ -43,6 +61,7 @@ pub async fn nostr_nip05(
|
||||
_ => serde_json::json!({ "names": {}, "relays": {} }),
|
||||
}
|
||||
}
|
||||
_ => serde_json::json!({ "names": {}, "relays": {} }),
|
||||
};
|
||||
|
||||
let mut response = Json(json).into_response();
|
||||
@@ -54,8 +73,10 @@ pub async fn nostr_nip05(
|
||||
}
|
||||
|
||||
/// Validate and canonicalize a NIP-05 handle: must be `local@domain` where domain
|
||||
/// matches the relay. Returns the lowercased canonical form, or an error message.
|
||||
pub(crate) fn canonicalize_nip05(raw: &str, relay_url: &str) -> Result<String, String> {
|
||||
/// matches the bound tenant host. Returns the lowercased canonical form, or an
|
||||
/// error message. `expected_host_or_url` may be either a bare Host authority or
|
||||
/// a relay URL; only its host/domain component is compared.
|
||||
pub(crate) fn canonicalize_nip05(raw: &str, expected_host_or_url: &str) -> Result<String, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("empty".into());
|
||||
@@ -66,17 +87,30 @@ pub(crate) fn canonicalize_nip05(raw: &str, relay_url: &str) -> Result<String, S
|
||||
if local.is_empty() || domain.is_empty() {
|
||||
return Err("nip05_handle must be in user@domain format".to_string());
|
||||
}
|
||||
let relay_domain = extract_domain(relay_url);
|
||||
let expected_domain = extract_domain(expected_host_or_url);
|
||||
let canonical_domain = domain.to_lowercase();
|
||||
if canonical_domain != relay_domain {
|
||||
if canonical_domain != expected_domain {
|
||||
return Err(format!(
|
||||
"nip05_handle domain must match this relay ({})",
|
||||
relay_domain
|
||||
expected_domain
|
||||
));
|
||||
}
|
||||
Ok(format!("{}@{}", local.to_lowercase(), canonical_domain))
|
||||
}
|
||||
|
||||
/// Build the relay URL advertised in the NIP-05 `relays` map for the bound
|
||||
/// tenant. Preserve the deployment scheme from config, but never the configured
|
||||
/// host: in a host-per-community deployment the request tenant host is the
|
||||
/// relay identity clients must follow back.
|
||||
pub(crate) fn relay_url_for_tenant_host(config_relay_url: &str, tenant_host: &str) -> String {
|
||||
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
|
||||
"wss"
|
||||
} else {
|
||||
"ws"
|
||||
};
|
||||
format!("{scheme}://{tenant_host}")
|
||||
}
|
||||
|
||||
/// Extract the domain (host) from a URL string.
|
||||
/// e.g. "ws://localhost:3000" → "localhost", "wss://buzz.block.xyz" → "buzz.block.xyz"
|
||||
pub(crate) fn extract_domain(url: &str) -> String {
|
||||
@@ -92,3 +126,29 @@ pub(crate) fn extract_domain(url: &str) -> String {
|
||||
.unwrap_or("localhost")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonicalize_nip05_accepts_bound_tenant_host_not_config_url() {
|
||||
assert_eq!(
|
||||
canonicalize_nip05("Alice@tenant-b.example", "tenant-b.example").unwrap(),
|
||||
"alice@tenant-b.example"
|
||||
);
|
||||
assert!(canonicalize_nip05("alice@config.example", "tenant-b.example").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_url_for_tenant_host_uses_config_scheme_but_tenant_host() {
|
||||
assert_eq!(
|
||||
relay_url_for_tenant_host("wss://config.example", "tenant-b.example"),
|
||||
"wss://tenant-b.example"
|
||||
);
|
||||
assert_eq!(
|
||||
relay_url_for_tenant_host("ws://config.example", "localhost:3100"),
|
||||
"ws://localhost:3100"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::{
|
||||
extract::{Path, State, WebSocketUpgrade},
|
||||
response::IntoResponse,
|
||||
@@ -30,9 +31,11 @@ use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_auth::generate_challenge;
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use buzz_db::channel::MemberRole;
|
||||
|
||||
use buzz_core::StoredEvent;
|
||||
use buzz_pubsub::EventTopic;
|
||||
|
||||
use crate::audio::room::PeerCtrl;
|
||||
use crate::state::AppState;
|
||||
@@ -56,9 +59,29 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub async fn ws_audio_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_audio_connection(socket, state, channel_id))
|
||||
// Row zero: bind this huddle-audio connection to its community from the
|
||||
// request host BEFORE the WebSocket upgrade, identical to the main relay
|
||||
// door. An unmapped host or lookup failure fails closed with a generic 404
|
||||
// — never a default tenant — so an unauthenticated caller cannot probe
|
||||
// which communities exist on this deployment.
|
||||
let raw_host = headers
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let tenant = match crate::tenant::bind_community(&state.db, raw_host).await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"relay: no community is configured for this host",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
ws.on_upgrade(move |socket| handle_audio_connection(socket, state, tenant, channel_id))
|
||||
}
|
||||
|
||||
/// Highest huddle audio protocol version this relay understands. Clients are
|
||||
@@ -84,7 +107,12 @@ fn default_protocol_version() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channel_id: Uuid) {
|
||||
async fn handle_audio_connection(
|
||||
socket: WebSocket,
|
||||
state: Arc<AppState>,
|
||||
tenant: TenantContext,
|
||||
channel_id: Uuid,
|
||||
) {
|
||||
let (mut ws_send, mut ws_recv) = socket.split();
|
||||
|
||||
let challenge = generate_challenge();
|
||||
@@ -127,7 +155,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
// Extract NIP-OA auth tag before verify_auth_event consumes the event.
|
||||
let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event);
|
||||
|
||||
let relay_url = state.config.relay_url.clone();
|
||||
let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant);
|
||||
let auth_ctx = match state
|
||||
.auth
|
||||
.verify_auth_event(auth_msg.event, &challenge, &relay_url)
|
||||
@@ -154,6 +182,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
tenant.community(),
|
||||
pubkey.as_bytes(),
|
||||
auth_tag_json.as_deref(),
|
||||
)
|
||||
@@ -171,7 +200,15 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = ensure_membership(&state, channel_id, &pubkey_bytes, parent_channel_id).await {
|
||||
if let Err(e) = ensure_membership(
|
||||
&state,
|
||||
&tenant,
|
||||
channel_id,
|
||||
&pubkey_bytes,
|
||||
parent_channel_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}");
|
||||
let _ = ws_send
|
||||
.send(WsMessage::Text(
|
||||
@@ -183,6 +220,35 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
return;
|
||||
}
|
||||
|
||||
// Huddle audio guardrail (plan §5b). Audio frames are relayed only within
|
||||
// a single pod; under horizontal scaling (any-pod-any-connection) two peers
|
||||
// in one huddle can land on different pods and never hear each other. A
|
||||
// multi-pod deployment sets `huddle_audio_available = false`, and we surface
|
||||
// a clear, client-handleable "unavailable" signal here — BEFORE joining a
|
||||
// room — rather than shipping a silent split-room. Single-pod deployments
|
||||
// leave the flag at its `true` default and keep today's behavior. The fix
|
||||
// is an out-of-relay media/SFU service (Tyler's long-term target), not
|
||||
// sticky-routing huddles into this rewrite.
|
||||
if !state.config.huddle_audio_available {
|
||||
debug!(
|
||||
channel_id = %channel_id,
|
||||
pubkey = %pubkey_hex,
|
||||
"huddle audio unavailable under horizontal scaling — rejecting join"
|
||||
);
|
||||
let _ = ws_send
|
||||
.send(WsMessage::Text(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"code": "huddle_audio_unavailable",
|
||||
"message": "huddle audio unavailable in this deployment"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let room = state.audio_rooms.get_or_create(channel_id);
|
||||
|
||||
// Re-check archived status after obtaining the room. This closes the
|
||||
@@ -191,7 +257,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
// get_or_create (the old room was already cleaned up). This DB check
|
||||
// catches that case. The room-level ended flag (checked inside add_peer)
|
||||
// handles the same-room case.
|
||||
match state.db.get_channel(channel_id).await {
|
||||
match state.db.get_channel(tenant.community(), channel_id).await {
|
||||
Ok(ch) if ch.archived_at.is_some() => {
|
||||
debug!(channel_id = %channel_id, "channel archived before room join");
|
||||
let _ = ws_send
|
||||
@@ -317,6 +383,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
let parent_id_for_event = parent_channel_id.unwrap_or(channel_id);
|
||||
emit_participant_event(
|
||||
&state,
|
||||
&tenant,
|
||||
Kind::Custom(48101),
|
||||
channel_id,
|
||||
parent_id_for_event,
|
||||
@@ -383,6 +450,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
|
||||
emit_participant_event(
|
||||
&state,
|
||||
&tenant,
|
||||
Kind::Custom(48102),
|
||||
channel_id,
|
||||
parent_id_for_event,
|
||||
@@ -393,7 +461,11 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
if should_auto_end {
|
||||
info!(channel_id = %channel_id, "audio room empty — auto-ending huddle");
|
||||
|
||||
match state.db.archive_channel(channel_id).await {
|
||||
match state
|
||||
.db
|
||||
.archive_channel(tenant.community(), channel_id)
|
||||
.await
|
||||
{
|
||||
Err(e) => {
|
||||
warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}");
|
||||
room.clear_ended();
|
||||
@@ -403,6 +475,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
|
||||
emit_participant_event(
|
||||
&state,
|
||||
&tenant,
|
||||
Kind::Custom(48103),
|
||||
channel_id,
|
||||
parent_id_for_event,
|
||||
@@ -621,6 +694,7 @@ async fn heartbeat_loop(
|
||||
|
||||
async fn ensure_membership(
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
channel_id: Uuid,
|
||||
pubkey_bytes: &[u8],
|
||||
parent_channel_id: Option<Uuid>,
|
||||
@@ -629,7 +703,7 @@ async fn ensure_membership(
|
||||
// This ensures auto-ended huddles can't be rejoined by existing members.
|
||||
let channel = state
|
||||
.db
|
||||
.get_channel(channel_id)
|
||||
.get_channel(tenant.community(), channel_id)
|
||||
.await
|
||||
.map_err(|e| format!("db error: {e}"))?;
|
||||
|
||||
@@ -639,7 +713,7 @@ async fn ensure_membership(
|
||||
|
||||
// Fast path: already a member.
|
||||
let is_member = state
|
||||
.is_member_cached(channel_id, pubkey_bytes)
|
||||
.is_member_cached(tenant.community(), channel_id, pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("db error: {e}"))?;
|
||||
|
||||
@@ -663,7 +737,7 @@ async fn ensure_membership(
|
||||
if channel.ttl_seconds.is_some() {
|
||||
if let Some(parent_id) = parent_channel_id {
|
||||
let parent_member = state
|
||||
.is_member_cached(parent_id, pubkey_bytes)
|
||||
.is_member_cached(tenant.community(), parent_id, pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("db error: {e}"))?;
|
||||
|
||||
@@ -671,6 +745,7 @@ async fn ensure_membership(
|
||||
state
|
||||
.db
|
||||
.add_member(
|
||||
tenant.community(),
|
||||
channel_id,
|
||||
pubkey_bytes,
|
||||
MemberRole::Member,
|
||||
@@ -678,7 +753,7 @@ async fn ensure_membership(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("auto-add failed: {e}"))?;
|
||||
state.invalidate_membership(channel_id, pubkey_bytes);
|
||||
state.invalidate_membership(tenant, channel_id, pubkey_bytes);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
@@ -690,6 +765,7 @@ async fn ensure_membership(
|
||||
|
||||
async fn emit_participant_event(
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
kind: Kind,
|
||||
channel_id: Uuid,
|
||||
parent_channel_id: Uuid,
|
||||
@@ -729,7 +805,11 @@ async fn emit_participant_event(
|
||||
// 1. Persist to DB so late-joining clients can reconstruct huddle state
|
||||
// from historical queries. Without this, lifecycle events only exist
|
||||
// for the duration of the Redis pub/sub delivery and are lost forever.
|
||||
let stored = match state.db.insert_event(&event, Some(parent_channel_id)).await {
|
||||
let stored = match state
|
||||
.db
|
||||
.insert_event(tenant.community(), &event, Some(parent_channel_id))
|
||||
.await
|
||||
{
|
||||
Ok((stored, true)) => stored,
|
||||
Ok((_, false)) => {
|
||||
// Duplicate — already persisted (e.g. concurrent emit). Skip fan-out
|
||||
@@ -758,17 +838,24 @@ async fn emit_participant_event(
|
||||
|
||||
// 2. Mark as locally-published before Redis broadcast to prevent
|
||||
// double-delivery when the event echoes back through the subscriber loop.
|
||||
state.mark_local_event(&event.id);
|
||||
state.mark_local_event(tenant.community(), &event.id);
|
||||
|
||||
// 3. Local fan-out to WS subscribers on this node, through the guarded send
|
||||
// path so a stale subscription on a removed/non-member connection cannot
|
||||
// receive this channel's audio lifecycle event (same gate as
|
||||
// dispatch_persistent_event in the ingest handler).
|
||||
crate::handlers::event::fan_out_event_to_local_subscribers(state, &stored).await;
|
||||
crate::handlers::event::fan_out_event_to_local_subscribers(state, tenant.community(), &stored)
|
||||
.await;
|
||||
|
||||
// 4. Cross-node broadcast via Redis pub/sub.
|
||||
if let Err(e) = state.pubsub.publish_event(parent_channel_id, &event).await {
|
||||
state.local_event_ids.invalidate(&event.id.to_bytes());
|
||||
if let Err(e) = state
|
||||
.pubsub
|
||||
.publish_event(tenant, EventTopic::Channel(parent_channel_id), &event)
|
||||
.await
|
||||
{
|
||||
state
|
||||
.local_event_ids
|
||||
.invalidate(&(tenant.community(), event.id.to_bytes()));
|
||||
warn!(
|
||||
event_id = %event_id_hex,
|
||||
channel_id = %parent_channel_id,
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
//! One-shot admin tool: re-index all kind:0 (user metadata) events in Typesense.
|
||||
//!
|
||||
//! Necessary after the indexer change that appends `display_name`/`name`/`nip05`
|
||||
//! values to the indexed content for kind:0 docs (see `buzz-search`'s
|
||||
//! `flatten_kind0_for_indexing`). Existing docs need to be rewritten with the
|
||||
//! appended tokens before they become searchable by display name.
|
||||
//!
|
||||
//! New / updated kind:0 events index correctly automatically — this tool only
|
||||
//! exists to backfill the existing population.
|
||||
//!
|
||||
//! Usage (from the repo root, with .env sourced):
|
||||
//!
|
||||
//! ```
|
||||
//! cargo run --release -p buzz-relay --bin buzz-reindex-kind0
|
||||
//! ```
|
||||
//!
|
||||
//! Idempotent — Typesense uses upsert semantics, so running twice is safe.
|
||||
//! Streams in batches so memory stays bounded regardless of relay size.
|
||||
//!
|
||||
//! ## Paging
|
||||
//!
|
||||
//! Walks `query_events` with a snapshot ceiling (`until = now()` at start) plus
|
||||
//! a keyset cursor over `(created_at, id)` matching the underlying
|
||||
//! `ORDER BY created_at DESC, id ASC` index. This guarantees:
|
||||
//!
|
||||
//! - No rows are skipped if new kind:0 events arrive during the run
|
||||
//! (they're newer than the snapshot, so they fall outside the predicate).
|
||||
//! - No rows are double-counted at page boundaries (the cursor advances
|
||||
//! strictly past the last row of each batch).
|
||||
//! - Bounded total work — won't chase its own tail under live write traffic.
|
||||
//!
|
||||
//! Newly-arrived kind:0 events that fall outside the snapshot are indexed by
|
||||
//! the relay's live write path anyway, so this backfill plus the live path
|
||||
//! together cover the full population.
|
||||
|
||||
use anyhow::Context;
|
||||
use chrono::{DateTime, Utc};
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
use buzz_db::{Db, DbConfig, EventQuery};
|
||||
use buzz_relay::config::Config;
|
||||
use buzz_search::{SearchConfig, SearchService};
|
||||
|
||||
/// Page size for the SQL → Typesense pipeline. Small enough to keep DB and
|
||||
/// Typesense memory comfortable, large enough to amortise per-batch overhead.
|
||||
const BATCH: i64 = 500;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(
|
||||
EnvFilter::from_default_env()
|
||||
.add_directive("buzz_reindex_kind0=info".parse()?)
|
||||
.add_directive("buzz_relay=info".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::from_env().context("loading relay config from environment")?;
|
||||
|
||||
let db_config = DbConfig {
|
||||
database_url: config.database_url.clone(),
|
||||
..DbConfig::default()
|
||||
};
|
||||
let db = Db::new(&db_config)
|
||||
.await
|
||||
.context("connecting to postgres")?;
|
||||
|
||||
// SearchConfig::default() reads TYPESENSE_URL / TYPESENSE_API_KEY /
|
||||
// TYPESENSE_COLLECTION from the environment, same as the relay does.
|
||||
let search = SearchService::new(SearchConfig::default());
|
||||
search
|
||||
.ensure_collection()
|
||||
.await
|
||||
.context("ensuring Typesense collection")?;
|
||||
|
||||
// Snapshot ceiling: we only reindex events that already exist at start.
|
||||
// Anything newer is handled by the relay's live indexing path.
|
||||
let snapshot: DateTime<Utc> = Utc::now();
|
||||
|
||||
// Keyset cursor over (created_at, id) — matches the underlying
|
||||
// `ORDER BY created_at DESC, id ASC` index. On the first iteration both
|
||||
// cursor fields are None and the predicate reduces to `created_at <= snapshot`.
|
||||
// Subsequent iterations advance to strictly past the last row of the prior batch.
|
||||
let mut cursor_until: DateTime<Utc> = snapshot;
|
||||
let mut cursor_before_id: Option<Vec<u8>> = None;
|
||||
|
||||
let mut total_indexed: usize = 0;
|
||||
let mut total_failed: usize = 0;
|
||||
let mut batches: usize = 0;
|
||||
|
||||
info!(?snapshot, "starting kind:0 reindex");
|
||||
|
||||
loop {
|
||||
let q = EventQuery {
|
||||
kinds: Some(vec![0]),
|
||||
limit: Some(BATCH),
|
||||
max_limit: Some(BATCH),
|
||||
until: Some(cursor_until),
|
||||
before_id: cursor_before_id.clone(),
|
||||
..EventQuery::default()
|
||||
};
|
||||
|
||||
let batch = db
|
||||
.query_events(&q)
|
||||
.await
|
||||
.context("querying kind:0 events")?;
|
||||
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let batch_len = batch.len();
|
||||
|
||||
// Capture the tail of the batch for cursor advance *before* the index
|
||||
// call, so we still advance even if indexing fails for this batch.
|
||||
// (We'd otherwise loop forever on a poisoned batch.)
|
||||
let tail = batch
|
||||
.last()
|
||||
.map(|ev| {
|
||||
let ts = ev.event.created_at.as_secs() as i64;
|
||||
let dt = DateTime::<Utc>::from_timestamp(ts, 0).unwrap_or(cursor_until);
|
||||
let id_bytes = ev.event.id.to_bytes().to_vec();
|
||||
(dt, id_bytes)
|
||||
})
|
||||
.expect("batch is non-empty (checked above)");
|
||||
|
||||
match search.index_batch(&batch).await {
|
||||
Ok(indexed) => {
|
||||
total_indexed += indexed;
|
||||
if indexed < batch_len {
|
||||
let failed = batch_len - indexed;
|
||||
total_failed += failed;
|
||||
warn!(failed, batch_len, "some events failed to index in batch");
|
||||
}
|
||||
info!(indexed, batch_len, batches, total_indexed, "indexed batch");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, batch_len, batches, "batch index failed entirely");
|
||||
total_failed += batch_len;
|
||||
}
|
||||
}
|
||||
|
||||
batches += 1;
|
||||
|
||||
// Tail of the prior batch becomes the cursor for the next page.
|
||||
// `query_events` will use the composite predicate
|
||||
// created_at < cursor_until OR (created_at = cursor_until AND id > cursor_before_id)
|
||||
// which exactly skips past the last row we just processed.
|
||||
cursor_until = tail.0;
|
||||
cursor_before_id = Some(tail.1);
|
||||
|
||||
// If we got fewer than BATCH back, we're at the tail of the table.
|
||||
if (batch_len as i64) < BATCH {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
total_indexed,
|
||||
total_failed, batches, "kind:0 reindex complete"
|
||||
);
|
||||
if total_failed > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,10 +31,6 @@ pub struct Config {
|
||||
pub database_url: String,
|
||||
/// Redis connection URL used by the pub/sub manager.
|
||||
pub redis_url: String,
|
||||
/// Typesense search server URL.
|
||||
pub typesense_url: String,
|
||||
/// Typesense API key.
|
||||
pub typesense_key: String,
|
||||
/// Public WebSocket URL of this relay, advertised in NIP-11.
|
||||
pub relay_url: String,
|
||||
/// Maximum number of concurrent WebSocket connections.
|
||||
@@ -80,6 +76,23 @@ pub struct Config {
|
||||
/// are permitted regardless of auth method (API token, NIP-42).
|
||||
pub require_relay_membership: bool,
|
||||
|
||||
/// Whether this deployment can serve huddle (voice) audio.
|
||||
///
|
||||
/// Huddle audio frames are relayed peer-to-peer *within a single pod*
|
||||
/// (`AudioRoomManager` is an in-process map; only huddle lifecycle events
|
||||
/// cross pods via Redis). Under horizontal scaling (any-pod-any-connection,
|
||||
/// plan §4 fork B) two peers in the same huddle can land on different pods
|
||||
/// and never hear each other. Rather than sticky-route huddles or ship a
|
||||
/// silent split-room (plan §5b, decided by Tyler), a horizontally-scaled
|
||||
/// deployment sets this `false` and the relay surfaces a clear, client-
|
||||
/// handleable "huddle audio unavailable" signal on join.
|
||||
///
|
||||
/// Defaults to `true` so single-pod deployments (the N=1 case) keep today's
|
||||
/// behavior unchanged. Operators running multiple relay pods MUST set
|
||||
/// `BUZZ_HUDDLE_AUDIO_AVAILABLE=false` until the out-of-relay media/SFU
|
||||
/// service lands.
|
||||
pub huddle_audio_available: bool,
|
||||
|
||||
/// Optional hex-encoded pubkey of the relay owner.
|
||||
/// When set, this pubkey is automatically bootstrapped into `relay_members`
|
||||
/// with the `owner` role on first startup.
|
||||
@@ -129,13 +142,30 @@ pub struct Config {
|
||||
pub web_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
|
||||
raw.parse::<SocketAddr>()
|
||||
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))
|
||||
}
|
||||
|
||||
fn ensure_git_repo_path(
|
||||
raw: impl Into<std::path::PathBuf>,
|
||||
) -> Result<std::path::PathBuf, ConfigError> {
|
||||
let git_repo_path = raw.into();
|
||||
if let Err(e) = std::fs::create_dir_all(&git_repo_path) {
|
||||
return Err(ConfigError::InvalidValue(format!(
|
||||
"BUZZ_GIT_REPO_PATH={} could not be created: {e}",
|
||||
git_repo_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(git_repo_path)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Loads configuration from environment variables, falling back to development defaults.
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
let bind_addr = std::env::var("BUZZ_BIND_ADDR")
|
||||
.unwrap_or_else(|_| "0.0.0.0:3000".to_string())
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))?;
|
||||
let bind_addr_raw =
|
||||
std::env::var("BUZZ_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
|
||||
let bind_addr = parse_bind_addr(&bind_addr_raw)?;
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
|
||||
@@ -143,12 +173,6 @@ impl Config {
|
||||
let redis_url =
|
||||
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
|
||||
|
||||
let typesense_url =
|
||||
std::env::var("TYPESENSE_URL").unwrap_or_else(|_| "http://localhost:8108".to_string());
|
||||
|
||||
let typesense_key =
|
||||
std::env::var("TYPESENSE_API_KEY").unwrap_or_else(|_| "buzz_dev_key".to_string());
|
||||
|
||||
let relay_url =
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
|
||||
|
||||
@@ -190,6 +214,12 @@ impl Config {
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
// Defaults true → single-pod (N=1) keeps today's huddle behavior. A
|
||||
// horizontally-scaled deployment sets this false; see the field doc.
|
||||
let huddle_audio_available = std::env::var("BUZZ_HUDDLE_AUDIO_AVAILABLE")
|
||||
.map(|v| !(v == "false" || v == "0"))
|
||||
.unwrap_or(true);
|
||||
|
||||
let allow_nip_oa_auth = std::env::var("BUZZ_ALLOW_NIP_OA_AUTH")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
@@ -273,26 +303,6 @@ impl Config {
|
||||
.unwrap_or(100 * 1024 * 1024),
|
||||
public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
|
||||
server_domain: std::env::var("BUZZ_MEDIA_SERVER_DOMAIN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
// Auto-derive from RELAY_URL so desktop uploads work out-of-the-box
|
||||
// without requiring an extra env var in dev mode.
|
||||
url::Url::parse(
|
||||
&relay_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://"),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
let host = u.host_str()?.to_string();
|
||||
match u.port() {
|
||||
Some(p) => Some(format!("{host}:{p}")),
|
||||
None => Some(host),
|
||||
}
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
||||
let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE")
|
||||
@@ -308,22 +318,9 @@ impl Config {
|
||||
}
|
||||
|
||||
// Git server config
|
||||
let git_repo_path: std::path::PathBuf = std::env::var("BUZZ_GIT_REPO_PATH")
|
||||
.unwrap_or_else(|_| "./repos".to_string())
|
||||
.into();
|
||||
// Ensure the git repo root exists. The smart-HTTP transport and the
|
||||
// kind:30617 side-effect handler both canonicalize this path; if it's
|
||||
// missing, all git operations 500 with "git service misconfigured" and
|
||||
// repo announcements silently fail to create their bare repo on disk.
|
||||
// Bootstrapping here makes the relay self-provision its own data dir
|
||||
// (matches how we treat other relay-owned paths) rather than requiring
|
||||
// ops to mkdir it out of band.
|
||||
if let Err(e) = std::fs::create_dir_all(&git_repo_path) {
|
||||
return Err(ConfigError::InvalidValue(format!(
|
||||
"BUZZ_GIT_REPO_PATH={} could not be created: {e}",
|
||||
git_repo_path.display()
|
||||
)));
|
||||
}
|
||||
let git_repo_path = ensure_git_repo_path(
|
||||
std::env::var("BUZZ_GIT_REPO_PATH").unwrap_or_else(|_| "./repos".to_string()),
|
||||
)?;
|
||||
let git_max_pack_bytes: u64 = std::env::var("BUZZ_GIT_MAX_PACK_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
@@ -373,8 +370,6 @@ impl Config {
|
||||
bind_addr,
|
||||
database_url,
|
||||
redis_url,
|
||||
typesense_url,
|
||||
typesense_key,
|
||||
relay_url,
|
||||
max_connections,
|
||||
max_concurrent_handlers,
|
||||
@@ -390,6 +385,7 @@ impl Config {
|
||||
metrics_port,
|
||||
pubkey_allowlist_enabled,
|
||||
require_relay_membership,
|
||||
huddle_audio_available,
|
||||
relay_owner_pubkey,
|
||||
allow_nip_oa_auth,
|
||||
media,
|
||||
@@ -440,15 +436,30 @@ mod tests {
|
||||
!config.allow_nip_oa_auth,
|
||||
"allow_nip_oa_auth should default to false"
|
||||
);
|
||||
assert!(
|
||||
config.huddle_audio_available,
|
||||
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huddle_audio_available_can_be_disabled_for_horizontal_scaling() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_HUDDLE_AUDIO_AVAILABLE", "false");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("BUZZ_HUDDLE_AUDIO_AVAILABLE");
|
||||
assert!(
|
||||
!config.huddle_audio_available,
|
||||
"BUZZ_HUDDLE_AUDIO_AVAILABLE=false must disable huddle audio (multi-pod deployments)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bind_addr_returns_error() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_BIND_ADDR", "not-an-addr");
|
||||
let result = Config::from_env();
|
||||
std::env::remove_var("BUZZ_BIND_ADDR");
|
||||
assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_))));
|
||||
assert!(matches!(
|
||||
parse_bind_addr("not-an-addr"),
|
||||
Err(ConfigError::InvalidBindAddr(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -460,33 +471,6 @@ mod tests {
|
||||
assert_eq!(config.max_frame_bytes, 262_144);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_auto_derived_from_relay_url() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
// Clear explicit override so auto-derive kicks in
|
||||
std::env::remove_var("BUZZ_MEDIA_SERVER_DOMAIN");
|
||||
std::env::set_var("RELAY_URL", "ws://localhost:3000");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("RELAY_URL");
|
||||
assert_eq!(
|
||||
config.media.server_domain.as_deref(),
|
||||
Some("localhost:3000")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_auto_derived_default_port() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::remove_var("BUZZ_MEDIA_SERVER_DOMAIN");
|
||||
std::env::set_var("RELAY_URL", "wss://relay.example.com");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("RELAY_URL");
|
||||
assert_eq!(
|
||||
config.media.server_domain.as_deref(),
|
||||
Some("relay.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_repo_path_is_created_if_missing() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
@@ -520,30 +504,13 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn git_repo_path_unwritable_returns_error() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
// Try to create a path under a regular file — must fail.
|
||||
// Using /dev/null as the parent guarantees create_dir_all fails on unix.
|
||||
let bogus = std::path::PathBuf::from("/dev/null/cannot-create-here");
|
||||
std::env::set_var("BUZZ_GIT_REPO_PATH", &bogus);
|
||||
let result = Config::from_env();
|
||||
std::env::remove_var("BUZZ_GIT_REPO_PATH");
|
||||
let result = ensure_git_repo_path(&bogus);
|
||||
assert!(
|
||||
matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_GIT_REPO_PATH")),
|
||||
"expected InvalidValue mentioning BUZZ_GIT_REPO_PATH, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_domain_explicit_override_wins() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_MEDIA_SERVER_DOMAIN", "custom.example.com");
|
||||
std::env::set_var("RELAY_URL", "ws://localhost:3000");
|
||||
let config = Config::from_env().expect("config");
|
||||
std::env::remove_var("BUZZ_MEDIA_SERVER_DOMAIN");
|
||||
std::env::remove_var("RELAY_URL");
|
||||
assert_eq!(
|
||||
config.media.server_domain.as_deref(),
|
||||
Some("custom.example.com")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
727
crates/buzz-relay/src/conformance/mod.rs
Normal file
727
crates/buzz-relay/src/conformance/mod.rs
Normal file
@@ -0,0 +1,727 @@
|
||||
//! Runtime conformance harness — the relay's side of the trace seam.
|
||||
//!
|
||||
//! This module hosts the [`Tracer`] re-export and the per-request emit
|
||||
//! helpers that translate the relay's actual decisions into [`TraceStep`]s
|
||||
//! from `buzz-conformance`. See `crates/buzz-conformance/src/lib.rs` for
|
||||
//! the schema and `docs/spec/MultiTenantRelay.tla` for what the schema is
|
||||
//! grounded in.
|
||||
//!
|
||||
//! ## Design rules (from `skill-runtime-formal-compliance`)
|
||||
//!
|
||||
//! 1. **Project, don't echo.** The trace carries opaque labels (community
|
||||
//! UUID, channel UUID, blake3-truncated actor) — never the event id,
|
||||
//! payload, pubkey bytes, signature, or wall-clock timestamps. The
|
||||
//! only fields that survive are the ones the spec's `Next` and
|
||||
//! `Inv_NonInterference` reason about.
|
||||
//! 2. **Don't normalize away violations.** The emitter records
|
||||
//! `claimed_community` (from the event's `h` tag) SEPARATELY from
|
||||
//! `resolved_community` (from `TenantContext::community()`). The
|
||||
//! checker's M2 bite depends on seeing both.
|
||||
//! 3. **Drop guard is load-bearing.** Every entry to a critical seam must
|
||||
//! construct an [`EmitGuard`]; if the seam exits without an emit, the
|
||||
//! guard's `Drop` records [`TraceAction::ImplBug`] which the checker
|
||||
//! treats as a coverage breach.
|
||||
//!
|
||||
//! ## Wire points
|
||||
//!
|
||||
//! - **ingest.rs:** AuthCheck at `check_channel_membership` call site;
|
||||
//! WriteInsert / WriteInsertGlobal / WriteDuplicate at the two
|
||||
//! `dispatch_persistent_event` sites; SanitizedError at the outer
|
||||
//! wrapper based on the IngestError variant.
|
||||
//! - **req.rs / event.rs:** (held back as additive patch for Eva to apply
|
||||
//! onto Max's req.rs writes — see thread `c882c9b1…`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use nostr::PublicKey;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use buzz_conformance::{
|
||||
AbstractState, ActorLabel, ChannelLabel, CommunityLabel, HostLabel, OpaqueId, SanitizedReason,
|
||||
TraceAction, TraceStep, Tracer, Verdict,
|
||||
};
|
||||
|
||||
mod tracers;
|
||||
pub use tracers::{JsonlTracer, NoopTracer};
|
||||
|
||||
/// Build the [`AbstractState`] for a request from its resolved tenant
|
||||
/// context and authenticated public key.
|
||||
///
|
||||
/// `community` and `host` come straight from `TenantContext` — server-
|
||||
/// resolved, never client input. `actor` is the lower 16 bytes of
|
||||
/// `blake3(pubkey_bytes)` as a hex string, opaque and stable across the
|
||||
/// run.
|
||||
pub fn state_for_request(tenant: &TenantContext, actor: &PublicKey) -> AbstractState {
|
||||
AbstractState {
|
||||
resolved_community: CommunityLabel::from_uuid(*tenant.community().as_uuid()),
|
||||
bound_host: HostLabel(tenant.host().to_string()),
|
||||
actor: actor_label(actor),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque actor label: first 16 hex chars of the pubkey. The pubkey is
|
||||
/// already a hash from the client's POV (Schnorr X-only) — equality of
|
||||
/// the prefix is equivalent to equality of the pubkey for tracing
|
||||
/// purposes, and the relay already prints full pubkey hexes elsewhere,
|
||||
/// so the prefix discloses nothing the rest of the log doesn't already.
|
||||
/// Using the pubkey directly also avoids dragging in a hash dep for what
|
||||
/// is observability code.
|
||||
fn actor_label(actor: &PublicKey) -> ActorLabel {
|
||||
let hex = actor.to_hex();
|
||||
let n = hex.len().min(16);
|
||||
ActorLabel(hex[..n].to_string())
|
||||
}
|
||||
|
||||
/// Opaque message id label: first 16 hex chars of the event id. Same
|
||||
/// rationale as actor labels — the id is already a sha256 hash.
|
||||
pub fn msg_id_label(event_id: &[u8]) -> OpaqueId {
|
||||
let mut out = String::with_capacity(16);
|
||||
for b in event_id.iter().take(8) {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(&mut out, "{b:02x}");
|
||||
}
|
||||
OpaqueId(out)
|
||||
}
|
||||
|
||||
/// Map a UUID channel id into a [`ChannelLabel`]. Channels are not secret
|
||||
/// — they appear in event `h` tags — so this is a direct wrap.
|
||||
pub fn channel_label(ch: Uuid) -> ChannelLabel {
|
||||
ChannelLabel(ch)
|
||||
}
|
||||
|
||||
/// Extract the *client-claimed* community from an event's `h` tag. Used
|
||||
/// to populate [`TraceAction`]'s `claimed_community` field. The relay
|
||||
/// does NOT trust this value for resolution — the resolver uses the
|
||||
/// server-owned channel→community map. Recording it separately is what
|
||||
/// makes the M2 (claim≠resolved) bite visible to the checker.
|
||||
///
|
||||
/// Returns `None` if there is no `h` tag, or the `h` tag does not parse
|
||||
/// as a UUID.
|
||||
pub fn claimed_community_from_event(event: &nostr::Event) -> Option<CommunityLabel> {
|
||||
for tag in event.tags.iter() {
|
||||
// The relay's existing convention: `h` tag carries the community
|
||||
// uuid (or channel uuid, ambiguous — but on the WRITE path the h
|
||||
// tag's documented use is the community claim).
|
||||
let raw = tag.as_slice();
|
||||
if raw.first().map(|s| s.as_str()) == Some("h") {
|
||||
if let Some(val) = raw.get(1) {
|
||||
if let Ok(parsed) = Uuid::parse_str(val) {
|
||||
return Some(CommunityLabel::from_uuid(parsed));
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a [`TraceStep::new`] with a freshly-computed [`AbstractState`].
|
||||
/// Convenience wrapper to keep the call sites in ingest.rs short.
|
||||
pub fn step(action: TraceAction, state: AbstractState) -> TraceStep {
|
||||
TraceStep::new(action, state)
|
||||
}
|
||||
|
||||
/// Record one step on the tracer. Equivalent to `tracer.record(step(...))`.
|
||||
/// Kept inline so the call sites stay tight and self-documenting.
|
||||
pub fn emit(tracer: &Arc<dyn Tracer>, action: TraceAction, state: AbstractState) {
|
||||
tracer.record(TraceStep::new(action, state));
|
||||
}
|
||||
|
||||
/// Record an [`TraceAction::AuthCheck`] step for a REQ-path membership
|
||||
/// decision. Callers pass the already-computed [`AbstractState`] for the
|
||||
/// request (built once at entry, see [`state_for_request`]) so the
|
||||
/// emit stays cheap on the hot path.
|
||||
///
|
||||
/// `claimed_community` is unconditionally `None` on the read path: the
|
||||
/// REQ wire has NO client-asserted community. The `h` filter carries
|
||||
/// a channel-id, not a community-id; tenant is host-resolved via
|
||||
/// `TenantContext`. Encoding `None` here (rather than copying the
|
||||
/// resolved community) is load-bearing — if a future regression ever
|
||||
/// starts reading a wire-community on REQ, the field would need a real
|
||||
/// value and that surfaces at code-review time instead of silently
|
||||
/// projecting away the M2 (claim ≠ resolved) bite.
|
||||
///
|
||||
/// The verdict mapping is `member → Allow`, `!member → Deny`, matching
|
||||
/// the relay's actual access decision at the membership-cache call
|
||||
/// site.
|
||||
pub fn record_req_authcheck(
|
||||
tracer: &Arc<dyn Tracer>,
|
||||
state: &AbstractState,
|
||||
channel_id: Uuid,
|
||||
member: bool,
|
||||
) {
|
||||
tracer.record(TraceStep::new(
|
||||
TraceAction::AuthCheck {
|
||||
channel: channel_label(channel_id),
|
||||
claimed_community: None,
|
||||
verdict: if member {
|
||||
Verdict::Allow
|
||||
} else {
|
||||
Verdict::Deny
|
||||
},
|
||||
},
|
||||
state.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Project a row's true community label, independent of the fetch
|
||||
/// query's WHERE clause. Encapsulates the (B) projection-strategy
|
||||
/// guard-rail Eva specified:
|
||||
///
|
||||
/// - If the row is **channel-scoped** (`row.channel_id == Some(ch)`):
|
||||
/// look up `ch` in `channel_communities` (precomputed via
|
||||
/// [`buzz_db::Buzz::communities_of_channels`]). On a hit, return the
|
||||
/// looked-up label. On a miss, return `None` — the caller MUST treat
|
||||
/// this as a coverage breach and fail closed.
|
||||
/// - If the row is **channel-less** (`row.channel_id == None`):
|
||||
/// project as the resolved community. Channel-less rows have no
|
||||
/// independent per-channel community to look up — the projection is
|
||||
/// honest for those rows, not a tautology (community-global rows are
|
||||
/// genuinely tenant-scoped).
|
||||
///
|
||||
/// The distinction "is this row channel-less?" comes from the row's
|
||||
/// own `channel_id`, not from the query filter, so a channel-scoped
|
||||
/// row CANNOT masquerade as channel-less to dodge the lookup.
|
||||
fn project_row_community(
|
||||
row_channel_id: Option<Uuid>,
|
||||
resolved: &CommunityLabel,
|
||||
channel_communities: &std::collections::HashMap<Uuid, buzz_core::CommunityId>,
|
||||
) -> Option<CommunityLabel> {
|
||||
match row_channel_id {
|
||||
None => Some(*resolved),
|
||||
Some(ch) => channel_communities
|
||||
.get(&ch)
|
||||
.map(|cid| CommunityLabel::from_uuid(*cid.as_uuid())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of projecting a row set's community labels for the read
|
||||
/// seam. Either a clean `Vec<CommunityLabel>` (one per row, same order)
|
||||
/// OR an [`TraceAction::ImplBug`] coverage breach if any channel-scoped
|
||||
/// row's channel id was missing from the lookup map.
|
||||
///
|
||||
/// Returning a discriminated outcome (rather than silently substituting
|
||||
/// the resolved community on missing-lookup) is what keeps the gate
|
||||
/// non-vacuous: a mutation that, say, soft-deletes a channel mid-query
|
||||
/// would land here and surface as an `ImplBug`, not slip past as a
|
||||
/// resolved-label projection.
|
||||
#[derive(Debug)]
|
||||
pub enum RowCommunityProjection {
|
||||
/// One label per input row, in the same order.
|
||||
Ok(Vec<CommunityLabel>),
|
||||
/// One or more channel-scoped rows had no entry in the lookup map.
|
||||
/// Carries the seam-name + the first offending channel id for
|
||||
/// debuggability; the checker treats `ImplBug` as a coverage breach.
|
||||
MissingLookup {
|
||||
/// Short stable tag identifying which seam projected without
|
||||
/// a lookup (used as the `ImplBug.kind` value).
|
||||
kind: &'static str,
|
||||
/// First channel id whose lookup was missing — kept for log
|
||||
/// debuggability, not consumed by the trace.
|
||||
first_missing_channel: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
/// Project `row_communities` for a row set, applying the (B) strategy
|
||||
/// guard-rail.
|
||||
///
|
||||
/// `rows` is the list of `(channel_id_option)` per row in the result
|
||||
/// set, in the order the relay will deliver them.
|
||||
/// `channel_communities` is the result of
|
||||
/// [`buzz_db::Buzz::communities_of_channels`] over the distinct
|
||||
/// channel ids in `rows`.
|
||||
pub fn project_row_communities(
|
||||
rows: &[Option<Uuid>],
|
||||
resolved: &CommunityLabel,
|
||||
channel_communities: &std::collections::HashMap<Uuid, buzz_core::CommunityId>,
|
||||
) -> RowCommunityProjection {
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
match project_row_community(*row, resolved, channel_communities) {
|
||||
Some(label) => out.push(label),
|
||||
None => {
|
||||
// `row` is Some(ch) (channel-less rows always project Some);
|
||||
// the unwrap is the offending channel id.
|
||||
let ch = row.expect("project_row_community returns None only for Some(ch)");
|
||||
return RowCommunityProjection::MissingLookup {
|
||||
kind: "row_community_lookup_missing",
|
||||
first_missing_channel: ch,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
RowCommunityProjection::Ok(out)
|
||||
}
|
||||
|
||||
/// Record a [`TraceAction::ReadMessageRows`] (non-search lane) or fail
|
||||
/// closed with an `ImplBug` step if the row community projection hit a
|
||||
/// missing-lookup.
|
||||
///
|
||||
/// `filter_channel` is the channel filter the query was scoped to (or
|
||||
/// `None` for a global query). Rows are presented as the row's own
|
||||
/// `channel_id` value — independent of the filter — so a channel-scoped
|
||||
/// row cannot evade the per-channel lookup.
|
||||
pub fn record_read_message_rows(
|
||||
tracer: &Arc<dyn Tracer>,
|
||||
state: &AbstractState,
|
||||
filter_channel: Option<Uuid>,
|
||||
rows: &[Option<Uuid>],
|
||||
channel_communities: &std::collections::HashMap<Uuid, buzz_core::CommunityId>,
|
||||
) {
|
||||
let projection = project_row_communities(rows, &state.resolved_community, channel_communities);
|
||||
match projection {
|
||||
RowCommunityProjection::Ok(row_communities) => {
|
||||
tracer.record(TraceStep::new(
|
||||
TraceAction::ReadMessageRows {
|
||||
channel: filter_channel.map(channel_label),
|
||||
row_communities,
|
||||
},
|
||||
state.clone(),
|
||||
));
|
||||
}
|
||||
RowCommunityProjection::MissingLookup {
|
||||
kind,
|
||||
first_missing_channel: _,
|
||||
} => {
|
||||
tracer.record(TraceStep::new(
|
||||
TraceAction::ImplBug {
|
||||
kind: kind.to_string(),
|
||||
},
|
||||
state.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search-lane companion to [`record_read_message_rows`]. Emits
|
||||
/// [`TraceAction::ReadByIdRows`] for the per-hit refetch; same
|
||||
/// projection + missing-lookup guard-rail.
|
||||
pub fn record_read_by_id_rows(
|
||||
tracer: &Arc<dyn Tracer>,
|
||||
state: &AbstractState,
|
||||
filter_channel: Option<Uuid>,
|
||||
rows: &[Option<Uuid>],
|
||||
channel_communities: &std::collections::HashMap<Uuid, buzz_core::CommunityId>,
|
||||
) {
|
||||
let projection = project_row_communities(rows, &state.resolved_community, channel_communities);
|
||||
match projection {
|
||||
RowCommunityProjection::Ok(row_communities) => {
|
||||
tracer.record(TraceStep::new(
|
||||
TraceAction::ReadByIdRows {
|
||||
channel: filter_channel.map(channel_label),
|
||||
row_communities,
|
||||
},
|
||||
state.clone(),
|
||||
));
|
||||
}
|
||||
RowCommunityProjection::MissingLookup {
|
||||
kind,
|
||||
first_missing_channel: _,
|
||||
} => {
|
||||
tracer.record(TraceStep::new(
|
||||
TraceAction::ImplBug {
|
||||
kind: kind.to_string(),
|
||||
},
|
||||
state.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII coverage-breach guard. Constructed at the top of any critical
|
||||
/// seam (currently: `ingest_event`); the guard observes a [`Tracer`]
|
||||
/// wrapper that counts emits. If the seam exits without any emit
|
||||
/// reaching the underlying tracer, `Drop` records a synthetic
|
||||
/// [`TraceAction::ImplBug`] step — the checker treats that as a
|
||||
/// coverage breach.
|
||||
///
|
||||
/// The guard wraps the original tracer so production code paths never
|
||||
/// need to "disarm" or pass anything around — they just call
|
||||
/// `tracer.record(...)` as before, and the wrapper bumps a counter. If
|
||||
/// at drop time the counter is zero, the guard emits ImplBug onto the
|
||||
/// underlying tracer.
|
||||
pub struct EmitGuard {
|
||||
/// The inner tracer, used both for the production emits during the
|
||||
/// request AND for the synthetic ImplBug on Drop if the request
|
||||
/// emitted nothing.
|
||||
inner: Arc<dyn Tracer>,
|
||||
state: AbstractState,
|
||||
counter: Arc<std::sync::atomic::AtomicUsize>,
|
||||
kind: &'static str,
|
||||
}
|
||||
|
||||
/// Wrapper tracer that bumps a counter on every record. Returned by
|
||||
/// [`EmitGuard::counting_tracer`].
|
||||
struct CountingTracer {
|
||||
inner: Arc<dyn Tracer>,
|
||||
counter: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CountingTracer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CountingTracer").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tracer for CountingTracer {
|
||||
fn record(&self, step: TraceStep) {
|
||||
self.counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.inner.record(step);
|
||||
}
|
||||
}
|
||||
|
||||
impl EmitGuard {
|
||||
/// Arm a new guard for the given seam name (e.g.
|
||||
/// `"ingest_exited_without_trace"`). Returns the guard along with a
|
||||
/// counting wrapper around `tracer` that callers should pass into
|
||||
/// the request path instead of the original `tracer`. Every emit
|
||||
/// against the wrapper bumps the guard's counter; if the count is
|
||||
/// still zero at Drop, the guard records an `ImplBug` on the
|
||||
/// original tracer.
|
||||
pub fn arm(
|
||||
tracer: Arc<dyn Tracer>,
|
||||
state: AbstractState,
|
||||
kind: &'static str,
|
||||
) -> (Self, Arc<dyn Tracer>) {
|
||||
let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counting: Arc<dyn Tracer> = Arc::new(CountingTracer {
|
||||
inner: tracer.clone(),
|
||||
counter: counter.clone(),
|
||||
});
|
||||
let guard = Self {
|
||||
inner: tracer,
|
||||
state,
|
||||
counter,
|
||||
kind,
|
||||
};
|
||||
(guard, counting)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EmitGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.counter.load(std::sync::atomic::Ordering::Relaxed) == 0 {
|
||||
let step = TraceStep::new(
|
||||
TraceAction::ImplBug {
|
||||
kind: self.kind.to_string(),
|
||||
},
|
||||
self.state.clone(),
|
||||
);
|
||||
self.inner.record(step);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an `IngestError` variant onto the closed `SanitizedReason`
|
||||
/// alphabet (spec line 778, `Inv_SanitizedErrors`). The alphabet is
|
||||
/// asserted 1:1 with the relay's error variants — if a fourth variant
|
||||
/// is ever added to `IngestError` this match goes non-exhaustive and
|
||||
/// CI catches it.
|
||||
pub fn sanitized_reason_for(err: &crate::handlers::ingest::IngestError) -> SanitizedReason {
|
||||
use crate::handlers::ingest::IngestError as E;
|
||||
match err {
|
||||
E::Rejected(_) => SanitizedReason::Invalid,
|
||||
E::AuthFailed(_) => SanitizedReason::Restricted,
|
||||
E::Internal(_) => SanitizedReason::ServerError,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Coverage-breach self-test for the [`EmitGuard`].
|
||||
//!
|
||||
//! The skill's "coverage breach" mode is the one that makes the
|
||||
//! whole gate non-decorative: a critical seam exiting without
|
||||
//! recording any trace step MUST surface as a failure. This test
|
||||
//! proves the mechanism — drop a guard without recording on the
|
||||
//! returned counting tracer, observe the synthetic `ImplBug` step
|
||||
//! land on the inner tracer.
|
||||
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory tracer that collects every step it sees. Used to
|
||||
/// observe the `ImplBug` step the `EmitGuard` Drop emits.
|
||||
#[derive(Debug, Default)]
|
||||
struct VecTracer {
|
||||
steps: Mutex<Vec<TraceStep>>,
|
||||
}
|
||||
|
||||
impl Tracer for VecTracer {
|
||||
fn record(&self, step: TraceStep) {
|
||||
self.steps.lock().expect("vec tracer mutex").push(step);
|
||||
}
|
||||
}
|
||||
|
||||
fn dummy_state() -> AbstractState {
|
||||
AbstractState {
|
||||
resolved_community: CommunityLabel::from_uuid(Uuid::from_u128(0xA)),
|
||||
bound_host: HostLabel("test.local".to_string()),
|
||||
actor: ActorLabel("0123456789abcdef".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_guard_drop_is_silent_when_an_emit_reached_the_tracer() {
|
||||
// Hold a typed handle to the VecTracer alongside the trait-
|
||||
// object Arc so we can inspect what was recorded.
|
||||
let typed = Arc::new(VecTracer::default());
|
||||
let inner: Arc<dyn Tracer> = typed.clone();
|
||||
|
||||
{
|
||||
let (guard, counting) = EmitGuard::arm(inner, dummy_state(), "should_not_fire");
|
||||
// Record one normal step through the counting wrapper.
|
||||
counting.record(TraceStep::new(
|
||||
TraceAction::SanitizedError {
|
||||
reason: SanitizedReason::Invalid,
|
||||
},
|
||||
dummy_state(),
|
||||
));
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
let steps = typed.steps.lock().expect("vec tracer mutex");
|
||||
assert_eq!(
|
||||
steps.len(),
|
||||
1,
|
||||
"exactly one step should be recorded — the SanitizedError, no ImplBug from Drop"
|
||||
);
|
||||
assert!(
|
||||
!matches!(steps[0].action, TraceAction::ImplBug { .. }),
|
||||
"Drop must NOT emit ImplBug when an emit reached the tracer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_guard_drop_records_exactly_one_impl_bug_when_no_emit() {
|
||||
// Same shape as the first test but using a typed handle that
|
||||
// actually lets us inspect the recorded steps.
|
||||
let typed = Arc::new(VecTracer::default());
|
||||
let inner: Arc<dyn Tracer> = typed.clone();
|
||||
|
||||
{
|
||||
let (guard, _counting) =
|
||||
EmitGuard::arm(inner, dummy_state(), "ingest_exited_without_trace");
|
||||
// No emit on `counting` — guard Drop fires the breach.
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
let steps = typed.steps.lock().expect("vec tracer mutex");
|
||||
assert_eq!(steps.len(), 1, "Drop must record exactly one ImplBug step");
|
||||
match &steps[0].action {
|
||||
TraceAction::ImplBug { kind } => {
|
||||
assert_eq!(
|
||||
kind, "ingest_exited_without_trace",
|
||||
"ImplBug kind must carry the seam name passed to `EmitGuard::arm`"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ImplBug action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify `record_req_authcheck` lands exactly one `AuthCheck` step
|
||||
/// on the tracer with the expected channel label and `Allow` verdict
|
||||
/// when the membership check returned true, and that
|
||||
/// `claimed_community` is `None` (load-bearing on the read path —
|
||||
/// see the helper's doc comment).
|
||||
#[test]
|
||||
fn record_req_authcheck_emits_allow_with_none_claim_when_member() {
|
||||
let typed = Arc::new(VecTracer::default());
|
||||
let inner: Arc<dyn Tracer> = typed.clone();
|
||||
let state = dummy_state();
|
||||
let ch_id = Uuid::from_u128(0xCAFE_F00D);
|
||||
|
||||
record_req_authcheck(&inner, &state, ch_id, true);
|
||||
|
||||
let steps = typed.steps.lock().expect("vec tracer mutex");
|
||||
assert_eq!(steps.len(), 1, "exactly one AuthCheck step");
|
||||
match &steps[0].action {
|
||||
TraceAction::AuthCheck {
|
||||
channel,
|
||||
claimed_community,
|
||||
verdict,
|
||||
} => {
|
||||
assert_eq!(
|
||||
channel,
|
||||
&channel_label(ch_id),
|
||||
"channel must come from the helper's `channel_id` arg"
|
||||
);
|
||||
assert!(
|
||||
claimed_community.is_none(),
|
||||
"REQ path has no wire-community claim — must be None"
|
||||
);
|
||||
assert!(
|
||||
matches!(verdict, Verdict::Allow),
|
||||
"member=true must map to Allow"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AuthCheck action, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
steps[0].state_after, state,
|
||||
"state must be the snapshot built at request entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion to the Allow test: confirms `member=false → Deny`. The
|
||||
/// two together pin the full verdict-mapping table — a mutation that
|
||||
/// inverts the boolean reds exactly one of them.
|
||||
#[test]
|
||||
fn record_req_authcheck_emits_deny_when_not_member() {
|
||||
let typed = Arc::new(VecTracer::default());
|
||||
let inner: Arc<dyn Tracer> = typed.clone();
|
||||
let state = dummy_state();
|
||||
let ch_id = Uuid::from_u128(0xBADD_BEEF);
|
||||
|
||||
record_req_authcheck(&inner, &state, ch_id, false);
|
||||
|
||||
let steps = typed.steps.lock().expect("vec tracer mutex");
|
||||
assert_eq!(steps.len(), 1);
|
||||
match &steps[0].action {
|
||||
TraceAction::AuthCheck { verdict, .. } => {
|
||||
assert!(
|
||||
matches!(verdict, Verdict::Deny),
|
||||
"member=false must map to Deny"
|
||||
);
|
||||
}
|
||||
other => panic!("expected AuthCheck action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel-less row projects as the resolved community — honest, not
|
||||
/// tautological (community-global rows have no per-channel lookup).
|
||||
#[test]
|
||||
fn project_row_communities_channelless_uses_resolved() {
|
||||
let resolved = CommunityLabel::from_uuid(Uuid::from_u128(0x42));
|
||||
let lookup = std::collections::HashMap::new();
|
||||
let rows = vec![None, None];
|
||||
|
||||
let projection = project_row_communities(&rows, &resolved, &lookup);
|
||||
match projection {
|
||||
RowCommunityProjection::Ok(labels) => {
|
||||
assert_eq!(labels.len(), 2);
|
||||
assert!(
|
||||
labels.iter().all(|l| l == &resolved),
|
||||
"all channel-less rows must project to resolved"
|
||||
);
|
||||
}
|
||||
RowCommunityProjection::MissingLookup { .. } => {
|
||||
panic!("channel-less rows must not surface missing-lookup")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel-scoped row with a foreign community in the lookup map
|
||||
/// projects to the foreign label — independent of the fetch query
|
||||
/// (this is the (B) strategy's non-tautological correctness). The
|
||||
/// resolved community is NOT substituted.
|
||||
#[test]
|
||||
fn project_row_communities_channel_scoped_uses_lookup_label() {
|
||||
use buzz_core::CommunityId;
|
||||
let resolved = CommunityLabel::from_uuid(Uuid::from_u128(0xA));
|
||||
let foreign = Uuid::from_u128(0xF0);
|
||||
let ch_id = Uuid::from_u128(0xC0);
|
||||
let mut lookup = std::collections::HashMap::new();
|
||||
lookup.insert(ch_id, CommunityId::from_uuid(foreign));
|
||||
|
||||
let projection = project_row_communities(&[Some(ch_id)], &resolved, &lookup);
|
||||
match projection {
|
||||
RowCommunityProjection::Ok(labels) => {
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(
|
||||
labels[0],
|
||||
CommunityLabel::from_uuid(foreign),
|
||||
"channel-scoped row must project to its OWN community, not resolved"
|
||||
);
|
||||
assert_ne!(labels[0], resolved, "must not substitute resolved");
|
||||
}
|
||||
other => panic!("expected Ok projection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The guard-rail bite: a channel-scoped row whose channel id is
|
||||
/// absent from the lookup map MUST surface as `MissingLookup`,
|
||||
/// never as a silent substitution to resolved. This is what makes
|
||||
/// the negative fixture (channel-scoped foreign-community row
|
||||
/// masquerading as channel-less) fail closed.
|
||||
#[test]
|
||||
fn project_row_communities_channel_scoped_missing_is_breach() {
|
||||
let resolved = CommunityLabel::from_uuid(Uuid::from_u128(0xA));
|
||||
let ch_id = Uuid::from_u128(0xDEAD);
|
||||
let lookup = std::collections::HashMap::new();
|
||||
|
||||
let projection = project_row_communities(&[Some(ch_id)], &resolved, &lookup);
|
||||
match projection {
|
||||
RowCommunityProjection::MissingLookup {
|
||||
kind,
|
||||
first_missing_channel,
|
||||
} => {
|
||||
assert_eq!(kind, "row_community_lookup_missing");
|
||||
assert_eq!(first_missing_channel, ch_id);
|
||||
}
|
||||
RowCommunityProjection::Ok(labels) => {
|
||||
panic!("missing lookup must be a breach, got Ok({labels:?})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `record_read_message_rows` on a missing-lookup row must record
|
||||
/// exactly one `ImplBug` step (not a `ReadMessageRows` with the
|
||||
/// resolved label substituted). The checker treats `ImplBug` as a
|
||||
/// coverage breach.
|
||||
#[test]
|
||||
fn record_read_message_rows_missing_lookup_emits_impl_bug() {
|
||||
let typed = Arc::new(VecTracer::default());
|
||||
let inner: Arc<dyn Tracer> = typed.clone();
|
||||
let state = dummy_state();
|
||||
let ch_id = Uuid::from_u128(0xDEAD);
|
||||
let lookup = std::collections::HashMap::new();
|
||||
|
||||
record_read_message_rows(&inner, &state, Some(ch_id), &[Some(ch_id)], &lookup);
|
||||
|
||||
let steps = typed.steps.lock().expect("vec tracer mutex");
|
||||
assert_eq!(steps.len(), 1);
|
||||
match &steps[0].action {
|
||||
TraceAction::ImplBug { kind } => {
|
||||
assert_eq!(kind, "row_community_lookup_missing");
|
||||
}
|
||||
other => panic!("expected ImplBug coverage breach, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `record_read_by_id_rows` Happy path: channel-scoped row whose
|
||||
/// lookup hits emits one `ReadByIdRows` with the per-row community
|
||||
/// label, not the resolved one.
|
||||
#[test]
|
||||
fn record_read_by_id_rows_ok_emits_read_by_id_rows() {
|
||||
use buzz_core::CommunityId;
|
||||
let typed = Arc::new(VecTracer::default());
|
||||
let inner: Arc<dyn Tracer> = typed.clone();
|
||||
let state = dummy_state();
|
||||
let foreign = Uuid::from_u128(0xF0);
|
||||
let ch_id = Uuid::from_u128(0xC0);
|
||||
let mut lookup = std::collections::HashMap::new();
|
||||
lookup.insert(ch_id, CommunityId::from_uuid(foreign));
|
||||
|
||||
record_read_by_id_rows(&inner, &state, None, &[Some(ch_id)], &lookup);
|
||||
|
||||
let steps = typed.steps.lock().expect("vec tracer mutex");
|
||||
assert_eq!(steps.len(), 1);
|
||||
match &steps[0].action {
|
||||
TraceAction::ReadByIdRows {
|
||||
channel,
|
||||
row_communities,
|
||||
} => {
|
||||
assert!(
|
||||
channel.is_none(),
|
||||
"search lane uses None for filter_channel"
|
||||
);
|
||||
assert_eq!(row_communities.len(), 1);
|
||||
assert_eq!(row_communities[0], CommunityLabel::from_uuid(foreign));
|
||||
}
|
||||
other => panic!("expected ReadByIdRows, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
73
crates/buzz-relay/src/conformance/tracers.rs
Normal file
73
crates/buzz-relay/src/conformance/tracers.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Concrete [`Tracer`] implementations. Production uses [`NoopTracer`];
|
||||
//! conformance tests + the CI replay job use [`JsonlTracer`].
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use buzz_conformance::{TraceStep, Tracer};
|
||||
|
||||
/// Zero-cost tracer used in production builds. Records nothing — the
|
||||
/// emitter call still constructs the action arguments, but the build can
|
||||
/// have the compiler eliminate them entirely behind a feature flag if
|
||||
/// the cost ever shows up in benches.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopTracer;
|
||||
|
||||
impl Tracer for NoopTracer {
|
||||
fn record(&self, _step: TraceStep) {}
|
||||
}
|
||||
|
||||
/// JSONL-to-file tracer for tests + the CI replay job. Each `record` call
|
||||
/// serializes the step as one line of JSON and appends it. The file is
|
||||
/// opened in append mode so multiple test runs accumulate; consumers are
|
||||
/// expected to truncate between runs.
|
||||
///
|
||||
/// The internal `Mutex<BufWriter<File>>` serializes writes — concurrent
|
||||
/// requests producing interleaved JSONL is fine on the read side because
|
||||
/// the spec doesn't model emission order, only set membership.
|
||||
pub struct JsonlTracer {
|
||||
out: Mutex<BufWriter<File>>,
|
||||
}
|
||||
|
||||
impl JsonlTracer {
|
||||
/// Open a new JSONL tracer writing to `path`. Truncates any existing
|
||||
/// file at that path so a fresh test run starts clean.
|
||||
pub fn create<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path)?;
|
||||
Ok(Self {
|
||||
out: Mutex::new(BufWriter::new(file)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JsonlTracer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("JsonlTracer").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tracer for JsonlTracer {
|
||||
fn record(&self, step: TraceStep) {
|
||||
// Acquire-and-write. If the lock is poisoned we accept the panic
|
||||
// — this is observability code and a poisoned lock means a worse
|
||||
// bug landed elsewhere.
|
||||
let mut guard = match self.out.lock() {
|
||||
Ok(g) => g,
|
||||
Err(e) => e.into_inner(),
|
||||
};
|
||||
// Best-effort: a write failure here loses one trace step but
|
||||
// must NOT take down the request path. The Drop guard's
|
||||
// coverage-breach action is the safety net for systemic loss.
|
||||
if let Ok(line) = serde_json::to_string(&step) {
|
||||
let _ = guard.write_all(line.as_bytes());
|
||||
let _ = guard.write_all(b"\n");
|
||||
let _ = guard.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user