Files
buzz/crates/sprout-proxy
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Wes
7a9218ba54 Update all non-major dependencies (#248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Wes <wesb@block.xyz>
2026-04-08 11:55:11 -07:00
..

sprout-proxy

NIP-28 compatibility proxy for Sprout. Lets standard Nostr clients (Coracle, Amethyst, nak, etc.) connect to a Sprout relay using familiar NIP-28 channel events.

Client (NIP-28)  ←→  sprout-proxy :4869  ←→  sprout-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

just setup    # Docker: Postgres + Redis
just relay    # Relay on :3000

2. Mint a proxy API token

The token's --pubkey must match the public key derived from SPROUT_PROXY_SERVER_KEY.

# Derive the public key from your server key
nak key public <SPROUT_PROXY_SERVER_KEY>

# Mint the token with that pubkey
cargo run -p sprout-admin -- mint-token \
  --name "sprout-proxy" \
  --scopes "proxy:submit,channels:read,messages:read" \
  --pubkey <derived-pubkey>

3. Configure environment

export SPROUT_UPSTREAM_URL=ws://localhost:3000
export SPROUT_PROXY_BIND_ADDR=0.0.0.0:4869
export SPROUT_PROXY_SERVER_KEY=<hex nsec from step 2>
export SPROUT_PROXY_SALT=$(openssl rand -hex 32)
export SPROUT_PROXY_API_TOKEN=<api token from step 2>
export SPROUT_PROXY_ADMIN_SECRET=<secret for admin API>

Put these in .env at the repo root — just loads it automatically.

4. Start the proxy

just proxy    # Proxy on :4869

Register a guest by their Nostr public key. They can then connect with any NIP-42-capable client — no token needed.

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-uuid1>,<channel-uuid2>"}'

6. Connect (pubkey-based)

Just add the proxy as a relay in your Nostr client:

ws://localhost:4869

The client handles NIP-42 authentication automatically. No token in the URL.

Alternative: Invite tokens (for ad-hoc sharing)

curl -X POST http://localhost:4869/admin/invite \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <SPROUT_PROXY_ADMIN_SECRET>" \
  -d '{"channels":"<channel-uuid>","hours":24,"max_uses":10}'

Connect with the token in the URL:

ws://localhost:4869?token=<invite_token>

Environment Variables

Variable Required Default Description
SPROUT_UPSTREAM_URL WebSocket URL of the Sprout relay
SPROUT_PROXY_SERVER_KEY Hex nsec for the proxy server keypair
SPROUT_PROXY_SALT Hex 32-byte salt for shadow key derivation (keep stable)
SPROUT_PROXY_API_TOKEN Sprout API token with proxy:submit, channels:read, and messages:read scopes
SPROUT_PROXY_BIND_ADDR 0.0.0.0:4869 Listen address
SPROUT_PROXY_ADMIN_SECRET Bearer secret for POST /admin/invite (unset = dev mode, no auth)
RUST_LOG sprout_proxy=info Log level

API Endpoints

GET /

  • With Accept: application/nostr+json → NIP-11 relay info document
  • With Upgrade: websocket → WebSocket connection (requires ?token=<invite>)

POST /admin/invite

Create an invite token. Requires Authorization: Bearer <SPROUT_PROXY_ADMIN_SECRET> (if secret is set).

Request:

{
  "channels": "<uuid1>,<uuid2>",
  "hours": 24,
  "max_uses": 10
}

Response:

{
  "token": "sprout_invite_<uuid>",
  "channels": ["<uuid1>", "<uuid2>"],
  "expires_at": "2026-03-12T22:00:00Z",
  "max_uses": 10
}

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

# Install
go install github.com/fiatjaf/nak@latest

# List channels
nak req -k 40 -l 10 --auth "ws://localhost:4869?token=<invite_token>"

# Subscribe to messages
nak req -k 42 --tag "e=<kind40_event_id>" -l 20 --auth "ws://localhost:4869?token=<invite_token>"

# Post a message
nak event -k 42 -c "Hello!" --tag "e=<kind40_event_id>" --sec <nsec> "ws://localhost:4869?token=<invite_token>"

E2E Test Script

# 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 SPROUT_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.