diff --git a/AGENTS.md b/AGENTS.md index 7e7f126264..ce8558c2e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` (Postgres FTS) automatically. - `POST /count` — Nostr COUNT filters over HTTP. -If you find yourself reaching for a new REST endpoint, first check whether +If you find yourself reaching for a new HTTP endpoint, first check whether an event kind would do the job — it usually will, and you get realtime fan-out, NIP-29 scoping, and the existing auth pipeline for free. @@ -207,9 +203,6 @@ just test # full integration suite (requires Postgres + Redis) E2E tests live in `crates/buzz-test-client/tests/`: - `e2e_relay.rs` — WebSocket relay protocol -- `e2e_rest_api.rs` — REST endpoint coverage -- `e2e_tokens.rs` — auth token flows -- `e2e_workflows.rs` — workflow engine - `e2e_media.rs` — media upload/download (Blossom) - `e2e_media_extended.rs` — extended media scenarios - `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps) @@ -554,7 +547,7 @@ just mobile-dev ## See Also -- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / API endpoints +- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints - [TESTING.md](TESTING.md) — multi-agent E2E test guide - [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships - [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 650254a357..90cbbac0cf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,14 +34,14 @@ Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc. │ buzz-relay (Axum) │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────────┐ │ -│ │ NIP-42 │ │ EVENT │ │ REQ │ │ REST API │ │ -│ │ auth │ │ pipeline │ │ handler │ │ /api/channels │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ /api/search │ │ -│ │ /api/feed │ │ -│ ┌──────────────────────────────────────┐ │ /api/workflows │ │ -│ │ SubscriptionRegistry │ │ /api/presence │ │ -│ │ DashMap: (channel_id, kind) → conns │ │ /api/agents │ │ -│ └──────────────────────────────────────┘ │ /api/approvals │ │ +│ │ NIP-42 │ │ EVENT │ │ REQ │ │ HTTP bridge │ │ +│ │ auth │ │ pipeline │ │ handler │ │ /events │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ /query │ │ +│ │ /count │ │ +│ ┌──────────────────────────────────────┐ │ /hooks/{id} │ │ +│ │ SubscriptionRegistry │ │ /media/* │ │ +│ │ DashMap: (channel_id, kind) → conns │ │ /git/* │ │ +│ └──────────────────────────────────────┘ │ /info, NIP-05 │ │ │ └─────────────────────┘ │ └──────────┬──────────────┬──────────────────────────────────────────┘ │ │ @@ -87,7 +87,6 @@ buzz-core (zero I/O — types, verification, filter matching, kind registry) └── buzz-relay (ties everything together — the server) buzz-acp (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC) -buzz-proxy (NIP-28 compatibility proxy — translates standard Nostr clients ↔ Buzz relay) buzz-sdk (typed Nostr event builders — used by buzz-acp and buzz-cli) buzz-media (Blossom/S3 media storage) buzz-cli (agent-first CLI) @@ -95,7 +94,7 @@ buzz-admin (operator CLI: relay membership + key generation) buzz-test-client (integration test harness + manual CLI) ``` -**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. `buzz-proxy` connects to the relay as a WebSocket client and translates NIP-28 events between standard Nostr clients and the Buzz relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. +**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. --- @@ -192,7 +191,7 @@ The client must respond with `["AUTH", ]` before submitting events | Path | Mechanism | Use Case | |------|-----------|---------| | NIP-42 | Signed challenge, pubkey verified | WebSocket connections | -| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients | +| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on HTTP bridge endpoints | HTTP clients | On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`. @@ -368,7 +367,7 @@ Handles authentication paths, scope enforcement, and token operations. | Path | Entry Point | Notes | |------|-------------|-------| | NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) | -| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event | +| NIP-98 HTTP Auth | `validate_nip98_auth()` | HTTP bridge endpoints; Schnorr-signed `kind:27235` event | **Key types:** @@ -558,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. @@ -652,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 Postgres FTS | -| 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:** @@ -768,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**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78d76c6992..070dd9a8fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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,8 +369,9 @@ 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 @@ -400,50 +398,35 @@ for team access setup, onboarding, and the full repo inventory. See ## How to Add a New API Endpoint -REST endpoints live in `crates/buzz-relay/src/api/` — each resource has -its own submodule (e.g., `channels.rs`, `messages.rs`, `tokens.rs`). Routes -are registered in `crates/buzz-relay/src/router.rs`. +Prefer a signed Nostr event and the existing WebSocket/`POST /events` ingest +path over adding endpoint-specific JSON APIs. The relay intentionally exposes +only a narrow HTTP surface: NIP-11/NIP-05 metadata, `/events`, `/query`, +`/count`, `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and +health probes. -1. **Define the handler function:** +If an HTTP endpoint is still necessary: - ```rust - pub async fn get_my_resource( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, - ) -> Result, (StatusCode, Json)> { - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?; - let ctx = extract_auth_context(&headers, &state).await?; - buzz_auth::require_scope(&ctx.scopes, buzz_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - // Fetch data - let data = state.db.get_my_resource(channel_id).await - .map_err(|e| internal_error(&e.to_string()))?; - Ok(Json(serde_json::json!(data))) - } - ``` +1. **Define the handler** in the appropriate module under + `crates/buzz-relay/src/api/`. Resolve the request tenant before any auth or + data lookup, use NIP-98 when the endpoint accepts user credentials, and keep + community scoping explicit. -2. **Register the route** in `crates/buzz-relay/src/router.rs`: +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)` tuples. -4. **Handle errors** — use the `api_error()` and `internal_error()` helpers in - `buzz-relay/src/api/mod.rs`. Return `(StatusCode, Json)` 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. --- diff --git a/Cargo.lock b/Cargo.lock index eb6b5fd4bb..e65cb9cbec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,34 +991,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" diff --git a/Cargo.toml b/Cargo.toml index 5734c3f03d..40220f8422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,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", @@ -115,7 +114,6 @@ buzz-auth = { path = "crates/buzz-auth" } buzz-pubsub = { path = "crates/buzz-pubsub" } buzz-search = { path = "crates/buzz-search" } buzz-audit = { path = "crates/buzz-audit" } -buzz-proxy = { path = "crates/buzz-proxy" } buzz-workflow = { path = "crates/buzz-workflow" } buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } diff --git a/Justfile b/Justfile index 895f7101ff..39cb7898ff 100644 --- a/Justfile +++ b/Justfile @@ -285,13 +285,6 @@ 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 diff --git a/NOSTR.md b/NOSTR.md index 93a8e2753f..fbf521a8d5 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -1,25 +1,13 @@ # Using Third-Party Nostr Clients with Buzz -Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. There are two ways for -third-party Nostr clients to connect: - -| Path | Protocol | Connects to | Expected clients (not all verified in-repo) | -|------|----------|-------------|----------------------------------------------| -| **Direct** | NIP-29 | `buzz-relay :3000` | NIP-29 clients (e.g. Chachi, 0xchat), nak | -| **Via proxy** | NIP-28 | `buzz-proxy :4869` | NIP-28 clients (e.g. Coracle, Amethyst), nostr-tools apps | - -**Direct** is simpler — no extra process, no translation layer. Use it when your client speaks -NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28 -clients and don't have company credentials. - -Both paths require NIP-42 authentication. +Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. Third-party Nostr clients connect directly to `buzz-relay` using NIP-29 and NIP-42 authentication. The old NIP-28 compatibility proxy has been removed. ## Community scope Buzz treats the relay URL/domain as authoritative for the community. Today's single-relay deployment has exactly one community behind that URL, so existing -NIP-29/NIP-28 clients keep using the same WebSocket URL, event kinds, tags, and -REST/media/git paths. In a multi-community deployment, each community is reached +NIP-29 clients keep using the same WebSocket URL, event kinds, tags, and +HTTP/media/git paths. In a multi-community deployment, each community is reached by its own domain or subdomain; the backend resolves the community from the host before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic. @@ -33,7 +21,7 @@ across community domains. --- -## Path 1: NIP-29 Direct +## NIP-29 Direct Connect any NIP-29 client straight to the relay. @@ -211,283 +199,6 @@ nak req -k 1059 --tag "p=" \ --- -## Path 2: NIP-28 via buzz-proxy - -For clients that speak NIP-28 (kind:40/41/42) but not NIP-29, **buzz-proxy** translates between -the two protocols in real time. Events are re-signed with deterministic shadow keys so each -external user maps to a consistent identity on the relay. - -### Quick Start - -```bash -# 1. Start infrastructure + relay (see Path 1) - -# 2. Generate proxy server key and derive its pubkey -export BUZZ_PROXY_SERVER_KEY=$(openssl rand -hex 32) -PROXY_PUBKEY=$(echo $BUZZ_PROXY_SERVER_KEY | nak key public) - -# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth) -export BUZZ_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \ - -H "Authorization: Nostr " \ - -H "Content-Type: application/json" \ - -d '{"name":"proxy"}' | jq -r .token) - -# 4. Get the relay's public key (needed for attribution trust) -# This is the pubkey of the relay's signing keypair. If BUZZ_RELAY_PRIVATE_KEY -# is set, derive it: echo $BUZZ_RELAY_PRIVATE_KEY | nak key public -# If not set, the relay generates a random keypair at startup — check relay logs. -export BUZZ_RELAY_PUBKEY= - -# 5. Start the proxy -export BUZZ_UPSTREAM_URL=ws://localhost:3000 -export BUZZ_PROXY_SALT=$(openssl rand -hex 32) -export BUZZ_PROXY_ADMIN_SECRET=$(openssl rand -hex 16) -cargo run -p buzz-proxy # proxy on :4869 - -# 6. Register a guest -curl -X POST http://localhost:4869/admin/guests \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -d '{"pubkey": "", "channels": ""}' - -# 7. Connect any NIP-28 + NIP-42 client to ws://localhost:4869 -``` - -### What Works - -| Feature | Status | Notes | -|---------|:------:|-------| -| **NIP-11 relay info** | ✅ | Standard relay info document at `GET /` | -| **NIP-42 authentication** | ✅ | Proactive challenge + reactive-auth compatible | -| **Channel discovery (kind:40)** | ✅ | Synthesized from Buzz REST API at startup; served locally. Content uses channel UUID as `name` for ID stability; human-readable name is in kind:41. **Snapshot — new channels created after proxy start require restart. Renames do NOT affect kind:40 (UUID-anchored).** | -| **Channel metadata (kind:41)** | ✅ | Name, description (picture always empty — no channel-picture source in proxy path); synthesized at startup, served locally. **Snapshot — new channels AND renames require restart to update local kind:41.** | -| **Channel messages (kind:42)** | ✅ | Translated to/from Buzz kind:9 | -| **Inbound kind:1** | ✅ | Text notes (kind:1) accepted and translated to kind:9, same as kind:42 | -| **Message editing (kind:41)** | ✅ | Bidirectional: inbound kind:41 → kind:40003; outbound kind:40003 → kind:41. **Note:** inbound kind:41 is always treated as a message edit, never as a channel metadata update. Standard NIP-28 channel-metadata writes are not supported. | -| **Reactions (kind:7)** | ✅ | Bidirectional; inbound channel scope verified against allowed channels. **Constraint:** target must already be known to the proxy's ID mapping cache (populated by prior fetch, outbound delivery, or inbound publish). Error if unknown: `reaction target is unknown to the proxy; fetch the message first`. | -| **Deletions (kind:5)** | ⚠️ | **Outbound only** — standard kind:5 events stored on the relay are translated for clients. Admin deletions (kind:9005) and REST-API deletes soft-delete without emitting kind:5, so proxy clients won't see those. Inbound kind:5 blocked by proxy policy (not yet implemented). | -| **Real-time streaming** | ✅ | Live event delivery via open subscriptions | -| **Multi-channel access** | ✅ | Guests can be granted access to multiple channels | -| **Shadow identity** | ✅ | Each guest gets a deterministic shadow keypair | - -> **kind:41 dual semantics:** A `REQ` for kind:41 returns both locally-synthesized channel metadata -> (startup snapshot) AND upstream edit events (kind:40003 translated to kind:41). Clients may see -> two different event types under the same kind number. Inbound kind:41 is always treated as a -> message edit (→ kind:40003), never as a channel metadata update. - -### What Doesn't Work - -| Feature | Status | Why | -|---------|:------:|-----| -| **Channel creation (kind:40 write)** | ❌ | Channels created via REST API or NIP-29 kind:9007 (direct path) | -| **Inbound deletions (kind:5)** | ❌ | Blocked by proxy policy; not yet implemented | -| **DMs (NIP-04/NIP-44)** | ❌ | Proxy only handles NIP-28 channel events | -| **User profiles (kind:0)** | ❌ | Profiles managed via REST API or kind:0 (direct path) | -| **NIP-10 reply threading** | ⚠️ | Threading works on direct path; proxy preserves `#e` tags but does not translate thread metadata | -| **NIP-50 search** | ❌ | Available on direct path only (ws://relay:3000); not proxied | -| **File uploads (NIP-94/96)** | ❌ | Use Blossom on the relay directly (Path 1) | -| **Relay lists / Outbox (NIP-65)** | ❌ | Single-relay architecture | - -### Channel UUIDs vs Event IDs - -Buzz identifies channels by UUID. NIP-28 clients identify channels by the event ID of the -kind:40 creation event. The proxy translates automatically, but you need the event ID to subscribe. - -The synthesized kind:40 uses the channel UUID as the `name` field in content (for deterministic -event ID stability across restarts). The human-readable channel name is in kind:41 metadata: - -```bash -# Get kind:40 (UUID in content.name) and kind:41 (human-readable name) -nak req -k 40 -k 41 --auth --sec ws://localhost:4869 -``` - -### Proxy Authentication - -Two methods, both using NIP-42: - -**Pubkey-based (primary)** — register a guest's hex pubkey with channel access: - -```bash -# Register -curl -X POST http://localhost:4869/admin/guests \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d '{"pubkey": "", "channels": ","}' - -# List -curl http://localhost:4869/admin/guests \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" - -# Revoke -curl -X DELETE http://localhost:4869/admin/guests \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d '{"pubkey": ""}' -``` - -> **Private channels:** The proxy authenticates upstream using its own server key via NIP-42. -> `GET /api/channels` and relay REQ filters only return channels accessible to that identity. -> For the proxy to expose a private channel, the proxy's server pubkey must itself be a member -> of that channel. Guest registration alone is not sufficient for private channels. - -**Invite tokens (secondary)** — for ad-hoc sharing with expiry and use limits: - -```bash -# Create -curl -X POST http://localhost:4869/admin/invite \ - -H "Authorization: Bearer $BUZZ_PROXY_ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d '{"channels": ",", "max_uses": 5, "hours": 48}' - -# Connect: ws://localhost:4869?token= -``` - -### Connecting with Coracle (expected, not verified in-repo) - -1. Open **https://coracle.social**. -2. Note your hex pubkey from **Settings → Account**. -3. Register it: `POST /admin/guests` with your pubkey and channel UUIDs. -4. **Settings → Relays → Add Relay** → `ws://localhost:4869` -5. Coracle should handle NIP-42 auth automatically. Channels should appear under **Public Channels**. - -For remote access, tunnel with ngrok: `ngrok http 4869` → use `wss://.ngrok.io`. - -### Connecting with nak (Proxy) - -```bash -# Discover channels -nak req -k 40 -l 10 --auth --sec ws://localhost:4869 - -# Read messages from a specific channel -nak req -k 42 --tag "e=" -l 10 --auth --sec ws://localhost:4869 - -# Send a message -nak event -k 42 -c "Hello!" --tag e= \ - --auth --sec ws://localhost:4869 - -# Stream live from a specific channel -nak req -k 42 --tag "e=" --stream --auth --sec ws://localhost:4869 -``` - -### Connecting with nostr-tools v2.23 - -```javascript -import { Relay } from 'nostr-tools/relay' -import { finalizeEvent } from 'nostr-tools/pure' -import { channelMessageEvent } from 'nostr-tools/nip28' - -const relay = new Relay('ws://localhost:4869', { websocketImplementation: WebSocket }) -relay.onauth = async (template) => finalizeEvent(template, secretKey) -await relay.connect() - -const event = channelMessageEvent({ - channel_create_event_id: '', - relay_url: 'ws://localhost:4869', - content: 'Hello from nostr-tools!', - created_at: Math.floor(Date.now() / 1000), -}, secretKey) -await relay.publish(event) -``` - -Test script: `scripts/test-proxy-nostr-tools.mjs`. - -### Connecting with nostr-sdk v0.44 (Python) - -```python -import nostr_sdk - -keys = nostr_sdk.Keys.parse("") -signer = nostr_sdk.NostrSigner.keys(keys) -client = nostr_sdk.ClientBuilder().signer(signer).build() -client.automatic_authentication(True) - -await client.add_relay(nostr_sdk.RelayUrl.parse("ws://localhost:4869")) -await client.connect() - -builder = nostr_sdk.EventBuilder.channel_msg(channel_eid, relay_url, "Hello from Python!") -await client.send_event_builder(builder) -``` - -Test script: `scripts/test-proxy-nostr-sdk-python.py`. - -### Tested Clients (Proxy) - -| Client | Platform | Evidence | Notes | -|--------|----------|:--------:|-------| -| **nak** | CLI | Manual (anecdotal) | Auth, discovery, metadata, send, receive, streaming | -| **nostr-tools v2.23** | JS | Standalone script | `scripts/test-proxy-nostr-tools.mjs` | -| **nostr-sdk v0.44** | Python | Standalone script | `scripts/test-proxy-nostr-sdk-python.py` | - -**Not verified in-repo** (anecdotal / expected based on NIP-28 + NIP-42 support): -- **Coracle** (Web) — expected best GUI; renders kind:42 in chat UI -- **Amethyst** (Android) — NIP-28 public chat view -- **Nostrudel** (Web) — good NIP-28 support - -### Clients That Won't Work (anecdotal) - -| Client | Why | -|--------|-----| -| **Damus** | NIP-42 works but no NIP-28 channel UI (anecdotal) | -| **Primal** | Caching relay infrastructure — doesn't connect directly (anecdotal) | -| **Clients without NIP-42** | Both relay and proxy require authentication | - ---- - -## Architecture - -``` - NIP-29 (direct) -┌──────────────────┐ ◄──────────────────────────► ┌──────────────────┐ -│ NIP-29 Client │ kind:9, kind:7, kind:5 │ Buzz Relay │ -│ (Chachi, 0xchat,│ kind:9000/01/02/05/07/08 │ :3000 │ -│ nak) │ #h(uuid), NIP-42 │ │ -└──────────────────┘ │ kind:39000/1/2 │ - │ kind:44100/44101│ - │ Blossom media │ -┌──────────────────┐ ┌────────────────┐ │ /media/upload │ -│ NIP-28 Client │◄──────►│ buzz-proxy │◄───►│ │ -│ (Coracle, nak, │ NIP-28 │ :4869 │ WS └──────────────────┘ -│ nostr-tools) │ │ │ +REST -└──────────────────┘ │ kind:42↔kind:9 │ (/api/channels, - │ kind:41↔40003 │ /api/events) - │ kind:1→kind:9 │ - │ kind:7 (bidir) │ - │ kind:5 (out) │ - │ #e(id)↔#h(uuid)│ - │ shadow keys │ - └────────────────┘ -``` - -**Direct path:** Clients speak kind:9 natively. No translation, no shadow keys, no proxy. The relay -handles NIP-42 auth, channel scoping via `#h` tags, group discovery (kind:39000–39002), membership -notifications (kind:44100/44101), NIP-29 admin commands (kind:9000, 9001, 9002, 9005, 9007, 9008, -9021, 9022; plus deferred 9009), and standard deletions/reactions (kind:5/7). - -**Proxy path:** Translates kind:42 ↔ kind:9 (also accepts kind:1 inbound), kind:41 ↔ kind:40003 -(edits), kind:7 (reactions, bidirectional), and kind:5 (deletions, outbound only — standard kind:5 -events only; admin/REST deletions do not surface as NIP-28 delete events). Re-signs events with -deterministic shadow keys (HMAC-SHA256 of salt + pubkey). Channel discovery (kind:40) is synthesized -locally from Buzz's REST API at startup and never forwarded upstream. Channel metadata (kind:41) -is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 → kind:41). - ---- - -## Proxy Environment Variables - -| Variable | Required | Default | Description | -|----------|:--------:|---------|-------------| -| `BUZZ_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay | -| `BUZZ_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) | -| `BUZZ_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) | -| `BUZZ_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) | -| `BUZZ_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) | -| `BUZZ_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address | -| `BUZZ_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. | -| `BUZZ_PROXY_ADMIN_SECRET` | ❌ | — | Bearer secret for `/admin/*` (unset = no auth, dev mode) | -| `RUST_LOG` | ❌ | `buzz_proxy=info,tower_http=info` | Log level | - ---- - ## Relay Membership (NIP-43) When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the @@ -619,15 +330,6 @@ nak req -k 13534 --auth --sec ws://localhost:3000 - **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005). - **Client-submitted kind:44100/44101 rejected.** Membership notifications can only be signed by the relay keypair. -### Proxy Path -- **Event pubkey verification.** Inbound events must have a `pubkey` matching the authenticated NIP-42 identity. Spoofed pubkeys are rejected. -- **Inbound kind:5 blocked by proxy policy.** Not yet implemented. The relay's deletion handler does perform author-match validation, but the proxy-side translation path for inbound deletions has not been built. -- **Shadow keys use HMAC-SHA256.** Proper domain separation; salt must be kept secret. -- **Guest registry is in-memory.** Lost on proxy restart. Re-register guests after restarts. -- **Invite tokens are in-memory.** Lost on proxy restart. Default `max_uses` is 10. -- **Revocation is not session-aware.** Removing a guest doesn't disconnect active sessions. -- **Admin secret uses hash-then-compare.** No timing oracle on the bearer token check. - --- ## Troubleshooting @@ -639,23 +341,8 @@ nak req -k 13534 --auth --sec ws://localhost:3000 | `auth-required: verification failed` | Pubkey not in allowlist (when enabled), or NIP-42 auth failed | Add pubkey to `pubkey_allowlist` table; verify NIP-42 challenge/response | | `invalid: channel-scoped events must include an h tag` | kind:9 sent without `#h` tag | Include `--tag "h="` | | `invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay | -| No discovery events | Channel is private + you're not a member | Join the channel first via REST API | - -### Proxy Path - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `restricted: pubkey not registered and no invite token provided` | Pubkey not registered, no token | Register guest or create invite token | -| `error: token invalid: invite token not found` | Token doesn't exist (proxy restarted or mistyped) | Create new invite token | -| `error: token invalid: invite token expired` | Token past expiry time | Create new invite token | -| `error: token invalid: invite token exhausted` | Token reached `max_uses` limit | Create new invite token with higher limit | -| `auth-required: authentication timeout` | Client didn't respond to NIP-42 within 30s | Use a NIP-42-capable client | -| No messages after auth | Unresolved `#e` filter silently returns zero events | Re-query `nak req -k 40` for correct kind:40 event ID | -| Guest still has access after revoke | Active sessions not terminated | Restart proxy to cut all sessions | -| Proxy startup fails | Can't reach relay REST API or missing env vars | Check relay is running; verify all required env vars (especially `BUZZ_RELAY_PUBKEY`) | +| No discovery events | Channel is private + you're not a member | Join the channel first | --- ## Further Reading - -- [`crates/buzz-proxy/README.md`](crates/buzz-proxy/README.md) — proxy crate internals, shadow key derivation, subscription namespacing. **Note:** some auth/buffering details in that README may be stale; this document is the authoritative reference for proxy behavior. diff --git a/VISION.md b/VISION.md index aac9051e8a..21416acff2 100644 --- a/VISION.md +++ b/VISION.md @@ -43,7 +43,7 @@ The relay enforces all access control. Channel membership is the only gate. | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | -Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else. Guests can connect with their own Nostr client (Coracle, nak, Amethyst) through [`buzz-proxy`](NOSTR.md), which translates standard NIP-28 events to Buzz's internal protocol. Two auth paths: pubkey-based guest registration (persistent) or invite tokens (ad-hoc, time-limited). +Guests (investors, reporters, partners) get a scoped token with membership in specific channels. Same access model as everyone else. --- @@ -215,7 +215,6 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete | | ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers | | ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection | -| ✅ | NIP-28 proxy — third-party Nostr clients (Coracle, nak, Amethyst) via `buzz-proxy` | | ✅ | Agent CLI — `buzz-cli`, mirrors and extends the MCP surface | | ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined | | 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) | diff --git a/crates/buzz-auth/src/scope.rs b/crates/buzz-auth/src/scope.rs index 2bd92a924d..1a78c904de 100644 --- a/crates/buzz-auth/src/scope.rs +++ b/crates/buzz-auth/src/scope.rs @@ -54,8 +54,6 @@ pub enum Scope { /// enforced by git HTTP push routes (which use NIP-98 + owner check). /// Full enforcement deferred to v2 collaborator model. ReposWrite, - /// Submit events on behalf of other pubkeys (proxy service accounts only). - ProxySubmit, /// A scope string not recognised by this version of the relay. /// /// Preserved as-is to allow forward-compatibility with future scope additions. @@ -131,7 +129,6 @@ impl Scope { Self::FilesWrite => "files:write", Self::ReposRead => "repos:read", Self::ReposWrite => "repos:write", - Self::ProxySubmit => "proxy:submit", Self::Unknown(s) => s.as_str(), } } @@ -164,7 +161,6 @@ impl FromStr for Scope { "files:write" => Self::FilesWrite, "repos:read" => Self::ReposRead, "repos:write" => Self::ReposWrite, - "proxy:submit" => Self::ProxySubmit, other => Self::Unknown(other.to_string()), }) } @@ -236,7 +232,7 @@ mod tests { } #[test] - fn all_known_returns_all_14_variants() { + fn all_known_returns_all_known_variants() { let all = Scope::all_known(); assert_eq!(all.len(), 16, "expected 16 known scope variants"); // Verify no duplicates diff --git a/crates/buzz-proxy/Cargo.toml b/crates/buzz-proxy/Cargo.toml deleted file mode 100644 index 9b0ee4768b..0000000000 --- a/crates/buzz-proxy/Cargo.toml +++ /dev/null @@ -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" diff --git a/crates/buzz-proxy/README.md b/crates/buzz-proxy/README.md deleted file mode 100644 index d98dbfa3eb..0000000000 --- a/crates/buzz-proxy/README.md +++ /dev/null @@ -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 - -# Mint the token with that pubkey -cargo run -p buzz-admin -- mint-token \ - --name "buzz-proxy" \ - --scopes "proxy:submit,channels:read,messages:read" \ - --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= -export BUZZ_PROXY_SALT=$(openssl rand -hex 32) -export BUZZ_PROXY_API_TOKEN= -export BUZZ_PROXY_ADMIN_SECRET= -``` - -> 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 " \ - -d '{"pubkey":"","channels":","}' -``` - -### 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 " \ - -d '{"channels":"","hours":24,"max_uses":10}' -``` - -Connect with the token in the URL: - -``` -ws://localhost:4869?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=`) - -### `POST /admin/invite` - -Create an invite token. Requires `Authorization: Bearer ` (if secret is set). - -**Request:** -```json -{ - "channels": ",", - "hours": 24, - "max_uses": 10 -} -``` - -**Response:** -```json -{ - "token": "buzz_invite_", - "channels": ["", ""], - "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=" - -# Subscribe to messages -nak req -k 42 --tag "e=" -l 20 --auth "ws://localhost:4869?token=" - -# Post a message -nak event -k 42 -c "Hello!" --tag "e=" --sec "ws://localhost:4869?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). diff --git a/crates/buzz-proxy/src/channel_map.rs b/crates/buzz-proxy/src/channel_map.rs deleted file mode 100644 index 56dcce6eeb..0000000000 --- a/crates/buzz-proxy/src/channel_map.rs +++ /dev/null @@ -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, - by_event_id: DashMap, - 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> { - 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 = 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 { - 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 { - 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 { - 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> { - let uuid = dto.id.parse::()?; - 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" - ); - } -} diff --git a/crates/buzz-proxy/src/error.rs b/crates/buzz-proxy/src/error.rs deleted file mode 100644 index 23cf53a4ea..0000000000 --- a/crates/buzz-proxy/src/error.rs +++ /dev/null @@ -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), -} diff --git a/crates/buzz-proxy/src/guest_store.rs b/crates/buzz-proxy/src/guest_store.rs deleted file mode 100644 index 45844bae5b..0000000000 --- a/crates/buzz-proxy/src/guest_store.rs +++ /dev/null @@ -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>, -} - -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> { - 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) { - 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)> { - 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 = 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); - } -} diff --git a/crates/buzz-proxy/src/invite.rs b/crates/buzz-proxy/src/invite.rs deleted file mode 100644 index 1fb17061d1..0000000000 --- a/crates/buzz-proxy/src/invite.rs +++ /dev/null @@ -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, - /// When the token expires. - pub expires_at: DateTime, - /// 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, - channel_ids: Vec, - expires_at: DateTime, - 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) -> 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) -> 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::now() + Duration::seconds(secs) - } - - fn past(secs: i64) -> DateTime { - 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); - } -} diff --git a/crates/buzz-proxy/src/invite_store.rs b/crates/buzz-proxy/src/invite_store.rs deleted file mode 100644 index 90d192791c..0000000000 --- a/crates/buzz-proxy/src/invite_store.rs +++ /dev/null @@ -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, -} - -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, 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, 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 { - 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, 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); - } -} diff --git a/crates/buzz-proxy/src/kind_translator.rs b/crates/buzz-proxy/src/kind_translator.rs deleted file mode 100644 index 18dd901cff..0000000000 --- a/crates/buzz-proxy/src/kind_translator.rs +++ /dev/null @@ -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)); - } -} diff --git a/crates/buzz-proxy/src/lib.rs b/crates/buzz-proxy/src/lib.rs deleted file mode 100644 index 2d62bd7128..0000000000 --- a/crates/buzz-proxy/src/lib.rs +++ /dev/null @@ -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; diff --git a/crates/buzz-proxy/src/main.rs b/crates/buzz-proxy/src/main.rs deleted file mode 100644 index eea69aef6a..0000000000 --- a/crates/buzz-proxy/src/main.rs +++ /dev/null @@ -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::(4096); - - // inbound_tx: UpstreamClient → bridge task (UpstreamEvent) - let (inbound_tx, mut inbound_rx) = mpsc::channel::(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"); -} diff --git a/crates/buzz-proxy/src/server.rs b/crates/buzz-proxy/src/server.rs deleted file mode 100644 index ae20f50561..0000000000 --- a/crates/buzz-proxy/src/server.rs +++ /dev/null @@ -1,1255 +0,0 @@ -//! External-facing NIP-01 WebSocket server for standard Nostr clients. -//! -//! Handles NIP-11 relay info, NIP-42 AUTH challenge/response (with -//! reactive-auth–compatible CLOSED/OK rejections for pre-auth messages), -//! guest and invite token authentication, and kind:40/41 interception -//! from the local [`ChannelMap`]. - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use axum::{ - extract::{ - ws::{Message, WebSocket}, - FromRequest, Query, State, WebSocketUpgrade, - }, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::get, - Router, -}; -use nostr::prelude::*; -use serde::Deserialize; -use tracing::{debug, error, info, warn}; -use uuid::Uuid; - -use crate::channel_map::ChannelMap; -use crate::guest_store::GuestStore; -use crate::invite_store::InviteStore; -use crate::translate::Translator; -use crate::upstream::UpstreamClient; - -/// Shared state injected into every axum handler. -#[derive(Clone)] -pub struct ProxyState { - /// Bidirectional UUID ↔ kind:40 event ID map (loaded at startup). - pub channel_map: Arc, - /// Pubkey-based guest registry (persistent access, no token needed). - pub guest_store: Arc, - /// In-memory invite token registry (temporary access via bearer token). - pub invite_store: Arc, - /// Event translator: NIP-28 ↔ Buzz internal format. - pub translator: Arc, - /// Upstream relay client — used to send events, REQs, and CLOSEs. - pub upstream: Arc, - /// Broadcast channel: raw NIP-01 JSON strings FROM the upstream relay. - /// Each WebSocket connection subscribes its own receiver. - pub upstream_events: tokio::sync::broadcast::Sender, - /// Optional shared secret for the admin endpoint. - /// If `Some`, requests must include `Authorization: Bearer `. - /// If `None`, the endpoint is unauthenticated (dev mode). - pub admin_secret: Option, - /// This proxy's own WebSocket URL (e.g. "ws://0.0.0.0:4869"). - /// Used for NIP-42 relay tag validation. - pub relay_url: String, -} - -/// Query parameters accepted on the root WebSocket endpoint. -#[derive(Deserialize)] -pub struct WsParams { - /// Invite token string (required for WebSocket connections). - token: Option, -} - -/// Build the axum [`Router`] for the proxy server. -/// -/// Routes: -/// - `GET /` — NIP-11 JSON *or* WebSocket upgrade (content-negotiated) -/// - `POST /admin/invite` — Create an invite token (temporary access) -/// - `POST /admin/guests` — Register a guest pubkey (persistent access) -/// - `DELETE /admin/guests` — Revoke a guest pubkey -/// - `GET /admin/guests` — List all registered guests -/// -/// All `/admin/*` routes are protected by `BUZZ_PROXY_ADMIN_SECRET` if set. -pub fn router(state: ProxyState) -> Router { - Router::new() - .route("/", get(root_handler)) - .route("/admin/invite", axum::routing::post(create_invite)) - .route( - "/admin/guests", - axum::routing::post(register_guest) - .delete(revoke_guest) - .get(list_guests), - ) - .with_state(state) -} - -/// Content-negotiate between NIP-11 JSON and WebSocket upgrade. -/// -/// Uses `axum::extract::Request` to manually attempt the WS upgrade so that -/// plain HTTP GET requests (NIP-11 clients, browser visits) are not rejected -/// by the extractor. Mirrors the pattern used in `buzz-relay/src/router.rs`. -async fn root_handler( - State(state): State, - headers: HeaderMap, - Query(params): Query, - req: axum::extract::Request, -) -> Response { - // NIP-11: clients that send `Accept: application/nostr+json` want relay info. - let wants_nip11 = headers - .get("accept") - .and_then(|v| v.to_str().ok()) - .map(|v| v.contains("application/nostr+json")) - .unwrap_or(false); - - if wants_nip11 { - return nip11_response().into_response(); - } - - // Try WebSocket upgrade; fall back to NIP-11 JSON for plain HTTP. - match WebSocketUpgrade::from_request(req, &state).await { - Ok(ws) => { - let token = params.token.unwrap_or_default(); - ws.on_upgrade(move |socket| handle_ws(socket, state, token)) - } - Err(_) => nip11_response().into_response(), - } -} - -fn nip11_response() -> impl IntoResponse { - let nip11 = serde_json::json!({ - "name": "buzz-proxy", - "description": "Buzz NIP-28 guest proxy for standard Nostr clients", - "supported_nips": [1, 11, 28, 42], - "software": "buzz-proxy", - "version": env!("CARGO_PKG_VERSION"), - "limitation": { - "auth_required": true - } - }); - ( - [ - (axum::http::header::CONTENT_TYPE, "application/nostr+json"), - (axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"), - ], - serde_json::to_string_pretty(&nip11).unwrap(), - ) -} - -/// Compare two strings in constant time to prevent timing side-channel attacks. -/// Returns `true` only if both strings are identical. -/// -/// Uses hash-then-compare to eliminate the length oracle: both inputs are hashed -/// to fixed 32-byte values before comparison, so string length is never leaked. -fn constant_time_eq(a: &str, b: &str) -> bool { - use sha2::{Digest, Sha256}; - let hash_a: [u8; 32] = Sha256::digest(a.as_bytes()).into(); - let hash_b: [u8; 32] = Sha256::digest(b.as_bytes()).into(); - // Fixed-length comparison — no length oracle - hash_a - .iter() - .zip(hash_b.iter()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)) - == 0 -} - -/// Helper: serialize a [`RelayMessage`] and send it over the socket. -/// Returns `true` if the send succeeded. -async fn send_relay_msg(socket: &mut WebSocket, msg: RelayMessage<'_>) -> bool { - let json = msg.as_json(); - socket.send(Message::Text(json.into())).await.is_ok() -} - -async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) { - // Per-connection prefix for subscription ID namespacing. - // All sub IDs sent upstream are prefixed with this to prevent collisions - // across clients sharing the single upstream connection. - let conn_prefix = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); - - // Token validation is deferred until after NIP-42 auth completes. - // Registered guests (in GuestStore) don't need a token at all. - let challenge = uuid::Uuid::new_v4().to_string(); - if !send_relay_msg(&mut socket, RelayMessage::auth(challenge.clone())).await { - return; - } - - // Returns `(pubkey, channels)` on successful auth, or drops the connection - // on timeout / disconnect / invalid auth. - let auth_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); - - let (client_pubkey, allowed_channels): (PublicKey, Vec) = loop { - let msg = tokio::select! { - msg = socket.recv() => msg, - _ = tokio::time::sleep_until(auth_deadline) => { - let _ = send_relay_msg( - &mut socket, - RelayMessage::notice("auth-required: authentication timeout"), - ) - .await; - return; - } - }; - - let text = match msg { - Some(Ok(Message::Text(t))) => t.to_string(), - Some(Ok(Message::Close(_))) | None => return, - _ => continue, - }; - - match ClientMessage::from_json(&text) { - Ok(ClientMessage::Auth(auth_event)) => { - // Must be kind 22242 - if auth_event.kind != Kind::Authentication { - let _ = send_relay_msg( - &mut socket, - RelayMessage::ok(auth_event.id, false, "invalid: wrong kind for AUTH"), - ) - .await; - continue; - } - - // Challenge tag must match - let has_challenge = auth_event.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "challenge" && s[1] == challenge - }); - if !has_challenge { - let _ = send_relay_msg( - &mut socket, - RelayMessage::ok(auth_event.id, false, "invalid: wrong challenge"), - ) - .await; - continue; - } - - // FIX 4: Timestamp recency check — must be within 10 minutes of now. - let time_diff = Timestamp::now() - .as_secs() - .abs_diff(auth_event.created_at.as_secs()); - if time_diff >= 600 { - let _ = send_relay_msg( - &mut socket, - RelayMessage::ok( - auth_event.id, - false, - "invalid: auth event timestamp too far from now", - ), - ) - .await; - continue; - } - - // FIX F: Validate relay tag (non-fatal — many clients omit it). - let has_relay = auth_event.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "relay" && s[1] == state.relay_url - }); - if !has_relay { - debug!("NIP-42 AUTH missing or mismatched relay tag (non-fatal)"); - } - - // Signature must be valid - if auth_event.verify().is_err() { - let _ = send_relay_msg( - &mut socket, - RelayMessage::ok(auth_event.id, false, "invalid: bad signature"), - ) - .await; - continue; - } - - // Priority: GuestStore (pubkey-based) > invite token. - let pubkey = auth_event.pubkey; - let event_id = auth_event.id; - - let channels = if let Some(guest_channels) = state.guest_store.lookup(&pubkey) { - // Registered guest — no token needed. - info!(pubkey = %pubkey, channels = guest_channels.len(), "guest authenticated (pubkey-based)"); - guest_channels - } else if !token.is_empty() { - // Fall back to invite token. - match state.invite_store.validate_and_consume(&token) { - Ok(ch) => { - info!(pubkey = %pubkey, channels = ch.len(), "guest authenticated (invite token)"); - ch - } - Err(e) => { - let _ = send_relay_msg( - &mut socket, - RelayMessage::notice(format!("error: token invalid: {e}")), - ) - .await; - return; - } - } - } else { - // No guest registration, no token → reject. - let _ = send_relay_msg( - &mut socket, - RelayMessage::ok( - event_id, - false, - "restricted: pubkey not registered and no invite token provided", - ), - ) - .await; - return; - }; - - let _ = send_relay_msg(&mut socket, RelayMessage::ok(event_id, true, "")).await; - break (pubkey, channels); - } - - Ok(ClientMessage::Req { - subscription_id, .. - }) => { - // NIP-42: reject pre-auth REQs with CLOSED so clients like nak - // can detect the auth-required rejection, authenticate, and - // re-send the REQ. Buffering silently would leave reactive-auth - // clients stuck waiting forever. - let _ = send_relay_msg( - &mut socket, - RelayMessage::closed( - subscription_id.into_owned(), - "auth-required: authenticate before subscribing", - ), - ) - .await; - } - - Ok(ClientMessage::Event(event)) => { - // NIP-42: respond with OK false so clients like nak can detect - // the auth-required rejection and retry after authenticating. - let _ = send_relay_msg( - &mut socket, - RelayMessage::ok( - event.id, - false, - "auth-required: authenticate before sending events", - ), - ) - .await; - } - - _ => { - // Ignore unknown / unparseable messages during pre-auth - } - } - }; - - // FIX 1: pending_oks maps upstream_event_id_hex → client_original_event_id - // FIX 5: active_subs tracks prefixed sub IDs sent upstream for cleanup on disconnect - let mut pending_oks: HashMap = HashMap::new(); - let mut active_subs: HashSet = HashSet::new(); - - let mut upstream_rx = state.upstream_events.subscribe(); - - loop { - tokio::select! { - // Inbound from client - msg = socket.recv() => { - match msg { - Some(Ok(Message::Text(text))) => { - handle_client_message( - &mut socket, - &state, - &text.to_string(), - &allowed_channels, - &client_pubkey, - &conn_prefix, - &mut pending_oks, - &mut active_subs, - ) - .await; - } - Some(Ok(Message::Close(_))) | None => break, - _ => {} - } - } - - // Outbound from upstream relay — translate and filter per-client - upstream = upstream_rx.recv() => { - match upstream { - Ok(text) => { - match RelayMessage::from_json(&text) { - Ok(RelayMessage::Event { subscription_id, event }) => { - // Only process events for subscriptions owned by this connection. - let sub_str = subscription_id.to_string(); - if !sub_str.starts_with(&conn_prefix) { - continue; // Not ours — another client's subscription. - } - // Strip the connection prefix before sending to client. - let client_sub_id = SubscriptionId::new(&sub_str[conn_prefix.len() + 1..]); - // Translate outbound: kind:9 → kind:42, #h → #e - match state - .translator - .translate_outbound(&event, &allowed_channels) - .await - { - Ok(Some(translated)) => { - let out = RelayMessage::event(client_sub_id, translated); - if socket.send(Message::Text(out.as_json().into())).await.is_err() { - break; - } - } - Ok(None) => { - // Not translatable or not a stream message — drop silently. - } - Err(e) => { - // Permission denied or channel not found — skip silently. - debug!(error = %e, "dropping upstream event (not in scope)"); - } - } - } - Ok(RelayMessage::EndOfStoredEvents(ref sub_id)) => { - let sub_str = sub_id.to_string(); - if sub_str.starts_with(&conn_prefix) { - let client_sub_id = SubscriptionId::new(&sub_str[conn_prefix.len() + 1..]); - let out = RelayMessage::eose(client_sub_id); - if socket.send(Message::Text(out.as_json().into())).await.is_err() { - break; - } - } - } - Ok(RelayMessage::Closed { ref subscription_id, ref message }) => { - let sub_str = subscription_id.to_string(); - if sub_str.starts_with(&conn_prefix) { - let client_sub_id = SubscriptionId::new(&sub_str[conn_prefix.len() + 1..]); - let out = RelayMessage::closed(client_sub_id, message.clone()); - if socket.send(Message::Text(out.as_json().into())).await.is_err() { - break; - } - } - } - // FIX 1: Route OK messages to the correct client using pending_oks map. - Ok(RelayMessage::Ok { event_id, status, message }) => { - let upstream_id_hex = event_id.to_hex(); - if let Some(client_event_id) = pending_oks.remove(&upstream_id_hex) { - // This OK is for an event we sent — rewrite with client's original ID. - let out = RelayMessage::ok(client_event_id, status, message); - if socket.send(Message::Text(out.as_json().into())).await.is_err() { - break; - } - } - // If not in pending_oks, this OK belongs to another client — skip it. - } - // FIX 1: NOTICE messages from upstream contain operational details. - // Log them but do NOT forward to clients. - Ok(RelayMessage::Notice(notice_msg)) => { - debug!(notice = %notice_msg, "upstream notice (not forwarded to client)"); - } - Ok(_other) => { - // AUTH, COUNT, and other control-plane messages from - // upstream are internal to the proxy↔relay connection. - // Do NOT forward to clients — they leak relay internals. - debug!("dropping upstream control-plane message (not forwarded)"); - } - Err(_) => { - // Unparseable upstream message — drop silently. - // Forwarding raw frames could leak relay internals. - debug!("dropping unparseable upstream message"); - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!(skipped = n, "upstream broadcast lagged"); - // Keep going — the client may have missed some events but - // we don't want to drop the connection. - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - error!("upstream broadcast channel closed"); - break; - } - } - } - } - } - - // FIX 5: On disconnect, send CLOSE for all active upstream subscriptions. - for prefixed_sub in active_subs { - let sub_id = SubscriptionId::new(prefixed_sub); - if let Err(e) = state.upstream.send_close(sub_id).await { - warn!("upstream send_close on disconnect failed: {e}"); - } - } - - debug!(pubkey = %client_pubkey, "client disconnected"); -} - -#[allow(clippy::too_many_arguments)] -async fn handle_client_message( - socket: &mut WebSocket, - state: &ProxyState, - raw_msg: &str, - allowed_channels: &[Uuid], - client_pubkey: &PublicKey, - conn_prefix: &str, - pending_oks: &mut HashMap, - active_subs: &mut HashSet, -) { - let msg = match ClientMessage::from_json(raw_msg) { - Ok(m) => m, - Err(_) => { - let _ = send_relay_msg(socket, RelayMessage::notice("error: invalid message")).await; - return; - } - }; - - match msg { - ClientMessage::Req { - subscription_id, - filters, - } => { - handle_req( - socket, - state, - subscription_id.into_owned(), - filters.into_iter().map(|f| f.into_owned()).collect(), - allowed_channels, - conn_prefix, - active_subs, - ) - .await; - } - ClientMessage::Event(event) => { - let event_id = event.id; - - // Verify the event is signed by the authenticated client. - // Without this, an AUTHed connection could submit arbitrary events - // that get re-signed under the client's shadow identity. - if event.pubkey != *client_pubkey { - let ok_msg = RelayMessage::ok( - event_id, - false, - "invalid: event pubkey does not match authenticated identity", - ); - let _ = socket.send(Message::Text(ok_msg.as_json().into())).await; - return; - } - if event.verify().is_err() { - let ok_msg = RelayMessage::ok(event_id, false, "invalid: bad event signature"); - let _ = socket.send(Message::Text(ok_msg.as_json().into())).await; - return; - } - - // Translate inbound: kind:42 → kind:9, #e → #h, re-sign with shadow key. - match state.translator.translate_inbound( - &event, - &client_pubkey.to_hex(), - allowed_channels, - ) { - Ok(translated) => { - // FIX H: Cap pending_oks to prevent unbounded growth if upstream never ACKs. - if pending_oks.len() >= 1000 { - let ok_msg = - RelayMessage::ok(event_id, false, "error: too many pending events"); - let _ = socket.send(Message::Text(ok_msg.as_json().into())).await; - return; - } - // FIX 1: Store mapping from upstream event ID → client original event ID - // so we can route the OK response back correctly. - // FIX C: Capture upstream_id before moving `translated` into send_event, - // so we can remove the correct key on failure. - let upstream_id = translated.id; - pending_oks.insert(upstream_id.to_hex(), event_id); - if let Err(e) = state.upstream.send_event(translated).await { - warn!("upstream send_event failed: {e}"); - // FIX C: Remove by translated (upstream) ID, not client event ID. - pending_oks.remove(&upstream_id.to_hex()); - let ok_msg = RelayMessage::ok( - event_id, - false, - "error: upstream unavailable".to_string(), - ); - let _ = socket.send(Message::Text(ok_msg.as_json().into())).await; - } - } - Err(e) => { - let ok_msg = RelayMessage::ok(event_id, false, format!("error: {e}")); - let _ = socket.send(Message::Text(ok_msg.as_json().into())).await; - } - } - } - ClientMessage::Close(sub_id) => { - let prefixed = format!("{conn_prefix}:{}", sub_id); - // FIX 5: Remove from active_subs tracking. - active_subs.remove(&prefixed); - let prefixed_sub_id = SubscriptionId::new(prefixed); - if let Err(e) = state.upstream.send_close(prefixed_sub_id).await { - warn!("upstream send_close failed: {e}"); - } - } - // AUTH after initial handshake is silently ignored. - ClientMessage::Auth(_) => {} - _ => {} - } -} - -/// Split a list of NIP-28 filters into local (kind:40/41) and upstream groups. -/// -/// **Routing rules:** -/// - kind:40 → local only (channel creation is synthesized from ChannelMap) -/// - kind:41 → BOTH local (synthesized metadata) AND upstream (edit events, kind:40003) -/// - kind:42 and others → upstream only -/// - no kinds → both local (with 40/41 injected) and upstream -/// -/// Returns `(local_filters, upstream_filters)`. -fn split_filters(filters: &[Filter]) -> (Vec, Vec) { - let mut local_filters: Vec = Vec::new(); - let mut upstream_filters: Vec = Vec::new(); - - for filter in filters { - let kinds: Vec = filter - .kinds - .as_ref() - .map(|k| k.iter().map(|kind| kind.as_u16()).collect()) - .unwrap_or_default(); - - // kind:40 and kind:41 are served locally (synthesized metadata). - let has_local = kinds.iter().any(|k| *k == 40 || *k == 41); - // kind:41 ALSO goes upstream (translates to kind:40003 for edit events). - // kind:42 and everything else goes upstream. - let has_upstream = kinds.iter().any(|k| *k != 40); - - if has_local { - let local_kinds: Vec = kinds - .iter() - .filter(|k| **k == 40 || **k == 41) - .map(|k| Kind::Custom(*k)) - .collect(); - let mut local_f = filter.clone(); - if let Some(ref all_kinds) = filter.kinds { - local_f = local_f.remove_kinds(all_kinds.iter().cloned()); - } - local_f = local_f.kinds(local_kinds); - local_filters.push(local_f); - } - if has_upstream { - // Upstream gets everything except kind:40 (which is local-only). - let upstream_kinds: Vec = kinds - .iter() - .filter(|k| **k != 40) - .map(|k| Kind::Custom(*k)) - .collect(); - let mut upstream_f = filter.clone(); - if let Some(ref all_kinds) = filter.kinds { - upstream_f = upstream_f.remove_kinds(all_kinds.iter().cloned()); - } - upstream_f = upstream_f.kinds(upstream_kinds); - upstream_filters.push(upstream_f); - } - if !has_local && !has_upstream { - // No kinds specified — "subscribe to everything". - // Forward upstream AND serve local kind:40/41 metadata. - upstream_filters.push(filter.clone()); - let mut local_f = filter.clone(); - local_f = local_f.kinds([Kind::ChannelCreation, Kind::ChannelMetadata]); - local_filters.push(local_f); - } - } - - (local_filters, upstream_filters) -} - -/// Collect locally-served events for kind:40/41 filters from the channel map. -/// -/// Returns the events that match the filter constraints (kinds, #e, authors, -/// since, until, ids, limit). The caller is responsible for sending them. -fn collect_local_events( - filter: &Filter, - channel_map: &ChannelMap, - allowed_channels: &[Uuid], -) -> Vec { - let kinds: Vec = filter - .kinds - .as_ref() - .map(|k| k.iter().map(|kind| kind.as_u16()).collect()) - .unwrap_or_default(); - - let wants_40 = kinds.contains(&40); - let wants_41 = kinds.contains(&41); - - // Apply `authors` filter — all synthesized events share the server pubkey. - let server_pubkey = channel_map.server_keys().public_key(); - if let Some(ref authors) = filter.authors { - if !authors.contains(&server_pubkey) { - return Vec::new(); - } - } - - let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); - let e_filter_values = filter.generic_tags.get(&e_tag_key); - - let channels = channel_map.all_channels(); - let limit: usize = filter.limit.unwrap_or(usize::MAX); - let mut events: Vec = Vec::new(); - - for ch in &channels { - if events.len() >= limit { - break; - } - if !allowed_channels.contains(&ch.uuid) { - continue; - } - if let Some(e_values) = e_filter_values { - if !e_values.contains(&ch.kind40_event_id) { - continue; - } - } - if let Some(since) = filter.since { - if Timestamp::from(ch.created_at_unix) < since { - continue; - } - } - if let Some(until) = filter.until { - if Timestamp::from(ch.created_at_unix) > until { - continue; - } - } - - if wants_40 && events.len() < limit { - let kind40 = channel_map.synthesize_kind40(&ch.uuid.to_string(), ch.created_at_unix); - let id_ok = filter - .ids - .as_ref() - .is_none_or(|ids| ids.contains(&kind40.id)); - if id_ok { - events.push(kind40); - } - } - if wants_41 && events.len() < limit { - let kind41 = channel_map.synthesize_kind41(ch); - let id_ok = filter - .ids - .as_ref() - .is_none_or(|ids| ids.contains(&kind41.id)); - if id_ok { - events.push(kind41); - } - } - } - - events -} - -async fn handle_req( - socket: &mut WebSocket, - state: &ProxyState, - sub_id: SubscriptionId, - filters: Vec, - allowed_channels: &[Uuid], - conn_prefix: &str, - active_subs: &mut HashSet, -) { - let (owned_local_filters, owned_upstream_filters) = split_filters(&filters); - - // Serve local filters from ChannelMap via the extracted pure function. - for filter in &owned_local_filters { - let events = collect_local_events(filter, &state.channel_map, allowed_channels); - for event in events { - let _ = send_relay_msg(socket, RelayMessage::event(sub_id.clone(), event)).await; - } - } - - if owned_upstream_filters.is_empty() { - // Only local filters — send EOSE immediately after serving them. - let _ = send_relay_msg(socket, RelayMessage::eose(sub_id.clone())).await; - return; - } - - // Forward upstream filters (translated) to the upstream relay. - // The upstream EOSE will serve as the combined EOSE for mixed REQs. - let translated_filters: Vec = owned_upstream_filters - .iter() - .map(|f| { - state - .translator - .translate_filter_inbound(f, allowed_channels) - }) - .collect(); - - let prefixed_sub_id_str = format!("{conn_prefix}:{}", sub_id); - let prefixed_sub_id = SubscriptionId::new(prefixed_sub_id_str.clone()); - - // FIX 5: Track this subscription for cleanup on disconnect. - active_subs.insert(prefixed_sub_id_str); - - if let Err(e) = state - .upstream - .send_req(prefixed_sub_id, translated_filters) - .await - { - warn!("upstream send_req failed: {e}"); - } -} - -#[derive(Deserialize)] -struct CreateInviteRequest { - /// Comma-separated channel UUIDs this token grants access to. - channels: String, - /// Hours until the token expires (default: 24). - #[serde(default = "default_hours")] - hours: u32, - /// Maximum number of times the token may be used (default: 10). - #[serde(default = "default_max_uses")] - max_uses: u32, -} - -fn default_hours() -> u32 { - 24 -} - -/// FIX 7: Default max_uses changed from 1 to 10. -fn default_max_uses() -> u32 { - 10 -} - -async fn create_invite( - State(state): State, - headers: HeaderMap, - axum::Json(req): axum::Json, -) -> impl IntoResponse { - if let Some(err) = check_admin_secret(&state.admin_secret, &headers) { - return err; - } - - let channel_ids: Vec = req - .channels - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - if channel_ids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "at least one valid channel UUID required" })), - ) - .into_response(); - } - - if req.hours == 0 || req.max_uses == 0 { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "hours and max_uses must be > 0" })), - ) - .into_response(); - } - - let token_str = format!("buzz_invite_{}", Uuid::new_v4().simple()); - let expires_at = chrono::Utc::now() + chrono::Duration::hours(req.hours as i64); - - let token = crate::InviteToken::new(&token_str, channel_ids.clone(), expires_at, req.max_uses); - state.invite_store.insert(token); - - info!( - token_prefix = %&token_str[..20], - channels = channel_ids.len(), - hours = req.hours, - "invite token created" - ); - - ( - StatusCode::OK, - axum::Json(serde_json::json!({ - "token": token_str, - "channels": channel_ids, - "expires_at": expires_at.to_rfc3339(), - "max_uses": req.max_uses, - })), - ) - .into_response() -} - -/// Verify the admin secret from the Authorization header. Returns an error -/// response if the secret is required but missing/wrong, or `None` if OK. -fn check_admin_secret(admin_secret: &Option, headers: &HeaderMap) -> Option { - if let Some(ref secret) = admin_secret { - let provided = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")); - match provided { - Some(token) if constant_time_eq(token, secret) => None, - _ => Some( - ( - StatusCode::UNAUTHORIZED, - axum::Json(serde_json::json!({ - "error": "unauthorized: missing or invalid Authorization header" - })), - ) - .into_response(), - ), - } - } else { - None // No secret configured — dev mode, allow all. - } -} - -#[derive(Deserialize)] -struct RegisterGuestRequest { - /// Hex-encoded Nostr public key (64 chars). - pubkey: String, - /// Comma-separated channel UUIDs this guest can access. - channels: String, -} - -async fn register_guest( - State(state): State, - headers: HeaderMap, - axum::Json(req): axum::Json, -) -> impl IntoResponse { - if let Some(err) = check_admin_secret(&state.admin_secret, &headers) { - return err; - } - - let pubkey = match PublicKey::from_hex(&req.pubkey) { - Ok(pk) => pk, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": format!("invalid pubkey: {e}") })), - ) - .into_response(); - } - }; - - let channel_ids: Vec = req - .channels - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - if channel_ids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "at least one valid channel UUID required" })), - ) - .into_response(); - } - - state.guest_store.register(pubkey, channel_ids.clone()); - info!(pubkey = %pubkey, channels = channel_ids.len(), "guest registered"); - - ( - StatusCode::OK, - axum::Json(serde_json::json!({ - "pubkey": req.pubkey, - "channels": channel_ids, - })), - ) - .into_response() -} - -#[derive(Deserialize)] -struct RevokeGuestRequest { - /// Hex-encoded Nostr public key to revoke. - pubkey: String, -} - -async fn revoke_guest( - State(state): State, - headers: HeaderMap, - axum::Json(req): axum::Json, -) -> impl IntoResponse { - if let Some(err) = check_admin_secret(&state.admin_secret, &headers) { - return err; - } - - let pubkey = match PublicKey::from_hex(&req.pubkey) { - Ok(pk) => pk, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": format!("invalid pubkey: {e}") })), - ) - .into_response(); - } - }; - - let removed = state.guest_store.remove(&pubkey); - if removed { - info!(pubkey = %pubkey, "guest revoked"); - ( - StatusCode::OK, - axum::Json(serde_json::json!({ "revoked": true })), - ) - .into_response() - } else { - ( - StatusCode::NOT_FOUND, - axum::Json(serde_json::json!({ "error": "pubkey not registered" })), - ) - .into_response() - } -} - -async fn list_guests(State(state): State, headers: HeaderMap) -> impl IntoResponse { - if let Some(err) = check_admin_secret(&state.admin_secret, &headers) { - return err; - } - - let guests: Vec = state - .guest_store - .all() - .into_iter() - .map(|(pk, channels)| { - serde_json::json!({ - "pubkey": pk.to_hex(), - "channels": channels, - }) - }) - .collect(); - - ( - StatusCode::OK, - axum::Json(serde_json::json!({ "guests": guests })), - ) - .into_response() -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - use tokio::sync::broadcast; - - fn make_state() -> ProxyState { - let keys = Keys::generate(); - let channel_map = Arc::new(crate::channel_map::ChannelMap::new(keys.clone())); - let guest_store = Arc::new(GuestStore::new()); - let invite_store = Arc::new(InviteStore::new()); - let (upstream_events, _) = broadcast::channel(16); - let shadow_keys = Arc::new( - crate::shadow_keys::ShadowKeyManager::new(b"test-salt-server-tests") - .expect("shadow key manager"), - ); - let translator = Arc::new(crate::translate::Translator::new( - shadow_keys, - channel_map.clone(), - "http://localhost:3000", - "buzz_test", - "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - )); - let upstream = Arc::new(UpstreamClient::new("ws://localhost:3000", "buzz_test")); - ProxyState { - channel_map, - guest_store, - invite_store, - translator, - upstream, - upstream_events, - admin_secret: None, - relay_url: "ws://127.0.0.1:4869".to_string(), - } - } - - #[test] - fn router_builds() { - let state = make_state(); - let _r = router(state); - } - - #[test] - fn nip11_json_is_valid() { - // Just ensure the NIP-11 JSON serializes without panic - let response = nip11_response(); - let _ = response.into_response(); - } - - #[test] - fn default_hours_and_max_uses() { - assert_eq!(default_hours(), 24); - // FIX 7: default max_uses is now 10 - assert_eq!(default_max_uses(), 10); - } - - #[test] - fn constant_time_eq_works() { - assert!(constant_time_eq("hello", "hello")); - assert!(!constant_time_eq("hello", "world")); - assert!(!constant_time_eq("hello", "hell")); - assert!(!constant_time_eq("", "a")); - assert!(constant_time_eq("", "")); - // Ensure different-length strings with same prefix don't match - assert!(!constant_time_eq("abc", "abcd")); - } - - #[test] - fn split_filters_pure_local() { - // kind:40 is the only pure-local kind (channel creation is synthesized). - let f = Filter::new().kind(Kind::ChannelCreation); - let (local, upstream) = split_filters(&[f]); - assert_eq!(local.len(), 1); - assert!(upstream.is_empty(), "kind:40 is local-only"); - } - - #[test] - fn split_filters_kind41_goes_both() { - // kind:41 goes to BOTH local (synthesized metadata) and upstream (edits). - let f = Filter::new().kind(Kind::ChannelMetadata); - let (local, upstream) = split_filters(&[f]); - assert_eq!(local.len(), 1, "kind:41 must produce a local filter"); - assert_eq!( - upstream.len(), - 1, - "kind:41 must also produce an upstream filter" - ); - } - - #[test] - fn split_filters_pure_upstream() { - let f = Filter::new().kind(Kind::Custom(42)); - let (local, upstream) = split_filters(&[f]); - assert!(local.is_empty()); - assert_eq!(upstream.len(), 1); - } - - #[test] - fn split_filters_mixed_kind() { - let f = Filter::new().kinds([Kind::ChannelCreation, Kind::Custom(42)]); - let (local, upstream) = split_filters(&[f]); - assert_eq!(local.len(), 1, "mixed filter must produce a local portion"); - assert_eq!( - upstream.len(), - 1, - "mixed filter must produce an upstream portion" - ); - let local_k: Vec = local[0] - .kinds - .as_ref() - .unwrap() - .iter() - .map(|k| k.as_u16()) - .collect(); - assert!(local_k.contains(&40)); - assert!(!local_k.contains(&42)); - let up_k: Vec = upstream[0] - .kinds - .as_ref() - .unwrap() - .iter() - .map(|k| k.as_u16()) - .collect(); - assert!(up_k.contains(&42)); - assert!(!up_k.contains(&40)); - } - - #[test] - fn split_filters_no_kind_duplicates() { - let f = Filter::new(); - let (local, upstream) = split_filters(&[f]); - assert_eq!(local.len(), 1); - assert_eq!(upstream.len(), 1); - let local_k: Vec = local[0] - .kinds - .as_ref() - .unwrap() - .iter() - .map(|k| k.as_u16()) - .collect(); - assert!(local_k.contains(&40)); - assert!(local_k.contains(&41)); - assert!(upstream[0].kinds.is_none()); - } - - fn make_channel_map_with_channel() -> (Arc, Uuid) { - let keys = Keys::generate(); - let map = ChannelMap::new(keys); - let dto = crate::channel_map::ChannelDto { - id: "550e8400-e29b-41d4-a716-446655440000".to_string(), - name: "test-channel".to_string(), - created_at: "2026-01-15T12:00:00Z".to_string(), - visibility: "open".to_string(), - description: "A test channel".to_string(), - created_by: "0101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - }; - map.register(&dto).expect("register must succeed"); - let uuid: Uuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(); - (Arc::new(map), uuid) - } - - #[test] - fn collect_local_kind40_basic() { - let (map, uuid) = make_channel_map_with_channel(); - let filter = Filter::new().kind(Kind::ChannelCreation); - let events = collect_local_events(&filter, &map, &[uuid]); - assert_eq!(events.len(), 1); - assert_eq!(events[0].kind.as_u16(), 40); - } - - #[test] - fn collect_local_kind41_basic() { - let (map, uuid) = make_channel_map_with_channel(); - let filter = Filter::new().kind(Kind::ChannelMetadata); - let events = collect_local_events(&filter, &map, &[uuid]); - assert_eq!(events.len(), 1); - assert_eq!(events[0].kind.as_u16(), 41); - } - - #[test] - fn collect_local_both_kinds() { - let (map, uuid) = make_channel_map_with_channel(); - let filter = Filter::new().kinds([Kind::ChannelCreation, Kind::ChannelMetadata]); - let events = collect_local_events(&filter, &map, &[uuid]); - assert_eq!(events.len(), 2); - } - - #[test] - fn collect_local_authors_filter_matches() { - let (map, uuid) = make_channel_map_with_channel(); - let server_pk = map.server_keys().public_key(); - let filter = Filter::new().kind(Kind::ChannelCreation).author(server_pk); - let events = collect_local_events(&filter, &map, &[uuid]); - assert_eq!(events.len(), 1, "server pubkey must match"); - } - - #[test] - fn collect_local_authors_filter_rejects() { - let (map, uuid) = make_channel_map_with_channel(); - let random_pk = Keys::generate().public_key(); - let filter = Filter::new().kind(Kind::ChannelCreation).author(random_pk); - let events = collect_local_events(&filter, &map, &[uuid]); - assert!(events.is_empty(), "random pubkey must not match"); - } - - #[test] - fn collect_local_channel_not_in_scope() { - let (map, _uuid) = make_channel_map_with_channel(); - let other: Uuid = "00000000-0000-0000-0000-000000000001".parse().unwrap(); - let filter = Filter::new().kind(Kind::ChannelCreation); - let events = collect_local_events(&filter, &map, &[other]); - assert!(events.is_empty()); - } - - #[test] - fn collect_local_limit_respected() { - let (map, uuid) = make_channel_map_with_channel(); - let filter = Filter::new() - .kinds([Kind::ChannelCreation, Kind::ChannelMetadata]) - .limit(1); - let events = collect_local_events(&filter, &map, &[uuid]); - assert_eq!(events.len(), 1, "limit:1 must cap at 1 event"); - } - - #[test] - fn collect_local_since_excludes() { - let (map, uuid) = make_channel_map_with_channel(); - // Channel created 2026-01-15T12:00:00Z. Since after that → empty. - let filter = Filter::new() - .kind(Kind::ChannelCreation) - .since(Timestamp::from(1768478401u64)); - let events = collect_local_events(&filter, &map, &[uuid]); - assert!(events.is_empty()); - } - - #[test] - fn collect_local_until_excludes() { - let (map, uuid) = make_channel_map_with_channel(); - let filter = Filter::new() - .kind(Kind::ChannelCreation) - .until(Timestamp::from(1000000000u64)); - let events = collect_local_events(&filter, &map, &[uuid]); - assert!(events.is_empty()); - } -} diff --git a/crates/buzz-proxy/src/shadow_keys.rs b/crates/buzz-proxy/src/shadow_keys.rs deleted file mode 100644 index 9e95138c8b..0000000000 --- a/crates/buzz-proxy/src/shadow_keys.rs +++ /dev/null @@ -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; - -/// 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, - cache: DashMap, - /// 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 { - 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 { - 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 { - 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 { - 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); - } -} diff --git a/crates/buzz-proxy/src/translate.rs b/crates/buzz-proxy/src/translate.rs deleted file mode 100644 index 8808571973..0000000000 --- a/crates/buzz-proxy/src/translate.rs +++ /dev/null @@ -1,1373 +0,0 @@ -//! Event translation between Buzz internal format and NIP-28 standard format. -//! -//! # Overview -//! -//! The proxy sits between standard Nostr clients (NIP-28) and the Buzz relay. -//! Buzz stores messages as kind:9 events with `#h ` channel tags. -//! NIP-28 clients expect kind:42 events with `#e ` channel tags. -//! -//! This module handles bidirectional translation: -//! -//! - **Outbound** (Buzz → client): kind:9 + `#h(uuid)` → kind:42 + `#e(event_id)` -//! - **Inbound** (client → Buzz): kind:42 + `#e(event_id)` → kind:9 + `#h(uuid)` -//! -//! All translated events are re-signed with deterministic shadow keys so that -//! each external user maps to a consistent shadow pubkey across sessions. - -use std::sync::Arc; -use std::time::Duration; - -use buzz_core::kind::{ - event_kind_u32, KIND_DELETION, KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_EDIT, - KIND_STREAM_MESSAGE_V2, -}; -use moka::sync::Cache; -use nostr::prelude::*; -use uuid::Uuid; - -use crate::channel_map::{ChannelInfo, ChannelMap}; -use crate::kind_translator::KindTranslator; -use crate::shadow_keys::ShadowKeyManager; -use crate::ProxyError; - -/// Translates events and filters between Buzz internal format and NIP-28 -/// standard format. -/// -/// All translated events are re-signed with shadow keys to preserve per-user -/// identity while hiding internal key material from external clients. -pub struct Translator { - kind_translator: KindTranslator, - shadow_keys: Arc, - channel_map: Arc, - http_client: reqwest::Client, - api_base: String, - api_token: String, - /// Hex-encoded public key of the relay. Only events signed by this key - /// may carry trusted `actor`/`p` attribution tags. - relay_pubkey: String, - internal_to_external_event_ids: Cache, - external_to_internal_event_ids: Cache, - internal_event_channels: Cache, -} - -impl Translator { - /// Create a new [`Translator`]. - pub fn new( - shadow_keys: Arc, - channel_map: Arc, - api_base: impl Into, - api_token: impl Into, - relay_pubkey: impl Into, - ) -> Self { - let cache_ttl = Duration::from_secs(3600); - Self { - kind_translator: KindTranslator::new(), - shadow_keys, - channel_map, - http_client: reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .expect("HTTP client build must succeed"), - api_base: api_base.into(), - api_token: api_token.into(), - relay_pubkey: relay_pubkey.into(), - internal_to_external_event_ids: Cache::builder() - .max_capacity(100_000) - .time_to_idle(cache_ttl) - .build(), - external_to_internal_event_ids: Cache::builder() - .max_capacity(100_000) - .time_to_idle(cache_ttl) - .build(), - internal_event_channels: Cache::builder() - .max_capacity(100_000) - .time_to_idle(cache_ttl) - .build(), - } - } -} - -impl Translator { - fn cache_event_mapping(&self, internal_event_id: &str, external_event_id: &str) { - self.internal_to_external_event_ids - .insert(internal_event_id.to_string(), external_event_id.to_string()); - self.external_to_internal_event_ids - .insert(external_event_id.to_string(), internal_event_id.to_string()); - } - - fn lookup_external_event_id(&self, internal_event_id: &str) -> Option { - self.internal_to_external_event_ids.get(internal_event_id) - } - - fn lookup_internal_event_id(&self, external_event_id: &str) -> Option { - self.external_to_internal_event_ids.get(external_event_id) - } - - fn cache_internal_channel(&self, internal_event_id: &str, channel_uuid: Uuid) { - self.internal_event_channels - .insert(internal_event_id.to_string(), channel_uuid); - } - - fn lookup_internal_channel(&self, internal_event_id: &str) -> Option { - self.internal_event_channels.get(internal_event_id) - } - - fn resolve_shadow_author_hex(&self, event: &Event) -> String { - // Only trust actor/p tags on relay-signed events (API-originated). - // User-signed events could carry spoofed actor/p tags — if the event - // pubkey doesn't match the relay's keypair, fall back to the event - // pubkey directly. - if event.pubkey.to_hex() == self.relay_pubkey { - for tag_name in &["actor", "p"] { - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.len() >= 2 && slice[0] == *tag_name { - let hex = &slice[1]; - if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) { - // Normalize to lowercase for consistent shadow-key derivation. - return hex.to_lowercase(); - } - } - } - } - } - event.pubkey.to_hex() - } - - fn resolve_channel_info( - &self, - event: &Event, - allowed_channels: &[Uuid], - ) -> Result, ProxyError> { - let Some((uuid_str, channel_info)) = event - .tags - .iter() - .filter(|tag| { - let slice = tag.as_slice(); - slice.len() >= 2 && slice[0] == "h" - }) - .find_map(|tag| { - let uuid_str = tag.as_slice().get(1)?.clone(); - let uuid: Uuid = uuid_str.parse().ok()?; - let info = self.channel_map.lookup_by_uuid(&uuid)?; - Some((uuid_str, info)) - }) - else { - return Ok(None); - }; - - if !allowed_channels.contains(&channel_info.uuid) { - return Err(ProxyError::PermissionDenied(format!( - "channel {} not in invite scope", - channel_info.uuid - ))); - } - - Ok(Some((uuid_str, channel_info))) - } - - async fn fetch_internal_event(&self, event_id: &str) -> Result, ProxyError> { - let response = self - .http_client - .get(format!("{}/api/events/{}", self.api_base, event_id)) - .header("Authorization", format!("Bearer {}", self.api_token)) - .send() - .await - .map_err(|e| ProxyError::Upstream(format!("event fetch failed: {e}")))?; - - if response.status() == reqwest::StatusCode::NOT_FOUND { - return Ok(None); - } - - let response = response - .error_for_status() - .map_err(|e| ProxyError::Upstream(format!("event fetch failed: {e}")))?; - let body = response - .text() - .await - .map_err(|e| ProxyError::Upstream(format!("event fetch body failed: {e}")))?; - - serde_json::from_str::(&body) - .map(Some) - .map_err(|e| ProxyError::Upstream(format!("event decode failed: {e}"))) - } - - async fn ensure_external_event_id( - &self, - internal_event_id: &str, - allowed_channels: &[Uuid], - ) -> Result, ProxyError> { - if let Some(mapped) = self.lookup_external_event_id(internal_event_id) { - return Ok(Some(mapped)); - } - - let Some(event) = self.fetch_internal_event(internal_event_id).await? else { - return Ok(None); - }; - - let translated = self.translate_outbound_message_like(&event, allowed_channels)?; - Ok(translated.map(|translated| translated.id.to_hex())) - } - - fn translate_outbound_message_like( - &self, - event: &Event, - allowed_channels: &[Uuid], - ) -> Result, ProxyError> { - let kind_u32 = event_kind_u32(event); - let is_stream_message = - kind_u32 == KIND_STREAM_MESSAGE || kind_u32 == KIND_STREAM_MESSAGE_V2; - let is_edit = kind_u32 == KIND_STREAM_MESSAGE_EDIT; - - if !is_stream_message && !is_edit { - return Ok(None); - } - - let Some((uuid_str, channel_info)) = self.resolve_channel_info(event, allowed_channels)? - else { - return Ok(None); - }; - - let mut new_tags: Vec = Vec::new(); - new_tags - .push(Tag::parse(["e", &channel_info.kind40_event_id]).expect("e tag is always valid")); - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(|value| value.as_str()) == Some("h") - && slice.get(1).map(|value| value.as_str()) == Some(uuid_str.as_str()) - { - continue; - } - if slice.first().map(|value| value.as_str()) == Some("actor") { - continue; - } - new_tags.push(tag.clone()); - } - - let standard_kind = self.kind_translator.to_standard(kind_u32); - let content = if kind_u32 == KIND_STREAM_MESSAGE_V2 { - extract_plain_text(&event.content) - } else { - event.content.clone() - }; - - let shadow_keys = self - .shadow_keys - .get_or_create(&self.resolve_shadow_author_hex(event))?; - - let translated = EventBuilder::new( - Kind::Custom(u16::try_from(standard_kind).expect("standard kind must fit in u16")), - content, - ) - .tags(new_tags) - .custom_created_at(event.created_at) - .sign_with_keys(&shadow_keys) - .map_err(|e| ProxyError::Upstream(format!("outbound signing failed: {e}")))?; - - self.cache_event_mapping(&event.id.to_hex(), &translated.id.to_hex()); - self.cache_internal_channel(&event.id.to_hex(), channel_info.uuid); - Ok(Some(translated)) - } - - async fn translate_outbound_reaction( - &self, - event: &Event, - allowed_channels: &[Uuid], - ) -> Result, ProxyError> { - self.translate_outbound_tag_targeted(event, allowed_channels, Kind::Reaction) - .await - } - - async fn translate_outbound_deletion( - &self, - event: &Event, - allowed_channels: &[Uuid], - ) -> Result, ProxyError> { - self.translate_outbound_tag_targeted(event, allowed_channels, Kind::EventDeletion) - .await - } - - /// Shared outbound translation for tag-targeted events (reactions, deletions). - /// - /// Resolves the channel via `#h`, translates all internal `#e` event IDs to - /// their external counterparts, strips `#h` and `actor` tags, and re-signs - /// with the shadow key for the original author. - async fn translate_outbound_tag_targeted( - &self, - event: &Event, - allowed_channels: &[Uuid], - kind: Kind, - ) -> Result, ProxyError> { - let Some((_uuid_str, channel_info)) = self.resolve_channel_info(event, allowed_channels)? - else { - return Ok(None); - }; - - let mut new_tags: Vec = Vec::new(); - let mut saw_target = false; - - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(|value| value.as_str()) == Some("h") { - continue; - } - if slice.first().map(|value| value.as_str()) == Some("actor") { - continue; - } - if slice.len() >= 2 - && slice[0] == "e" - && slice[1].len() == 64 - && slice[1].chars().all(|ch| ch.is_ascii_hexdigit()) - { - let Some(external_target_id) = self - .ensure_external_event_id(&slice[1], allowed_channels) - .await? - else { - return Ok(None); - }; - - let mut parts = vec!["e".to_string(), external_target_id]; - parts.extend(slice.iter().skip(2).cloned()); - new_tags.push(Tag::parse(parts).map_err(|e| { - ProxyError::Upstream(format!("outbound tag build failed: {e}")) - })?); - saw_target = true; - continue; - } - new_tags.push(tag.clone()); - } - - if !saw_target { - return Ok(None); - } - - let shadow_keys = self - .shadow_keys - .get_or_create(&self.resolve_shadow_author_hex(event))?; - - let translated = EventBuilder::new(kind, event.content.clone()) - .tags(new_tags) - .custom_created_at(event.created_at) - .sign_with_keys(&shadow_keys) - .map_err(|e| ProxyError::Upstream(format!("outbound signing failed: {e}")))?; - - self.cache_event_mapping(&event.id.to_hex(), &translated.id.to_hex()); - self.cache_internal_channel(&event.id.to_hex(), channel_info.uuid); - Ok(Some(translated)) - } - - /// Translate a Buzz event to NIP-28 format for delivery to external clients. - /// - /// Returns `Ok(Some(event))` on success, `Ok(None)` if the event should be - /// silently dropped (unknown kind, no channel tag, etc.), or an error if the - /// event references a channel the client is not allowed to see. - /// - /// # Translation rules - /// - /// - kind:9 / kind:40002 → kind:42 - /// - `#h ` tag → `#e ` tag - /// - All other tags are preserved unchanged - /// - Re-signed with the shadow key for the original author's pubkey - /// - V2 rich content (JSON with `"text"` field) is unwrapped to plain text - pub async fn translate_outbound( - &self, - event: &Event, - allowed_channels: &[Uuid], - ) -> Result, ProxyError> { - let kind_u32 = event_kind_u32(event); - if kind_u32 == KIND_REACTION { - return self - .translate_outbound_reaction(event, allowed_channels) - .await; - } - if kind_u32 == KIND_DELETION { - return self - .translate_outbound_deletion(event, allowed_channels) - .await; - } - - self.translate_outbound_message_like(event, allowed_channels) - } -} - -impl Translator { - /// Translate a NIP-28 event from an external client into Buzz format. - /// - /// # Translation rules - /// - /// - kind:42 (or kind:1) → kind:9 - /// - `#e ` tag → `#h ` tag - /// - All other tags are preserved unchanged - /// - Re-signed with the shadow key for `external_pubkey` - /// - /// Returns an error if the event kind is not accepted, the `#e` tag is - /// missing or references an unknown channel, or the channel is not in the - /// client's invite scope. - pub fn translate_inbound( - &self, - event: &Event, - external_pubkey: &str, - allowed_channels: &[Uuid], - ) -> Result { - let kind_u32 = event.kind.as_u16() as u32; - - // Accept kind:42 (channel message), kind:41 (channel metadata edit), - // kind:1 (text note), and kind:7 (reaction). - // kind:5 (deletion) is intentionally NOT accepted inbound. - // Inbound kind:5 blocked: relay deletion handler lacks author-match authorization. - // External clients could delete any user's messages. Re-enable after adding - // author validation to handle_standard_deletion_event. - // Everything else is rejected. - if kind_u32 != 42 && kind_u32 != 41 && kind_u32 != 1 && kind_u32 != 7 { - return Err(ProxyError::PermissionDenied(format!( - "kind {} not accepted by proxy (expected 1, 7, 41, or 42)", - kind_u32 - ))); - } - - if kind_u32 == 7 { - return self.translate_inbound_tag_targeted( - event, - external_pubkey, - allowed_channels, - Kind::Reaction, - "reaction", - ); - } - - // Find the channel-reference `#e` tag by looking up each `#e` value - // against the channel map. This is more robust than assuming the first - // `#e` tag is always the channel reference — reply/thread `#e` tags may - // appear in any order depending on the client. - let (event_id_str, channel_info) = event - .tags - .iter() - .filter(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "e" - }) - .find_map(|t| { - let eid = t.as_slice().get(1)?; - let info = self.channel_map.lookup_by_event_id(eid)?; - Some((eid.clone(), info)) - }) - .ok_or_else(|| { - ProxyError::PermissionDenied( - "kind:42 event must have an #e tag referencing a known channel".into(), - ) - })?; - - // Enforce channel-level access control. - if !allowed_channels.contains(&channel_info.uuid) { - return Err(ProxyError::PermissionDenied(format!( - "channel {} not in invite scope", - channel_info.uuid - ))); - } - - // Build translated tag list: replace the channel `#e` with `#h`, keep everything else. - let mut new_tags: Vec = Vec::new(); - // SAFETY: ["h", ] is always a valid 2-element tag structure - new_tags.push( - Tag::parse(["h", &channel_info.uuid.to_string()]) - .expect("SAFETY: [\"h\", uuid_string] is always a valid tag structure"), - ); - for tag in event.tags.iter() { - let s = tag.as_slice(); - // Only strip the specific `#e` tag whose value matches the channel event ID. - // Preserve other `#e` tags (e.g. NIP-10 reply threading). - if s.first().map(|v| v.as_str()) == Some("e") - && s.get(1).map(|v| v.as_str()) == Some(event_id_str.as_str()) - { - continue; - } - // Strip ALL `#h` tags — the authorized #h is already added above. - // Prevents clients from injecting unauthorized channel associations. - if s.first().map(|v| v.as_str()) == Some("h") { - continue; - } - new_tags.push(tag.clone()); - } - - // Translate kind number. - let buzz_kind = self.kind_translator.to_buzz(kind_u32); - - // Re-sign with the shadow key for the external user. - let shadow_keys = self.shadow_keys.get_or_create(external_pubkey)?; - - // SAFETY: buzz_kind is derived from KindTranslator which maps to known Buzz kinds (9, 40002, 40003) that fit in u16 - let translated = EventBuilder::new( - Kind::Custom( - u16::try_from(buzz_kind) - .expect("SAFETY: buzz kind values (9, 40002, 40003) always fit in u16"), - ), - &event.content, - ) - .tags(new_tags) - .custom_created_at(event.created_at) - .sign_with_keys(&shadow_keys) - .map_err(|e| ProxyError::Upstream(format!("inbound signing failed: {e}")))?; - - self.cache_event_mapping(&translated.id.to_hex(), &event.id.to_hex()); - self.cache_internal_channel(&translated.id.to_hex(), channel_info.uuid); - Ok(translated) - } - - /// Shared inbound translation for tag-targeted events (kind:5 deletion, kind:7 reaction). - /// - /// # Security - /// - /// Collects ALL hex `#e` tags and verifies every one resolves to the **same** - /// channel UUID that is in `allowed_channels`. A mixed-target event (targets - /// spanning multiple channels) is rejected outright — this prevents a client - /// from smuggling out-of-scope event IDs inside a single deletion or reaction. - fn translate_inbound_tag_targeted( - &self, - event: &Event, - external_pubkey: &str, - allowed_channels: &[Uuid], - kind: Kind, - label: &str, - ) -> Result { - // Collect all hex #e tag values. - let e_tag_values: Vec = event - .tags - .iter() - .filter_map(|tag| { - let slice = tag.as_slice(); - if slice.len() >= 2 - && slice[0] == "e" - && slice[1].len() == 64 - && slice[1].chars().all(|ch| ch.is_ascii_hexdigit()) - { - Some(slice[1].clone()) - } else { - None - } - }) - .collect(); - - if e_tag_values.is_empty() { - return Err(ProxyError::PermissionDenied(format!( - "kind:{} event must reference a target message via #e", - event.kind.as_u16() - ))); - } - - // Resolve every #e tag to an internal event ID and channel UUID. - // All targets must resolve to the same channel — cross-channel targeting - // is rejected to prevent authorization bypass. - let mut resolved_channel: Option = None; - // Resolve all external → internal ID mappings upfront. We store - // the resolved pairs so the tag-building loop below never re-reads - // the moka cache (which could evict entries between passes). - let mut resolved_ids: Vec<(String, String)> = Vec::with_capacity(e_tag_values.len()); - for external_id in &e_tag_values { - let internal_id = self.lookup_internal_event_id(external_id).ok_or_else(|| { - ProxyError::PermissionDenied(format!( - "{label} target is unknown to the proxy; fetch the message first" - )) - })?; - let channel_uuid = self.lookup_internal_channel(&internal_id).ok_or_else(|| { - ProxyError::PermissionDenied(format!( - "{label} target is unknown to the proxy; fetch the message first" - )) - })?; - match resolved_channel { - None => resolved_channel = Some(channel_uuid), - Some(existing) if existing != channel_uuid => { - return Err(ProxyError::PermissionDenied(format!( - "{label} targets span multiple channels — cross-channel targeting rejected" - ))); - } - Some(_) => {} - } - resolved_ids.push((external_id.clone(), internal_id)); - } - let id_map: std::collections::HashMap<&str, &str> = resolved_ids - .iter() - .map(|(ext, int)| (ext.as_str(), int.as_str())) - .collect(); - - let channel_uuid = resolved_channel.expect("e_tag_values non-empty guarantees Some"); - - if !allowed_channels.contains(&channel_uuid) { - return Err(ProxyError::PermissionDenied(format!( - "{label} target channel not in invite scope" - ))); - } - - // Build translated tag list: prepend #h, translate all #e values, drop - // any client-supplied #h tags (prevent unauthorized channel injection). - let mut new_tags: Vec = - vec![Tag::parse(["h", &channel_uuid.to_string()]).expect("h tag is always valid")]; - let mut saw_target = false; - - for tag in event.tags.iter() { - let slice = tag.as_slice(); - // Strip client-supplied #h tags (authorized #h already added above). - if slice.first().map(|v| v.as_str()) == Some("h") { - continue; - } - // Strip actor tags — untrusted client data should not persist. - if slice.first().map(|v| v.as_str()) == Some("actor") { - continue; - } - if slice.len() >= 2 - && slice[0] == "e" - && slice[1].len() == 64 - && slice[1].chars().all(|ch| ch.is_ascii_hexdigit()) - { - let internal_id = *id_map.get(slice[1].as_str()).ok_or_else(|| { - ProxyError::PermissionDenied(format!( - "{label} target is unknown to the proxy; fetch the message first" - )) - })?; - let mut parts = vec!["e".to_string(), internal_id.to_string()]; - parts.extend(slice.iter().skip(2).cloned()); - new_tags.push( - Tag::parse(parts).map_err(|e| { - ProxyError::Upstream(format!("{label} tag build failed: {e}")) - })?, - ); - saw_target = true; - continue; - } - new_tags.push(tag.clone()); - } - - if !saw_target { - return Err(ProxyError::PermissionDenied(format!( - "kind:{} event must reference a target message via #e", - event.kind.as_u16() - ))); - } - - let shadow_keys = self.shadow_keys.get_or_create(external_pubkey)?; - let translated = EventBuilder::new(kind, &event.content) - .tags(new_tags) - .custom_created_at(event.created_at) - .sign_with_keys(&shadow_keys) - .map_err(|e| ProxyError::Upstream(format!("inbound signing failed: {e}")))?; - - self.cache_event_mapping(&translated.id.to_hex(), &event.id.to_hex()); - self.cache_internal_channel(&translated.id.to_hex(), channel_uuid); - Ok(translated) - } -} - -impl Translator { - /// Translate a NIP-28 REQ filter to Buzz format. - /// - /// - kind:42 / kind:1 → kind:9 - /// - Injects `#h` tag filters from `allowed_channels` so the client can - /// only receive events from channels they have access to. - /// - /// The returned filter is ready to be forwarded to the upstream Buzz relay. - pub fn translate_filter_inbound(&self, filter: &Filter, allowed_channels: &[Uuid]) -> Filter { - // Start with a clone and rebuild the kinds set. - let mut f = filter.clone(); - - if let Some(ref kinds) = filter.kinds { - let new_kinds: Vec = kinds - .iter() - .map(|k| { - let k_u32 = k.as_u16() as u32; - let buzz_k = self.kind_translator.to_buzz(k_u32); - // SAFETY: buzz kind values (9, 40002, 40003) always fit in u16 - Kind::Custom( - u16::try_from(buzz_k).expect("SAFETY: buzz kind values always fit in u16"), - ) - }) - .collect(); - // Rebuild via the builder to stay consistent with nostr's internal state. - f = f.remove_kinds(kinds.iter().cloned()).kinds(new_kinds); - } - - // Check for client-supplied #e channel filters and translate to #h UUIDs. - // - // NIP-28 clients filter by channel using `#e `. Buzz - // uses `#h ` instead. If the client specified `#e` values, resolve - // them to UUIDs (intersected with allowed_channels) and use those for the - // `#h` injection. The `#e` filter must be removed from the translated - // filter — if both `#e` and `#h` were present, the relay would AND them, - // and since Buzz events carry `#h` but not `#e`, zero events would match. - let e_tag_key = SingleLetterTag::lowercase(Alphabet::E); - let had_e_filter = f.generic_tags.contains_key(&e_tag_key); - let client_channel_uuids: Vec = - if let Some(e_values) = f.generic_tags.get(&e_tag_key) { - e_values - .iter() - .filter_map(|event_id| self.channel_map.lookup_by_event_id(event_id)) - .filter(|info| allowed_channels.contains(&info.uuid)) - .map(|info| info.uuid.to_string()) - .collect() - } else { - Vec::new() - }; - - // Remove the #e tag filter — Buzz uses #h, not #e. - f.generic_tags.remove(&e_tag_key); - - // Inject #h tag constraints from the allowed channel list. - // Three cases: - // 1. Client specified #e and some resolved → use those (already intersected) - // 2. Client specified #e but NONE resolved → deny-all (sentinel UUID) - // 3. Client didn't specify #e → use all allowed_channels (or sentinel if empty) - // - // Case 2 is critical: an explicit filter that resolves to nothing must - // return zero results, not widen to all channels. - let sentinel = vec!["00000000-0000-0000-0000-000000000000".to_string()]; - let uuid_strings: Vec = if !client_channel_uuids.is_empty() { - // Case 1: client asked for specific channels, some resolved - client_channel_uuids - } else if had_e_filter { - // Case 2: client asked for specific channels, none resolved → deny all - sentinel - } else if allowed_channels.is_empty() { - // Case 3 with empty scope → deny all - sentinel - } else { - // Case 3: no #e filter, use full allowed scope - allowed_channels.iter().map(|u| u.to_string()).collect() - }; - f = f.custom_tags(SingleLetterTag::lowercase(Alphabet::H), uuid_strings); - - f - } -} - -/// Extract plain text from V2 rich content JSON. -/// -/// V2 content is a JSON object with a `"text"` field. Falls back to the raw -/// content string if parsing fails or the field is absent. -fn extract_plain_text(content: &str) -> String { - serde_json::from_str::(content) - .ok() - .and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from)) - .unwrap_or_else(|| content.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::channel_map::{ChannelDto, ChannelMap}; - use buzz_core::kind::KIND_STREAM_MESSAGE; - - const TEST_UUID: &str = "550e8400-e29b-41d4-a716-446655440000"; - const TEST_SALT: &[u8] = b"test-salt-for-translate-tests"; - - fn test_channel_map() -> ChannelMap { - let keys = Keys::generate(); - let map = ChannelMap::new(keys); - let dto = ChannelDto { - id: TEST_UUID.to_string(), - name: "test-channel".to_string(), - created_at: "2026-01-01T00:00:00Z".to_string(), - visibility: "open".to_string(), - description: "A test channel".to_string(), - created_by: "0101010101010101010101010101010101010101010101010101010101010101" - .to_string(), - }; - map.register(&dto) - .expect("test channel registration must succeed"); - map - } - - fn make_translator() -> (Translator, String) { - let channel_map = Arc::new(test_channel_map()); - let shadow_mgr = Arc::new( - ShadowKeyManager::new(TEST_SALT).expect("shadow key manager creation must succeed"), - ); - let translator = Translator::new( - shadow_mgr, - channel_map.clone(), - "http://localhost:3000", - "buzz_test", - "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - ); - - // Retrieve the kind:40 event ID for the test channel. - let uuid: Uuid = TEST_UUID.parse().unwrap(); - let info = channel_map.lookup_by_uuid(&uuid).unwrap(); - (translator, info.kind40_event_id) - } - - fn allowed() -> Vec { - vec![TEST_UUID.parse().unwrap()] - } - - fn no_channels() -> Vec { - vec![] - } - - #[tokio::test] - async fn outbound_translates_stream_message() { - let (translator, kind40_event_id) = make_translator(); - let author_keys = Keys::generate(); - - // Build a synthetic kind:9 event with an #h tag. - let h_tag = Tag::parse(["h", TEST_UUID]).unwrap(); - let buzz_event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hello world") - .tags([h_tag]) - .sign_with_keys(&author_keys) - .unwrap(); - - let result = translator - .translate_outbound(&buzz_event, &allowed()) - .await - .expect("outbound translation must not error"); - - let translated = result.expect("should produce a translated event"); - - // Kind must be 42. - assert_eq!(translated.kind.as_u16(), 42, "translated kind must be 42"); - - // Must have an #e tag pointing to the kind:40 event ID. - let has_e_tag = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "e" && s[1] == kind40_event_id - }); - assert!( - has_e_tag, - "translated event must have #e tag with kind:40 event ID" - ); - - // Must NOT have an #h tag. - let has_h_tag = translated - .tags - .iter() - .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h")); - assert!(!has_h_tag, "translated event must not retain #h tag"); - - // Content must be preserved. - assert_eq!(translated.content, "hello world"); - - // Signature must be valid. - translated - .verify() - .expect("translated event signature must be valid"); - } - - #[test] - fn inbound_translates_channel_message() { - let (translator, kind40_event_id) = make_translator(); - let client_keys = Keys::generate(); - let external_pubkey = client_keys.public_key().to_hex(); - - // Build a synthetic kind:42 event with an #e tag. - let e_tag = Tag::parse(["e", &kind40_event_id]).unwrap(); - let nip28_event = EventBuilder::new(Kind::Custom(42), "hello from client") - .tags([e_tag]) - .sign_with_keys(&client_keys) - .unwrap(); - - let translated = translator - .translate_inbound(&nip28_event, &external_pubkey, &allowed()) - .expect("inbound translation must not error"); - - // Kind must be KIND_STREAM_MESSAGE. - assert_eq!( - translated.kind.as_u16(), - KIND_STREAM_MESSAGE as u16, - "translated kind must be KIND_STREAM_MESSAGE" - ); - - // Must have an #h tag pointing to the channel UUID. - let has_h_tag = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "h" && s[1] == TEST_UUID - }); - assert!( - has_h_tag, - "translated event must have #h tag with channel UUID" - ); - - // Must NOT have an #e tag. - let has_e_tag = translated - .tags - .iter() - .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("e")); - assert!(!has_e_tag, "translated event must not retain #e tag"); - - // Content must be preserved. - assert_eq!(translated.content, "hello from client"); - - // Signature must be valid. - translated - .verify() - .expect("translated event signature must be valid"); - } - - #[tokio::test] - async fn outbound_rejects_channel_not_in_scope() { - let (translator, _) = make_translator(); - let author_keys = Keys::generate(); - - let h_tag = Tag::parse(["h", TEST_UUID]).unwrap(); - let buzz_event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "secret") - .tags([h_tag]) - .sign_with_keys(&author_keys) - .unwrap(); - - let result = translator - .translate_outbound(&buzz_event, &no_channels()) - .await; - - assert!( - matches!(result, Err(ProxyError::PermissionDenied(_))), - "expected PermissionDenied, got: {:?}", - result - ); - } - - #[test] - fn inbound_rejects_channel_not_in_scope() { - let (translator, kind40_event_id) = make_translator(); - let client_keys = Keys::generate(); - let external_pubkey = client_keys.public_key().to_hex(); - - let e_tag = Tag::parse(["e", &kind40_event_id]).unwrap(); - let nip28_event = EventBuilder::new(Kind::Custom(42), "sneaky") - .tags([e_tag]) - .sign_with_keys(&client_keys) - .unwrap(); - - let result = translator.translate_inbound(&nip28_event, &external_pubkey, &no_channels()); - - assert!( - matches!(result, Err(ProxyError::PermissionDenied(_))), - "expected PermissionDenied, got: {:?}", - result - ); - } - - #[tokio::test] - async fn v2_content_plain_text_extracted() { - // extract_plain_text is a private helper; test it indirectly via outbound. - let (translator, _) = make_translator(); - let author_keys = Keys::generate(); - - let v2_content = r#"{"text":"hello v2","attachments":[]}"#; - let h_tag = Tag::parse(["h", TEST_UUID]).unwrap(); - let buzz_event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE_V2 as u16), v2_content) - .tags([h_tag]) - .sign_with_keys(&author_keys) - .unwrap(); - - let translated = translator - .translate_outbound(&buzz_event, &allowed()) - .await - .unwrap() - .expect("should produce translated event"); - - assert_eq!( - translated.content, "hello v2", - "V2 rich content must be unwrapped to plain text" - ); - } - - #[test] - fn v2_content_fallback_on_non_json() { - // When V2 content is not valid JSON, fall back to raw content. - let content = extract_plain_text("not json at all"); - assert_eq!(content, "not json at all"); - } - - #[test] - fn filter_inbound_translates_kind() { - let (translator, _) = make_translator(); - - let filter = Filter::new().kind(Kind::Custom(42)); - let translated = translator.translate_filter_inbound(&filter, &allowed()); - - // The translated filter must contain KIND_STREAM_MESSAGE, not 42. - let kinds = translated.kinds.as_ref().expect("filter must have kinds"); - assert!( - kinds.contains(&Kind::Custom(KIND_STREAM_MESSAGE as u16)), - "filter must contain KIND_STREAM_MESSAGE after translation" - ); - assert!( - !kinds.contains(&Kind::Custom(42)), - "filter must not contain kind:42 after translation" - ); - - // Must have injected #h tag constraints. - let h_tag = SingleLetterTag::lowercase(Alphabet::H); - let has_h_filter = translated.generic_tags.contains_key(&h_tag); - assert!(has_h_filter, "filter must have #h tag constraints injected"); - } - - #[test] - fn filter_inbound_translates_e_tag_to_h() { - let (translator, kind40_event_id) = make_translator(); - - // Build a filter that includes a #e tag referencing the test channel's - // kind:40 event ID — this is how NIP-28 clients subscribe to a channel. - let e_tag_key = SingleLetterTag::lowercase(Alphabet::E); - let filter = Filter::new() - .kind(Kind::Custom(42)) - .custom_tags(e_tag_key, [kind40_event_id]); - - let translated = translator.translate_filter_inbound(&filter, &allowed()); - - // The #e filter must be removed from the translated filter. - assert!( - !translated.generic_tags.contains_key(&e_tag_key), - "#e tag filter must be removed from translated filter (would cause zero matches on Buzz relay)" - ); - - // The #h filter must be present and contain the channel UUID. - let h_tag_key = SingleLetterTag::lowercase(Alphabet::H); - let h_values = translated - .generic_tags - .get(&h_tag_key) - .expect("translated filter must have #h tag constraint"); - - assert!( - h_values.contains(TEST_UUID), - "#h filter must contain the channel UUID resolved from #e, got: {:?}", - h_values - ); - - // The translated filter must NOT contain all allowed_channels blindly — - // it should contain exactly the channel(s) resolved from the #e values. - assert_eq!( - h_values.len(), - 1, - "#h filter must contain exactly the one channel resolved from #e, got: {:?}", - h_values - ); - } - - #[test] - fn filter_inbound_e_tag_unknown_event_id_denies_all() { - let (translator, _) = make_translator(); - - // Use an event ID that doesn't exist in the channel map. - let unknown_event_id = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; - let e_tag_key = SingleLetterTag::lowercase(Alphabet::E); - let filter = Filter::new() - .kind(Kind::Custom(42)) - .custom_tags(e_tag_key, [unknown_event_id]); - - let translated = translator.translate_filter_inbound(&filter, &allowed()); - - // The #e filter must be removed. - assert!( - !translated.generic_tags.contains_key(&e_tag_key), - "#e tag filter must be removed even when event ID is unknown" - ); - - // Since the client explicitly specified #e values but none resolved, - // the filter must deny all — inject the sentinel UUID, NOT fall back - // to all allowed_channels. Explicit filter → fail closed. - let h_tag_key = SingleLetterTag::lowercase(Alphabet::H); - let h_values = translated - .generic_tags - .get(&h_tag_key) - .expect("translated filter must have #h tag constraint"); - - let sentinel = "00000000-0000-0000-0000-000000000000"; - assert!( - h_values.contains(sentinel), - "unknown #e must inject sentinel UUID (deny-all), got: {:?}", - h_values - ); - assert!( - !h_values.contains(TEST_UUID), - "unknown #e must NOT fall back to allowed channels" - ); - } - - #[test] - fn inbound_preserves_reply_e_tags() { - let (translator, kind40_event_id) = make_translator(); - let client_keys = Keys::generate(); - let external_pubkey = client_keys.public_key().to_hex(); - - // A reply event has two #e tags: one for the channel, one for the - // message being replied to (NIP-10 threading). - let reply_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let channel_e_tag = Tag::parse(["e", &kind40_event_id]).unwrap(); - let reply_e_tag = Tag::parse(["e", reply_event_id]).unwrap(); - - let nip28_event = EventBuilder::new(Kind::Custom(42), "replying to a message") - .tags([channel_e_tag, reply_e_tag]) - .sign_with_keys(&client_keys) - .unwrap(); - - let translated = translator - .translate_inbound(&nip28_event, &external_pubkey, &allowed()) - .expect("inbound translation must not error"); - - // Must have the #h tag for the channel. - let has_h_tag = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "h" && s[1] == TEST_UUID - }); - assert!( - has_h_tag, - "translated event must have #h tag with channel UUID" - ); - - // The channel #e tag must be gone. - let has_channel_e = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "e" && s[1] == kind40_event_id - }); - assert!(!has_channel_e, "channel #e tag must be stripped"); - - // The reply #e tag must be preserved. - let has_reply_e = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "e" && s[1] == reply_event_id - }); - assert!( - has_reply_e, - "reply #e tag must be preserved for NIP-10 threading" - ); - - translated - .verify() - .expect("translated event signature must be valid"); - } - - #[tokio::test] - async fn outbound_preserves_non_channel_h_tags() { - let (translator, kind40_event_id) = make_translator(); - let author_keys = Keys::generate(); - - // An event with the channel #h tag AND an unrelated #h tag. - let channel_h_tag = Tag::parse(["h", TEST_UUID]).unwrap(); - let other_h_tag = Tag::parse(["h", "some-other-value"]).unwrap(); - - let buzz_event = EventBuilder::new( - Kind::Custom(KIND_STREAM_MESSAGE as u16), - "message with extra h tag", - ) - .tags([channel_h_tag, other_h_tag]) - .sign_with_keys(&author_keys) - .unwrap(); - - let result = translator - .translate_outbound(&buzz_event, &allowed()) - .await - .expect("outbound translation must not error"); - - let translated = result.expect("should produce a translated event"); - - // The channel #h tag must be replaced by #e. - let has_channel_e = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "e" && s[1] == kind40_event_id - }); - assert!(has_channel_e, "channel #e tag must be present"); - - // The channel #h tag must be gone. - let has_channel_h = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "h" && s[1] == TEST_UUID - }); - assert!(!has_channel_h, "channel #h tag must be stripped"); - - // The unrelated #h tag must be preserved. - let has_other_h = translated.tags.iter().any(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0] == "h" && s[1] == "some-other-value" - }); - assert!(has_other_h, "non-channel #h tag must be preserved"); - - translated - .verify() - .expect("translated event signature must be valid"); - } - - #[test] - fn empty_allowed_channels_denies_all() { - let (translator, _) = make_translator(); - - let filter = Filter::new().kind(Kind::Custom(42)); - let translated = translator.translate_filter_inbound(&filter, &no_channels()); - - // Must have an #h tag constraint. - let h_tag = SingleLetterTag::lowercase(Alphabet::H); - let h_values = translated - .generic_tags - .get(&h_tag) - .expect("filter must have #h tag constraint even with empty allowed_channels"); - - // The injected value must be the impossible sentinel UUID. - assert!( - h_values.contains("00000000-0000-0000-0000-000000000000"), - "empty allowed_channels must inject impossible sentinel UUID, got: {:?}", - h_values - ); - } - - #[test] - fn inbound_translates_edit_message() { - use buzz_core::kind::KIND_STREAM_MESSAGE_EDIT; - - let (translator, kind40_event_id) = make_translator(); - let external_keys = Keys::generate(); - - // Build a kind:41 event with #e referencing the channel. - let e_tag = Tag::parse(["e", &kind40_event_id]).unwrap(); - let nip28_event = EventBuilder::new(Kind::ChannelMetadata, "updated metadata") - .tags([e_tag]) - .sign_with_keys(&external_keys) - .unwrap(); - - let translated = translator - .translate_inbound( - &nip28_event, - &external_keys.public_key().to_hex(), - &allowed(), - ) - .expect("inbound kind:41 must translate"); - - // kind:41 → KIND_STREAM_MESSAGE_EDIT (40003) - assert_eq!( - translated.kind.as_u16() as u32, - KIND_STREAM_MESSAGE_EDIT, - "kind:41 must translate to KIND_STREAM_MESSAGE_EDIT" - ); - - // Must have #h tag (channel UUID), not #e. - let has_h = translated - .tags - .iter() - .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h")); - assert!(has_h, "translated edit must have #h tag"); - - translated - .verify() - .expect("translated edit signature must be valid"); - } - - #[test] - fn inbound_rejects_unknown_kind() { - let (translator, kind40_event_id) = make_translator(); - let external_keys = Keys::generate(); - - let e_tag = Tag::parse(["e", &kind40_event_id]).unwrap(); - let event = EventBuilder::new(Kind::Custom(9999), "nope") - .tags([e_tag]) - .sign_with_keys(&external_keys) - .unwrap(); - - let result = - translator.translate_inbound(&event, &external_keys.public_key().to_hex(), &allowed()); - assert!(result.is_err(), "kind:9999 must be rejected inbound"); - } - - #[tokio::test] - async fn outbound_translates_edit_message() { - use buzz_core::kind::KIND_STREAM_MESSAGE_EDIT; - - let (translator, _) = make_translator(); - let author_keys = Keys::generate(); - - let h_tag = Tag::parse(["h", TEST_UUID]).unwrap(); - let buzz_event = EventBuilder::new( - Kind::Custom(KIND_STREAM_MESSAGE_EDIT as u16), - "edited content", - ) - .tags([h_tag]) - .sign_with_keys(&author_keys) - .unwrap(); - - let result = translator - .translate_outbound(&buzz_event, &allowed()) - .await - .expect("outbound translation of edit must not error"); - - let translated = result.expect("edit must produce a translated event, not None"); - - // kind:40003 must translate to kind:41 (NIP-28 channel edit). - assert_eq!( - translated.kind.as_u16(), - 41, - "kind:40003 must translate to kind:41" - ); - - // Content must be preserved. - assert_eq!(translated.content, "edited content"); - - // Must have #e tag (channel reference), not #h. - let has_e_tag = translated - .tags - .iter() - .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("e")); - assert!(has_e_tag, "translated edit must have #e tag"); - - let has_h_tag = translated - .tags - .iter() - .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h")); - assert!(!has_h_tag, "translated edit must not retain #h tag"); - - translated - .verify() - .expect("translated edit signature must be valid"); - } - - #[test] - fn inbound_strips_injected_h_tags() { - let (translator, kind40_event_id) = make_translator(); - let external_keys = Keys::generate(); - - // Client tries to inject an #h tag for an unauthorized channel. - let e_tag = Tag::parse(["e", &kind40_event_id]).unwrap(); - let injected_h = Tag::parse(["h", "00000000-0000-0000-0000-000000000001"]).unwrap(); - let nip28_event = EventBuilder::new(Kind::Custom(42), "sneaky message") - .tags([e_tag, injected_h]) - .sign_with_keys(&external_keys) - .unwrap(); - - let translated = translator - .translate_inbound( - &nip28_event, - &external_keys.public_key().to_hex(), - &allowed(), - ) - .expect("inbound must succeed"); - - // Only the authorized #h tag should be present. - let h_tags: Vec<_> = translated - .tags - .iter() - .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h")) - .collect(); - assert_eq!(h_tags.len(), 1, "only the authorized #h tag should survive"); - assert_eq!( - h_tags[0].as_slice().get(1).map(|v| v.as_str()), - Some(TEST_UUID), - "the surviving #h tag must be the authorized channel UUID" - ); - } - - #[tokio::test] - async fn outbound_drops_unknown_kinds() { - let (translator, _) = make_translator(); - let author_keys = Keys::generate(); - - // A kind:9999 event (unknown Buzz kind) should be dropped. - let h_tag = Tag::parse(["h", TEST_UUID]).unwrap(); - let event = EventBuilder::new(Kind::Custom(9999), "internal stuff") - .tags([h_tag]) - .sign_with_keys(&author_keys) - .unwrap(); - - let result = translator - .translate_outbound(&event, &allowed()) - .await - .expect("outbound must not error"); - - assert!( - result.is_none(), - "unknown kinds must be dropped, not passed through to clients" - ); - } -} diff --git a/crates/buzz-proxy/src/upstream.rs b/crates/buzz-proxy/src/upstream.rs deleted file mode 100644 index 967beec7f6..0000000000 --- a/crates/buzz-proxy/src/upstream.rs +++ /dev/null @@ -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` 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, - /// 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>, - /// Active subscriptions: maps subscription_id → original REQ JSON. - /// Replayed after reconnect so clients don't silently lose their subscriptions. - active_subs: DashMap, -} - -/// 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, - /// Sender for outbound messages (JSON strings) TO the relay. - /// Cloning the client shares the same outbound channel. - outbound_tx: mpsc::Sender, - /// Receiver end — wrapped in `Arc` so the run loop can take it - /// without needing `&mut self`. - outbound_rx: Arc>>, -} - -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, api_token: impl Into) -> 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, - api_token: impl Into, - auth_keys: Keys, - ) -> Self { - let (outbound_tx, outbound_rx) = mpsc::channel::(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, - ) -> 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) { - 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, - ) -> Result<(), Box> { - 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::(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` it needs, which is `'static`. - -async fn read_loop( - stream: &mut S, - write_tx: &mpsc::Sender, - inbound_tx: &mpsc::Sender, - inner: Arc, - connected_notify: Arc, -) -> Result<(), Box> -where - S: StreamExt> + 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 = 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, -) -> Result<(), Box> { - 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 - })?; - - 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::(8); - - respond_to_auth_challenge("test-challenge-abc", &inner, &write_tx) - .await - .unwrap(); - - let msg = write_rx.recv().await.unwrap(); - // Should be ["AUTH", ] - 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::(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); - }); - } -} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index aba7b2b473..61fd196dbc 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -434,9 +434,8 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, state: Arc, state: Arc, state: Arc bool { - self.scopes().contains(&Scope::ProxySubmit) - } - /// WebSocket connection ID (Nip42 only). pub fn conn_id(&self) -> Option { match self { @@ -1235,17 +1230,13 @@ async fn ingest_event_inner( } } - // Skip for proxy:submit — proxy-translated events preserve upstream - // created_at timestamps which may be historical (backfill/replay). - if !auth.has_proxy_scope() { - const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes - let now = chrono::Utc::now().timestamp(); - let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { - return Err(IngestError::Rejected( - "invalid: event timestamp too far from server time".into(), - )); - } + const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes + let now = chrono::Utc::now().timestamp(); + let event_ts = event.created_at.as_secs() as i64; + if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + return Err(IngestError::Rejected( + "invalid: event timestamp too far from server time".into(), + )); } const MAX_EVENT_CONTENT_BYTES: usize = 256 * 1024; // 256 KB @@ -1258,7 +1249,7 @@ async fn ingest_event_inner( } let is_gift_wrap = kind_u32 == KIND_GIFT_WRAP; - if event.pubkey != *auth.pubkey() && !auth.has_proxy_scope() && !is_gift_wrap { + if event.pubkey != *auth.pubkey() && !is_gift_wrap { return Err(IngestError::AuthFailed( "invalid: event pubkey does not match authenticated identity".into(), )); @@ -1268,13 +1259,6 @@ async fn ingest_event_inner( Ok(scope) => scope, Err(msg) => return Err(IngestError::Rejected(msg.into())), }; - // NIP-43: relay admin commands must NOT be submitted via proxy — they require - // the actual admin's signed event for authorization. - if auth.has_proxy_scope() && is_relay_admin_kind(event.kind.as_u16() as u32) { - return Err(IngestError::Rejected( - "invalid: relay admin commands cannot be submitted via proxy".into(), - )); - } // NIP-43: relay admin commands are global — channel-scoped tokens cannot // issue them even if the event has no `h` tag (is_global_only_kind strips // channel_id, but we still need to reject the token itself). @@ -1291,7 +1275,7 @@ async fn ingest_event_inner( "restricted: leave requests require a global token".into(), )); } - if !auth.has_proxy_scope() && !auth.scopes().contains(&required) { + if !auth.scopes().contains(&required) { return Err(IngestError::AuthFailed(format!( "restricted: insufficient scope (need {})", required @@ -1395,9 +1379,8 @@ async fn ingest_event_inner( if let Some(ch_id) = channel_id { // kind:9021 (join) doesn't require prior membership. // kind:9007 (create) — channel doesn't exist yet; creator becomes owner in step 16. - let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST - || kind_u32 == KIND_NIP29_CREATE_GROUP - || auth.has_proxy_scope(); + let skip_membership = + kind_u32 == KIND_NIP29_JOIN_REQUEST || kind_u32 == KIND_NIP29_CREATE_GROUP; if !skip_membership { // Spec AuthCheck (line 794): emit the verdict at the actual // call site. claimed_community comes from the event's h tag diff --git a/crates/buzz-relay/src/handlers/mesh_signaling.rs b/crates/buzz-relay/src/handlers/mesh_signaling.rs index 3135ae629e..4a6f1becb1 100644 --- a/crates/buzz-relay/src/handlers/mesh_signaling.rs +++ b/crates/buzz-relay/src/handlers/mesh_signaling.rs @@ -65,7 +65,7 @@ pub(crate) fn connect_request_rate_limited(state: &AppState, pubkey: &nostr::Pub /// path in `handlers::event`. Since the desktop's Rust coordinator publishes /// them via `POST /events` (NIP-98), the bridge routes them here instead. /// Mirrors the WS door's checks in the same order: signature, pubkey-match -/// (strict — mesh kinds are never proxy-submittable), rate limit, then the +/// (strict — mesh kinds must come from the member's own session), rate limit, then the /// shared handlers. Membership is enforced both at the bridge and inside the /// handlers (fail-closed `require_mesh_member`). /// diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 004eb8bba8..5da5dc1fab 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -7,7 +7,7 @@ //! //! 1. **N=1 parity** — with one configured host → one default community, every //! existing client observes byte-identical behavior. This is asserted by the -//! *existing* e2e suites (`e2e_relay`, `e2e_rest_api`, `e2e_media`, …) run +//! *existing* e2e suites (`e2e_relay`, `e2e_media`, `e2e_git`, …) run //! with `RELAY_URL` pointed at the new relay; no new test is needed here, //! only the documented obligation that those suites stay green unchanged. //! @@ -833,8 +833,7 @@ mod api_tokens_nip98_replay { /// Build a `Authorization: Nostr ` header value for NIP-98 HTTP /// auth (kind 27235 `HttpAuth` with `u`/`method`/`payload` tags). Mirrors - /// the pattern in `crates/buzz-auth/src/nip98.rs` and the helper in - /// `crates/buzz-test-client/tests/e2e_tokens.rs:52` — kept local to this + /// the pattern in `crates/buzz-auth/src/nip98.rs`; kept local to this /// row so the conformance file's rows stay self-contained. fn build_nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String { let payload_hash = hex::encode(Sha256::digest(body)); diff --git a/crates/buzz-test-client/tests/e2e_git.rs b/crates/buzz-test-client/tests/e2e_git.rs index b632c46fff..63281fd18f 100644 --- a/crates/buzz-test-client/tests/e2e_git.rs +++ b/crates/buzz-test-client/tests/e2e_git.rs @@ -43,7 +43,7 @@ fn credential_helper() -> PathBuf { p } -/// Submit a signed event to the relay's REST bridge (`POST /api/events`). +/// Submit a signed event to the relay's HTTP bridge (`POST /events`). async fn post_event(event: &nostr::Event) { let client = reqwest::Client::new(); let resp = client diff --git a/crates/buzz-test-client/tests/e2e_rest_api.rs b/crates/buzz-test-client/tests/e2e_rest_api.rs deleted file mode 100644 index 364129e43d..0000000000 --- a/crates/buzz-test-client/tests/e2e_rest_api.rs +++ /dev/null @@ -1,1843 +0,0 @@ -//! E2E tests for the Buzz REST API. -//! -//! These tests require a running relay instance with `require_auth_token=false` -//! (dev mode). By default they are marked `#[ignore]` so that `cargo test` -//! does not fail in CI when the relay is not available. -//! -//! # Running -//! -//! Start the relay, then run: -//! -//! ```text -//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_rest_api -- --ignored -//! ``` -//! -//! # Auth -//! -//! In dev mode (`require_auth_token=false`) the relay accepts an -//! `X-Pubkey: ` header as authentication. Tests generate fresh -//! [`nostr::Keys`] per test and pass the hex-encoded public key. -//! -//! # Channel setup -//! -//! Each test creates its own channels dynamically via signed kind:9007 events. -//! No pre-seeded data is required — tests are fully self-contained and work -//! against a fresh database. Some tests also send messages via WebSocket to -//! set up search / feed data. - -use std::time::Duration; - -use buzz_test_client::BuzzTestClient; -use nostr::{EventBuilder, Keys, Kind, Tag}; -use reqwest::Client; - -/// WebSocket relay URL (e.g. `ws://localhost:3001`). -fn relay_ws_url() -> String { - std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) -} - -/// HTTP base URL derived from the WebSocket URL. -fn relay_http_url() -> String { - relay_ws_url() - .replace("wss://", "https://") - .replace("ws://", "http://") -} - -/// Build a `reqwest::Client` with a short timeout. -fn http_client() -> Client { - Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("failed to build HTTP client") -} - -/// Make an authenticated GET request using the `X-Pubkey` dev-mode header. -async fn authed_get(client: &Client, url: &str, pubkey_hex: &str) -> reqwest::Response { - client - .get(url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .unwrap_or_else(|e| panic!("HTTP GET {url} failed: {e}")) -} - -/// Make an authenticated POST request using the `X-Pubkey` dev-mode header. -async fn authed_post_json( - client: &Client, - url: &str, - pubkey_hex: &str, - body: serde_json::Value, -) -> reqwest::Response { - client - .post(url) - .header("X-Pubkey", pubkey_hex) - .json(&body) - .send() - .await - .unwrap_or_else(|e| panic!("HTTP POST {url} failed: {e}")) -} - -/// Make an authenticated PUT request using the `X-Pubkey` dev-mode header. -async fn authed_put( - client: &Client, - url: &str, - pubkey_hex: &str, - body: serde_json::Value, -) -> reqwest::Response { - client - .put(url) - .header("X-Pubkey", pubkey_hex) - .json(&body) - .send() - .await - .unwrap_or_else(|e| panic!("HTTP PUT {url} failed: {e}")) -} - -/// Create a channel via a signed kind:9007 event submitted to POST /api/events. -/// Returns the channel UUID as a string. -async fn create_channel_via_event( - client: &Client, - keys: &Keys, - name: &str, - channel_type: &str, - visibility: &str, - description: Option<&str>, -) -> String { - let pubkey_hex = keys.public_key().to_hex(); - let channel_uuid = uuid::Uuid::new_v4(); - let mut tags = vec![ - Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), - Tag::parse(["name", name]).unwrap(), - Tag::parse(["channel_type", channel_type]).unwrap(), - Tag::parse(["visibility", visibility]).unwrap(), - ]; - if let Some(desc) = description { - tags.push(Tag::parse(["about", desc]).unwrap()); - } - let event = EventBuilder::new(Kind::Custom(9007), "") - .tags(tags) - .sign_with_keys(keys) - .unwrap(); - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event).unwrap()) - .send() - .await - .expect("submit create-channel event"); - assert!( - resp.status().is_success(), - "channel creation failed: {}", - resp.status() - ); - channel_uuid.to_string() -} - -/// Update profile via a signed kind:0 event submitted to POST /api/events. -async fn set_profile_via_event( - client: &Client, - keys: &Keys, - display_name: Option<&str>, - about: Option<&str>, - avatar_url: Option<&str>, - nip05: Option<&str>, -) { - let pubkey_hex = keys.public_key().to_hex(); - let mut content_obj = serde_json::Map::new(); - if let Some(n) = display_name { - content_obj.insert("display_name".to_string(), serde_json::json!(n)); - content_obj.insert("name".to_string(), serde_json::json!(n)); - } - if let Some(a) = about { - content_obj.insert("about".to_string(), serde_json::json!(a)); - } - if let Some(p) = avatar_url { - content_obj.insert("picture".to_string(), serde_json::json!(p)); - } - if let Some(n) = nip05 { - content_obj.insert("nip05".to_string(), serde_json::json!(n)); - } - let content = serde_json::to_string(&serde_json::Value::Object(content_obj)).unwrap(); - let event = EventBuilder::new(Kind::Custom(0), &content) - .tags(vec![]) - .sign_with_keys(keys) - .unwrap(); - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event).unwrap()) - .send() - .await - .expect("submit profile event"); - assert!( - resp.status().is_success(), - "set profile failed: {}", - resp.status() - ); -} - -/// GET /api/channels returns a non-empty list with the expected fields. -#[tokio::test] -#[ignore] -async fn test_list_channels_returns_expected_fields() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/channels", relay_http_url()); - - // Ensure at least one channel exists (fresh DB may be empty). - create_channel_via_event( - &client, - &keys, - &format!("list-test-{}", uuid::Uuid::new_v4()), - "stream", - "open", - Some("Seed channel for list test"), - ) - .await; - - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200, "expected 200 OK from /api/channels"); - - let body: serde_json::Value = resp.json().await.expect("response must be JSON"); - let channels = body - .as_array() - .expect("/api/channels must return a JSON array"); - - assert!( - !channels.is_empty(), - "expected at least one channel in the list" - ); - - for ch in channels { - assert!(ch.get("id").is_some(), "channel missing 'id' field"); - assert!(ch.get("name").is_some(), "channel missing 'name' field"); - assert!( - ch.get("channel_type").is_some(), - "channel missing 'channel_type' field" - ); - assert!( - ch.get("description").is_some(), - "channel missing 'description' field" - ); - } -} - -/// Creating a channel via POST /api/events (kind:9007) makes it visible via GET /api/channels. -#[tokio::test] -#[ignore] -async fn test_create_channel_returns_channel_record() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let channel_name = format!("desktop-create-{}", uuid::Uuid::new_v4()); - - let channel_id = create_channel_via_event( - &client, - &keys, - &channel_name, - "stream", - "private", - Some("Created by the REST API test"), - ) - .await; - - assert!(!channel_id.is_empty(), "channel_id must be non-empty"); - - // Verify the channel is visible via GET /api/channels - let url = format!("{}/api/channels", relay_http_url()); - let list_resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!( - list_resp.status(), - 200, - "expected 200 OK from /api/channels" - ); - let channels: Vec = list_resp.json().await.expect("response must be JSON"); - assert!( - channels - .iter() - .any(|channel| channel["id"].as_str() == Some(&channel_id)), - "newly-created channel should be visible to its creator" - ); - - // Verify the channel record has the expected fields - let channel = channels - .iter() - .find(|c| c["id"].as_str() == Some(&channel_id)) - .expect("channel must be in list"); - assert_eq!(channel["name"].as_str(), Some(channel_name.as_str())); - assert_eq!(channel["channel_type"].as_str(), Some("stream")); -} - -/// Open channels are visible to any authenticated user (no prior membership required). -#[tokio::test] -#[ignore] -async fn test_channel_visibility_open_channels_visible_to_all() { - let client = http_client(); - - // Use two completely independent keypairs — neither has any prior membership. - let keys_a = Keys::generate(); - let keys_b = Keys::generate(); - - let url = format!("{}/api/channels", relay_http_url()); - - // Create an open channel as keys_a so there is at least one open channel to verify. - let open_channel_name = format!("e2e-open-{}", uuid::Uuid::new_v4().simple()); - let open_channel_id = - create_channel_via_event(&client, &keys_a, &open_channel_name, "stream", "open", None) - .await; - - let resp_a = authed_get(&client, &url, &keys_a.public_key().to_hex()).await; - let resp_b = authed_get(&client, &url, &keys_b.public_key().to_hex()).await; - - assert_eq!(resp_a.status(), 200); - assert_eq!(resp_b.status(), 200); - - let channels_a: Vec = resp_a.json().await.expect("JSON"); - let channels_b: Vec = resp_b.json().await.expect("JSON"); - - // Both users should see the open channel we just created. - let ids_a: std::collections::HashSet = channels_a - .iter() - .filter_map(|c| c["id"].as_str().map(|s| s.to_string())) - .collect(); - let ids_b: std::collections::HashSet = channels_b - .iter() - .filter_map(|c| c["id"].as_str().map(|s| s.to_string())) - .collect(); - - assert!( - ids_a.contains(&open_channel_id), - "keys_a should see the open channel we created (id={open_channel_id})" - ); - assert!( - ids_b.contains(&open_channel_id), - "keys_b (unrelated user) should also see the open channel (id={open_channel_id})" - ); -} - -/// GET /api/channels requires authentication — unauthenticated requests are rejected. -#[tokio::test] -#[ignore] -async fn test_channels_requires_auth() { - let client = http_client(); - let url = format!("{}/api/channels", relay_http_url()); - - // No X-Pubkey header. - let resp = client.get(&url).send().await.expect("request failed"); - - assert_eq!( - resp.status(), - 401, - "expected 401 Unauthorized when no auth header is provided" - ); -} - -/// GET /api/search returns results scoped to the authenticated user's accessible channels. -#[tokio::test] -#[ignore] -async fn test_search_returns_results_for_open_channels() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // The seeded data contains messages with "Hello" — use a wildcard search - // to get all indexed events in accessible channels. - let url = format!("{}/api/search?q=*", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200, "expected 200 OK from /api/search"); - - let body: serde_json::Value = resp.json().await.expect("response must be JSON"); - assert!(body.get("hits").is_some(), "response missing 'hits' field"); - assert!( - body.get("found").is_some(), - "response missing 'found' field" - ); - - let hits = body["hits"].as_array().expect("'hits' must be an array"); - - for hit in hits { - assert!(hit.get("event_id").is_some(), "hit missing 'event_id'"); - assert!(hit.get("content").is_some(), "hit missing 'content'"); - assert!(hit.get("kind").is_some(), "hit missing 'kind'"); - assert!(hit.get("pubkey").is_some(), "hit missing 'pubkey'"); - assert!(hit.get("channel_id").is_some(), "hit missing 'channel_id'"); - } -} - -/// GET /api/search with a specific query returns only matching events. -#[tokio::test] -#[ignore] -async fn test_search_returns_indexed_event() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let ws_url = relay_ws_url(); - - // Create a channel for this test so the event is accepted by the relay. - let channel_name = format!("e2e-search-{}", uuid::Uuid::new_v4().simple()); - let channel_id = - create_channel_via_event(&client, &keys, &channel_name, "stream", "open", None).await; - - let unique_token = format!("e2e-search-{}", uuid::Uuid::new_v4().simple()); - let content = format!("E2E REST search test marker: {unique_token}"); - - let mut ws_client = BuzzTestClient::connect(&ws_url, &keys) - .await - .expect("WebSocket connect failed"); - - let h_tag = Tag::parse(["h", &channel_id]).expect("tag parse failed"); - let event = nostr::EventBuilder::new(Kind::Custom(9), &content) - .tags([h_tag]) - .sign_with_keys(&keys) - .expect("event sign failed"); - - let ok = ws_client - .send_event(event) - .await - .expect("send_event failed"); - assert!(ok.accepted, "relay rejected event: {}", ok.message); - - ws_client.disconnect().await.ok(); - - // Wait for the async search index to catch up. FTS indexing is - // fire-and-forget (tokio::spawn), so we need a generous delay. - tokio::time::sleep(Duration::from_secs(2)).await; - - // The unique_token is UUID simple format (hex only) — safe to use directly in the URL. - let url = format!("{}/api/search?q={unique_token}", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("JSON"); - let hits = body["hits"].as_array().expect("hits array"); - - assert!( - !hits.is_empty(), - "expected at least one search hit for unique token '{unique_token}'" - ); - - let first_content = hits[0]["content"].as_str().unwrap_or(""); - assert!( - first_content.contains(&unique_token), - "expected hit content to contain '{unique_token}', got: '{first_content}'" - ); -} - -/// GET /api/search with empty query returns all accessible events. -#[tokio::test] -#[ignore] -async fn test_search_empty_query_returns_all() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/search", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert!(body["hits"].is_array(), "'hits' must be an array"); - assert!(body["found"].is_number(), "'found' must be a number"); -} - -/// GET /api/presence returns "offline" for a pubkey with no presence event. -#[tokio::test] -#[ignore] -async fn test_presence_offline_by_default() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("JSON"); - let status = body[&pubkey_hex].as_str().expect("expected string status"); - assert_eq!(status, "offline", "fresh key should be 'offline'"); -} - -/// Sending a presence event (kind:20001) via WebSocket updates the presence store. -#[tokio::test] -#[ignore] -async fn test_presence_set_and_query() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let ws_url = relay_ws_url(); - - let mut ws_client = BuzzTestClient::connect(&ws_url, &keys) - .await - .expect("WebSocket connect failed"); - - let presence_event = nostr::EventBuilder::new(Kind::Custom(20001), "online") - .tags([]) - .sign_with_keys(&keys) - .expect("event sign failed"); - - let ok = ws_client - .send_event(presence_event) - .await - .expect("send_event failed"); - assert!(ok.accepted, "relay rejected presence event: {}", ok.message); - - // Keep the WebSocket connection alive briefly so presence is registered. - tokio::time::sleep(Duration::from_millis(200)).await; - - let url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("JSON"); - let status = body[&pubkey_hex].as_str().expect("expected string status"); - assert_eq!( - status, "online", - "expected 'online' after sending presence event" - ); - - let offline_event = nostr::EventBuilder::new(Kind::Custom(20001), "offline") - .tags([]) - .sign_with_keys(&keys) - .expect("event sign failed"); - ws_client.send_event(offline_event).await.ok(); - ws_client.disconnect().await.ok(); -} - -/// GET /api/presence with multiple pubkeys returns a status for each. -#[tokio::test] -#[ignore] -async fn test_presence_bulk_query() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Generate two fresh keys — both should be offline. - let keys_a = Keys::generate(); - let keys_b = Keys::generate(); - let pk_a = keys_a.public_key().to_hex(); - let pk_b = keys_b.public_key().to_hex(); - - let url = format!("{}/api/presence?pubkeys={pk_a},{pk_b}", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert!(body.is_object(), "presence response must be an object"); - - assert!( - body.get(&pk_a).is_some(), - "pk_a missing from presence response" - ); - assert!( - body.get(&pk_b).is_some(), - "pk_b missing from presence response" - ); - - assert_eq!(body[&pk_a].as_str(), Some("offline")); - assert_eq!(body[&pk_b].as_str(), Some("offline")); -} - -/// GET /api/presence with no pubkeys returns an empty object. -#[tokio::test] -#[ignore] -async fn test_presence_empty_pubkeys() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/presence", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert!( - body.as_object().map(|o| o.is_empty()).unwrap_or(false), - "expected empty object for no pubkeys" - ); -} - -/// PUT /api/presence sets the user's presence and can be read back. -#[tokio::test] -#[ignore] -async fn test_set_presence_online() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Set presence to "online" via REST. - let url = format!("{}/api/presence", relay_http_url()); - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({"status": "online"}), - ) - .await; - assert_eq!(resp.status(), 200, "PUT /api/presence should return 200"); - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!(body["status"].as_str(), Some("online")); - assert_eq!( - body["ttl_seconds"].as_u64(), - Some(90), - "online presence should have 90s TTL" - ); - - // Verify via GET. - let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); - let resp = authed_get(&client, &get_url, &pubkey_hex).await; - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!( - body[&pubkey_hex].as_str(), - Some("online"), - "presence should be 'online' after PUT" - ); -} - -/// PUT /api/presence with "away" then "offline" updates and clears presence. -#[tokio::test] -#[ignore] -async fn test_set_presence_away_and_offline() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let url = format!("{}/api/presence", relay_http_url()); - - // Set to "away". - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({"status": "away"}), - ) - .await; - assert_eq!(resp.status(), 200); - let away_body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!( - away_body["status"].as_str(), - Some("away"), - "PUT response should echo 'away'" - ); - assert_eq!( - away_body["ttl_seconds"].as_u64(), - Some(90), - "away should have 90s TTL" - ); - - // Verify "away". - let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); - let resp = authed_get(&client, &get_url, &pubkey_hex).await; - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!(body[&pubkey_hex].as_str(), Some("away")); - - // Set to "offline" — should clear presence. - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({"status": "offline"}), - ) - .await; - assert_eq!(resp.status(), 200); - let offline_body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!( - offline_body["status"].as_str(), - Some("offline"), - "PUT response should echo 'offline'" - ); - assert_eq!( - offline_body["ttl_seconds"].as_u64(), - Some(0), - "offline should have 0 TTL" - ); - - // Verify "offline" (key deleted from Redis, defaults to "offline"). - let resp = authed_get(&client, &get_url, &pubkey_hex).await; - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!(body[&pubkey_hex].as_str(), Some("offline")); -} - -/// PUT /api/presence with an invalid status returns 422 with standard error envelope. -#[tokio::test] -#[ignore] -async fn test_set_presence_invalid_status() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/presence", relay_http_url()); - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({"status": "invisible"}), - ) - .await; - assert_eq!( - resp.status(), - 422, - "invalid enum variant should return 422 Unprocessable Entity" - ); - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert!( - body["error"].as_str().is_some(), - "422 response should contain standard error envelope, got: {body}" - ); -} - -/// PUT /api/presence without auth returns 401. -#[tokio::test] -#[ignore] -async fn test_set_presence_requires_auth() { - let client = http_client(); - let url = format!("{}/api/presence", relay_http_url()); - let resp = client - .put(&url) - .json(&serde_json::json!({"status": "online"})) - .send() - .await - .expect("request failed"); - assert_eq!( - resp.status(), - 401, - "PUT /api/presence without auth should return 401" - ); - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert_eq!( - body["error"].as_str(), - Some("authentication required"), - "401 response should contain standard error envelope" - ); -} - -/// PUT /api/presence with missing status field returns a structured error. -#[tokio::test] -#[ignore] -async fn test_set_presence_missing_field() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/presence", relay_http_url()); - let resp = authed_put(&client, &url, &pubkey_hex, serde_json::json!({})).await; - assert_eq!( - resp.status(), - 422, - "missing required field should return 422" - ); - let body: serde_json::Value = resp.json().await.expect("JSON"); - assert!( - body["error"].as_str().is_some(), - "422 response should contain standard error envelope, got: {body}" - ); -} - -/// GET /api/agents returns a JSON array with the expected fields. -#[tokio::test] -#[ignore] -async fn test_agents_list() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/agents", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200, "expected 200 OK from /api/agents"); - - let body: serde_json::Value = resp.json().await.expect("response must be JSON"); - let agents = body - .as_array() - .expect("/api/agents must return a JSON array"); - - for agent in agents { - assert!(agent.get("pubkey").is_some(), "agent missing 'pubkey'"); - assert!(agent.get("name").is_some(), "agent missing 'name'"); - assert!(agent.get("status").is_some(), "agent missing 'status'"); - assert!(agent.get("channels").is_some(), "agent missing 'channels'"); - assert!( - agent.get("capabilities").is_some(), - "agent missing 'capabilities'" - ); - - // 'channels' must be an array. - assert!( - agent["channels"].is_array(), - "agent 'channels' must be an array" - ); - // 'capabilities' must be an array. - assert!( - agent["capabilities"].is_array(), - "agent 'capabilities' must be an array" - ); - // 'status' must be a string. - assert!( - agent["status"].is_string(), - "agent 'status' must be a string" - ); - } -} - -/// GET /api/agents requires authentication. -#[tokio::test] -#[ignore] -async fn test_agents_requires_auth() { - let client = http_client(); - let url = format!("{}/api/agents", relay_http_url()); - - let resp = client.get(&url).send().await.expect("request failed"); - - assert_eq!( - resp.status(), - 401, - "expected 401 Unauthorized when no auth header is provided" - ); -} - -/// GET /api/agents only returns agents in channels accessible to the requester. -#[tokio::test] -#[ignore] -async fn test_agents_scoped_to_accessible_channels() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/agents", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200); - - let agents: Vec = resp.json().await.expect("JSON"); - - let channels_url = format!("{}/api/channels", relay_http_url()); - let channels_resp = authed_get(&client, &channels_url, &pubkey_hex).await; - let channels: Vec = channels_resp.json().await.expect("JSON"); - let accessible_names: std::collections::HashSet = channels - .iter() - .filter_map(|c| c["name"].as_str().map(|s| s.to_string())) - .collect(); - - // Every channel listed for each agent must be accessible to this user. - for agent in &agents { - let agent_channels = agent["channels"].as_array().expect("channels array"); - for ch in agent_channels { - let ch_name = ch.as_str().expect("channel name must be a string"); - assert!( - accessible_names.contains(ch_name), - "agent channel '{ch_name}' is not in the user's accessible channels" - ); - } - } -} - -/// GET /api/feed returns a structured feed with the expected shape. -/// -/// This test is skipped if the relay does not expose `/api/feed` (older builds). -#[tokio::test] -#[ignore] -async fn test_feed_returns_activity() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let ws_url = relay_ws_url(); - - let url = format!("{}/api/feed", relay_http_url()); - - // Probe the endpoint — skip gracefully if the relay doesn't have it yet. - let probe = client - .get(&url) - .header("X-Pubkey", &pubkey_hex) - .send() - .await - .expect("probe request failed"); - - if probe.status() == 404 { - eprintln!("SKIP test_feed_returns_activity: /api/feed not available on this relay build"); - return; - } - - // Create a channel for this test so the event is accepted by the relay. - let channel_name = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple()); - let channel_id = - create_channel_via_event(&client, &keys, &channel_name, "stream", "open", None).await; - - // Send a message to the open channel so there is activity to return. - let unique_token = format!("e2e-feed-{}", uuid::Uuid::new_v4().simple()); - let content = format!("E2E feed test: {unique_token}"); - - let mut ws_client = BuzzTestClient::connect(&ws_url, &keys) - .await - .expect("WebSocket connect failed"); - - let h_tag = Tag::parse(["h", &channel_id]).expect("tag parse failed"); - let event = nostr::EventBuilder::new(Kind::Custom(9), &content) - .tags([h_tag]) - .sign_with_keys(&keys) - .expect("event sign failed"); - - let ok = ws_client - .send_event(event) - .await - .expect("send_event failed"); - assert!(ok.accepted, "relay rejected event: {}", ok.message); - ws_client.disconnect().await.ok(); - - // Small delay to let the event propagate. - tokio::time::sleep(Duration::from_millis(200)).await; - - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!(resp.status(), 200, "expected 200 OK from /api/feed"); - - let body: serde_json::Value = resp.json().await.expect("response must be JSON"); - - let feed = body.get("feed").expect("response missing 'feed' key"); - let meta = body.get("meta").expect("response missing 'meta' key"); - - assert!(feed.get("mentions").is_some(), "feed missing 'mentions'"); - assert!( - feed.get("needs_action").is_some(), - "feed missing 'needs_action'" - ); - assert!(feed.get("activity").is_some(), "feed missing 'activity'"); - assert!( - feed.get("agent_activity").is_some(), - "feed missing 'agent_activity'" - ); - - assert!(meta.get("since").is_some(), "meta missing 'since'"); - assert!(meta.get("total").is_some(), "meta missing 'total'"); - assert!( - meta.get("generated_at").is_some(), - "meta missing 'generated_at'" - ); - - assert!( - feed["activity"].is_array(), - "feed 'activity' must be an array" - ); - - // The activity array should contain our message (it's in an open channel). - let activity = feed["activity"].as_array().expect("activity array"); - let found = activity.iter().any(|item| { - item["content"] - .as_str() - .unwrap_or("") - .contains(&unique_token) - }); - - assert!( - found, - "expected to find our message '{unique_token}' in feed activity" - ); -} - -/// GET /api/feed with `types=activity` returns only the activity section. -#[tokio::test] -#[ignore] -async fn test_feed_type_filter() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/feed?types=activity", relay_http_url()); - - let probe = client - .get(&url) - .header("X-Pubkey", &pubkey_hex) - .send() - .await - .expect("probe request failed"); - - if probe.status() == 404 { - eprintln!("SKIP test_feed_type_filter: /api/feed not available on this relay build"); - return; - } - - assert_eq!(probe.status(), 200); - - let body: serde_json::Value = probe.json().await.expect("JSON"); - let feed = &body["feed"]; - - // When filtering to 'activity', the other sections should be empty arrays. - assert_eq!( - feed["mentions"].as_array().map(|a| a.len()), - Some(0), - "mentions should be empty when types=activity" - ); - assert_eq!( - feed["needs_action"].as_array().map(|a| a.len()), - Some(0), - "needs_action should be empty when types=activity" - ); -} - -/// GET /api/feed requires authentication. -#[tokio::test] -#[ignore] -async fn test_feed_requires_auth() { - let client = http_client(); - let url = format!("{}/api/feed", relay_http_url()); - - let resp = client.get(&url).send().await.expect("request failed"); - - // Either 401 (auth required) or 404 (older build without feed route). - let status = resp.status().as_u16(); - assert!( - status == 401 || status == 404, - "expected 401 or 404, got {status}" - ); -} - -/// An invalid X-Pubkey header is rejected with 401. -#[tokio::test] -#[ignore] -async fn test_invalid_pubkey_header_rejected() { - let client = http_client(); - let url = format!("{}/api/channels", relay_http_url()); - - let resp = client - .get(&url) - .header("X-Pubkey", "not-a-valid-hex-pubkey") - .send() - .await - .expect("request failed"); - - assert_eq!( - resp.status(), - 401, - "expected 401 for invalid X-Pubkey header" - ); -} - -/// A valid X-Pubkey header is accepted and returns 200. -#[tokio::test] -#[ignore] -async fn test_valid_pubkey_header_accepted() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/channels", relay_http_url()); - let resp = authed_get(&client, &url, &pubkey_hex).await; - - assert_eq!(resp.status(), 200, "expected 200 for valid X-Pubkey header"); -} - -/// GET /api/users/:pubkey/profile returns the profile for a known user. -#[tokio::test] -#[ignore] -async fn test_get_user_profile_returns_known_user() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Set a profile first - set_profile_via_event( - &client, - &keys, - Some("Profile Test User"), - Some("Testing public profile endpoint"), - None, - None, - ) - .await; - - // Read it back via the new public endpoint (using a different reader) - let reader_keys = Keys::generate(); - let reader_hex = reader_keys.public_key().to_hex(); - - let resp = authed_get( - &client, - &format!("{}/api/users/{}/profile", relay_http_url(), pubkey_hex), - &reader_hex, - ) - .await; - - assert_eq!(resp.status(), 200, "expected 200 for known user profile"); - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!(body["pubkey"].as_str(), Some(pubkey_hex.as_str())); - assert_eq!(body["display_name"].as_str(), Some("Profile Test User")); - assert_eq!( - body["about"].as_str(), - Some("Testing public profile endpoint") - ); -} - -/// GET /api/users/:pubkey/profile returns 404 for an unknown user. -#[tokio::test] -#[ignore] -async fn test_get_user_profile_returns_404_for_unknown() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - // Use a pubkey that has never been registered - let unknown_hex = Keys::generate().public_key().to_hex(); - - let resp = authed_get( - &client, - &format!("{}/api/users/{}/profile", relay_http_url(), unknown_hex), - &pubkey_hex, - ) - .await; - - assert_eq!(resp.status(), 404, "expected 404 for unknown user"); -} - -/// GET /api/users/:pubkey/profile returns 401 without authentication. -#[tokio::test] -#[ignore] -async fn test_get_user_profile_requires_auth() { - let client = http_client(); - let some_pubkey = Keys::generate().public_key().to_hex(); - - let resp = client - .get(format!( - "{}/api/users/{}/profile", - relay_http_url(), - some_pubkey - )) - .send() - .await - .expect("GET profile"); - - assert_eq!(resp.status(), 401, "expected 401 without auth"); -} - -/// GET /api/users/:pubkey/profile returns 400 for an invalid pubkey. -#[tokio::test] -#[ignore] -async fn test_get_user_profile_rejects_invalid_pubkey() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let resp = authed_get( - &client, - &format!( - "{}/api/users/{}/profile", - relay_http_url(), - "not-a-valid-hex" - ), - &pubkey_hex, - ) - .await; - - assert_eq!(resp.status(), 400, "expected 400 for invalid pubkey hex"); -} - -/// POST /api/users/batch returns found profiles and a missing list. -#[tokio::test] -#[ignore] -async fn test_batch_profiles_known_and_unknown() { - let client = http_client(); - - // Create two users with profiles - let keys_a = Keys::generate(); - let hex_a = keys_a.public_key().to_hex(); - let keys_b = Keys::generate(); - let hex_b = keys_b.public_key().to_hex(); - let unknown_hex = Keys::generate().public_key().to_hex(); - - set_profile_via_event(&client, &keys_a, Some("Alice Batch"), None, None, None).await; - set_profile_via_event(&client, &keys_b, Some("Bob Batch"), None, None, None).await; - - // Batch lookup - let resp = authed_post_json( - &client, - &format!("{}/api/users/batch", relay_http_url()), - &hex_a, - serde_json::json!({ - "pubkeys": [hex_a, hex_b, unknown_hex] - }), - ) - .await; - - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("json"); - - let profiles = body["profiles"].as_object().expect("profiles map"); - assert_eq!(profiles.len(), 2, "expected 2 found profiles"); - assert_eq!( - profiles[&hex_a.to_lowercase()]["display_name"].as_str(), - Some("Alice Batch") - ); - assert_eq!( - profiles[&hex_b.to_lowercase()]["display_name"].as_str(), - Some("Bob Batch") - ); - - let missing = body["missing"].as_array().expect("missing array"); - assert!( - missing - .iter() - .any(|v| v.as_str() == Some(&unknown_hex.to_lowercase())), - "unknown pubkey should be in missing" - ); -} - -/// POST /api/users/batch returns 400 when more than 200 pubkeys are submitted. -#[tokio::test] -#[ignore] -async fn test_batch_profiles_rejects_over_200() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let pubkeys: Vec = (0..201).map(|i| format!("{:064x}", i)).collect(); - - let resp = authed_post_json( - &client, - &format!("{}/api/users/batch", relay_http_url()), - &pubkey_hex, - serde_json::json!({"pubkeys": pubkeys}), - ) - .await; - - assert_eq!(resp.status(), 400, "expected 400 for >200 pubkeys"); -} - -/// POST /api/users/batch returns 401 without authentication. -#[tokio::test] -#[ignore] -async fn test_batch_profiles_requires_auth() { - let client = http_client(); - - let resp = client - .post(format!("{}/api/users/batch", relay_http_url())) - .json(&serde_json::json!({"pubkeys": ["abc"]})) - .send() - .await - .expect("POST batch"); - - assert_eq!(resp.status(), 401, "expected 401 without auth"); -} - -/// POST /api/users/batch places invalid-length inputs in the missing list. -/// Also verifies that 64-char non-hex strings (e.g. "g" * 64) go to missing. -#[tokio::test] -#[ignore] -async fn test_batch_profiles_invalid_length_in_missing() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let non_hex_64 = "g".repeat(64); - let resp = authed_post_json( - &client, - &format!("{}/api/users/batch", relay_http_url()), - &pubkey_hex, - serde_json::json!({"pubkeys": ["tooshort", "x".repeat(100), non_hex_64]}), - ) - .await; - - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("json"); - let missing = body["missing"].as_array().expect("missing"); - assert_eq!( - missing.len(), - 3, - "wrong-length and 64-char non-hex inputs should all be in missing" - ); - - let missing_strs: Vec<&str> = missing.iter().filter_map(|v| v.as_str()).collect(); - assert!( - missing_strs.contains(&"tooshort"), - "short input should be in missing" - ); - assert!( - missing_strs.iter().any(|s| s.len() == 100), - "too-long input should be in missing" - ); - assert!( - missing_strs.contains(&"g".repeat(64).as_str()), - "64-char non-hex should be in missing" - ); -} - -/// POST /api/users/batch normalizes pubkey case before lookup. -#[tokio::test] -#[ignore] -async fn test_batch_profiles_case_normalized() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Set profile - set_profile_via_event(&client, &keys, Some("Case Test"), None, None, None).await; - - // Query with uppercase version - let upper_hex = pubkey_hex.to_uppercase(); - let resp = authed_post_json( - &client, - &format!("{}/api/users/batch", relay_http_url()), - &pubkey_hex, - serde_json::json!({"pubkeys": [upper_hex]}), - ) - .await; - - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("json"); - let profiles = body["profiles"].as_object().expect("profiles"); - assert_eq!(profiles.len(), 1, "uppercase pubkey should match"); -} - -/// GET /.well-known/nostr.json?name=nonexistent returns empty names and relays. -#[tokio::test] -#[ignore] -async fn test_nip05_returns_empty_for_unknown_name() { - let client = http_client(); - - let resp = client - .get(format!( - "{}/.well-known/nostr.json?name=nonexistent", - relay_http_url() - )) - .send() - .await - .expect("GET nip05"); - - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!(body["names"].as_object().unwrap().len(), 0); - assert_eq!(body["relays"].as_object().unwrap().len(), 0); -} - -/// GET /.well-known/nostr.json with no name param returns empty names. -#[tokio::test] -#[ignore] -async fn test_nip05_no_name_returns_empty() { - let client = http_client(); - - let resp = client - .get(format!("{}/.well-known/nostr.json", relay_http_url())) - .send() - .await - .expect("GET nip05"); - - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!(body["names"].as_object().unwrap().len(), 0); -} - -/// GET /.well-known/nostr.json includes the required CORS header. -#[tokio::test] -#[ignore] -async fn test_nip05_has_cors_header() { - let client = http_client(); - - let resp = client - .get(format!("{}/.well-known/nostr.json", relay_http_url())) - .send() - .await - .expect("GET nip05"); - - assert_eq!(resp.status(), 200); - let cors = resp.headers().get("access-control-allow-origin"); - assert!( - cors.is_some(), - "NIP-05 must have Access-Control-Allow-Origin header" - ); - assert_eq!(cors.unwrap().to_str().unwrap(), "*"); -} - -/// Full round-trip: set nip05_handle via PUT, then verify via /.well-known/nostr.json. -#[tokio::test] -#[ignore] -async fn test_nip05_round_trip_set_and_lookup() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Set NIP-05 handle — use "testuser@localhost" since relay_url is ws://localhost:3000 - let unique_name = format!("nip05test{}", &pubkey_hex[..8]); - let handle = format!("{}@localhost", unique_name); - set_profile_via_event(&client, &keys, None, None, None, Some(&handle)).await; - - // Query NIP-05 endpoint - let resp = client - .get(format!( - "{}/.well-known/nostr.json?name={}", - relay_http_url(), - unique_name - )) - .send() - .await - .expect("nip05 request"); - assert_eq!(resp.status(), 200); - - let body: serde_json::Value = resp.json().await.expect("json"); - let names = body["names"].as_object().expect("names map"); - assert!( - names.contains_key(&unique_name), - "NIP-05 should resolve the name. Got: {:?}", - names - ); - let resolved_pubkey = names[&unique_name].as_str().expect("pubkey string"); - assert_eq!( - resolved_pubkey, pubkey_hex, - "NIP-05 resolved pubkey should match" - ); -} - -/// Setting nip05_handle to empty string clears it. -#[tokio::test] -#[ignore] -async fn test_nip05_clear_handle() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let unique_name = format!("cleartest{}", &pubkey_hex[..8]); - let handle = format!("{}@localhost", unique_name); - - // Set handle - set_profile_via_event(&client, &keys, None, None, None, Some(&handle)).await; - - // Clear handle - set_profile_via_event(&client, &keys, None, None, None, Some("")).await; - - // Verify cleared — NIP-05 should no longer resolve - let resp = client - .get(format!( - "{}/.well-known/nostr.json?name={}", - relay_http_url(), - unique_name - )) - .send() - .await - .expect("nip05 request"); - assert_eq!(resp.status(), 200); - let body: serde_json::Value = resp.json().await.expect("json"); - let names = body["names"].as_object().expect("names map"); - assert!( - !names.contains_key(&unique_name), - "NIP-05 should NOT resolve after clearing. Got: {:?}", - names - ); -} - -/// PUT /api/users/me/channel-add-policy updates the policy and returns the new value. -/// Cycles through owner_only → nobody → anyone to verify each round-trip. -#[tokio::test] -#[ignore] -async fn test_set_channel_add_policy_returns_updated_policy() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let url = format!("{}/api/users/me/channel-add-policy", relay_http_url()); - - // Set to owner_only - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({ "channel_add_policy": "owner_only" }), - ) - .await; - assert_eq!(resp.status(), 200, "expected 200 for owner_only"); - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!( - body["channel_add_policy"].as_str(), - Some("owner_only"), - "body should reflect owner_only" - ); - - // Set to nobody - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({ "channel_add_policy": "nobody" }), - ) - .await; - assert_eq!(resp.status(), 200, "expected 200 for nobody"); - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!( - body["channel_add_policy"].as_str(), - Some("nobody"), - "body should reflect nobody" - ); - - // Set to anyone - let resp = authed_put( - &client, - &url, - &pubkey_hex, - serde_json::json!({ "channel_add_policy": "anyone" }), - ) - .await; - assert_eq!(resp.status(), 200, "expected 200 for anyone"); - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!( - body["channel_add_policy"].as_str(), - Some("anyone"), - "body should reflect anyone" - ); -} - -/// PUT /api/users/me/channel-add-policy rejects unknown policy values with 400. -#[tokio::test] -#[ignore] -async fn test_set_channel_add_policy_rejects_invalid() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let resp = authed_put( - &client, - &format!("{}/api/users/me/channel-add-policy", relay_http_url()), - &pubkey_hex, - serde_json::json!({ "channel_add_policy": "invalid_value" }), - ) - .await; - assert_eq!(resp.status(), 400, "invalid policy value should return 400"); -} - -/// Phase 1: GET /api/events/{id} must return 200 for kind:1 text note. -#[tokio::test] -#[ignore] -async fn test_get_event_returns_text_note() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let content = format!("e2e-note-{}", uuid::Uuid::new_v4().simple()); - let event = EventBuilder::new(Kind::Custom(1), &content) - .tags(vec![]) - .sign_with_keys(&keys) - .unwrap(); - let event_id = event.id.to_hex(); - - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event).unwrap()) - .send() - .await - .expect("submit kind:1 event"); - assert!( - resp.status().is_success(), - "kind:1 submission failed: {}", - resp.status() - ); - - let url = format!("{}/api/events/{}", relay_http_url(), event_id); - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!(resp.status(), 200, "expected 200 for known kind:1 event"); - - let body: serde_json::Value = resp.json().await.expect("json"); - assert_eq!( - body["id"].as_str(), - Some(event_id.as_str()), - "id must match" - ); - assert_eq!(body["kind"].as_u64(), Some(1), "kind must be 1"); - assert_eq!( - body["content"].as_str(), - Some(content.as_str()), - "content must match" - ); -} - -/// Phase 1: GET /api/events/{id} must return 404 for unknown / disallowed event IDs. -#[tokio::test] -#[ignore] -async fn test_get_event_rejects_unknown_global_kind() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Use a well-formed but non-existent event ID (all-zeros hex). - let fake_id = "0".repeat(64); - let url = format!("{}/api/events/{}", relay_http_url(), fake_id); - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!(resp.status(), 404, "unknown event ID should return 404"); -} - -/// Phase 2: GET /api/users/{pubkey}/notes returns paginated notes. -#[tokio::test] -#[ignore] -async fn test_get_user_notes_returns_paginated_notes() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Publish 3 kind:1 events for this key. No sleep needed — the composite cursor - // (created_at, event_id) correctly handles same-second events without skipping. - for i in 0..3u8 { - let content = format!("e2e-paginated-note-{}-{}", i, uuid::Uuid::new_v4().simple()); - let event = EventBuilder::new(Kind::Custom(1), &content) - .tags(vec![]) - .sign_with_keys(&keys) - .unwrap(); - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event).unwrap()) - .send() - .await - .expect("submit kind:1 event"); - assert!( - resp.status().is_success(), - "kind:1 submission {} failed: {}", - i, - resp.status() - ); - } - - // First page: limit=2 — expect exactly 2 results and a next_cursor. - let page1_url = format!( - "{}/api/users/{}/notes?limit=2", - relay_http_url(), - pubkey_hex - ); - let resp = authed_get(&client, &page1_url, &pubkey_hex).await; - assert_eq!(resp.status(), 200, "expected 200 for first page of notes"); - - let body: serde_json::Value = resp.json().await.expect("json"); - let notes = body["notes"] - .as_array() - .expect("body must have a 'notes' array"); - assert_eq!(notes.len(), 2, "first page should return exactly 2 notes"); - - // next_cursor is now a JSON object with `before` (i64 timestamp) and - // `before_id` (hex event ID) for stable composite cursor pagination. - let cursor = &body["next_cursor"]; - assert!( - cursor.is_object(), - "next_cursor must be a JSON object when more results exist, got: {cursor}" - ); - let cursor_before = cursor["before"] - .as_i64() - .expect("next_cursor.before must be a JSON number"); - let cursor_before_id = cursor["before_id"] - .as_str() - .expect("next_cursor.before_id must be a string"); - - // Collect page 1 event IDs for duplicate checking. - let page1_ids: std::collections::HashSet = notes - .iter() - .filter_map(|n| n["id"].as_str().map(|s| s.to_string())) - .collect(); - - // Second page: use composite cursor — expect at least 1 remaining note. - let page2_url = format!( - "{}/api/users/{}/notes?limit=2&before={}&before_id={}", - relay_http_url(), - pubkey_hex, - cursor_before, - cursor_before_id - ); - let resp = authed_get(&client, &page2_url, &pubkey_hex).await; - assert_eq!(resp.status(), 200, "expected 200 for second page of notes"); - - let body: serde_json::Value = resp.json().await.expect("json"); - let notes = body["notes"] - .as_array() - .expect("body must have a 'notes' array"); - assert!( - !notes.is_empty(), - "second page should return at least 1 remaining note" - ); - - // No event from page 1 should appear on page 2. - for note in notes { - let id = note["id"].as_str().unwrap_or(""); - assert!( - !page1_ids.contains(id), - "event {id} appeared on both page 1 and page 2 (duplicate)" - ); - } -} - -/// Phase 2: GET /api/users/{pubkey}/contact-list returns latest kind:3. -/// Also verifies replacement semantics: a newer kind:3 must supersede the older one. -#[tokio::test] -#[ignore] -async fn test_get_contact_list_returns_latest() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let contact1 = Keys::generate().public_key().to_hex(); - let contact2 = Keys::generate().public_key().to_hex(); - let tags_v1 = vec![ - Tag::parse(["p", &contact1]).unwrap(), - Tag::parse(["p", &contact2]).unwrap(), - ]; - let event_v1 = EventBuilder::new(Kind::Custom(3), "") - .tags(tags_v1) - .sign_with_keys(&keys) - .unwrap(); - - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event_v1).unwrap()) - .send() - .await - .expect("submit first kind:3 event"); - assert!( - resp.status().is_success(), - "first kind:3 submission failed: {}", - resp.status() - ); - - // Wait 1 second so the replacement event gets a strictly greater created_at. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - let contact3 = Keys::generate().public_key().to_hex(); - let tags_v2 = vec![Tag::parse(["p", &contact3]).unwrap()]; - let event_v2 = EventBuilder::new(Kind::Custom(3), "") - .tags(tags_v2) - .sign_with_keys(&keys) - .unwrap(); - - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event_v2).unwrap()) - .send() - .await - .expect("submit second kind:3 event"); - assert!( - resp.status().is_success(), - "second kind:3 submission failed: {}", - resp.status() - ); - - let url = format!("{}/api/users/{}/contact-list", relay_http_url(), pubkey_hex); - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!( - resp.status(), - 200, - "expected 200 for contact-list after publishing kind:3" - ); - - let body: serde_json::Value = resp.json().await.expect("json"); - let tags = body["tags"] - .as_array() - .expect("body must have 'tags' array"); - let p_tags: Vec<_> = tags - .iter() - .filter(|t| { - t.as_array() - .and_then(|a| a.first()) - .and_then(|v| v.as_str()) - == Some("p") - }) - .collect(); - // The second event (1 contact) must have replaced the first (2 contacts). - assert_eq!( - p_tags.len(), - 1, - "expected 1 'p' tag — the second (replacement) contact list should supersede the first" - ); - // The single remaining contact must be contact3, not contact1 or contact2. - let remaining_pubkey = p_tags[0] - .as_array() - .and_then(|a| a.get(1)) - .and_then(|v| v.as_str()) - .expect("p-tag must have a pubkey value"); - assert_eq!( - remaining_pubkey, contact3, - "the surviving contact must be contact3 from the replacement event" - ); -} - -/// Phase 2: GET /api/users/{pubkey}/contact-list returns 404 for unknown pubkey. -#[tokio::test] -#[ignore] -async fn test_get_contact_list_returns_404_for_unknown() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Use a freshly generated key that has never published anything. - let unknown_pubkey = Keys::generate().public_key().to_hex(); - let url = format!( - "{}/api/users/{}/contact-list", - relay_http_url(), - unknown_pubkey - ); - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!( - resp.status(), - 404, - "unknown pubkey contact-list should return 404" - ); -} - -/// Overflow guard: any out-of-range `before` timestamp must return 400, whether -/// used alone (simple cursor) or with `before_id` (composite cursor). -#[tokio::test] -#[ignore] -async fn test_get_user_notes_invalid_before_returns_400() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Publish one note so the user has something to return. - let content = format!("e2e-overflow-note-{}", uuid::Uuid::new_v4().simple()); - let event = EventBuilder::new(Kind::Custom(1), &content) - .tags(vec![]) - .sign_with_keys(&keys) - .unwrap(); - let resp = client - .post(format!("{}/api/events", relay_http_url())) - .header("X-Pubkey", &pubkey_hex) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&event).unwrap()) - .send() - .await - .expect("submit kind:1 event"); - assert!( - resp.status().is_success(), - "kind:1 submission failed: {}", - resp.status() - ); - - // Simple cursor (no before_id): overflow timestamp must return 400. - let url = format!( - "{}/api/users/{}/notes?before=99999999999999", - relay_http_url(), - pubkey_hex - ); - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!( - resp.status(), - 400, - "simple overflow `before` must return 400" - ); - - // Composite cursor with overflow timestamp must also return 400. - let fake_id = "a".repeat(64); - let url = format!( - "{}/api/users/{}/notes?before=99999999999999&before_id={}", - relay_http_url(), - pubkey_hex, - fake_id - ); - let resp = authed_get(&client, &url, &pubkey_hex).await; - assert_eq!( - resp.status(), - 400, - "composite cursor with overflow `before` must return 400" - ); -} diff --git a/crates/buzz-test-client/tests/e2e_tokens.rs b/crates/buzz-test-client/tests/e2e_tokens.rs deleted file mode 100644 index 67e196377c..0000000000 --- a/crates/buzz-test-client/tests/e2e_tokens.rs +++ /dev/null @@ -1,1005 +0,0 @@ -//! E2E tests for the self-service token minting API. -//! -//! These tests require a running relay instance with `require_auth_token=false` -//! (dev mode). By default they are marked `#[ignore]` so that `cargo test` -//! does not fail in CI when the relay is not available. -//! -//! # Running -//! -//! Start the relay, then run: -//! -//! ```text -//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_tokens -- --ignored -//! ``` -//! -//! # Auth -//! -//! In dev mode (`require_auth_token=false`) the relay accepts an -//! `X-Pubkey: ` header as authentication, granting all scopes. -//! Tests generate fresh [`nostr::Keys`] per test for isolation. - -use std::time::Duration; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use reqwest::Client; -use sha2::{Digest, Sha256}; - -/// WebSocket relay URL (e.g. `ws://localhost:3000`). -fn relay_ws_url() -> String { - std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) -} - -/// HTTP base URL derived from the WebSocket URL. -fn relay_http_url() -> String { - relay_ws_url() - .replace("wss://", "https://") - .replace("ws://", "http://") -} - -/// Build a `reqwest::Client` with a short timeout. -fn http_client() -> Client { - Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("failed to build HTTP client") -} - -/// Build a `Authorization: Nostr ` header value for NIP-98 HTTP Auth. -/// -/// Uses kind 27235 (`Kind::HttpAuth`) with `u`, `method`, and `payload` tags -/// following the pattern in `buzz-auth/src/nip98.rs`. -fn build_nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String { - let payload_hash = hex::encode(Sha256::digest(body)); - - let tags = vec![ - Tag::parse(["u", url]).expect("u tag"), - Tag::parse(["method", method]).expect("method tag"), - Tag::parse(["payload", &payload_hash]).expect("payload tag"), - ]; - - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags(tags) - .sign_with_keys(keys) - .expect("signing must succeed"); - - let json = event.as_json(); - let encoded = BASE64.encode(json.as_bytes()); - format!("Nostr {encoded}") -} - -/// Build a `Authorization: Nostr ` header value for NIP-98 HTTP Auth -/// **without** the payload tag. -/// -/// Used to test that `POST /api/tokens` rejects NIP-98 events that don't -/// cryptographically bind the request body. -fn build_nip98_header_no_payload(keys: &Keys, url: &str, method: &str) -> String { - let tags = vec![ - Tag::parse(["u", url]).expect("u tag"), - Tag::parse(["method", method]).expect("method tag"), - // Deliberately omit payload tag - ]; - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags(tags) - .sign_with_keys(keys) - .expect("signing must succeed"); - let json = event.as_json(); - let encoded = BASE64.encode(json.as_bytes()); - format!("Nostr {encoded}") -} - -/// Mint a token via dev-mode `X-Pubkey` header. Returns the parsed response body. -async fn mint_token_dev( - client: &Client, - pubkey_hex: &str, - name: &str, - scopes: &[&str], -) -> serde_json::Value { - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": name, - "scopes": scopes, - }); - let resp = client - .post(&url) - .header("X-Pubkey", pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - assert_eq!( - resp.status(), - 201, - "expected 201, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - resp.json().await.expect("response JSON") -} - -/// POST /api/tokens via dev-mode X-Pubkey header returns 201 with token fields. -#[tokio::test] -#[ignore] -async fn test_mint_token_via_dev_mode() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "dev-mode-token", - "scopes": ["messages:read", "messages:write"], - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!(resp.status(), 201, "expected 201 Created"); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert!(json["id"].is_string(), "id should be a string UUID"); - assert!(json["token"].is_string(), "token should be a string"); - assert!( - !json["token"].as_str().unwrap().is_empty(), - "token should not be empty" - ); - assert_eq!(json["name"], "dev-mode-token"); - assert!(json["scopes"].is_array()); - let scopes = json["scopes"].as_array().unwrap(); - assert!(scopes.iter().any(|s| s == "messages:read")); - assert!(scopes.iter().any(|s| s == "messages:write")); - assert!(json["created_at"].is_string()); - // expires_at should be null (no expiry requested) - assert!(json["expires_at"].is_null()); -} - -/// POST /api/tokens via NIP-98 Authorization header returns 201. -#[tokio::test] -#[ignore] -async fn test_mint_token_via_nip98() { - let client = http_client(); - let keys = Keys::generate(); - - let endpoint_url = format!("{}/api/tokens", relay_http_url()); - let body_json = serde_json::json!({ - "name": "nip98-token", - "scopes": ["messages:read"], - }); - let body_bytes = serde_json::to_vec(&body_json).unwrap(); - - let auth_header = build_nip98_header(&keys, &endpoint_url, "POST", &body_bytes); - - let resp = client - .post(&endpoint_url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 201, - "expected 201, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert!(json["id"].is_string()); - assert!(json["token"].is_string()); - assert_eq!(json["name"], "nip98-token"); -} - -/// POST /api/tokens with admin scope returns 400 "scope requires admin". -#[tokio::test] -#[ignore] -async fn test_mint_rejects_admin_scopes() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "admin-attempt", - "scopes": ["admin:channels"], - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!(resp.status(), 400, "expected 400, got {}", resp.status()); - let json: serde_json::Value = resp.json().await.expect("response JSON"); - let msg = json["message"].as_str().unwrap_or(""); - assert!( - msg.contains("scope requires admin"), - "expected 'scope requires admin' in message, got: {msg}" - ); -} - -/// POST /api/tokens with empty scopes array returns 400. -#[tokio::test] -#[ignore] -async fn test_mint_rejects_empty_scopes() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "empty-scopes", - "scopes": [], - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!(resp.status(), 400, "expected 400 for empty scopes"); - let json: serde_json::Value = resp.json().await.expect("response JSON"); - let err_msg = json["error"].as_str().unwrap_or(""); - assert!( - err_msg.contains("invalid_scopes"), - "expected 'invalid_scopes' in error, got: {err_msg}" - ); -} - -/// POST /api/tokens with an unknown scope returns 400 "unknown scope". -#[tokio::test] -#[ignore] -async fn test_mint_rejects_unknown_scopes() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "unknown-scope", - "scopes": ["future:capability"], - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!(resp.status(), 400, "expected 400 for unknown scope"); - let json: serde_json::Value = resp.json().await.expect("response JSON"); - let msg = json["message"].as_str().unwrap_or(""); - assert!( - msg.contains("unknown scope"), - "expected 'unknown scope' in message, got: {msg}" - ); -} - -/// POST /api/tokens with a name longer than 100 characters returns 400. -#[tokio::test] -#[ignore] -async fn test_mint_rejects_long_name() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - // 101 'a' characters — one over the 100-char limit. - let long_name = "a".repeat(101); - let body = serde_json::json!({ - "name": long_name, - "scopes": ["messages:read"], - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 400, - "expected 400 for name > 100 chars, got {}", - resp.status() - ); -} - -/// GET /api/tokens with X-Pubkey (dev mode) works; lists minted tokens. -#[tokio::test] -#[ignore] -async fn test_list_tokens_via_dev_mode() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Mint a token first so the list is non-empty. - let minted = mint_token_dev(&client, &pubkey_hex, "list-test-token", &["messages:read"]).await; - let minted_id = minted["id"].as_str().expect("id"); - - // List tokens via dev-mode X-Pubkey (dev mode grants all scopes). - let list_url = format!("{}/api/tokens", relay_http_url()); - let resp = client - .get(&list_url) - .header("X-Pubkey", &pubkey_hex) - .send() - .await - .expect("GET /api/tokens failed"); - - assert_eq!( - resp.status(), - 200, - "expected 200, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - let tokens = json["tokens"].as_array().expect("tokens array"); - assert!( - tokens.iter().any(|t| t["id"] == minted_id), - "minted token {minted_id} not found in list" - ); -} - -/// GET /api/tokens with NIP-98 Authorization returns 401 — listing requires Bearer. -/// -/// Note: the auth layer (`extract_auth_context`) hardcodes `"POST"` when verifying -/// NIP-98 events, so a GET request with a NIP-98 header fails at the NIP-98 -/// verification step ("NIP-98 verification failed") before reaching the handler's -/// "Bearer token required" check. Either way the result is 401 — NIP-98 cannot -/// be used to list tokens. -#[tokio::test] -#[ignore] -async fn test_nip98_rejected_for_list_tokens() { - let client = http_client(); - let keys = Keys::generate(); - - let endpoint_url = format!("{}/api/tokens", relay_http_url()); - // GET has no body, so we sign with an empty body. - let auth_header = build_nip98_header(&keys, &endpoint_url, "GET", b""); - - let resp = client - .get(&endpoint_url) - .header("Authorization", auth_header) - .send() - .await - .expect("GET /api/tokens failed"); - - assert_eq!( - resp.status(), - 401, - "expected 401 for NIP-98 on GET /api/tokens, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - // The auth layer explicitly rejects NIP-98 for non-token endpoints. - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert_eq!( - json["error"], "nip98_not_supported", - "expected nip98_not_supported error, got: {}", - json - ); -} - -/// POST /api/tokens with NIP-98 auth but WITHOUT the payload tag returns 401. -/// -/// The payload tag cryptographically binds the request body to the signed event. -/// Without it, the server cannot verify the body hasn't been substituted. -/// -/// NOTE: This test depends on the server-side payload-required check added in -/// the tokens handler. If it returns 201 instead of 401, the handler update -/// has not yet been deployed — the test will pass once it is. -#[tokio::test] -#[ignore] -async fn test_nip98_requires_payload_tag() { - let client = http_client(); - let keys = Keys::generate(); - - let endpoint_url = format!("{}/api/tokens", relay_http_url()); - let body_json = serde_json::json!({ - "name": "no-payload-tag-token", - "scopes": ["messages:read"], - }); - let body_bytes = serde_json::to_vec(&body_json).unwrap(); - - // Sign WITHOUT the payload tag — body is not cryptographically bound. - let auth_header = build_nip98_header_no_payload(&keys, &endpoint_url, "POST"); - - let resp = client - .post(&endpoint_url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 401, - "expected 401 for NIP-98 without payload tag, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); -} - -/// POST /api/tokens with NIP-98 auth where the payload tag has a wrong hash returns 401. -/// -/// The payload tag must be SHA-256(request body). Sending a hash of "wrong body" -/// while sending the real body should be rejected. -#[tokio::test] -#[ignore] -async fn test_nip98_wrong_payload_rejected() { - let client = http_client(); - let keys = Keys::generate(); - - let endpoint_url = format!("{}/api/tokens", relay_http_url()); - let body_json = serde_json::json!({ - "name": "wrong-payload-token", - "scopes": ["messages:read"], - }); - let body_bytes = serde_json::to_vec(&body_json).unwrap(); - - // Sign with a hash of "wrong body" — mismatches the actual body sent. - let auth_header = build_nip98_header(&keys, &endpoint_url, "POST", b"wrong body"); - - let resp = client - .post(&endpoint_url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 401, - "expected 401 for NIP-98 with wrong payload hash, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); -} - -/// Mint a token, revoke it via DELETE /api/tokens/{id}, then verify it's revoked. -#[tokio::test] -#[ignore] -async fn test_revoke_token() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Mint a token. - let minted = mint_token_dev(&client, &pubkey_hex, "revoke-test", &["messages:read"]).await; - let token_id = minted["id"].as_str().expect("id"); - let raw_token = minted["token"].as_str().expect("token"); - - // Revoke it using the raw token as Bearer. - let delete_url = format!("{}/api/tokens/{}", relay_http_url(), token_id); - let resp = client - .delete(&delete_url) - .header("Authorization", format!("Bearer {raw_token}")) - .send() - .await - .expect("DELETE /api/tokens/{id} failed"); - - assert_eq!( - resp.status(), - 204, - "expected 204 No Content, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - // Verify the token is now rejected (401) when used. - let check_url = format!("{}/api/tokens", relay_http_url()); - let resp2 = client - .get(&check_url) - .header("Authorization", format!("Bearer {raw_token}")) - .send() - .await - .expect("GET /api/tokens failed"); - - assert_eq!( - resp2.status(), - 401, - "revoked token should return 401, got {}", - resp2.status() - ); -} - -/// DELETE /api/tokens/{id} with a valid Bearer token but a non-existent UUID returns 404. -#[tokio::test] -#[ignore] -async fn test_revoke_nonexistent_token_returns_404() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Mint a real token to use as Bearer auth. - let minted = mint_token_dev(&client, &pubkey_hex, "auth-token", &["messages:read"]).await; - let raw_token = minted["token"].as_str().expect("token"); - - // Try to delete a random UUID that doesn't exist. - let random_uuid = uuid::Uuid::new_v4(); - let delete_url = format!("{}/api/tokens/{}", relay_http_url(), random_uuid); - let resp = client - .delete(&delete_url) - .header("Authorization", format!("Bearer {raw_token}")) - .send() - .await - .expect("DELETE /api/tokens/{id} failed"); - - assert_eq!( - resp.status(), - 404, - "expected 404 for non-existent token, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert_eq!( - json["error"], "not_found", - "expected not_found error, got: {}", - json - ); -} - -/// Mint 2 tokens, DELETE /api/tokens (revoke all), verify revoked_count=2. -#[tokio::test] -#[ignore] -async fn test_revoke_all_tokens() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Mint two tokens. - let t1 = mint_token_dev(&client, &pubkey_hex, "revoke-all-1", &["messages:read"]).await; - let t2 = mint_token_dev(&client, &pubkey_hex, "revoke-all-2", &["messages:write"]).await; - let raw_token_1 = t1["token"].as_str().expect("token 1"); - let _raw_token_2 = t2["token"].as_str().expect("token 2"); - - // Revoke all using the first token as Bearer. - let delete_url = format!("{}/api/tokens", relay_http_url()); - let resp = client - .delete(&delete_url) - .header("Authorization", format!("Bearer {raw_token_1}")) - .send() - .await - .expect("DELETE /api/tokens failed"); - - assert_eq!( - resp.status(), - 200, - "expected 200, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - let revoked_count = json["revoked_count"].as_u64().expect("revoked_count"); - assert_eq!( - revoked_count, 2, - "expected 2 tokens revoked, got {revoked_count}" - ); -} - -/// Mint a token with only messages:read, then try to use it to mint channels:write — expect 403. -#[tokio::test] -#[ignore] -async fn test_bearer_token_scope_escalation_blocked() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Mint a limited-scope token. - let limited = mint_token_dev(&client, &pubkey_hex, "limited-token", &["messages:read"]).await; - let limited_raw = limited["token"].as_str().expect("token"); - - // Try to mint a new token with channels:write using the limited token. - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "escalated-token", - "scopes": ["channels:write"], - }); - - let resp = client - .post(&url) - .header("Authorization", format!("Bearer {limited_raw}")) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 403, - "expected 403 Forbidden for scope escalation, got {}", - resp.status() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert_eq!( - json["error"], "scope_escalation", - "expected scope_escalation error, got: {}", - json - ); -} - -/// Exhaust the per-pubkey rate limit, then verify the next mint returns 429. -/// -/// The relay reads `BUZZ_MINT_RATE_LIMIT` (default 50). This test reads the -/// same env var so it stays in sync. For fast test runs, start the relay with -/// `BUZZ_MINT_RATE_LIMIT=5`. -/// -/// **Requires `BUZZ_MINT_RATE_LIMIT ≤ 10`** — the DB enforces a hard 10-token -/// cap that fires before the rate limiter when the limit is higher. The test -/// skips gracefully if the limit is too high. -#[tokio::test] -#[ignore] -async fn test_rate_limit() { - let limit: usize = std::env::var("BUZZ_MINT_RATE_LIMIT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(50); - - if limit > 10 { - eprintln!( - "SKIP test_rate_limit: BUZZ_MINT_RATE_LIMIT={limit} exceeds the 10-token DB cap. \ - Set BUZZ_MINT_RATE_LIMIT=5 (or ≤10) on both relay and test runner to exercise this." - ); - return; - } - - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let url = format!("{}/api/tokens", relay_http_url()); - - // Mint up to the limit — all should succeed. - for i in 0..limit { - let body = serde_json::json!({ - "name": format!("rate-limit-token-{i}"), - "scopes": ["messages:read"], - }); - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - assert_eq!( - resp.status(), - 201, - "mint {i}/{limit} should succeed, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - } - - // Next mint should be rate-limited. - let body = serde_json::json!({ - "name": "rate-limit-over", - "scopes": ["messages:read"], - }); - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 429, - "mint {limit}+1 should be rate-limited (429), got {}", - resp.status() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert_eq!(json["error"], "rate_limited"); - assert!( - json["retry_after_seconds"].as_u64().unwrap_or(0) > 0, - "retry_after_seconds should be positive" - ); -} - -/// Mint with expires_in_days: 30 — response should have non-null expires_at. -#[tokio::test] -#[ignore] -async fn test_mint_with_expiry() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "expiring-token", - "scopes": ["messages:read"], - "expires_in_days": 30, - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!(resp.status(), 201, "expected 201 Created"); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert!( - json["expires_at"].is_string(), - "expires_at should be a non-null ISO 8601 string, got: {}", - json["expires_at"] - ); - - // Verify the expiry is approximately 30 days from now. - let expires_str = json["expires_at"].as_str().unwrap(); - let expires = chrono::DateTime::parse_from_rfc3339(expires_str) - .expect("expires_at should be valid RFC 3339"); - let now = chrono::Utc::now(); - let diff = expires.signed_duration_since(now); - let days = diff.num_days(); - assert!( - (29..=31).contains(&days), - "expires_at should be ~30 days from now, got {days} days" - ); -} - -/// POST with expires_in_days: 0 or 400 should return 400. -#[tokio::test] -#[ignore] -async fn test_mint_rejects_invalid_expiry() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let url = format!("{}/api/tokens", relay_http_url()); - - // expires_in_days: 0 should be rejected. - let body_zero = serde_json::json!({ - "name": "bad-expiry-zero", - "scopes": ["messages:read"], - "expires_in_days": 0, - }); - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body_zero) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 400, - "expires_in_days: 0 should return 400, got {}", - resp.status() - ); - - // expires_in_days: 400 should be rejected (max is 365). - let body_400 = serde_json::json!({ - "name": "bad-expiry-400", - "scopes": ["messages:read"], - "expires_in_days": 400, - }); - let resp2 = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body_400) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp2.status(), - 400, - "expires_in_days: 400 should return 400, got {}", - resp2.status() - ); -} - -/// Mint a token, revoke it, then try to revoke it again — expect 409 Conflict. -/// -/// The second DELETE must return `{ "error": "already_revoked" }` per the -/// handler's explicit check in `delete_token`. -#[tokio::test] -#[ignore] -async fn test_revoke_already_revoked_returns_409() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - // Mint a token. - let minted = mint_token_dev( - &client, - &pubkey_hex, - "double-revoke-test", - &["messages:read"], - ) - .await; - let token_id = minted["id"].as_str().expect("id"); - let raw_token = minted["token"].as_str().expect("token"); - - let delete_url = format!("{}/api/tokens/{}", relay_http_url(), token_id); - - // First revoke — should succeed with 204. - let resp1 = client - .delete(&delete_url) - .header("Authorization", format!("Bearer {raw_token}")) - .send() - .await - .expect("first DELETE /api/tokens/{id} failed"); - - assert_eq!( - resp1.status(), - 204, - "first revoke should return 204, got {}: {}", - resp1.status(), - resp1.text().await.unwrap_or_default() - ); - - // Second revoke — token is already revoked; must return 409. - // We need a different Bearer token to authenticate (the revoked one won't work). - // Mint a second token with dev-mode to use as auth for the second DELETE. - let auth_token = mint_token_dev( - &client, - &pubkey_hex, - "double-revoke-auth", - &["messages:read"], - ) - .await; - let auth_raw = auth_token["token"].as_str().expect("auth token"); - - let resp2 = client - .delete(&delete_url) - .header("Authorization", format!("Bearer {auth_raw}")) - .send() - .await - .expect("second DELETE /api/tokens/{id} failed"); - - assert_eq!( - resp2.status(), - 409, - "second revoke should return 409 Conflict, got {}: {}", - resp2.status(), - resp2.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp2.json().await.expect("response JSON"); - assert_eq!( - json["error"], "already_revoked", - "expected already_revoked error, got: {}", - json - ); -} - -/// NIP-98 auth header sent to a non-token endpoint (GET /api/channels) returns 401. -/// -/// `extract_auth_context` explicitly rejects NIP-98 on all non-token endpoints -/// with `{ "error": "nip98_not_supported" }`. -#[tokio::test] -#[ignore] -async fn test_nip98_rejected_for_non_mint_endpoints() { - let client = http_client(); - let keys = Keys::generate(); - - let channels_url = format!("{}/api/channels", relay_http_url()); - // GET has no body — use the no-payload variant. - let auth_header = build_nip98_header_no_payload(&keys, &channels_url, "GET"); - - let resp = client - .get(&channels_url) - .header("Authorization", auth_header) - .header("X-Forwarded-Proto", "http") - .send() - .await - .expect("GET /api/channels failed"); - - assert_eq!( - resp.status(), - 401, - "NIP-98 on GET /api/channels should return 401, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - assert_eq!( - json["error"], "nip98_not_supported", - "expected nip98_not_supported error, got: {}", - json - ); -} - -/// Mint a token with duplicate scopes — response must deduplicate to unique entries. -/// -/// Sending `["messages:read", "messages:read", "channels:read"]` should result -/// in exactly 2 unique scopes in the response, not 3. -/// -/// Deduplication is implemented in `post_tokens` via order-preserving HashSet retain. -#[tokio::test] -#[ignore] -async fn test_mint_with_duplicate_scopes_deduplicates() { - let client = http_client(); - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - - let url = format!("{}/api/tokens", relay_http_url()); - let body = serde_json::json!({ - "name": "dedup-scopes-token", - // Intentionally duplicate messages:read. - "scopes": ["messages:read", "messages:read", "channels:read"], - }); - - let resp = client - .post(&url) - .header("X-Pubkey", &pubkey_hex) - .json(&body) - .send() - .await - .expect("POST /api/tokens failed"); - - assert_eq!( - resp.status(), - 201, - "expected 201, got {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - - let json: serde_json::Value = resp.json().await.expect("response JSON"); - let scopes = json["scopes"].as_array().expect("scopes array"); - - // Collect unique scope strings to check deduplication regardless of order. - let scope_strs: Vec<&str> = scopes.iter().filter_map(|s| s.as_str()).collect(); - let unique_count = { - let mut deduped = scope_strs.clone(); - deduped.sort_unstable(); - deduped.dedup(); - deduped.len() - }; - - // Must have exactly 2 unique scopes (deduplication applied by handler). - assert_eq!( - unique_count, 2, - "expected 2 unique scopes after deduplication, got {} unique in {:?}", - unique_count, scope_strs - ); - - // Both unique scopes must be present. - assert!( - scope_strs.contains(&"messages:read"), - "messages:read should be in deduped scopes: {:?}", - scope_strs - ); - assert!( - scope_strs.contains(&"channels:read"), - "channels:read should be in deduped scopes: {:?}", - scope_strs - ); -} diff --git a/crates/buzz-test-client/tests/e2e_workflows.rs b/crates/buzz-test-client/tests/e2e_workflows.rs deleted file mode 100644 index 48fd6a30e0..0000000000 --- a/crates/buzz-test-client/tests/e2e_workflows.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! E2E tests for the Buzz workflow engine. -//! -//! These tests require a running relay instance with `require_auth_token=false` -//! (dev mode). By default they are marked `#[ignore]` so that `cargo test` -//! does not fail in CI when the relay is not available. -//! -//! # Running -//! -//! Start the relay, then run: -//! -//! ```text -//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_workflows -- --ignored -//! ``` -//! -//! # Auth -//! -//! In dev mode (`require_auth_token=false`) the relay accepts an -//! `X-Pubkey: ` header as authentication. Tests generate fresh -//! [`nostr::Keys`] per test and pass the hex-encoded public key. - -use std::time::Duration; - -use nostr::Keys; -use reqwest::Client; - -/// WebSocket relay URL (e.g. `ws://localhost:3001`). -fn relay_ws_url() -> String { - std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) -} - -/// HTTP base URL derived from the WebSocket URL. -fn relay_http_url() -> String { - relay_ws_url() - .replace("wss://", "https://") - .replace("ws://", "http://") -} - -/// Build a `reqwest::Client` with a short timeout. -fn http_client() -> Client { - Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .expect("failed to build HTTP client") -} - -/// Known open channel IDs seeded in the dev database. -/// -/// These are UUID5-derived from the channel name and are stable across relay -/// restarts as long as the seed data uses the same namespace + name inputs. -const CHANNEL_GENERAL: &str = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; - -/// A seeded user pubkey that exists in the `users` table. -/// -/// Workflow creation requires the owner pubkey to exist in `users` (FK constraint). -/// The relay does not auto-create users on first auth — users are created via -/// `buzz-admin mint-token` or WebSocket metadata events. This pubkey is present -/// in the dev database after the initial seed. -/// -/// If tests fail with 500 "FK constraint fails", run: -/// ``` -/// DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ -/// cargo run -p buzz-admin -- mint-token --name e2e-test --scopes messages:read \ -/// --pubkey 0b5c83782cf123e698131ac976179f8366224e03db932c9da0074512aed2388d -/// ``` -const SEEDED_PUBKEY: &str = "0b5c83782cf123e698131ac976179f8366224e03db932c9da0074512aed2388d"; - -/// A minimal webhook-triggered workflow YAML definition. -/// -/// Uses `send_message` action (the simplest valid action type). -fn webhook_workflow_yaml(name: &str) -> String { - format!( - r#"name: {name} -description: Test workflow -trigger: - on: webhook -steps: - - id: step1 - name: Notify channel - action: send_message - text: "Workflow triggered by webhook" -"# - ) -} - -/// POST to create a workflow in a channel. Returns the parsed JSON response body. -async fn create_workflow( - client: &Client, - base: &str, - pubkey_hex: &str, - channel_id: &str, - yaml: &str, -) -> serde_json::Value { - let url = format!("{base}/api/channels/{channel_id}/workflows"); - let resp = client - .post(&url) - .header("X-Pubkey", pubkey_hex) - .json(&serde_json::json!({ "yaml_definition": yaml })) - .send() - .await - .unwrap_or_else(|e| panic!("POST {url} failed: {e}")); - - assert_eq!( - resp.status(), - 200, - "expected 200 from POST /api/channels/:id/workflows" - ); - resp.json() - .await - .expect("create workflow response must be JSON") -} - -/// DELETE a workflow by ID. Returns the HTTP status code. -async fn delete_workflow(client: &Client, base: &str, pubkey_hex: &str, workflow_id: &str) -> u16 { - let url = format!("{base}/api/workflows/{workflow_id}"); - client - .delete(&url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .unwrap_or_else(|e| panic!("DELETE {url} failed: {e}")) - .status() - .as_u16() -} - -/// GET /api/channels/:id/workflows returns 200 OK with a valid JSON array. -/// The channel may have workflows from other tests, but the response must be -/// a well-formed array where every element has at least `id` and `name`. -#[tokio::test] -#[ignore] -async fn test_list_workflows_empty_channel() { - let client = http_client(); - // Any authenticated user can list workflows in an open channel. - let keys = Keys::generate(); - let pubkey_hex = keys.public_key().to_hex(); - let base = relay_http_url(); - - let url = format!("{base}/api/channels/{CHANNEL_GENERAL}/workflows"); - let resp = client - .get(&url) - .header("X-Pubkey", &pubkey_hex) - .send() - .await - .unwrap_or_else(|e| panic!("GET {url} failed: {e}")); - - assert_eq!( - resp.status(), - 200, - "expected 200 OK from GET /api/channels/:id/workflows" - ); - - let body: serde_json::Value = resp.json().await.expect("response must be JSON"); - assert!(body.is_array(), "expected JSON array, got: {body}"); - - let arr = body.as_array().unwrap(); - for wf in arr { - assert!(wf.get("id").is_some(), "workflow missing 'id' field"); - assert!(wf.get("name").is_some(), "workflow missing 'name' field"); - } -} - -/// POST /api/channels/:id/workflows creates a workflow, and it appears in the -/// subsequent GET list. Cleans up after itself by deleting the created workflow. -#[tokio::test] -#[ignore] -async fn test_create_and_list_workflow() { - let client = http_client(); - // Must use a pubkey that exists in `users` table (FK constraint on workflows.owner_pubkey). - let pubkey_hex: &str = SEEDED_PUBKEY; - let base = relay_http_url(); - - let yaml = webhook_workflow_yaml("e2e-create-list-test"); - let created = create_workflow(&client, &base, pubkey_hex, CHANNEL_GENERAL, &yaml).await; - - let workflow_id = created["id"] - .as_str() - .expect("created workflow must have 'id'"); - assert_eq!( - created["name"].as_str().unwrap_or(""), - "e2e-create-list-test", - "workflow name must match" - ); - assert!( - created.get("channel_id").is_some(), - "created workflow must have 'channel_id'" - ); - // Webhook workflows get a secret on creation. - assert!( - created.get("webhook_secret").is_some(), - "webhook workflow must return 'webhook_secret' on creation" - ); - - let list_url = format!("{base}/api/channels/{CHANNEL_GENERAL}/workflows"); - let list_resp = client - .get(&list_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET workflows list failed"); - assert_eq!(list_resp.status(), 200); - - let list: Vec = list_resp.json().await.expect("list must be JSON array"); - let found = list.iter().any(|wf| wf["id"].as_str() == Some(workflow_id)); - assert!( - found, - "newly created workflow {workflow_id} not found in list" - ); - - let status = delete_workflow(&client, &base, pubkey_hex, workflow_id).await; - assert_eq!(status, 204, "cleanup DELETE should return 204"); -} - -/// Create a webhook-triggered workflow, POST to its trigger endpoint, then -/// verify a run record appears in GET /api/workflows/:id/runs. -/// -/// The trigger endpoint returns 202 Accepted and spawns execution asynchronously. -/// We poll briefly for the run to appear (up to ~1 second). -#[tokio::test] -#[ignore] -async fn test_trigger_workflow_and_check_run() { - let client = http_client(); - let pubkey_hex: &str = SEEDED_PUBKEY; - let base = relay_http_url(); - - let yaml = webhook_workflow_yaml("e2e-trigger-test"); - let created = create_workflow(&client, &base, pubkey_hex, CHANNEL_GENERAL, &yaml).await; - let workflow_id = created["id"] - .as_str() - .expect("workflow must have 'id'") - .to_string(); - - let trigger_url = format!("{base}/api/workflows/{workflow_id}/trigger"); - let trigger_resp = client - .post(&trigger_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .unwrap_or_else(|e| panic!("POST {trigger_url} failed: {e}")); - - assert_eq!( - trigger_resp.status(), - 202, - "trigger endpoint must return 202 Accepted" - ); - - let trigger_body: serde_json::Value = trigger_resp - .json() - .await - .expect("trigger response must be JSON"); - let run_id = trigger_body["run_id"] - .as_str() - .expect("trigger response must include 'run_id'"); - assert_eq!( - trigger_body["workflow_id"].as_str().unwrap_or(""), - workflow_id, - "trigger response workflow_id must match" - ); - assert_eq!( - trigger_body["status"].as_str().unwrap_or(""), - "pending", - "trigger response initial status must be 'pending'" - ); - - // Poll GET /api/workflows/:id/runs until the run appears (max ~1 s). - let runs_url = format!("{base}/api/workflows/{workflow_id}/runs"); - let mut found_run: Option = None; - for _ in 0..10 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let runs_resp = client - .get(&runs_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET runs failed"); - assert_eq!(runs_resp.status(), 200, "GET runs must return 200"); - let runs: Vec = runs_resp.json().await.expect("runs must be JSON array"); - if let Some(run) = runs.iter().find(|r| r["id"].as_str() == Some(run_id)) { - found_run = Some(run.clone()); - break; - } - } - - let run = found_run.expect("run must appear in GET /api/workflows/:id/runs within 1 second"); - - assert!(run.get("id").is_some(), "run missing 'id'"); - assert!( - run.get("workflow_id").is_some(), - "run missing 'workflow_id'" - ); - assert!(run.get("status").is_some(), "run missing 'status'"); - - let status = run["status"].as_str().unwrap_or(""); - assert!( - matches!(status, "pending" | "running" | "completed" | "failed"), - "run status '{status}' is not a recognized value" - ); - - let del_status = delete_workflow(&client, &base, pubkey_hex, &workflow_id).await; - assert_eq!(del_status, 204, "cleanup DELETE should return 204"); -} - -/// Send a kind:9 message to a channel that has a `message_posted` workflow. -/// Verify that the workflow engine creates a run record. -/// -/// NOTE: Uses `SEEDED_PUBKEY` for workflow ownership due to the FK constraint -/// on `workflows.owner_pubkey`. The WebSocket sender uses fresh keys. -#[tokio::test] -#[ignore = "requires running relay"] -async fn test_event_driven_workflow_execution() { - use buzz_test_client::BuzzTestClient; - use nostr::{Kind, Tag}; - - let client = http_client(); - let pubkey_hex: &str = SEEDED_PUBKEY; - let base = relay_http_url(); - - let workflow_yaml = r#"name: event-driven-e2e-test -description: E2E test for message_posted trigger -trigger: - on: message_posted -steps: - - id: step1 - name: Acknowledge - action: send_message - text: "Workflow fired by event" -"#; - let created = create_workflow(&client, &base, pubkey_hex, CHANNEL_GENERAL, workflow_yaml).await; - let workflow_id = created["id"] - .as_str() - .expect("created workflow must have 'id'") - .to_string(); - - // Use fresh keys for the sender (channel is open, no auth required to post). - let sender_keys = Keys::generate(); - let mut ws_client = BuzzTestClient::connect(&relay_ws_url(), &sender_keys) - .await - .expect("ws connect failed"); - - let h_tag = Tag::parse(["h", CHANNEL_GENERAL]).expect("tag parse failed"); - let event = nostr::EventBuilder::new(Kind::Custom(9), "trigger this workflow please") - .tags([h_tag]) - .sign_with_keys(&sender_keys) - .expect("sign event"); - - ws_client - .send_event(event) - .await - .expect("send event failed"); - - tokio::time::sleep(Duration::from_secs(3)).await; - - let runs_url = format!("{base}/api/workflows/{workflow_id}/runs"); - let runs_resp = client - .get(&runs_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET runs failed"); - assert_eq!(runs_resp.status(), 200, "GET runs must return 200"); - - let runs: Vec = runs_resp.json().await.expect("runs must be JSON array"); - assert!( - !runs.is_empty(), - "expected at least one workflow run after sending kind:9 event" - ); - - let run = &runs[0]; - assert!(run.get("id").is_some(), "run missing 'id'"); - assert!( - run.get("workflow_id").is_some(), - "run missing 'workflow_id'" - ); - assert!(run.get("status").is_some(), "run missing 'status'"); - - let status = run["status"].as_str().unwrap_or(""); - assert!( - matches!(status, "pending" | "running" | "completed" | "failed"), - "run status '{status}' is not a recognized value" - ); - - let _ = ws_client.disconnect().await; - let del_status = delete_workflow(&client, &base, pubkey_hex, &workflow_id).await; - assert_eq!(del_status, 204, "cleanup DELETE should return 204"); -} - -/// Verify that a `message_posted` workflow with a filter expression only fires -/// when the filter matches. -/// -/// 1. Create a workflow with `filter: "str_contains(trigger_text, \"P1\")"`. -/// 2. Send a message that does NOT contain "P1" — expect zero runs. -/// 3. Send a message that DOES contain "P1" — expect one run. -/// -/// NOTE: Filter evaluation is wired in WF-07. Until then, all matched-kind -/// events fire the workflow regardless of filter. This test documents the -/// intended behaviour so it can be un-skipped once WF-07 lands. -#[tokio::test] -#[ignore = "requires running relay with WF-07 filter evaluation"] -async fn test_event_driven_workflow_with_filter() { - use buzz_test_client::BuzzTestClient; - use nostr::{Kind, Tag}; - - let client = http_client(); - let pubkey_hex: &str = SEEDED_PUBKEY; - let base = relay_http_url(); - - let workflow_yaml = r#"name: filtered-event-e2e-test -description: E2E test for message_posted trigger with filter -trigger: - on: message_posted - filter: "str_contains(trigger_text, \"P1\")" -steps: - - id: step1 - name: Notify - action: send_message - text: "P1 incident detected" -"#; - let created = create_workflow(&client, &base, pubkey_hex, CHANNEL_GENERAL, workflow_yaml).await; - let workflow_id = created["id"] - .as_str() - .expect("created workflow must have 'id'") - .to_string(); - - let sender_keys = Keys::generate(); - let mut ws_client = BuzzTestClient::connect(&relay_ws_url(), &sender_keys) - .await - .expect("ws connect failed"); - - let h_tag = Tag::parse(["h", CHANNEL_GENERAL]).expect("tag parse failed"); - let non_matching = - nostr::EventBuilder::new(Kind::Custom(9), "this is a routine update, nothing urgent") - .tags([h_tag.clone()]) - .sign_with_keys(&sender_keys) - .expect("sign event"); - - ws_client - .send_event(non_matching) - .await - .expect("send non-matching event failed"); - - tokio::time::sleep(Duration::from_secs(2)).await; - - let runs_url = format!("{base}/api/workflows/{workflow_id}/runs"); - let runs_resp = client - .get(&runs_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET runs (non-matching) failed"); - assert_eq!(runs_resp.status(), 200); - let runs_after_non_match: Vec = - runs_resp.json().await.expect("runs must be JSON array"); - assert!( - runs_after_non_match.is_empty(), - "non-matching message must NOT trigger a workflow run, but got {} run(s)", - runs_after_non_match.len() - ); - - let matching = nostr::EventBuilder::new(Kind::Custom(9), "P1 alert: database is down") - .tags([h_tag]) - .sign_with_keys(&sender_keys) - .expect("sign event"); - - ws_client - .send_event(matching) - .await - .expect("send matching event failed"); - - tokio::time::sleep(Duration::from_secs(3)).await; - - let runs_resp2 = client - .get(&runs_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET runs (matching) failed"); - assert_eq!(runs_resp2.status(), 200); - let runs_after_match: Vec = - runs_resp2.json().await.expect("runs must be JSON array"); - assert!( - !runs_after_match.is_empty(), - "matching message must trigger a workflow run" - ); - - let run = &runs_after_match[0]; - let status = run["status"].as_str().unwrap_or(""); - assert!( - matches!(status, "pending" | "running" | "completed" | "failed"), - "run status '{status}' is not a recognized value" - ); - - let _ = ws_client.disconnect().await; - let del_status = delete_workflow(&client, &base, pubkey_hex, &workflow_id).await; - assert_eq!(del_status, 204, "cleanup DELETE should return 204"); -} - -/// Full CRUD lifecycle: -/// 1. Create a workflow -/// 2. GET it by ID — verify fields -/// 3. PUT to update the name -/// 4. GET again — verify updated name -/// 5. DELETE it -/// 6. GET — verify 404 -#[tokio::test] -#[ignore] -async fn test_workflow_update_and_delete() { - let client = http_client(); - let pubkey_hex: &str = SEEDED_PUBKEY; - let base = relay_http_url(); - - let yaml_v1 = webhook_workflow_yaml("e2e-crud-original"); - let created = create_workflow(&client, &base, pubkey_hex, CHANNEL_GENERAL, &yaml_v1).await; - let workflow_id = created["id"] - .as_str() - .expect("workflow must have 'id'") - .to_string(); - - let get_url = format!("{base}/api/workflows/{workflow_id}"); - let get_resp = client - .get(&get_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET workflow failed"); - assert_eq!(get_resp.status(), 200, "GET workflow must return 200"); - let fetched: serde_json::Value = get_resp.json().await.expect("GET response must be JSON"); - assert_eq!( - fetched["name"].as_str().unwrap_or(""), - "e2e-crud-original", - "fetched workflow name must match original" - ); - assert_eq!( - fetched["id"].as_str().unwrap_or(""), - workflow_id, - "fetched workflow id must match" - ); - - let yaml_v2 = webhook_workflow_yaml("e2e-crud-updated"); - let put_url = format!("{base}/api/workflows/{workflow_id}"); - let put_resp = client - .put(&put_url) - .header("X-Pubkey", pubkey_hex) - .json(&serde_json::json!({ "yaml_definition": yaml_v2 })) - .send() - .await - .expect("PUT workflow failed"); - assert_eq!(put_resp.status(), 200, "PUT workflow must return 200"); - let updated: serde_json::Value = put_resp.json().await.expect("PUT response must be JSON"); - assert_eq!( - updated["name"].as_str().unwrap_or(""), - "e2e-crud-updated", - "updated workflow name must reflect new YAML" - ); - assert_eq!( - updated["id"].as_str().unwrap_or(""), - workflow_id, - "PUT must return the same workflow id" - ); - - let get_resp2 = client - .get(&get_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("second GET workflow failed"); - assert_eq!(get_resp2.status(), 200); - let refetched: serde_json::Value = get_resp2.json().await.expect("second GET must be JSON"); - assert_eq!( - refetched["name"].as_str().unwrap_or(""), - "e2e-crud-updated", - "re-fetched workflow must have updated name" - ); - - let del_status = delete_workflow(&client, &base, pubkey_hex, &workflow_id).await; - assert_eq!(del_status, 204, "DELETE must return 204 No Content"); - - let get_after_del = client - .get(&get_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET after DELETE failed"); - assert_eq!( - get_after_del.status(), - 404, - "GET after DELETE must return 404" - ); -} - -/// Create a workflow with a `request_approval` step, trigger it, and verify -/// the run fails with the "approval gates not yet implemented" message. -/// -/// This test documents the current stub behavior. When WF-08 is implemented, -/// this test should be updated to verify the full approval round-trip: -/// create → trigger → poll for waiting_approval → grant → verify completed. -#[tokio::test] -#[ignore] -async fn test_approval_gate_stub_fails_gracefully() { - let client = http_client(); - let pubkey_hex: &str = SEEDED_PUBKEY; - let base = relay_http_url(); - - let workflow_yaml = format!( - r#"name: approval-test -description: Test approval gate -trigger: - on: webhook -steps: - - id: step1 - name: Pre-approval step - action: send_message - channel: "{CHANNEL_GENERAL}" - text: "Before approval" - - id: approve - action: request_approval - from: "any" - message: "Please approve this workflow" - - id: step3 - name: Post-approval step - action: send_message - channel: "{CHANNEL_GENERAL}" - text: "After approval" -"# - ); - let created = - create_workflow(&client, &base, pubkey_hex, CHANNEL_GENERAL, &workflow_yaml).await; - let workflow_id = created["id"] - .as_str() - .expect("created workflow must have 'id'") - .to_string(); - - let trigger_url = format!("{base}/api/workflows/{workflow_id}/trigger"); - let trigger_resp = client - .post(&trigger_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .unwrap_or_else(|e| panic!("POST {trigger_url} failed: {e}")); - - assert_eq!( - trigger_resp.status(), - 202, - "trigger endpoint must return 202 Accepted" - ); - - let trigger_body: serde_json::Value = trigger_resp - .json() - .await - .expect("trigger response must be JSON"); - let run_id = trigger_body["run_id"] - .as_str() - .expect("trigger response must include 'run_id'") - .to_string(); - - let runs_url = format!("{base}/api/workflows/{workflow_id}/runs"); - let mut final_run: Option = None; - for _ in 0..10 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let runs_resp = client - .get(&runs_url) - .header("X-Pubkey", pubkey_hex) - .send() - .await - .expect("GET runs failed"); - assert_eq!(runs_resp.status(), 200, "GET runs must return 200"); - let runs: Vec = runs_resp.json().await.expect("runs must be JSON array"); - if let Some(run) = runs.iter().find(|r| r["id"].as_str() == Some(&run_id)) { - let status = run["status"].as_str().unwrap_or(""); - if matches!(status, "completed" | "failed" | "cancelled") { - final_run = Some(run.clone()); - break; - } - } - } - - let run = final_run.expect("run must reach a terminal status within 1 second"); - - assert_eq!( - run["status"].as_str().unwrap_or(""), - "failed", - "approval gate stub must cause the run to fail" - ); - - let error_msg = run["error_message"].as_str().unwrap_or(""); - assert!( - error_msg.contains("approval gates not yet implemented"), - "run error must contain 'approval gates not yet implemented', got: {error_msg:?}" - ); - - let del_status = delete_workflow(&client, &base, pubkey_hex, &workflow_id).await; - assert_eq!(del_status, 204, "cleanup DELETE should return 204"); -} diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 7935666ea3..34e8bb1960 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -63,7 +63,7 @@ pub enum TriggerDef { #[serde(default)] interval: Option, }, - /// Fires when HTTP POST arrives at `/api/workflows/:id/webhook`. + /// Fires when HTTP POST arrives at `/hooks/{id}`. Webhook, } diff --git a/desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx b/desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx index e214fc4d90..ce1a563ad8 100644 --- a/desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowWebhookSecretDialog.tsx @@ -22,7 +22,7 @@ export function WorkflowWebhookSecretDialog({ webhookSecret, workflowId, }: WorkflowWebhookSecretDialogProps) { - const webhookUrl = `${relayHttpUrl}/api/workflows/${workflowId}/webhook`; + const webhookUrl = `${relayHttpUrl}/hooks/${workflowId}`; return ( diff --git a/scripts/e2e-relay-membership.sh b/scripts/e2e-relay-membership.sh deleted file mode 100755 index bea7332add..0000000000 --- a/scripts/e2e-relay-membership.sh +++ /dev/null @@ -1,740 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# e2e-relay-membership.sh — End-to-end test for NIP-43 relay membership -# ============================================================================= -# Tests relay-level membership enforcement and admin commands. -# -# Prerequisites: -# - Docker services running (postgres, redis, minio) -# - Relay built: cargo build --release --bin buzz-relay -# - nak available on PATH (for event signing) -# -# What it tests: -# 1. Non-member REST calls are rejected (401/403) -# 2. Owner adds admin via kind:9030 -# 3. Admin adds member via kind:9030 -# 4. Owner changes member role via kind:9032 -# 5. Admin removes member via kind:9031 -# 6. Members cannot add others (permission denied) -# 7. Admins cannot change roles (permission denied) -# 8. Owner cannot be removed -# 9. GET /api/relay/members returns correct list -# 10. GET /api/relay/members/me returns correct role -# 11. NIP-11 self field is advertised -# 12. kind:13534 membership list published after add -# 13. kind:8000 member-added announcement published -# 14. kind:28936 leave request (happy path) -# 15. kind:8001 member-removed announcement after leave -# 16. Leave request without NIP-70 - tag is rejected -# 17. Stale admin command rejected (replay protection) -# 18. Owner cannot leave (lockout prevention) -# 19. Admin cannot add another admin -# 20. Admin cannot remove the owner -# ============================================================================= -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -cd "${REPO_ROOT}" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -log() { echo -e "${BLUE}[e2e-membership]${NC} $*"; } -success() { echo -e "${GREEN}[e2e-membership]${NC} ✓ $*"; } -warn() { echo -e "${YELLOW}[e2e-membership]${NC} ⚠ $*"; } - -# Failure counter — tests record failures and continue; fatal() exits immediately. -FAILURES=0 - -fail() { - echo -e "${RED}[e2e-membership]${NC} ✗ $*" >&2 - FAILURES=$((FAILURES + 1)) -} - -fatal() { - echo -e "${RED}[e2e-membership]${NC} ✗ FATAL: $*" >&2 - cleanup - exit 1 -} - -# ── Cleanup ─────────────────────────────────────────────────────────────────── - -RELAY_PID="" - -cleanup() { - if [[ -n "$RELAY_PID" ]]; then - kill "$RELAY_PID" 2>/dev/null || true - wait "$RELAY_PID" 2>/dev/null || true - fi -} -trap cleanup EXIT - -# ── Helpers: keypair generation ─────────────────────────────────────────────── - -generate_keypair() { - openssl rand -hex 32 -} - -derive_pubkey() { - local privkey="$1" - python3 -c " -P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F -Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 -Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8 - -def point_add(p1, p2): - if p1 is None: return p2 - if p2 is None: return p1 - x1, y1 = p1; x2, y2 = p2 - if x1 == x2 and y1 != y2: return None - if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P - else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P - x3 = (lam*lam - x1 - x2) % P - y3 = (lam*(x1-x3) - y1) % P - return (x3, y3) - -def scalar_mult(k, point): - result = None; addend = point - while k: - if k & 1: result = point_add(result, addend) - addend = point_add(addend, addend) - k >>= 1 - return result - -pub = scalar_mult(int('$privkey', 16), (Gx, Gy)) -print(format(pub[0], '064x')) -" -} - -# ── Helper: sign and send a Nostr event via nak ─────────────────────────────── -# -# Usage: send_event [tag ...] -# Each tag is passed as a separate argument in nak --tag format: "key=value" -# -# Returns the relay response on stdout. - -send_event() { - local privkey="$1" - local kind="$2" - local content="$3" - shift 3 - local tag_args=() - for t in "$@"; do - tag_args+=(--tag "$t") - done - - nak event \ - --sec "$privkey" \ - --kind "$kind" \ - --content "$content" \ - "${tag_args[@]}" \ - ws://localhost:3000 2>&1 -} - -# ── Helper: REST call with X-Pubkey header (dev mode) ──────────────────────── -# -# When BUZZ_REQUIRE_AUTH_TOKEN=false the relay accepts an X-Pubkey header -# containing the caller's hex pubkey — no token minting required. -# This is the correct pattern for dev-mode E2E tests. -# -# Returns the HTTP status code on stdout. - -rest_call() { - local privkey="$1" - local method="$2" - local path="$3" - local body="${4:-}" - - local pubkey - pubkey=$(derive_pubkey "$privkey") - - local curl_args=(-s -o /dev/null -w "%{http_code}" -X "$method" - -H "X-Pubkey: $pubkey") - if [[ -n "$body" ]]; then - curl_args+=(-H "Content-Type: application/json" -d "$body") - fi - - curl "${curl_args[@]}" "http://localhost:3000${path}" -} - -# ── Helper: REST GET, return body ───────────────────────────────────────────── - -rest_get_body() { - local privkey="$1" - local path="$2" - - local pubkey - pubkey=$(derive_pubkey "$privkey") - - curl -s -X GET \ - -H "X-Pubkey: $pubkey" \ - "http://localhost:3000${path}" -} - -# ── Helper: check relay OK response ────────────────────────────────────────── -# -# Returns 0 if the response is ["OK", , true, ...], 1 otherwise. - -is_ok() { - echo "$1" | python3 -c " -import sys, json -try: - data = json.load(sys.stdin) - if isinstance(data, list) and len(data) >= 3 and data[0] == 'OK' and data[2] is True: - sys.exit(0) -except: pass -sys.exit(1) -" 2>/dev/null -} - -# ── Helper: check relay rejection (OK false) ───────────────────────────────── -# -# Returns 0 if the response is ["OK", , false, ...], 1 otherwise. - -is_rejected() { - echo "$1" | python3 -c " -import sys, json -try: - data = json.load(sys.stdin) - if isinstance(data, list) and len(data) >= 3 and data[0] == 'OK' and data[2] is False: - sys.exit(0) -except: pass -sys.exit(1) -" 2>/dev/null -} - -# ── Start relay ─────────────────────────────────────────────────────────────── - -log "Starting relay with relay membership enforcement enabled..." - -if [[ -f .env ]]; then - set -o allexport - source .env - set +o allexport -fi - -export BUZZ_BIND_ADDR="0.0.0.0:3000" -export RELAY_URL="ws://localhost:3000" -export RUST_LOG="buzz_relay=warn" -export BUZZ_REQUIRE_AUTH_TOKEN=false -export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true - -# Generate owner keypair BEFORE relay start — main.rs requires RELAY_OWNER_PUBKEY -log "Generating owner keypair..." -OWNER_PRIVKEY=$(generate_keypair) -OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY") -export RELAY_OWNER_PUBKEY="$OWNER_PUBKEY" -log "Owner pubkey: $OWNER_PUBKEY" - -# Generate a stable relay signing key for NIP-43 self-signed events (kind:13534, etc.) -RELAY_SK=$(generate_keypair) -export BUZZ_RELAY_PRIVATE_KEY="$RELAY_SK" -log "Relay signing key set (NIP-43 self-signed events enabled)" - -# Kill any existing relay -pkill -f "buzz-relay" 2>/dev/null || true -sleep 1 - -./target/release/buzz-relay > /tmp/buzz-relay-membership-e2e.log 2>&1 & -RELAY_PID=$! - -for i in $(seq 1 15); do - if curl -s http://localhost:3000/ -H "Accept: application/nostr+json" | grep -q "Buzz"; then - break - fi - if [[ $i -eq 15 ]]; then - fatal "Relay did not start. Check /tmp/buzz-relay-membership-e2e.log" - fi - sleep 1 -done -success "Relay started (PID $RELAY_PID)" - -# ── Generate identities ─────────────────────────────────────────────────────── - -# Generate remaining keypairs (after relay is running) -log "Generating remaining keypairs..." -ADMIN_PRIVKEY=$(generate_keypair) -ADMIN_PUBKEY=$(derive_pubkey "$ADMIN_PRIVKEY") -MEMBER_PRIVKEY=$(generate_keypair) -MEMBER_PUBKEY=$(derive_pubkey "$MEMBER_PRIVKEY") -NONMEMBER_PRIVKEY=$(generate_keypair) -NONMEMBER_PUBKEY=$(derive_pubkey "$NONMEMBER_PRIVKEY") -ATTACKER_PRIVKEY=$(generate_keypair) -ATTACKER_PUBKEY=$(derive_pubkey "$ATTACKER_PRIVKEY") - -log " Owner: ${OWNER_PUBKEY:0:16}..." -log " Admin: ${ADMIN_PUBKEY:0:16}..." -log " Member: ${MEMBER_PUBKEY:0:16}..." -log " Non-member:${NONMEMBER_PUBKEY:0:16}..." -log " Attacker: ${ATTACKER_PUBKEY:0:16}..." - -# ── Test 1: Non-member REST calls are rejected ──────────────────────────────── - -log "Test 1: Non-member REST calls should be rejected..." - -STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/channels) -if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then - success "Unauthenticated request rejected ($STATUS)" -else - fail "Expected 401/403 for unauthenticated request, got $STATUS" -fi - -# Non-member with valid auth should also be rejected -STATUS=$(rest_call "$NONMEMBER_PRIVKEY" GET /api/channels) -if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then - success "Non-member request rejected ($STATUS)" -else - fail "Non-member got $STATUS — expected 401 or 403 (membership enforcement bypass!)" -fi - -# ── Test 1b: Non-member WebSocket event should be rejected ─────────────────── - -log "Test 1b: Non-member WebSocket event should be rejected..." - -# Non-member tries to send a stream message over WebSocket. -# The relay enforces NIP-43 during NIP-42 AUTH, so the event will be rejected -# before it is processed. Channel ID is irrelevant — auth fires first. -RESULT=$(send_event "$NONMEMBER_PRIVKEY" 9 "h=00000000-0000-0000-0000-000000000000" 2>&1 || true) -if echo "$RESULT" | grep -qi 'not a relay member\|restricted\|auth\|false'; then - success "Non-member WebSocket event rejected" -else - warn "Could not verify WebSocket non-member rejection: $RESULT" -fi - -# ── Test 2: Owner adds admin (kind:9030) ────────────────────────────────────── - -log "Test 2: Owner adds admin via kind:9030..." - -RESULT=$(send_event "$OWNER_PRIVKEY" 9030 "" \ - "p=$ADMIN_PUBKEY" \ - "role=admin") -if is_ok "$RESULT"; then - success "Owner added admin (kind:9030 accepted)" -else - fail "Owner failed to add admin: $RESULT" -fi - -sleep 1 - -# Verify admin appears in member list -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$ADMIN_PUBKEY"; then - success "Admin pubkey appears in /api/relay/members" -else - fail "Admin pubkey not found in /api/relay/members: $MEMBERS" -fi - -# ── Test 3: Admin adds member (kind:9030) ───────────────────────────────────── - -log "Test 3: Admin adds member via kind:9030..." - -RESULT=$(send_event "$ADMIN_PRIVKEY" 9030 "" \ - "p=$MEMBER_PUBKEY" \ - "role=member") -if is_ok "$RESULT"; then - success "Admin added member (kind:9030 accepted)" -else - fail "Admin failed to add member: $RESULT" -fi - -sleep 1 - -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then - success "Member pubkey appears in /api/relay/members" -else - fail "Member pubkey not found in /api/relay/members: $MEMBERS" -fi - -# ── Test 4: Owner changes role (kind:9032) ──────────────────────────────────── - -log "Test 4: Owner changes member role to admin via kind:9032..." - -RESULT=$(send_event "$OWNER_PRIVKEY" 9032 "" \ - "p=$MEMBER_PUBKEY" \ - "role=admin") -if is_ok "$RESULT"; then - success "Owner changed member role (kind:9032 accepted)" -else - fail "Owner failed to change role: $RESULT" -fi - -sleep 1 - -# Verify role changed -MEMBER_INFO=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBER_INFO" | python3 -c " -import sys, json -data = json.load(sys.stdin) -members = data if isinstance(data, list) else data.get('members', []) -for m in members: - if m.get('pubkey', '').startswith('${MEMBER_PUBKEY:0:8}') or m.get('pubkey') == '${MEMBER_PUBKEY}': - if m.get('role') == 'admin': - sys.exit(0) -sys.exit(1) -" 2>/dev/null; then - success "Member role updated to admin in /api/relay/members" -else - fail "Role change not reflected in /api/relay/members response" -fi - -# Reset member back to 'member' role for subsequent tests -send_event "$OWNER_PRIVKEY" 9032 "" \ - "p=$MEMBER_PUBKEY" \ - "role=member" > /dev/null 2>&1 || true -sleep 1 - -# ── Test 5: Admin removes member (kind:9031) ────────────────────────────────── - -log "Test 5: Admin removes member via kind:9031..." - -RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \ - "p=$MEMBER_PUBKEY") -if is_ok "$RESULT"; then - success "Admin removed member (kind:9031 accepted)" -else - fail "Admin failed to remove member: $RESULT" -fi - -sleep 1 - -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then - fail "Member pubkey still present in /api/relay/members after removal" -else - success "Member pubkey absent from /api/relay/members after removal" -fi - -# Re-add member for permission tests -send_event "$ADMIN_PRIVKEY" 9030 "" \ - "p=$MEMBER_PUBKEY" \ - "role=member" > /dev/null 2>&1 || true -sleep 1 - -# ── Test 6: Members cannot add others ──────────────────────────────────────── - -log "Test 6: Member attempts to add non-member (should be denied)..." - -RESULT=$(send_event "$MEMBER_PRIVKEY" 9030 "" \ - "p=$NONMEMBER_PUBKEY" \ - "role=member") -if is_ok "$RESULT"; then - fail "Member was able to add another user (should be denied)" -elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then - success "Member add attempt rejected" -else - fail "Ambiguous response for member add attempt: $RESULT" -fi - -# ── Test 7: Admins cannot change roles ─────────────────────────────────────── - -log "Test 7: Admin attempts to change owner role (should be denied)..." - -RESULT=$(send_event "$ADMIN_PRIVKEY" 9032 "" \ - "p=$OWNER_PUBKEY" \ - "role=member") -if is_ok "$RESULT"; then - fail "Admin was able to change owner role (should be denied)" -elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then - success "Admin role-change on owner rejected" -else - fail "Ambiguous response for admin role-change attempt: $RESULT" -fi - -# ── Test 8: Owner cannot be removed ────────────────────────────────────────── - -log "Test 8: Admin attempts to remove owner (should be denied)..." - -RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \ - "p=$OWNER_PUBKEY") -if is_ok "$RESULT"; then - fail "Admin was able to remove the owner (should be denied)" -elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then - success "Owner removal attempt rejected" -else - fail "Ambiguous response for owner removal attempt: $RESULT" -fi - -# Verify owner still present -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then - success "Owner still present in /api/relay/members" -else - fail "Owner missing from /api/relay/members after removal attempt" -fi - -# ── Test 9: GET /api/relay/members returns correct list ─────────────────────── - -log "Test 9: GET /api/relay/members returns expected members..." - -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -STATUS=$(rest_call "$OWNER_PRIVKEY" GET /api/relay/members) - -if [[ "$STATUS" == "200" ]]; then - success "GET /api/relay/members returned 200" -else - fail "GET /api/relay/members returned $STATUS (expected 200)" -fi - -for pubkey in "$OWNER_PUBKEY" "$ADMIN_PUBKEY" "$MEMBER_PUBKEY"; do - if echo "$MEMBERS" | grep -q "$pubkey"; then - success " ${pubkey:0:16}... present in member list" - else - fail " ${pubkey:0:16}... missing from member list" - fi -done - -if echo "$MEMBERS" | grep -q "$NONMEMBER_PUBKEY"; then - fail "Non-member ${NONMEMBER_PUBKEY:0:16}... should not appear in member list" -else - success "Non-member correctly absent from member list" -fi - -# ── Test 10: GET /api/relay/members/me returns correct role ────────────────── - -log "Test 10: GET /api/relay/members/me returns correct role..." - -for pair in "${OWNER_PRIVKEY}:owner" "${ADMIN_PRIVKEY}:admin" "${MEMBER_PRIVKEY}:member"; do - privkey="${pair%%:*}" - expected_role="${pair##*:}" - STATUS=$(rest_call "$privkey" GET /api/relay/members/me) - BODY=$(rest_get_body "$privkey" /api/relay/members/me) - - if [[ "$STATUS" == "200" ]]; then - if echo "$BODY" | grep -q "\"$expected_role\""; then - success " /api/relay/members/me: $expected_role role confirmed (status $STATUS)" - else - warn " /api/relay/members/me: expected role '$expected_role', got: $BODY" - fi - else - warn " /api/relay/members/me returned $STATUS for $expected_role" - fi -done - -# Non-member should get 403/404 -STATUS=$(rest_call "$NONMEMBER_PRIVKEY" GET /api/relay/members/me) -if [[ "$STATUS" == "403" || "$STATUS" == "404" ]]; then - success "Non-member /api/relay/members/me correctly rejected ($STATUS)" -else - warn "Non-member /api/relay/members/me returned $STATUS (expected 403/404)" -fi - -# ── Test 11: NIP-11 self field is advertised ────────────────────────────────── - -log "Test 11: NIP-11 self field is advertised..." - -RELAY_HTTP="http://localhost:3000" -SELF_PK=$(curl -s -H "Accept: application/nostr+json" "$RELAY_HTTP" | jq -r '.self // empty') -if [[ -n "$SELF_PK" && "$SELF_PK" != "null" ]]; then - success "NIP-11 self field present: ${SELF_PK:0:16}..." -else - fail "NIP-11 self field missing from relay info document" -fi - -# ── Test 12: kind:13534 membership list published after add ─────────────────── -# -# After Test 3 added a member, the relay should have published a signed -# kind:13534 membership list event to the DB. - -log "Test 12: kind:13534 membership list published after member add..." - -COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=13534;" 2>/dev/null | tr -d ' \n') -if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then - success "kind:13534 membership list found in DB ($COUNT row(s))" -else - fail "kind:13534 not found in DB — relay may not be publishing membership lists" -fi - -# ── Test 13: kind:8000 member-added announcement published ──────────────────── - -log "Test 13: kind:8000 member-added announcement published..." - -COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=8000;" 2>/dev/null | tr -d ' \n') -if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then - success "kind:8000 member-added announcement found in DB ($COUNT row(s))" -else - fail "kind:8000 not found in DB — relay may not be publishing member-added announcements" -fi - -# ── Test 14: kind:28936 leave request (happy path) ──────────────────────────── -# -# Member sends a valid leave request with the NIP-70 protected-event `-` tag. -# The relay should accept it and remove the member. - -log "Test 14: kind:28936 leave request (happy path)..." - -RESULT=$(send_event "$MEMBER_PRIVKEY" 28936 "" "-=") -if is_ok "$RESULT"; then - success "Leave request accepted (kind:28936 with NIP-70 - tag)" -else - fail "Leave request rejected: $RESULT" -fi - -sleep 1 - -# Verify member was removed -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then - fail "Member still present in /api/relay/members after leave request" -else - success "Member correctly absent from /api/relay/members after leave" -fi - -# ── Test 15: kind:8001 member-removed announcement after leave ──────────────── - -log "Test 15: kind:8001 member-removed announcement published after leave..." - -COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=8001;" 2>/dev/null | tr -d ' \n') -if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then - success "kind:8001 member-removed announcement found in DB ($COUNT row(s))" -else - fail "kind:8001 not found in DB — relay may not be publishing member-removed announcements" -fi - -# ── Test 16: Leave request without NIP-70 - tag is rejected ────────────────── -# -# Re-add the member first, then send a leave WITHOUT the required `-` tag. -# The relay must reject it (NIP-70 enforcement). - -log "Test 16: Leave request without NIP-70 - tag is rejected..." - -# Re-add member -send_event "$ADMIN_PRIVKEY" 9030 "" \ - "p=$MEMBER_PUBKEY" \ - "role=member" > /dev/null 2>&1 || true -sleep 1 - -# Send leave without the - tag -RESULT=$(send_event "$MEMBER_PRIVKEY" 28936 "") -if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|nip-70\|protected\|false'; then - success "Leave without NIP-70 - tag correctly rejected" -else - fail "Leave without NIP-70 - tag was accepted (should be rejected): $RESULT" -fi - -# ── Test 17: Stale admin command rejected (replay protection) ───────────────── -# -# An admin command with a created_at more than 5 minutes in the past must be -# rejected to prevent replay attacks. - -log "Test 17: Stale admin command rejected (replay protection)..." - -OLD_TS=$(($(date +%s) - 300)) -# Use nak directly to control the timestamp; pipe output to the relay -RESULT=$(nak event \ - --sec "$ADMIN_PRIVKEY" \ - --kind 9030 \ - --created-at "$OLD_TS" \ - --tag "p=$ATTACKER_PUBKEY" \ - --tag "role=member" \ - --content "" \ - ws://localhost:3000 2>&1 || true) -if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'stale\|old\|expired\|restricted\|false'; then - success "Stale admin command (created_at -5m) rejected" -else - fail "Stale admin command was accepted (replay protection missing): $RESULT" -fi - -# Verify attacker was NOT added -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$ATTACKER_PUBKEY"; then - fail "Attacker was added via stale command (replay protection bypass!)" -else - success "Attacker not present in member list after stale command" -fi - -# ── Test 18: Owner cannot leave (lockout prevention) ───────────────────────── -# -# The relay must refuse a leave request from the owner to prevent lockout. - -log "Test 18: Owner cannot leave (lockout prevention)..." - -RESULT=$(send_event "$OWNER_PRIVKEY" 28936 "" "-=") -if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'owner\|restricted\|denied\|forbidden\|false'; then - success "Owner leave request correctly rejected" -else - fail "Owner leave request was accepted (lockout prevention missing): $RESULT" -fi - -# Verify owner still present -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then - success "Owner still present in /api/relay/members after leave attempt" -else - fail "Owner missing from /api/relay/members after leave attempt" -fi - -# ── Test 19: Admin cannot add another admin ─────────────────────────────────── -# -# Admins can only add members. Elevating to admin requires owner privilege. - -log "Test 19: Admin cannot add another admin..." - -RESULT=$(send_event "$ADMIN_PRIVKEY" 9030 "" \ - "p=$ATTACKER_PUBKEY" \ - "role=admin") -if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission\|false'; then - success "Admin cannot add another admin (correctly rejected)" -else - fail "Admin was able to add an admin (privilege escalation!): $RESULT" -fi - -# Verify attacker was NOT added as admin -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | python3 -c " -import sys, json -data = json.load(sys.stdin) -members = data if isinstance(data, list) else data.get('members', []) -for m in members: - if m.get('pubkey') == '${ATTACKER_PUBKEY}' and m.get('role') == 'admin': - sys.exit(0) -sys.exit(1) -" 2>/dev/null; then - fail "Attacker has admin role in member list (privilege escalation confirmed!)" -else - success "Attacker not present as admin in member list" -fi - -# ── Test 20: Admin cannot remove the owner ──────────────────────────────────── - -log "Test 20: Admin cannot remove the owner..." - -RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \ - "p=$OWNER_PUBKEY") -if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission\|false'; then - success "Admin cannot remove the owner (correctly rejected)" -else - fail "Admin was able to remove the owner (privilege escalation!): $RESULT" -fi - -# Verify owner still present -MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members) -if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then - success "Owner still present in /api/relay/members after admin removal attempt" -else - fail "Owner missing from /api/relay/members — admin removed owner!" -fi - -# ── Summary ─────────────────────────────────────────────────────────────────── - -echo "" -echo "Final member list:" -echo "─────────────────────" -rest_get_body "$OWNER_PRIVKEY" /api/relay/members | python3 -m json.tool 2>/dev/null || true -echo "" - -if [[ "$FAILURES" -eq 0 ]]; then - echo -e "${GREEN}════════════════════════════════════════════════════════${NC}" - echo -e "${GREEN} ✅ All NIP-43 relay membership E2E tests passed!${NC}" - echo -e "${GREEN}════════════════════════════════════════════════════════${NC}" -else - echo -e "${RED}════════════════════════════════════════════════════════${NC}" - echo -e "${RED} ❌ ${FAILURES} test(s) failed!${NC}" - echo -e "${RED}════════════════════════════════════════════════════════${NC}" - exit 1 -fi diff --git a/scripts/test-proxy-e2e-live.sh b/scripts/test-proxy-e2e-live.sh deleted file mode 100755 index f42328a3d4..0000000000 --- a/scripts/test-proxy-e2e-live.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -# Live e2e test for buzz-proxy — requires relay on :3000 and proxy on :4869 -set -euo pipefail - -PROXY_URL="ws://0.0.0.0:4869" -PROXY_HTTP="http://localhost:4869" -ADMIN_SECRET="test-admin-secret" - -PASS=0 -FAIL=0 - -ok() { PASS=$((PASS+1)); echo " ✅ $1"; } -fail() { FAIL=$((FAIL+1)); echo " ❌ $1"; } - -echo "═══ buzz-proxy e2e tests ═══" -echo "" - -# ── Test 1: NIP-11 ────────────────────────────────────────────────────── -echo "1. NIP-11 relay info" -NIP11=$(curl -sf "$PROXY_HTTP" -H "Accept: application/nostr+json") -if echo "$NIP11" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['name']=='buzz-proxy'" 2>/dev/null; then - ok "NIP-11 returns valid relay info" -else - fail "NIP-11 check failed" -fi - -# ── Test 2: Register guest ────────────────────────────────────────────── -echo "2. Guest registration (pubkey-based)" -GUEST_SK=$(nak key generate) -GUEST_PK=$(nak key public "$GUEST_SK") -CHANNEL_UUID="6f953bb4-f761-4bc2-98de-392470c2b897" - -REG=$(curl -sf -X POST "$PROXY_HTTP/admin/guests" \ - -H "Authorization: Bearer $ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d "{\"pubkey\": \"$GUEST_PK\", \"channels\": \"$CHANNEL_UUID\"}") -if echo "$REG" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['pubkey']=='$GUEST_PK'" 2>/dev/null; then - ok "Guest registered: $GUEST_PK" -else - fail "Guest registration failed: $REG" -fi - -# ── Test 3: List guests ───────────────────────────────────────────────── -echo "3. List guests" -LIST=$(curl -sf "$PROXY_HTTP/admin/guests" -H "Authorization: Bearer $ADMIN_SECRET") -if echo "$LIST" | python3 -c "import sys,json; d=json.load(sys.stdin); assert len(d['guests'])>=1" 2>/dev/null; then - ok "Guest list returns registered guests" -else - fail "Guest list failed: $LIST" -fi - -# ── Test 4: Admin auth required ───────────────────────────────────────── -echo "4. Admin auth enforcement" -NOAUTH=$(curl -s -o /dev/null -w "%{http_code}" "$PROXY_HTTP/admin/guests") -if [ "$NOAUTH" = "401" ]; then - ok "Admin endpoint rejects unauthenticated requests" -else - fail "Expected 401, got $NOAUTH" -fi - -# ── Test 5: WebSocket AUTH challenge ──────────────────────────────────── -echo "5. WebSocket NIP-42 challenge" -CHALLENGE=$(echo "" | timeout 3 websocat -t -1 "$PROXY_URL" 2>/dev/null | head -1) -if echo "$CHALLENGE" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); assert d[0]=='AUTH'" 2>/dev/null; then - ok "Proxy sends NIP-42 AUTH challenge on connect" -else - fail "No AUTH challenge received: $CHALLENGE" -fi - -# ── Test 6: Full NIP-42 handshake (pubkey-based, no token) ───────────── -echo "6. Full NIP-42 auth handshake (pubkey-based)" - -# Use a Python script to do the full handshake on a single WebSocket connection. -RESPONSE=$(uv run python3 -c " -import asyncio, json, subprocess, sys - -async def test(): - import websockets - async with websockets.connect('$PROXY_URL') as ws: - # 1. Receive AUTH challenge - msg = json.loads(await ws.recv()) - assert msg[0] == 'AUTH', f'Expected AUTH, got {msg[0]}' - challenge = msg[1] - - # 2. Create signed auth event with nak - auth_json = subprocess.check_output([ - 'nak', 'event', '--sec', '$GUEST_SK', '-k', '22242', - '--tag', 'relay=$PROXY_URL', - '--tag', f'challenge={challenge}', - '-c', '' - ], text=True).strip() - - # 3. Send AUTH response - await ws.send(json.dumps(['AUTH', json.loads(auth_json)])) - - # 4. Read OK - ok_msg = json.loads(await ws.recv()) - print(json.dumps(ok_msg)) - -asyncio.run(test()) -" 2>&1) || true - -echo " Response: $RESPONSE" - -if echo "$RESPONSE" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); assert d[0]=='OK' and d[2]==True" 2>/dev/null; then - ok "NIP-42 auth succeeded (pubkey-based, no token)" -else - # Fall back: try without websockets library (use websocat with named pipe) - fail "NIP-42 auth handshake: $RESPONSE" -fi - -# ── Test 7: Create invite token ───────────────────────────────────────── -echo "7. Invite token creation" -INVITE=$(curl -sf -X POST "$PROXY_HTTP/admin/invite" \ - -H "Authorization: Bearer $ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d "{\"channels\": \"$CHANNEL_UUID\", \"hours\": 1, \"max_uses\": 5}") -TOKEN=$(echo "$INVITE" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])" 2>/dev/null) -if [ -n "$TOKEN" ]; then - ok "Invite token created: ${TOKEN:0:30}..." -else - fail "Invite token creation failed: $INVITE" -fi - -# ── Test 8: Revoke guest ──────────────────────────────────────────────── -echo "8. Guest revocation" -REVOKE=$(curl -sf -X DELETE "$PROXY_HTTP/admin/guests" \ - -H "Authorization: Bearer $ADMIN_SECRET" \ - -H "Content-Type: application/json" \ - -d "{\"pubkey\": \"$GUEST_PK\"}") -if echo "$REVOKE" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['revoked']==True" 2>/dev/null; then - ok "Guest revoked" -else - fail "Guest revocation failed: $REVOKE" -fi - -# ── Summary ───────────────────────────────────────────────────────────── -echo "" -echo "═══ Results: $PASS passed, $FAIL failed ═══" -[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/scripts/test-proxy-e2e.sh b/scripts/test-proxy-e2e.sh deleted file mode 100755 index 3ed1e69ccb..0000000000 --- a/scripts/test-proxy-e2e.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# End-to-end test for buzz-proxy -# Prerequisites: -# - Buzz relay running on :3000 (just relay) -# - Buzz proxy running on :4869 (just proxy) -# - websocat installed (cargo install websocat) -# - curl installed -# - jq installed -set -euo pipefail - -PROXY_URL="${PROXY_URL:-ws://localhost:4869}" -PROXY_HTTP="${PROXY_HTTP:-http://localhost:4869}" -RELAY_HTTP="${RELAY_HTTP:-http://localhost:3000}" - -echo "=== Buzz Proxy E2E Test ===" -echo "Proxy: $PROXY_URL" -echo "Relay: $RELAY_HTTP" - -# Step 1: Check NIP-11 -echo "" -echo "--- Step 1: NIP-11 Info Document ---" -NIP11=$(curl -sf -H "Accept: application/nostr+json" "$PROXY_HTTP/" || true) -if [ -z "$NIP11" ]; then - echo "FAIL: NIP-11 endpoint not responding" - exit 1 -fi -echo "$NIP11" | jq . -echo "PASS: NIP-11 document served" - -# Step 2: Create invite token -echo "" -echo "--- Step 2: Create Invite Token ---" -# Get a channel UUID from the relay -CHANNEL_ID=$(curl -sf "$RELAY_HTTP/api/channels" -H "X-Pubkey: 0101010101010101010101010101010101010101010101010101010101010101" | jq -r '.[0].id // empty') -if [ -z "$CHANNEL_ID" ]; then - echo "WARN: No channels found in relay. Creating a test channel..." - CHANNEL_ID=$(curl -sf -X POST "$RELAY_HTTP/api/channels" \ - -H "Content-Type: application/json" \ - -H "X-Pubkey: 0101010101010101010101010101010101010101010101010101010101010101" \ - -d '{"name":"proxy-test","channel_type":"stream","visibility":"open"}' | jq -r '.id') -fi -echo "Using channel: $CHANNEL_ID" - -INVITE=$(curl -sf -X POST "$PROXY_HTTP/admin/invite" \ - -H "Content-Type: application/json" \ - -d "{\"channels\": \"$CHANNEL_ID\", \"hours\": 1, \"max_uses\": 10}" || true) -if [ -z "$INVITE" ]; then - echo "FAIL: Could not create invite token" - exit 1 -fi -TOKEN=$(echo "$INVITE" | jq -r '.token') -echo "Token: ${TOKEN:0:30}..." -echo "PASS: Invite token created" - -# Step 3: Connect and test NIP-42 + kind:40 query -echo "" -echo "--- Step 3: WebSocket Connection Test ---" -echo "Connecting to $PROXY_URL?token=$TOKEN ..." - -# Use a timeout approach: send a REQ for kind:40, expect AUTH challenge + EOSE -# websocat with -t (text) and timeout -RESULT=$(echo '["REQ","test-sub",{"kinds":[40],"limit":10}]' | \ - timeout 5 websocat -t -1 "$PROXY_URL?token=$TOKEN" 2>/dev/null || true) - -if [ -z "$RESULT" ]; then - echo "FAIL: No response from proxy (timeout or connection refused)" - echo "Make sure the proxy is running: just proxy" - exit 1 -fi - -echo "Response: $RESULT" - -# Check if we got an AUTH challenge (expected before we authenticate) -if echo "$RESULT" | grep -q '"AUTH"'; then - echo "PASS: Received NIP-42 AUTH challenge" -else - echo "INFO: Did not receive AUTH challenge in first message" -fi - -echo "" -echo "=== Test Summary ===" -echo "NIP-11: PASS" -echo "Invite creation: PASS" -echo "WebSocket connection: PASS" -echo "" -echo "For full interactive testing, use:" -echo " websocat $PROXY_URL?token=$TOKEN" -echo " Then send: [\"AUTH\", ]" -echo " Then send: [\"REQ\",\"sub1\",{\"kinds\":[40],\"limit\":10}]" diff --git a/scripts/test-proxy-nostr-sdk-python.py b/scripts/test-proxy-nostr-sdk-python.py deleted file mode 100644 index 4b0a11edf9..0000000000 --- a/scripts/test-proxy-nostr-sdk-python.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -""" -buzz-proxy E2E test using nostr-sdk v0.44 (Python bindings for rust-nostr) - -nostr-sdk is the official Rust Nostr SDK with Python/Swift/Flutter bindings. -Second most popular Nostr SDK after nostr-tools, powers native clients. - -Tests: - 1. NIP-42 authentication (automatic_authentication) - 2. Channel discovery (kind:40) - 3. Channel metadata (kind:41) - 4. Send channel message (kind:42 via EventBuilder.channel_msg) - 5. Read messages (kind:42 fetch + round-trip verification) -""" - -import asyncio -import json -import time -from datetime import timedelta - -import nostr_sdk - -RELAY_URL = "ws://localhost:4869" -PRIVKEY_HEX = open("/tmp/guest3_privkey.txt").read().strip() -CHANNEL_EVENT_ID_HEX = "8155f2a8685beb2b4c5ac1796d555ac0b8e8ae1d3a76a7a3f63cce57905cddeb" - -results = [] - -def ok(name, detail=""): - results.append((name, True)) - print(f" ✅ {name}" + (f" — {detail}" if detail else "")) - -def fail(name, detail=""): - results.append((name, False)) - print(f" ❌ {name}" + (f" — {detail}" if detail else "")) - - -async def main(): - print("=" * 64) - print(" nostr-sdk v0.44 (Python/Rust) E2E TEST against buzz-proxy") - print("=" * 64) - - keys = nostr_sdk.Keys.parse(PRIVKEY_HEX) - pubkey = keys.public_key().to_hex() - print(f" Relay: {RELAY_URL}") - print(f" Pubkey: {pubkey[:24]}...") - print() - - relay_url = nostr_sdk.RelayUrl.parse(RELAY_URL) - channel_eid = nostr_sdk.EventId.parse(CHANNEL_EVENT_ID_HEX) - timeout = timedelta(seconds=10) - - signer = nostr_sdk.NostrSigner.keys(keys) - client = nostr_sdk.ClientBuilder().signer(signer).build() - client.automatic_authentication(True) - - # ── 1. Connect + NIP-42 Auth ───────────────────────────────────────── - print("[1/5] Connect + NIP-42 Authentication") - try: - await client.add_relay(relay_url) - await client.connect() - await asyncio.sleep(2) - ok("NIP-42 Auth", f"automatic_authentication as {pubkey[:16]}...") - except Exception as e: - fail("NIP-42 Auth", str(e)) - return - - # ── 2. Channel Discovery (kind:40) ─────────────────────────────────── - print("\n[2/5] Channel Discovery (kind:40)") - try: - f40 = nostr_sdk.Filter().kind(nostr_sdk.Kind(40)).limit(100) - events = await client.fetch_events(f40, timeout) - evts = events.to_vec() - if evts: - for e in evts: - content = json.loads(e.content()) if e.content() else {} - name = content.get("name", "?") - print(f" • {name} (id: {e.id().to_hex()[:24]}...)") - ok("Channel Discovery", f"{len(evts)} channel(s)") - else: - fail("Channel Discovery", "no kind:40 events") - except Exception as e: - fail("Channel Discovery", str(e)) - - # ── 3. Channel Metadata (kind:41) ──────────────────────────────────── - print("\n[3/5] Channel Metadata (kind:41)") - try: - f41 = nostr_sdk.Filter().kind(nostr_sdk.Kind(41)).event(channel_eid).limit(5) - meta = await client.fetch_events(f41, timeout) - meta_vec = meta.to_vec() - if meta_vec: - c = json.loads(meta_vec[0].content()) - ok("Channel Metadata", f'name="{c.get("name")}", about="{c.get("about","")[:50]}"') - else: - fail("Channel Metadata", "no kind:41 events") - except Exception as e: - fail("Channel Metadata", str(e)) - - # ── 4. Send Channel Message (kind:42) ──────────────────────────────── - print("\n[4/5] Send Channel Message (EventBuilder.channel_msg)") - ts = int(time.time()) - msg_text = f"Hello from nostr-sdk Python! 🐍 [{ts}]" - try: - builder = nostr_sdk.EventBuilder.channel_msg(channel_eid, relay_url, msg_text) - output = await client.send_event_builder(builder) - sent_id = output.id.to_hex() # .id is a property, not a method - ok("Send Message", f'"{msg_text}" (id: {sent_id[:16]}...)') - except Exception as e: - fail("Send Message", str(e)) - - # ── 5. Read Messages + Round-Trip ──────────────────────────────────── - print("\n[5/5] Read Messages + Round-Trip Verification") - try: - f42 = nostr_sdk.Filter().kind(nostr_sdk.Kind(42)).event(channel_eid).limit(10) - msgs = await client.fetch_events(f42, timeout) - msg_vec = msgs.to_vec() - found_ours = False - print(f" {len(msg_vec)} message(s):") - for e in msg_vec: - marker = "→ " if "nostr-sdk Python" in e.content() else " " - print(f" {marker}[{e.author().to_hex()[:12]}...] {e.content()[:65]}") - if "nostr-sdk Python" in e.content(): - found_ours = True - if found_ours: - ok("Read + Round-Trip", f"our message verified among {len(msg_vec)}") - elif msg_vec: - ok("Read + Round-Trip", f"{len(msg_vec)} messages (ours may be propagating)") - else: - fail("Read + Round-Trip", "no messages") - except Exception as e: - fail("Read + Round-Trip", str(e)) - - await client.disconnect() - - # ── Summary ────────────────────────────────────────────────────────── - print("\n" + "=" * 64) - print(" RESULTS") - print("=" * 64) - all_pass = True - for name, passed in results: - print(f" {'✅ PASS' if passed else '❌ FAIL'} {name}") - if not passed: - all_pass = False - print("=" * 64) - if all_pass: - print(" 🎉 ALL TESTS PASSED — nostr-sdk Python works with buzz-proxy!") - else: - print(" ⚠️ Some tests failed") - print("=" * 64) - - -asyncio.run(main()) diff --git a/scripts/test-proxy-nostr-tools.mjs b/scripts/test-proxy-nostr-tools.mjs deleted file mode 100644 index 3cdc2891dc..0000000000 --- a/scripts/test-proxy-nostr-tools.mjs +++ /dev/null @@ -1,260 +0,0 @@ -/** - * buzz-proxy E2E test using nostr-tools v2.23.3 - * - * nostr-tools is the most widely-used Nostr library in the ecosystem, - * powering Coracle, Snort, Damus Web, and hundreds of other clients. - * - * Tests the full NIP-28 channel flow through buzz-proxy: - * 1. NIP-42 authentication (automatic via onauth callback) - * 2. Channel discovery (kind:40) - * 3. Channel metadata (kind:41) - * 4. Send a channel message (kind:42 via NIP-28 channelMessageEvent) - * 5. Receive messages (kind:42 subscription) - * 6. Live streaming (real-time event delivery) - */ - -import { Relay } from 'nostr-tools/relay' -import { finalizeEvent, getPublicKey } from 'nostr-tools/pure' -import { channelMessageEvent } from 'nostr-tools/nip28' -import { hexToBytes } from 'nostr-tools/utils' -import WebSocket from 'ws' - -// ── Config ──────────────────────────────────────────────────────────────────── - -const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:4869' -const GUEST_PRIVKEY_HEX = process.env.GUEST_PRIVKEY -const CHANNEL_EVENT_ID = process.env.CHANNEL_EVENT_ID - -if (!GUEST_PRIVKEY_HEX || !CHANNEL_EVENT_ID) { - console.error('Usage: GUEST_PRIVKEY= CHANNEL_EVENT_ID= node test-buzz-proxy.mjs') - process.exit(1) -} - -const secretKey = hexToBytes(GUEST_PRIVKEY_HEX) -const pubkey = getPublicKey(secretKey) - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -const results = [] -function pass(name, detail) { - results.push({ name, ok: true }) - console.log(` ✅ ${name}${detail ? ` — ${detail}` : ''}`) -} -function fail(name, detail) { - results.push({ name, ok: false }) - console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`) -} - -/** Collect events from a subscription until EOSE, with timeout. */ -function list(relay, filters, timeoutMs = 8000) { - return new Promise((resolve, reject) => { - const events = [] - const timer = setTimeout(() => { - sub.close() - resolve(events) - }, timeoutMs) - - const sub = relay.subscribe(filters, { - onevent(evt) { events.push(evt) }, - oneose() { - clearTimeout(timer) - sub.close() - resolve(events) - }, - }) - }) -} - -// ── Main ────────────────────────────────────────────────────────────────────── - -async function main() { - console.log('═'.repeat(64)) - console.log(' nostr-tools v2.23 E2E TEST against buzz-proxy') - console.log('═'.repeat(64)) - console.log(` Relay: ${RELAY_URL}`) - console.log(` Pubkey: ${pubkey.slice(0, 24)}...`) - console.log() - - // ── 1. Connect + NIP-42 Auth ────────────────────────────────────────── - - console.log('[1/6] Connect + NIP-42 Authentication') - - // nostr-tools' Relay class calls onauth when it receives an AUTH challenge. - // We set it before connecting so the handshake is automatic. - const relay = new Relay(RELAY_URL, { websocketImplementation: WebSocket }) - - // Wire up the NIP-42 auth callback BEFORE connecting. - // nostr-tools calls: relay.auth(onauth) → onauth(makeAuthEvent(url, challenge)) - // So onauth receives an unsigned event template and must return a signed event. - let authResolved = false - relay.onauth = async (authEventTemplate) => { - const signed = finalizeEvent(authEventTemplate, secretKey) - authResolved = true - return signed - } - - try { - await relay.connect() - } catch (e) { - fail('Connect', e.message) - process.exit(1) - } - - // Wait a moment for the AUTH challenge to arrive and be handled - // nostr-tools' onauth is called synchronously when the AUTH message arrives - await new Promise(r => setTimeout(r, 1500)) - - if (authResolved) { - pass('NIP-42 Auth', `challenge received, signed as ${pubkey.slice(0, 16)}...`) - } else { - // Auth might still work reactively — the proxy sends CLOSED/OK auth-required - // which triggers nostr-tools to call relay.auth() - console.log(' ⏳ No proactive AUTH challenge yet — testing reactive auth...') - } - - // ── 2. Channel Discovery (kind:40) ──────────────────────────────────── - - console.log('\n[2/6] Channel Discovery (kind:40)') - - try { - const channels = await list(relay, [{ kinds: [40], limit: 100 }]) - if (channels.length > 0) { - for (const evt of channels) { - try { - const c = JSON.parse(evt.content) - console.log(` • ${c.name || '(unnamed)'} (id: ${evt.id.slice(0, 24)}...)`) - } catch { console.log(` • (id: ${evt.id.slice(0, 24)}...)`) } - } - pass('Channel Discovery', `${channels.length} channel(s)`) - } else { - fail('Channel Discovery', 'no kind:40 events returned') - } - } catch (e) { - fail('Channel Discovery', e.message) - } - - // ── 3. Channel Metadata (kind:41) ───────────────────────────────────── - - console.log('\n[3/6] Channel Metadata (kind:41)') - - try { - const meta = await list(relay, [{ kinds: [41], '#e': [CHANNEL_EVENT_ID], limit: 5 }]) - if (meta.length > 0) { - const c = JSON.parse(meta[0].content) - pass('Channel Metadata', `name="${c.name}", about="${(c.about || '').slice(0, 50)}"`) - } else { - fail('Channel Metadata', 'no kind:41 events') - } - } catch (e) { - fail('Channel Metadata', e.message) - } - - // ── 4. Send Channel Message (kind:42 via NIP-28) ────────────────────── - - console.log('\n[4/6] Send Channel Message (NIP-28 channelMessageEvent)') - - const ts = Math.floor(Date.now() / 1000) - const msgText = `Hello from nostr-tools! 📦 [${ts}]` - - let sentEvent - try { - sentEvent = channelMessageEvent({ - channel_create_event_id: CHANNEL_EVENT_ID, - relay_url: RELAY_URL, - content: msgText, - created_at: ts, - }, secretKey) - - await relay.publish(sentEvent) - pass('Send Message', `"${msgText}" (id: ${sentEvent.id.slice(0, 16)}...)`) - } catch (e) { - fail('Send Message', e.message) - } - - // ── 5. Read Messages (kind:42) ──────────────────────────────────────── - - console.log('\n[5/6] Read Messages (kind:42 query)') - - try { - const msgs = await list(relay, [{ kinds: [42], '#e': [CHANNEL_EVENT_ID], limit: 10 }]) - let foundOurs = false - console.log(` ${msgs.length} message(s):`) - for (const evt of msgs) { - const marker = evt.content.includes('nostr-tools') ? '→ ' : ' ' - console.log(` ${marker}[${evt.pubkey.slice(0, 12)}...] ${evt.content.slice(0, 65)}`) - if (evt.content.includes('nostr-tools')) foundOurs = true - } - if (foundOurs) { - pass('Read Messages', `found our message among ${msgs.length}`) - } else if (msgs.length > 0) { - pass('Read Messages', `${msgs.length} messages (ours may be propagating)`) - } else { - fail('Read Messages', 'no messages') - } - } catch (e) { - fail('Read Messages', e.message) - } - - // ── 6. Live Streaming ───────────────────────────────────────────────── - - console.log('\n[6/6] Live Streaming (real-time event delivery)') - - try { - const streamResult = await new Promise((resolve) => { - const timer = setTimeout(() => { sub.close(); resolve(false) }, 8000) - - const sub = relay.subscribe([{ - kinds: [42], - '#e': [CHANNEL_EVENT_ID], - since: ts, - }], { - onevent(evt) { - if (evt.content.includes('stream-ping')) { - clearTimeout(timer) - sub.close() - resolve(true) - } - }, - oneose() { - // After EOSE, publish a new message that should arrive live - const ping = channelMessageEvent({ - channel_create_event_id: CHANNEL_EVENT_ID, - relay_url: RELAY_URL, - content: `nostr-tools stream-ping ${Date.now()}`, - created_at: Math.floor(Date.now() / 1000), - }, secretKey) - relay.publish(ping).catch(() => {}) - }, - }) - }) - - if (streamResult) { - pass('Live Streaming', 'received real-time event') - } else { - fail('Live Streaming', 'timed out') - } - } catch (e) { - fail('Live Streaming', e.message) - } - - // ── Summary ─────────────────────────────────────────────────────────── - - relay.close() - - console.log('\n' + '═'.repeat(64)) - console.log(' RESULTS') - console.log('═'.repeat(64)) - let allPass = true - for (const r of results) { - console.log(` ${r.ok ? '✅ PASS' : '❌ FAIL'} ${r.name}`) - if (!r.ok) allPass = false - } - console.log('═'.repeat(64)) - console.log(allPass - ? ' 🎉 ALL TESTS PASSED — nostr-tools works with buzz-proxy!' - : ' ⚠️ Some tests failed') - console.log('═'.repeat(64)) - process.exit(allPass ? 0 : 1) -} - -main().catch(e => { console.error('Fatal:', e); process.exit(1) })