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.
- list_relay_agents returns empty in serverless: managed agents are local
desktop subprocesses, not a relay registry. Fixes 'agent parse failed:
missing field name' from foreign kind-10100 events on public relays.
- submit_event_ws retries once on all-relays-rate-limited (public relays
throttle the write burst when adding an agent). Succeeds if any relay
accepts. Fixes 'Failed to add goose: rate-limited'.
- delete_channel publishes NIP-09 (kind 5) deletion over the 39000/39002
addressable coordinates in serverless mode (the kind-9008 command is a
no-op on a generic relay). Owner-only.
- Tests + file-size exception.
The serverless membership event only stored bare ['p', pubkey] tags with
no role, so every member — including the channel creator — read back as
'member'. This hid the member-invite card on private channels (it requires
owner/admin) and diverged from server mode, where the relay assigns the
creator 'owner'.
Store roles as the 4th p-tag element (['p', pubkey, '', role]) — the NIP-29
convention channel_members_from_event already reads. Creator = owner; new
members default to 'member'; role changes remove+re-add; existing members
keep their role across read-modify-write. Matches server-mode data shape.
The serverless create_channel and open_dm re-queried the relay for the
kind:39000 they had just published, to build ChannelInfo. On a public relay
that immediate round-trip races against propagation/indexing lag and can
return empty, throwing 'metadata not yet available' — surfacing as a private
channel / DM that silently fails to appear in the UI.
Both now derive ChannelInfo directly from the locally-signed metadata event
(we created it, so is_member = true), with no relay round-trip and no race.
Add a live, #[ignore]d end-to-end test proving a HarnessRelay in serverless
mode (as a managed agent runs) receives + decrypts a NIP-17 gift-wrapped
message over a real public relay and surfaces it via next_event() with the
verified sender preserved. Matches the repo's e2e convention (RELAY_URL env,
default damus). Also wire RELAY_URL into the existing serverless live tests so
they aren't hardcoded to damus.
- Delete orphaned HarnessRelay::connect() wrapper (callers use
connect_with_mode); fold its doc into connect_with_mode.
- Gate BgState::new() behind #[cfg(test)] (test-only now that the
runtime path uses with_serverless) instead of #[allow(dead_code)].
- Move KIND_GIFT_WRAP into the encrypted test module (test-only).
No #[allow(dead_code)] / cfg_attr dead-code suppressions remain on
branch-new code. Verified with RUSTFLAGS='-D dead_code -D unused'.
The agent harness now subscribes to its own gift-wrap inbox (kind 1059,
#p = agent) alongside membership notifications, unwraps each, recovers
the inner kind-9 rumor (verified sender + h tag), and forwards it as a
normal SproutEvent. The respond-to allowlist gate and rule matching run
unchanged — so an agent in an encrypted private channel only responds to
blessed npubs, exactly as on the Sprout server.
- send_gift_wrap_subscribe (mirrors observer-control), tied to membership
sub (active + reconnect restore)
- dispatch: 1059 on GIFT_WRAP_SUB_ID -> UnwrappedGift -> rumor_to_event
- rumor_to_event: UnsignedEvent -> Event (seal already verified sender;
pipeline never reads .sig)
- nip59 enabled workspace-wide; unit test covers unwrap->channel event
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.
Serverless workspaces now take a comma-separated relay list (seeded with the
5 default public relays) for redundancy:
- ws_relay query/submit/publish fan out to all relays concurrently; reads
merge+dedup by event id, writes succeed if any relay accepts
- relay_ws_urls_with_override parses the comma list; live WS uses first relay
- setup UIs (Welcome + AddWorkspace) have multi-select relay chips
- integration test proves publish-to-2 / read-deduped-from-2
The 'join to participate, nothing happens' bug: when a user creates or
joins a serverless channel they p-tag themselves (the signer). nostr 0.44
strips self-referencing p tags at sign time unless .allow_self_tagging()
is set, so kind:39002 membership events were published with an EMPTY
member list. get_channels then queries 39002 by #p:[me], finds nothing,
and the UI stays stuck on 'join to participate' forever.
(This worked in the old TS/slackest codebase because nostr-tools does not
perform that scrub.)
Fix: add .allow_self_tagging() to the serverless 39000 + 39002 builders
in both desktop events.rs and the shared sprout-sdk builders (CLI/agents).
Reproduced and verified end-to-end against wss://relay.damus.io via a new
#[ignore] integration test (serverless_create_join_roundtrip): create ->
join -> both members visible. Added no-network unit regression guards
(serverless_{members,metadata}_keeps_self_p_tag) in both crates.
clippy clean (desktop+sdk+cli); desktop + sdk tests pass; file-size gate ok.
Add DEFAULT_PUBLIC_RELAYS (sourced from deez/crates/mesh-client
DEFAULT_RELAYS) as quick-pick chips when creating a serverless
workspace, in both the Add Workspace dialog and the Welcome screen.
- relay.damus.io, nos.lol, relay.nostr.band, nostr.land, nostr.wine
- chips one-tap fill the relay URL field (serverless mode only)
- DEFAULT_SERVERLESS_RELAY prefills when serverless is first enabled
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.
The release notes extraction failed on two counts: awk errors when
CHANGELOG.md doesn't exist (first release has no changelog), and
head -n -1 is a GNU extension unsupported on macOS runners. Guard
with a file existence check and replace head with sed '$d'.