Files
buzz/docs/SPROUT_LITE_MODE.md

16 KiB
Raw Permalink Blame History

Sprout Serverless Mode

Status: Implemented on branch micn/serverless-mode. Channels, DMs, messages, agents (with npub-allowlist permissions), and end-to-end-encrypted private channels + DMs (NIP-17) all work against any generic public Nostr relay — single or multiple, for redundancy — with zero Sprout server infrastructure. See "Implementation" below.

TL;DR for testing

  1. Build & run the desktop app (just dev).
  2. On the welcome screen (or + Add workspace), tick Serverless mode and enter a public relay, e.g. wss://relay.damus.io or wss://nos.lol.
  3. Create a channel, send messages, open a DM. All of it round-trips through the public relay over plain WebSocket — no Postgres, no /query, no auth server.

Server-only surfaces (search, pulse, projects, workflows) are hidden in this mode. Agents are not yet functional serverless (see below).


Sprout Lite — Serverless Relay Mode

A design note for a "no-server" mode in Sprout, modeled on ../slackest: point the desktop client directly at public (or named) Nostr relays, show channels and DMs, and run with zero Sprout server infrastructure — no sprout-relay, no Postgres, no Redis, no Typesense, no auth/membership.


The two worlds today

Note: The sections from here down to "Implementation (what shipped)" are the original design exploration, including the rejected NIP-28/Slackest approach. What actually shipped is Option A (same app, same native Sprout kinds, plain-WS transport) — jump to "Implementation (what shipped on micn/serverless-mode)" for the authoritative description.

Sprout (server mode — what exists now)

The desktop client is thin. It assumes a smart server (sprout-relay) that owns most of the logic:

Concern How Sprout does it today
Transport Native WebSocket via the Tauri Rust backend (invoke, Channel), not a browser socket
Auth NIP-42 AUTH handshake on every connect (relayClientSession.ts); relay rejects unauthed sessions
Channels NIP-29 groups — scoped by h tags, membership enforced server-side (migrations/0001_relay_members.sql, sprout-auth)
Queries Relay p-gate: a REQ with no kinds returns 403 (see AGENTS.md gotchas)
Messages Custom kinds: 9/40002 stream messages, 45001/45003 forum (sprout-core/src/kind.rs, desktop/.../kinds.ts)
Search Server-side Typesense (sprout-search), via NIP-50 search filters
Threads reply_count/descendant_count materialized in Postgres by the relay
Presence/typing Redis pub/sub fan-out (sprout-pubsub)
DMs Routed through relay membership/auth

So "Sprout" = a specific opinionated relay + a client that depends on it.

Slackest (the serverless reference)

../slackest is ~1,200 lines of vanilla TS that talks to any relay:

Concern How Slackest does it
Transport nostr-tools SimplePool directly in the page (browser WS)
Auth None — local nsec, sign-and-publish
Channels NIP-28 (kind 40 create, kind 42 message, e-tag root)
Queries Plain relay REQ with kinds — works on any public relay
Search Client-side only
DMs NIP-04 (kind 4, encrypted)
Identity Generate or import nsec, persisted in Tauri store / localStorage
State All client-side: joined channels, DM contacts, relays, profiles

No server. The relay is a dumb event store. State lives in the client and in whatever public relays you point at.


What "Sprout Lite" means

A mode where Sprout behaves like Slackest: the relay is just a public Nostr relay, all coordination logic moves to (or is skipped in) the client, and none of the sprout-* server crates are involved.

The hard truth up front: Sprout's current channel model (NIP-29 + AUTH + server membership + custom kinds) does not work on a dumb public relay. Lite mode therefore needs a second protocol profile based on open NIPs (NIP-28 channels, NIP-04/NIP-17 DMs, kind 0 profiles) — exactly Slackest's model.

So this is less "flip a flag" and more "add a relay adapter + protocol profile behind the existing client UI."


Sprout already has a workspace concept (features/workspaces/) where each workspace = a relay URL + identity. That's the natural seam. Add a workspace kind:

// features/workspaces/types.ts
type WorkspaceMode = "sprout" | "lite";

type Workspace = {
  id: string;
  name: string;
  relayUrl: string;       // lite: one of several public relays
  relays?: string[];      // lite: a relay set (SimplePool)
  pubkey: string;
  mode: WorkspaceMode;    // NEW
  addedAt: string;
};

Then introduce a client interface that both modes implement, so the React feature hooks don't care which world they're in:

interface SproutClient {
  connect(): Promise<void>;
  disconnect(): void;
  publish(event: UnsignedEvent): Promise<RelayEvent>;
  subscribe(filters: RelaySubscriptionFilter[], onEvent): Subscription;
  queryHistory(filters): Promise<RelayEvent[]>;
  connectionState: ConnectionState;
}
  • RelayClient (existing, relayClientSession.ts) → the sprout impl (Tauri WS + NIP-42 + NIP-29).
  • LiteRelayClient (new) → the lite impl, a thin wrapper over nostr-tools SimplePool, basically Slackest's nostr.ts adapted to the SproutClient interface (no AUTH, no h tags, kinds-only REQs).

