The serverless transport was behaving like it talked to the bespoke Sprout
server instead of a standard multi-relay Nostr client. Three real bugs:
1. Split-brain: messages were sent over the multi-relay pool (landing on
whichever relay accepts, e.g. nos.lol when damus rate-limits) but READ over
a single-relay live WS (damus only) — so your own message was invisible.
Fix: in serverless, send + history + LIVE SUBSCRIPTION all go through the
Rust multi-relay pool. New persistent pool subscribe()/unsubscribe() keeps a
REQ open on ALL relays at once and merges+dedups (standard Nostr, like damus
SimplePool). New commands: query_channel_messages, subscribe/unsubscribe_
channel_messages; events streamed to the UI via serverless-event:<channel>.
2. Query flood: get_channels ran {kinds:[39000],limit:5000} (discover ALL
network channels) + a 500-id member-count batch on a public relay. Fix:
serverless skips network-wide discovery; only your member channels are
listed (4 scoped queries).
3. Diagnostics: added [serverless]/[pool] backend logging and [relay] webview
console logging so failures are visible, not silent.
Proven with live tests against damus+nos.lol: multi-relay publish survives
per-relay rate-limit, and the live subscription delivers a message published
to a different relay than the naive primary (the split-brain fix).
Root cause of 'everything is rate-limited' / 'noting too much': the
serverless transport opened a FRESH WebSocket per query and per publish
(connect→send→close). get_channels fires ~10 queries, create channel 2
publishes, add-agent several more — each a new connection. Public relays
(damus, nos.lol) aggressively rate-limit connection storms + event
bursts, so every action failed.
Fix (validated against damus/nostr-tools idiomatic patterns + NIP-01/65):
- ws_pool.rs: one persistent WebSocket per relay, reused for all
queries/publishes; background reader dispatches by sub/event id;
transparent reconnect; NIP-42 AUTH answered on the same socket.
- Don't block on slow relays: publish returns on FIRST acceptance
(FuturesUnordered); connect has a 4s timeout; failed relays enter a
30s cooldown so a dead relay isn't re-dialed every query.
- Multi-relay publish (success if any accepts) confirmed idiomatic —
not a mistake; it's the resilience model and hides per-relay rate caps.
- Pool cleared on workspace switch.
Verified live: 5-relay burst (15 queries + 6 publishes) that previously
hung 3+min now completes in 17s with zero rate-limits; create→join
roundtrip passes on 2 relays. 426 unit tests pass, clippy clean.
New live test: serverless_burst_no_rate_limit.
Serverless private channels and DMs are made private by encryption (no
server to enforce access). Messages are NIP-17 gift-wrapped (kind 1059)
to every member; the relay only sees opaque blobs addressed by #p.
- crate::encrypted: build_gift_wraps (one per member) + unwrap_gift
- send_channel_message: routes serverless DM/private channels through
encryption (rumor=kind9 with h tag -> seal -> wrap per member)
- decrypt_gift_wrap command + relayClient encrypted fetch/subscribe
(query kind1059 #p=me, decrypt, route by inner h tag)
- proven end-to-end over wss://relay.damus.io (group A->{A,B,C}, B reads)
Agent-side gift-wrap support (sprout-acp) follows separately.
Adds a 'serverless' workspace mode that points the desktop app at any generic
public Nostr relay (e.g. wss://relay.damus.io) with zero Sprout server
infrastructure — no Postgres, no HTTP /query|/events bridge, no NIP-98 auth.
Reuses Sprout's native event kinds (39000 channel metadata, 39002 membership,
kind 9 messages, h-tag scoping); 'serverless' is purely a transport + auth
concern, so the server-mode path is untouched.
Transport: reads/writes go over plain WebSocket (REQ/EOSE, EVENT/OK) instead
of the HTTP bridge; NIP-42 AUTH is answered only if the relay challenges.
Desktop backend:
- AppState.serverless flag + is_serverless(); apply_workspace gains a flag
- ws_relay.rs: query_relay_ws / submit_event_ws / publish_signed_event_ws
- relay.rs: query_relay/submit_event + agent profile sync branch to WS
- events.rs: build_channel_metadata_serverless (39000) + members (39002)
- create_channel/open_dm publish those events directly (DM ids = UUIDv5 over
sorted participants so both sides converge without a server)
- managed agents inherit SPROUT_SERVERLESS into the ACP subprocess env
Agents (full support, not a follow-up):
- sprout-acp: RestClient + HarnessRelay gain serverless mode; query/submit use
plain WS, NIP-42 handshake skipped on (re)connect; SPROUT_SERVERLESS env/arg
- sprout-cli: SproutClient serverless WS query/submit; --serverless flag,
to_ws_url helper, NetworkMsg error variant
- npub respond-to permissions are in-process and work unchanged
Frontend:
- Workspace.mode + workspaceMode()/isServerlessWorkspace() helpers
- relayClient.setServerless() skips the AUTH-wait on connect; late challenges
still answered so writes succeed on relays that require auth
- ServerlessContext/useIsServerless() hides search, pulse, projects, workflows
- Serverless toggle in the Add Workspace dialog and Welcome screen
Docs: docs/SPROUT_LITE_MODE.md updated with the implementation + testing steps.