The desktop encrypted_recipients() lookup used .first() on a multi-relay
merge of the addressable kind:39002 membership event and silently fell
back to encrypting to only self when the lookup came back empty. With the
membership event present on only some relays (split-brain / propagation
lag), a private-channel message could be gift-wrapped to just the sender —
so the agent (and other members) never received it, with no visible error.
Now: pick the NEWEST 39002 by created_at, and if an encrypted channel
resolves to no other members, return an error so the send fails loudly and
can be retried, instead of silently dropping every other recipient.
Replies in encrypted serverless channels (DM/private) were falling through
to the plaintext path — a privacy leak: the reply content went to public
relays as a cleartext kind-9. The encrypt branch was gated on
parent_event_id.is_none(), so only top-level messages were gift-wrapped.
Fix: encrypt replies too. The NIP-10 thread tags now live INSIDE the rumor
(the encrypted inner event), so threading is preserved without leaking the
reply. The thread root is resolved locally from decrypted messages (the
parent rumor isn't queryable in plaintext on the relay) and passed via a new
root_event_id command arg.
Proven by encrypted_threaded_reply_is_wrapped_and_preserves_thread: asserts
the wire event is kind-1059 (not plaintext kind-9), content never leaks, and
the decrypted rumor carries the NIP-10 root + reply e-tags.
- Route agent typing indicators (kind 20002) through the serverless pool
subscription so the 'agent is typing…' signal shows in serverless mode
(previously dropped — CHANNEL_EVENT_KINDS excluded 20002). useChannelTyping
now listens on serverless-event:<channel> when serverless; appendMessage
skips ephemeral typing events so they never enter the timeline.
- Retry serverless history fetch with short backoff on cold start: public
relays return 0/partial while pooled connections warm up, causing the
'blank for a minute after launch' feeling. Retry until events arrive.
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).
- 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.
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.
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.
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.