# Using Third-Party Nostr Clients with Sprout Sprout 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 | `sprout-relay :3000` | NIP-29 clients (e.g. Chachi, 0xchat), nak | | **Via proxy** | NIP-28 | `sprout-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. --- ## Path 1: NIP-29 Direct Connect any NIP-29 client straight to the relay. ### Quick Start ```bash # 1. (Optional) Enable pubkey allowlist — must be set BEFORE relay startup export SPROUT_PUBKEY_ALLOWLIST=true # 2. Start the relay (auto-starts Docker services and runs migrations) just relay & # relay on :3000 # 3. Add a pubkey to the allowlist (if enabled) # Insert directly — there is no CLI command for this yet. PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \ "INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))" # 5. Connect any NIP-29 + NIP-42 client to ws://localhost:3000 ``` ### What Works | Feature | Status | Notes | |---------|:------:|-------| | **Group chat (kind:9)** | ✅ | Send/receive messages with `#h ` tag | | **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) | | **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required | | **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. | | **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility` and `channel_type` | | **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. | | **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. | | **Edit group metadata (kind:9002)** | ✅ | `name`/`about` tags: owner/admin. `topic`/`purpose` tags: any member. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | | **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members | | **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, global scope | | **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers | | **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) | | **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist | | **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` | | **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) | | **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":[""]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. | | **NIP-10 threads** | ✅ | WS-submitted replies with `["e","","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. | | **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored globally (channel_id=None). Delivered via `#p`-filtered subscriptions. Not indexed in search. | | **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. | | **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | | **Edits (kind:40003)** | ⚠️ | Works on the wire but Sprout-only — no standard NIP-29 client renders these | | **Rich content (kind:40002)** | ⚠️ | Works on the wire but Sprout-only — no standard NIP-29 client renders these | ### What Doesn't Work | Feature | Status | Why | |---------|:------:|-----| | **Create invite (kind:9009)** | ⚠️ | Accepted and stored, but side-effect handler is deferred (no-op with warning log) | | **Group roles (kind:39003)** | ❌ | Defined in kind registry but not emitted by the relay | | **DMs** | ⚠️ | NIP-17 gift wraps supported; NIP-04/NIP-44 not implemented. kind:10050 (DM relay list) deferred. | ### Pubkey Allowlist When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey (no API token) are checked against the `pubkey_allowlist` table. This lets you open the relay to specific external Nostr identities without granting full access. - Users with valid **API tokens** bypass the allowlist. - **Fail-closed:** if the DB lookup fails, the connection is denied. - Default: `false` (all authenticated pubkeys accepted). - Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message). - Manage the allowlist via direct SQL (no CLI command yet): ```sql INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex')); DELETE FROM pubkey_allowlist WHERE pubkey = decode('<64-char-hex-pubkey>', 'hex'); SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist; ``` ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention): | Kind | Tags | Content | |------|------|---------| | **39000** | `d=`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Sprout channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. | | **39001** | `d=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `p` tags for all members | Member list | Events are stored **channel-scoped** so access control applies — private channel member lists are only visible to members. Discover groups via historical REQ: ```bash # All groups you can see nak req -k 39000 --auth --sec ws://localhost:3000 # Specific group's members (filter by d tag) nak req -k 39002 --tag "d=" --auth --sec ws://localhost:3000 ``` > **Note:** Channel-scoped storage means live global subscriptions (`{kinds:[39000]}`) won't > receive these via fan-out. Clients discover groups via historical REQ queries. Live push for > open-channel discovery is a future enhancement. ### Membership Notifications The relay emits relay-signed notifications when members are added or removed: | Kind | Meaning | Tags | Scope | |------|---------|------|-------| | **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Global | | **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Global | Stored globally (`channel_id = None`) so agents and clients can subscribe without knowing channel UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair may sign these. > **Subscription constraint:** Global REQs that can match p-gated kinds (44100, 44101, 1059) **must** > include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects > subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership > changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`. ```bash nak req -k 44100 -k 44101 --tag "p=" \ --auth --sec ws://localhost:3000 ``` ### Sending Messages ```bash # Send a kind:9 message nak event -k 9 -c "Hello from NIP-29!" --tag "h=" \ --auth --sec ws://localhost:3000 # Subscribe to channel messages nak req -k 9 --tag "h=" --stream \ --auth --sec ws://localhost:3000 # React to a message (#h optional but recommended; channel derived from #e target) nak event -k 7 -c "+" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 # Delete a message (#h optional; #e required; must be self-authored) nak event -k 5 -c "reason" --tag "h=" --tag "e=" \ --auth --sec ws://localhost:3000 # Create a group nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \ --auth --sec ws://localhost:3000 # Search messages (NIP-50) nak req -k 9 --tag "h=" --search "search query" -l 20 \ --auth --sec ws://localhost:3000 # Reply to a message (NIP-10 threading) nak event -k 9 -c "Reply text" --tag "h=" \ --tag "e=;;reply" \ --auth --sec ws://localhost:3000 # Fetch gift-wrapped DMs (NIP-17) nak req -k 1059 --tag "p=" \ --auth --sec ws://localhost:3000 ``` ### Tested Clients (Direct) | Client | Platform | Evidence | Notes | |--------|----------|:--------:|-------| | **SproutTestClient** | Rust (repo) | Automated E2E | Full NIP-29 flow: discovery (39000/39001/39002), kind:9 send/receive, reactions, deletions, h-tag enforcement | | **E2E nostr interop** | Rust (repo) | Automated E2E | NIP-50 search (3 tests), NIP-10 threads (3 tests), NIP-17 gift wraps (3 tests), DM discovery (1 test) | | **nak** | CLI | Manual (verified) | kind:9 send/recv, NIP-50 search, NIP-10 thread replies, group discovery | **Not verified in-repo** (anecdotal / expected based on NIP-29 support): - **Chachi** (Web/Mobile) — NDK-based; NIP-29 native - **0xchat** (Mobile) — NIP-29 native --- ## Path 2: NIP-28 via sprout-proxy For clients that speak NIP-28 (kind:40/41/42) but not NIP-29, **sprout-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 SPROUT_PROXY_SERVER_KEY=$(openssl rand -hex 32) PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public) # 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth) export SPROUT_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 SPROUT_RELAY_PRIVATE_KEY # is set, derive it: echo $SPROUT_RELAY_PRIVATE_KEY | nak key public # If not set, the relay generates a random keypair at startup — check relay logs. export SPROUT_RELAY_PUBKEY= # 5. Start the proxy export SPROUT_UPSTREAM_URL=ws://localhost:3000 export SPROUT_PROXY_SALT=$(openssl rand -hex 32) export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16) cargo run -p sprout-proxy # proxy on :4869 # 6. Register a guest curl -X POST http://localhost:4869/admin/guests \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $SPROUT_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 Sprout 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 Sprout 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 Sprout 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 $SPROUT_PROXY_ADMIN_SECRET" \ -H "Content-Type: application/json" \ -d '{"pubkey": "", "channels": ","}' # List curl http://localhost:4869/admin/guests \ -H "Authorization: Bearer $SPROUT_PROXY_ADMIN_SECRET" # Revoke curl -X DELETE http://localhost:4869/admin/guests \ -H "Authorization: Bearer $SPROUT_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 $SPROUT_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 │ Sprout 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 │◄──────►│ sprout-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 Sprout'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 | |----------|:--------:|---------|-------------| | `SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay | | `SPROUT_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) | | `SPROUT_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) | | `SPROUT_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) | | `SPROUT_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) | | `SPROUT_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address | | `SPROUT_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. | | `SPROUT_PROXY_ADMIN_SECRET` | ❌ | — | Bearer secret for `/admin/*` (unset = no auth, dev mode) | | `RUST_LOG` | ❌ | `sprout_proxy=info,tower_http=info` | Log level | --- ## Relay Environment Variables (NIP-29 relevant) | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `SPROUT_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth | | `SPROUT_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) | | `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections | --- ## Security Notes ### Direct Path - **Pubkey allowlist is fail-closed.** DB errors deny the connection. - **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. - **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected. - **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed). - **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 ### Direct Path | Symptom | Cause | Fix | |---------|-------|-----| | `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 `SPROUT_RELAY_PUBKEY`) | --- ## Further Reading - [`crates/sprout-proxy/README.md`](crates/sprout-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.