useWorkspaceInit.ts picks the implementation based on workspace.mode and stashes it in the same singleton slot the app already uses. Because the app already key-remounts on workspace switch and has a resetWorkspaceState() contract, swapping client implementations per workspace is well-supported — the new lite client just needs its own reset hook registered there.

Why an adapter and not a fork

  • Reuses the entire desktop UI (sidebar, message list, composer, modals, themes) — no second app to maintain.
  • Reuses workspace switching, drafts, profile caches.
  • Keeps the door open to a workspace list that mixes a corporate Sprout relay and public Nostr channels side by side.

Protocol profile for Lite mode

Lite mode speaks open NIPs only (same as Slackest):

Feature Lite kind / NIP Sprout kind (for contrast)
Profile 0 (NIP-01 metadata) 0
Channel create 40 (NIP-28) 39000 channel metadata + NIP-29 group
Channel message 42 (NIP-28), e-tag root 9 / 40002, h-tag
DM 4 (NIP-04) or 1059+14 (NIP-17 gift wrap) server-routed
Reaction 7 (NIP-25) 7
Profile lookup 0 subscription by author server-side

A small kind-mapping layer lets the existing message components render either profile. The renderer cares about {author, text, createdAt, roomKey} — the lite adapter normalizes NIP-28 events into that shape, just like ingestMessage does in Slackest's main.ts.

