Skip to content

feat(sprout-proxy): add /public read-only WebSocket endpoint#274

Open
tlongwell-block wants to merge 1 commit intomainfrom
feat/public-read-only-ws
Open

feat(sprout-proxy): add /public read-only WebSocket endpoint#274
tlongwell-block wants to merge 1 commit intomainfrom
feat/public-read-only-ws

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Summary

Add a /public read-only WebSocket endpoint to sprout-proxy that allows unauthenticated Nostr clients to subscribe to designated public channels without authentication. This enables embedding live channel feeds in websites, dashboards, and third-party tools.

What it does

  • New /public route — content-negotiates between NIP-11 JSON and WebSocket upgrade, just like the authenticated root handler
  • Read-only enforcement — only REQ and CLOSE are accepted; EVENT messages are rejected with ["OK", <id>, false, "restricted: read-only access"]
  • Channel scoping — only channels listed in SPROUT_PROXY_PUBLIC_CHANNELS (comma-separated UUIDs) are accessible; all others are invisible
  • Reuses handle_req — the public handler shares the same REQ/filter/upstream pipeline as authenticated connections, avoiding code duplication

DoS Protection (8 layers)

Layer Limit Purpose
Global connection cap MAX_PUBLIC_CONNECTIONS = 100 Bound total anonymous connections
Per-connection sub limit MAX_PUBLIC_SUBS_PER_CONN = 5 Prevent subscription flooding
Per-REQ filter limit MAX_PUBLIC_FILTERS_PER_REQ = 3 Prevent expensive fan-out queries
Message size limit MAX_PUBLIC_MSG_BYTES = 4096 Reject oversized payloads before JSON parsing
Backfill limit MAX_PUBLIC_LIMIT = 200 Cap events per subscription
Idle timeout PUBLIC_IDLE_TIMEOUT = 120s Reclaim abandoned connections
Lifetime cap SPROUT_PROXY_PUBLIC_LIFETIME_SECS (default 3600) Force reconnect after 1 hour
WebSocket frame limit max_message_size + max_frame_size Protocol-level rejection of oversized frames

Subscription Lifecycle

The implementation tracks subscriptions with dual sets (active_subs for cap counting, upstream_subs for CLOSE routing), handling all 7 lifecycle events:

  1. REQ (local-only) — served from ChannelMap, tracked in active_subs only
  2. REQ (upstream) — forwarded after translation, tracked in both sets (only after successful send)
  3. REQ (replacement) — tears down existing upstream sub before creating new one (NIP-01 compliance)
  4. Client CLOSE — removes from active_subs, sends upstream CLOSE only if in upstream_subs
  5. Upstream CLOSED — removes from both sets before forwarding to client (prevents slot leak)
  6. Disconnect cleanup — sends CLOSE for all upstream_subs entries
  7. Failed send_req — no tracking (prevents phantom slot consumption)

Bug Fixes (also applied to authenticated handler)

During 9 rounds of iterative review, 12 bugs were found and fixed:

  • Subscription slot leak on upstream CLOSEDactive_subs wasn't cleaned up when upstream sent CLOSED, permanently consuming a slot
  • Phantom slot on failed send_req — subscription was tracked before the upstream send succeeded
  • Missing upstream CLOSE on REQ replacement — NIP-01 requires tearing down the old sub when a client sends a new REQ with the same ID
  • nip11_response unwrap — replaced .unwrap() with proper error handling
  • Local-only REQs not tracked — kind:40/41 subs bypassed the cap counter

Configuration

# Required: comma-separated channel UUIDs to expose publicly
SPROUT_PROXY_PUBLIC_CHANNELS="uuid1,uuid2,uuid3"

# Optional: connection lifetime in seconds (default: 3600)
SPROUT_PROXY_PUBLIC_LIFETIME_SECS=3600

When SPROUT_PROXY_PUBLIC_CHANNELS is empty or unset, the /public route is not registered at all.

Files Changed

File Changes Description
crates/sprout-proxy/src/server.rs +1071 Public handler, DoS limits, NIP-11, 15 new tests
crates/sprout-proxy/src/main.rs +69 Env var parsing, state wiring
crates/sprout-proxy/src/upstream.rs +21 Insert-after-success pattern, active_sub_ids() test helper
crates/sprout-proxy/Cargo.toml +4 Dev dependencies (http-body-util, tower)
Cargo.lock +2 Lockfile update

Testing

  • 98/98 tests passing
  • Clippy clean (zero warnings) ✅
  • New tests cover: EVENT rejection, oversized message handling, subscription limit enforcement, filter limit enforcement, CLOSE freeing slots, invalid JSON handling, upstream CLOSED slot recovery, channel isolation, NIP-11 content negotiation, CORS headers, route registration/non-registration, constant sanity checks, POST rejection
  • Regression test for the upstream CLOSED slot leak uses broadcast injection to simulate relay behavior

Review History

9 rounds of iterative crossfire review (dual-model: GPT-5 + Claude), progressing from 7.5 → 9.5/10. Each round targeted real correctness bugs, not style nits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant