2026-03-12 22:16:00 -04:00
# Using Third-Party Nostr Clients with Sprout
2026-03-15 21:25:10 -04:00
Sprout is a Nostr relay that speaks NIP-29 (relay-based groups) natively. There are two ways for
third-party Nostr clients to connect:
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
| 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 Okta/API-token credentials.
Both paths require NIP-42 authentication.
---
## Path 1: NIP-29 Direct
Connect any NIP-29 client straight to the relay.
### Quick Start
2026-03-12 22:16:00 -04:00
```bash
# 1. Start infrastructure
2026-03-15 21:25:10 -04:00
docker compose up -d
# 2. (Optional) Enable pubkey allowlist — must be set BEFORE relay startup
export SPROUT_PUBKEY_ALLOWLIST = true
# 3. Start the relay (runs migrations automatically)
2026-03-12 22:16:00 -04:00
cargo run -p sprout-relay & # relay on :3000
2026-03-15 21:25:10 -04:00
# 4. Add a pubkey to the allowlist (if enabled)
# Insert directly — there is no CLI command for this yet.
2026-04-05 13:55:29 -04:00
PGPASSWORD = sprout_dev psql -h localhost -U sprout -d sprout -c \
"INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"
2026-03-15 21:25:10 -04:00
# 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 <channel-uuid>` 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. |
2026-03-16 11:15:06 -04:00
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d` , `name` , `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels |
2026-03-15 21:25:10 -04:00
| **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) |
2026-03-16 11:15:06 -04:00
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","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. |
2026-04-05 13:55:29 -04:00
| **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. |
2026-03-15 21:25:10 -04:00
| **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 |
2026-03-16 11:15:06 -04:00
| **DMs** | ⚠️ | NIP-17 gift wraps supported; NIP-04/NIP-44 not implemented. kind:10050 (DM relay list) deferred. |
2026-03-15 21:25:10 -04:00
### Pubkey Allowlist
When `SPROUT_PUBKEY_ALLOWLIST=true` , NIP-42 connections that authenticate with only a pubkey
(no JWT, no API token) are checked against the `pubkey_allowlist` table. This lets you open the
relay to specific external Nostr identities without granting full Okta/API-token access.
- Users with valid **API tokens** (`sprout_*` ) or **Okta JWTs** 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
2026-04-05 13:55:29 -04:00
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;
2026-03-15 21:25:10 -04:00
` ``
### 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 |
|------|------|---------|
2026-03-16 11:15:06 -04:00
| **39000** | ` d=<uuid>`, ` 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. |
2026-03-15 21:25:10 -04:00
| **39001** | ` d=<uuid>`, ` p` tags with role label (` owner`, ` admin`) | Admin list |
| **39002** | ` d=<uuid>`, ` 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 <privkey> ws://localhost:3000
# Specific group's members (filter by d tag)
nak req -k 39002 --tag "d=<channel-uuid>" --auth --sec <privkey> 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.
2026-04-05 13:55:29 -04:00
> **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`.
2026-03-15 21:25:10 -04:00
` ``bash
nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> ws://localhost:3000
` ``
### Sending Messages
` ``bash
# Send a kind:9 message
nak event -k 9 -c "Hello from NIP-29!" --tag "h=<channel-uuid>" \
--auth --sec <privkey> ws://localhost:3000
# Subscribe to channel messages
nak req -k 9 --tag "h=<channel-uuid>" --stream \
--auth --sec <privkey> ws://localhost:3000
# React to a message (#h optional but recommended; channel derived from #e target)
nak event -k 7 -c "+" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
--auth --sec <privkey> ws://localhost:3000
# Delete a message (#h optional; #e required; must be self-authored)
nak event -k 5 -c "reason" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
--auth --sec <privkey> ws://localhost:3000
# Create a group
nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \
--auth --sec <privkey> ws://localhost:3000
2026-03-16 11:15:06 -04:00
# Search messages (NIP-50)
nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
--auth --sec <privkey> ws://localhost:3000
# Reply to a message (NIP-10 threading)
nak event -k 9 -c "Reply text" --tag "h=<channel-uuid>" \
--tag "e=<parent-event-id>;;reply" \
--auth --sec <privkey> ws://localhost:3000
# Fetch gift-wrapped DMs (NIP-17)
nak req -k 1059 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> ws://localhost:3000
2026-03-15 21:25:10 -04:00
` ``
### 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 |
2026-03-16 11:15:06 -04:00
| **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 |
2026-03-15 21:25:10 -04:00
**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)
2026-03-12 22:16:00 -04:00
# 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)
2026-03-15 21:25:10 -04:00
# 3. Mint a proxy API token
2026-03-12 22:16:00 -04:00
cargo run -p sprout-admin -- mint-token \
--name "sprout-proxy" \
--scopes "proxy:submit,channels:read,messages:read" \
--pubkey $PROXY_PUBKEY
2026-03-15 21:25:10 -04:00
# 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=<relay-hex-pubkey>
# 5. Start the proxy
2026-03-12 22:16:00 -04:00
export SPROUT_UPSTREAM_URL=ws://localhost:3000
export SPROUT_PROXY_SALT=$(openssl rand -hex 32)
export SPROUT_PROXY_API_TOKEN=<token from step 3>
export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16)
cargo run -p sprout-proxy # proxy on :4869
2026-03-15 21:25:10 -04:00
# 6. Register a guest
2026-03-12 22:16:00 -04:00
curl -X POST http://localhost:4869/admin/guests \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SPROUT_PROXY_ADMIN_SECRET" \
-d '{"pubkey": "<guest-hex-pubkey>", "channels": "<channel-uuid>"}'
2026-03-15 21:25:10 -04:00
# 7. Connect any NIP-28 + NIP-42 client to ws://localhost:4869
2026-03-12 22:16:00 -04:00
` ``
2026-03-15 21:25:10 -04:00
### What Works
2026-03-12 22:16:00 -04:00
| Feature | Status | Notes |
|---------|:------:|-------|
| **NIP-11 relay info** | ✅ | Standard relay info document at ` GET /` |
| **NIP-42 authentication** | ✅ | Proactive challenge + reactive-auth compatible |
2026-03-15 21:25:10 -04:00
| **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.** |
2026-03-14 10:51:20 -04:00
| **Channel messages (kind:42)** | ✅ | Translated to/from Sprout kind:9 |
2026-03-15 21:25:10 -04:00
| **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). |
2026-03-12 22:16:00 -04:00
| **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 |
2026-03-15 21:25:10 -04:00
> **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
2026-03-12 22:16:00 -04:00
| Feature | Status | Why |
|---------|:------:|-----|
2026-03-15 21:25:10 -04:00
| **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) |
2026-03-16 11:15:06 -04:00
| **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 |
2026-03-15 21:25:10 -04:00
| **File uploads (NIP-94/96)** | ❌ | Use Blossom on the relay directly (Path 1) |
| **Relay lists / Outbox (NIP-65)** | ❌ | Single-relay architecture |
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
### Channel UUIDs vs Event IDs
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
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.
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
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:
2026-03-12 22:16:00 -04:00
` ``bash
2026-03-15 21:25:10 -04:00
# Get kind:40 (UUID in content.name) and kind:41 (human-readable name)
nak req -k 40 -k 41 --auth --sec <privkey> ws://localhost:4869
2026-03-12 22:16:00 -04:00
` ``
2026-03-15 21:25:10 -04:00
### Proxy Authentication
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
Two methods, both using NIP-42:
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
**Pubkey-based (primary)** — register a guest's hex pubkey with channel access:
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
` ``bash
# Register
curl -X POST http://localhost:4869/admin/guests \
-H "Authorization: Bearer $SPROUT_PROXY_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{"pubkey": "<hex>", "channels": "<uuid1>,<uuid2>"}'
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
# 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": "<hex>"}'
` ``
> **Private channels:** The proxy authenticates upstream using its own server key and API token.
> ` 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": "<uuid1>,<uuid2>", "max_uses": 5, "hours": 48}'
# Connect: ws://localhost:4869?token=<invite_token>
` ``
### Connecting with Coracle (expected, not verified in-repo)
1. Open **https://coracle.social**.
2. Note your hex pubkey from **Settings → Account**.
3. Register it: ` POST /admin/guests` with your pubkey and channel UUIDs.
4. **Settings → Relays → Add Relay** → ` ws://localhost:4869`
5. Coracle should handle NIP-42 auth automatically. Channels should appear under **Public Channels**.
For remote access, tunnel with ngrok: ` ngrok http 4869` → use ` wss://<subdomain>.ngrok.io`.
### Connecting with nak (Proxy)
2026-03-12 22:16:00 -04:00
` ``bash
# Discover channels
nak req -k 40 -l 10 --auth --sec <privkey> ws://localhost:4869
2026-03-15 21:25:10 -04:00
# Read messages from a specific channel
nak req -k 42 --tag "e=<kind40-event-id>" -l 10 --auth --sec <privkey> ws://localhost:4869
2026-03-12 22:16:00 -04:00
# Send a message
2026-03-15 21:25:10 -04:00
nak event -k 42 -c "Hello!" --tag e=<kind40-event-id> \
2026-03-12 22:16:00 -04:00
--auth --sec <privkey> ws://localhost:4869
2026-03-15 21:25:10 -04:00
# Stream live from a specific channel
nak req -k 42 --tag "e=<kind40-event-id>" --stream --auth --sec <privkey> ws://localhost:4869
2026-03-12 22:16:00 -04:00
` ``
2026-03-15 21:25:10 -04:00
### Connecting with nostr-tools v2.23
2026-03-12 22:16:00 -04:00
` ``javascript
import { Relay } from 'nostr-tools/relay'
import { finalizeEvent } from 'nostr-tools/pure'
import { channelMessageEvent } from 'nostr-tools/nip28'
const relay = new Relay('ws://localhost:4869', { websocketImplementation: WebSocket })
relay.onauth = async (template) => finalizeEvent(template, secretKey)
await relay.connect()
const event = channelMessageEvent({
channel_create_event_id: '<kind:40 event ID>',
relay_url: 'ws://localhost:4869',
content: 'Hello from nostr-tools!',
created_at: Math.floor(Date.now() / 1000),
}, secretKey)
await relay.publish(event)
` ``
2026-03-15 21:25:10 -04:00
Test script: ` scripts/test-proxy-nostr-tools.mjs`.
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
### Connecting with nostr-sdk v0.44 (Python)
2026-03-12 22:16:00 -04:00
` ``python
import nostr_sdk
keys = nostr_sdk.Keys.parse("<hex-privkey>")
signer = nostr_sdk.NostrSigner.keys(keys)
client = nostr_sdk.ClientBuilder().signer(signer).build()
client.automatic_authentication(True)
await client.add_relay(nostr_sdk.RelayUrl.parse("ws://localhost:4869"))
await client.connect()
builder = nostr_sdk.EventBuilder.channel_msg(channel_eid, relay_url, "Hello from Python!")
await client.send_event_builder(builder)
` ``
2026-03-15 21:25:10 -04:00
Test script: ` scripts/test-proxy-nostr-sdk-python.py`.
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
### Tested Clients (Proxy)
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
| 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` |
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
**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
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
### Clients That Won't Work (anecdotal)
2026-03-12 22:16:00 -04:00
| Client | Why |
|--------|-----|
2026-03-15 21:25:10 -04:00
| **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 |
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
---
2026-03-12 22:16:00 -04:00
## Architecture
` ``
2026-03-15 21:25:10 -04:00
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 │
└────────────────┘
2026-03-12 22:16:00 -04:00
` ``
2026-03-15 21:25:10 -04:00
**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,
2026-04-05 13:55:29 -04:00
9021, 9022; plus deferred 9009), and standard deletions/reactions (kind:5/7).
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
**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).
2026-03-12 22:16:00 -04:00
2026-03-15 21:25:10 -04:00
---
## Proxy Environment Variables
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| ` SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay |
| ` 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_PROXY_API_TOKEN` | ✅ | — | API token with ` proxy:submit,channels:read,messages:read` |
| ` 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 JWT/API token for all connections |
---
## Security Notes
### Direct Path
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
- **API token / Okta JWT 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=<channel-uuid>"` |
| ` invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay |
| No discovery events | Channel is private + you're not a member | Join the channel first via REST API |
### Proxy Path
| Symptom | Cause | Fix |
|---------|-------|-----|
| ` restricted: pubkey not registered and no invite token provided` | Pubkey not registered, no token | Register guest or create invite token |
| ` error: token invalid: invite token not found` | Token doesn't exist (proxy restarted or mistyped) | Create new invite token |
| ` error: token invalid: invite token expired` | Token past expiry time | Create new invite token |
| ` error: token invalid: invite token exhausted` | Token reached ` max_uses` limit | Create new invite token with higher limit |
| ` auth-required: authentication timeout` | Client didn't respond to NIP-42 within 30s | Use a NIP-42-capable client |
| No messages after auth | Unresolved ` #e ` filter silently returns zero events | Re-query ` nak req -k 40` for correct kind:40 event ID |
| Guest still has access after revoke | Active sessions not terminated | Restart proxy to cut all sessions |
| Proxy startup fails | Can't reach relay REST API or missing env vars | Check relay is running; verify all required env vars (especially ` SPROUT_RELAY_PUBKEY`) |
---
2026-03-12 22:16:00 -04:00
## Further Reading
2026-03-15 21:25:10 -04:00
- [` 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.