Files
buzz/docs/SPROUT_LITE_MODE.md
T
Michael Neale 84f84931f1 feat(serverless): run channels, DMs, messages, and agents against a generic Nostr relay
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.
2026-06-01 20:42:29 +10:00

291 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sprout Serverless Mode
> **Status:** Implemented (first cut) on branch `micn/serverless-mode`.
> Channels, DMs, and messages work against any generic public Nostr relay with
> **zero** Sprout server infrastructure. See "Implementation" below for what
> shipped and "Agents" for the known follow-up.
## 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
### 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."
---
## Recommended approach: Adapter behind the Workspace abstraction
Sprout already has a **workspace** concept (`features/workspaces/`) where each
workspace = a relay URL + identity. That's the natural seam. Add a workspace
*kind*:
```ts
// 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:
```ts
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.
- Real Nostr interop — channels visible to any NIP-28 client.
**Lose (vs. server mode)**
- No membership/access control — public channels are public.
- 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 agents/workflows/huddle audio/git hosting — those are `sprout-relay`
features with no dumb-relay equivalent. Lite mode should **hide** these
surfaces rather than break 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 1–3 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.rs`** — `query_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.rs`** — `query_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.rs`** — `build_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.ts`** — `setServerless(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`).
### What works serverless today
Channels (create/list/join via 39000+39002), channel messages (kind 9, live +
history), DMs (deterministic channel id, 39000+39002), profiles (kind 0),
reactions/edits/deletes (standard kinds) — all over plain WS against any relay.
---
## Agents in serverless mode (known follow-up)
Agents **launch** fine (they're local subprocesses with their own keypair) and
the desktop-side attach (kind 39002 membership) works over the serverless WS
path. **But the agent harness `sprout-acp` does not yet work against a generic
relay**: it routes all reads and some writes through the Sprout HTTP bridge
(`POST /query`, `POST /events`) with NIP-98 + a NIP-OA `x-auth-tag`, and relies
on server-side NIP-29 membership scoping. So an attached agent can't currently
discover channels or build prompt context on a dumb relay.
The fix is the *same pattern* applied here, one layer down: give `sprout-acp`'s
`RestClient` a serverless mode that swaps `bridge_post("/query")` /
`("/events")` for plain-WS REQ/EVENT (it already holds a WS connection for the
live stream). The npub-permission / respond-to gate is in-process and needs no
server, so once the transport is swapped, agent permissions work unchanged.
Until then, serverless mode hides nothing about agents but they will be inert
in a serverless channel.