Channel identity in lite mode is the kind-40 event id, not a name (per Slackest's README) — so the sidebar shows display labels but joins/dedupes on the event id, and the "add channel" modal accepts a pasted channel id.


What you gain and what you lose

Gain

  • Run Sprout against wss://relay.damus.io, wss://nos.lol, etc. with no backend at all.
  • Zero infra to stand up for demos, personal use, or interop testing.
  • Multi-relay redundancy — a serverless workspace holds a list of relays; publishes fan out to all, reads merge + dedup, survive any single relay outage.
  • Agents work — same respond-to / npub-allowlist permission model as the server, enforced in the sprout-acp harness (see below).
  • Private channels + DMs are end-to-end encrypted (NIP-17), so privacy doesn't depend on a trusted server.

Lose (vs. server mode)

  • No server-side access control on public channels — an open channel is open to anyone (which is the point). Privacy for closed groups is provided by encryption instead (NIP-17), not relay enforcement.
  • No server-side search (client-side only, over what you've fetched).
  • No materialized thread counts, presence fan-out, or read-state sync across devices (could be reintroduced later via NIP-29-capable public relays or client-side computation).
  • No workflows / huddle audio / git hosting — those are sprout-relay features with no dumb-relay equivalent. Serverless mode hides these surfaces rather than breaking on them.

Implementation sketch (incremental, low-risk)

  1. Add mode to Workspace and default everything existing to "sprout" (workspaceStorage.ts migration — mirror the existing nsec-strip migration pattern).
  2. Vendor nostr-tools into desktop/package.json (it's already used in slackest; Sprout doesn't ship it client-side today).
  3. Add LiteRelayClient in shared/api/ implementing the SproutClient interface over SimplePool. Port the publish/subscribe/DM-decrypt logic from ../slackest/src/nostr.ts.
  4. Extract a SproutClient interface and make RelayClient conform; wire client selection in useWorkspaceInit.ts by workspace.mode.
  5. Add a kind-mapping/normalization layer so message + channel hooks consume a profile-agnostic shape.
  6. Add an "Add public workspace" flow in onboarding/workspace UI: enter a relay set, generate or import nsec, pick/create NIP-28 channels.
  7. Feature-gate server-only surfaces (agents, workflows, huddle, search- server, presence) behind mode === "sprout".
  8. Register the lite client's reset in resetWorkspaceState().

Steps 13 are independently shippable and unlock a Slackest-equivalent in a single new workspace, without touching the server-mode path at all.


Quick prototype option

If the goal is just to see it working fast, the lowest-effort path is to keep ../slackest as a separate tiny app and treat it as the lite reference client — it already does exactly this. The doc above is the path to folding that capability into the Sprout desktop app so there's one client with a mode switch, rather than two apps.


Implementation (what shipped on micn/serverless-mode)

We took Option A: same app, same native event kinds (39000 channel metadata, 39002 membership, kind 9 messages, h-tag scoping), just pointed at a generic relay. "Serverless" is a transport + auth concern, not a different protocol. Nothing on the existing Sprout-server path changed.

Rust backend (desktop/src-tauri)

  • AppState.serverless (atomic flag) + AppState::is_serverless(). Set by apply_workspace(relay_url, nsec, serverless, …).
  • ws_relay.rsquery_relay_ws (REQ → collect until EOSE → CLOSE) and submit_event_ws (EVENT → wait for OK). NIP-42 AUTH is answered only if the relay challenges; timeouts degrade to best-effort rather than hard-failing.
  • relay.rsquery_relay / submit_event now branch: serverless → ws_relay::* (plain WS), otherwise → the existing HTTP bridge (/query, /events + NIP-98). Every caller (channels, DMs, messages) is unchanged.
  • events.rsbuild_channel_metadata_serverless (kind 39000) and build_channel_members_serverless (kind 39002). In server mode the relay materialises these from command kinds (9007 create, 41010 dm-open); on a dumb relay the client publishes them directly.
  • commands/channels.rs create_channel and commands/dms.rs open_dm branch on is_serverless(): publish 39000 + 39002 directly instead of a server command. DM channel ids are derived deterministically (UUIDv5 over the sorted participant set) so both sides converge without a server assigning one.

Frontend (desktop/src)

  • Workspace.mode: "sprout" | "serverless" (+ workspaceMode() / isServerlessWorkspace() helpers; legacy entries default to sprout).
  • relayClientSession.tssetServerless(true) makes connect() resolve as soon as the socket opens (no blocking on a NIP-42 challenge). Late challenges are still signed so writes succeed on relays that require auth.
  • useWorkspaceInit.ts — calls relayClient.setServerless() and passes the flag to applyWorkspace() before AppShell preconnects.
  • ServerlessContext + useIsServerless() — feature-gates search (Typesense), pulse, projects (git hosting), and workflows in the UI. They degrade to hidden, not broken.
  • Add-workspace + welcome UIs gained a Serverless mode toggle with a public-relay default (wss://relay.damus.io).

Multi-relay (redundancy)

A serverless workspace's relayUrl may be a comma-separated list of relays (seeded in the UI with the public defaults below). The Rust transport fans out: submit_event_ws publishes to all relays (succeeds if any accepts); query_relay_ws queries all concurrently and merges + dedups events by id (succeeds if any responds). The live WebSocket connects to the first (primary) relay. A single dead relay therefore doesn't break reads, writes, or history.

Default public relays (desktop/src/features/workspaces/defaultRelays.ts, sourced from deez): relay.damus.io, nos.lol, relay.nostr.band, nostr.land, nostr.wine.

Agents (same permission model as the server)

Agents work in serverless mode with the same npub-allowlist / respond-to gate as the Sprout server. The gate lives in the sprout-acp harness, not the agent, and runs before any event reaches the agent subprocess — so an agent in a public channel only ever responds to blessed npubs, exactly as configured in the GUI (owner-only / allowlist / anyone / nobody). This is unchanged from server mode; serverless only swaps the transport:

  • sprout-acp's RestClient / HarnessRelay got a serverless mode that swaps the HTTP bridge (/query, /events + NIP-98) for plain-WS REQ/EVENT. Plumbed via SPROUT_SERVERLESS, set by the desktop when launching the agent.
  • sprout-cli (what agents shell out to for writes) got the same WS transport and a --serverless flag.
  • Attaching an agent publishes its pubkey into the channel's kind-39002 member list (so in an encrypted channel the sender wraps a copy to the agent and it can decrypt + respond).

Encrypted private channels + DMs (NIP-17)

On a dumb relay, "private" can't mean server-enforced access — every stored event is world-readable. So in serverless mode, DMs and private-visibility channels are made private by encryption (NIP-17 / NIP-59 gift wrap) instead:

message (kind 9 rumor, with the channel `h` tag)
  → seal      (kind 13, nip44-encrypted to one recipient)
    → gift wrap (kind 1059, ephemeral key, `#p` = recipient, random timestamp)

One gift wrap is published per member (including the sender, so it can read back its own messages). The relay only ever stores opaque kind 1059 blobs addressed by #p — it never sees the channel id, content, or real author. On read, the client subscribes to kind 1059 #p=me, decrypts each in Rust (crate::encrypted + the decrypt_gift_wrap command), and routes the recovered kind-9 rumor (which carries the h tag) into the normal message pipeline. The agent harness does the symmetric thing: it subscribes to its own gift-wrap inbox, unwraps, and feeds the inner event through the respond-to gate unchanged.

Open/public channels stay plaintext kind 9 (that's the point of a public channel). Encryption applies only to DMs and private channels.

This is the small-group model: O(N) gift wraps per message (which naturally caps practical group size), no shared group key, and no forward secrecy on member removal (a removed member keeps any messages they already received). Suitable for small trusted groups; a shared-key / MLS scheme would be required for large groups or forward-secrecy guarantees.

Files: desktop/src-tauri/src/encrypted.rs (crypto + tests), commands/encrypted.rs (decrypt_gift_wrap), commands/messages.rs (encrypted_recipients + send_encrypted_message routing), shared/api/relayClientSession.ts (encrypted history/live read), crates/sprout-acp/src/relay.rs (agent gift-wrap inbox).

What works serverless today

Channels (create/list/join via 39000+39002), channel messages (kind 9, live + history), DMs and private channels (NIP-17 encrypted), profiles (kind 0), reactions/edits/deletes (standard kinds), agents (npub-allowlist gated), multi-relay redundancy — all over plain WS against any public relay, with no Sprout server.