mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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.
291 lines
13 KiB
Markdown
291 lines
13 KiB
Markdown
# 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.
|