mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
docs: ARCHITECTURE.md accuracy pass — verified against every line of code (#236)
This commit is contained in:
+197
-108
@@ -6,7 +6,7 @@ Sprout is a self-hosted team communication platform built on the Nostr protocol
|
||||
|
||||
The relay is the single source of truth. All reads and writes flow through it. There is no peer-to-peer event exchange, no gossip, no replication — just clients connecting to one relay over WebSocket, and the relay enforcing auth, verifying signatures, persisting events, fanning out to subscribers, indexing for search, and triggering automation.
|
||||
|
||||
Sprout is a Rust monorepo (~22.7K LOC across 13 crates), licensed Apache 2.0 under Block, Inc.
|
||||
Sprout is a Rust monorepo (~72K LOC across 17 crates), licensed Apache 2.0 under Block, Inc.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,10 +53,10 @@ Sprout is a Rust monorepo (~22.7K LOC across 13 crates), licensed Apache 2.0 und
|
||||
Redis PUBLISH occurs for channel-scoped events.
|
||||
PSUBSCRIBE subscriber loop runs and a consumer task
|
||||
fans out received events to local WS connections
|
||||
(multi-node fan-out wired; local-echo dedup is TODO).
|
||||
(multi-node fan-out wired; local-echo dedup via AppState.local_event_ids).
|
||||
|
||||
┌──────────────┐
|
||||
│ Typesense │ ← sprout-search (async, spawned per event)
|
||||
│ Typesense │ ← sprout-search (bounded worker queue)
|
||||
│ (full-text │
|
||||
│ search) │
|
||||
└──────────────┘
|
||||
@@ -78,9 +78,13 @@ sprout-core (zero I/O — types, verification, filter matching, kind registry)
|
||||
│
|
||||
└── sprout-relay (ties everything together — the server)
|
||||
|
||||
sprout-mcp (agent API surface — stdio MCP server; no sprout-* Cargo deps)
|
||||
sprout-mcp (agent API surface — stdio MCP server; depends on sprout-core and sprout-sdk)
|
||||
sprout-acp (agent harness — bridges relay @mentions → AI agents via ACP/JSON-RPC)
|
||||
sprout-proxy (NIP-28 compatibility proxy — translates standard Nostr clients ↔ Sprout relay)
|
||||
sprout-huddle (LiveKit audio/video integration — standalone, not wired into relay)
|
||||
sprout-sdk (typed Nostr event builders — used by sprout-mcp, sprout-acp, and sprout-cli)
|
||||
sprout-media (Blossom/S3 media storage)
|
||||
sprout-cli (agent-first CLI)
|
||||
sprout-admin (operator CLI: mint/list API tokens)
|
||||
sprout-test-client (integration test harness + manual CLI)
|
||||
```
|
||||
@@ -130,7 +134,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa
|
||||
| 46001–46012 | KIND_WORKFLOW_* | Workflow execution events |
|
||||
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |
|
||||
|
||||
`sprout-core` defines all 74 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Sprout uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
|
||||
`sprout-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Sprout uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
|
||||
|
||||
Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `sprout-core/src/kind.rs` and imported by `sprout-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `sprout-core/src/kind.rs`; `sprout-mcp/src/server.rs` uses the constant via import.
|
||||
|
||||
@@ -149,7 +153,7 @@ Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `sprout-core/src/kind
|
||||
| Relay → Client | `["NOTICE", "message"]` | Informational message |
|
||||
| Relay → Client | `["AUTH", <challenge>]` | Authentication challenge |
|
||||
|
||||
Max frame size: 65,536 bytes. Max subscriptions per connection: 100. Max historical results per filter: 500.
|
||||
Max frame size: 65,536 bytes. Max subscriptions per connection: 1024. Max historical results per filter: 500.
|
||||
|
||||
---
|
||||
|
||||
@@ -172,7 +176,7 @@ The client must respond with `["AUTH", <signed-event>]` before submitting events
|
||||
| Path | Mechanism | Use Case |
|
||||
|------|-----------|---------|
|
||||
| NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay |
|
||||
| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth` tag | Human SSO via Okta |
|
||||
| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta |
|
||||
| NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts |
|
||||
| HTTP Bearer JWT | `Authorization: Bearer <jwt>` header on REST endpoints | REST API clients |
|
||||
|
||||
@@ -188,7 +192,7 @@ Three concurrent tasks run for the lifetime of the connection:
|
||||
|
||||
A `CancellationToken` coordinates shutdown across all three loops.
|
||||
|
||||
Slow clients: `ConnectionState::send()` uses `try_send` — if the send buffer is full, the connection is cancelled immediately (no backpressure, no queuing).
|
||||
Slow clients: `ConnectionState::send()` uses `try_send` — if the send buffer is full, a grace counter increments. After `SLOW_CLIENT_GRACE_LIMIT` (3) consecutive full-buffer events, the connection is cancelled. A successful send resets the counter.
|
||||
|
||||
### Step 5: Cleanup
|
||||
|
||||
@@ -212,19 +216,19 @@ When the relay receives `["EVENT", <event>]`, the handler in `handlers/event.rs`
|
||||
4. EPHEMERAL ROUTE — kind 20000–29999 → ephemeral sub-pipeline (see below)
|
||||
5. VERIFY — spawn_blocking(verify_event) — Schnorr sig + ID hash
|
||||
6. MEMBERSHIP — channel_id in event tags? → check_channel_membership
|
||||
7. DB INSERT — db.insert_event (INSERT IGNORE — idempotent)
|
||||
7. DB INSERT — db.insert_event (ON CONFLICT DO NOTHING — idempotent)
|
||||
8. REDIS PUBLISH — pubsub.publish_event (if channel-scoped)
|
||||
9. FAN-OUT — sub_registry.fan_out → conn_manager.send_to
|
||||
10. SEARCH INDEX — search.index_event (spawned async, non-blocking)
|
||||
10. SEARCH INDEX — search_index_tx.send (bounded worker queue, non-blocking)
|
||||
11. AUDIT LOG — audit.log (spawned async, non-blocking)
|
||||
12. WORKFLOW TRIGGER — wf.on_event (spawned async, excludes kinds 46001–46012)
|
||||
```
|
||||
|
||||
Steps 10–12 are fire-and-forget: they are spawned as independent async tasks. A failure in search indexing or audit logging does not fail the event submission. The client receives `["OK", <id>, true, ""]` at the end of the pipeline (after all spawns), not immediately after DB insert.
|
||||
Steps 10–12 are fire-and-forget. Search indexing is sent to a bounded worker queue (`search_index_tx`, capacity 1000); audit and workflow triggers are spawned as independent async tasks. A failure in any of these does not fail the event submission. The client receives `["OK", <id>, true, ""]` at the end of the pipeline, not immediately after DB insert.
|
||||
|
||||
Step 9 (fan-out) explicitly **excludes** global subscriptions (no `channel_id` constraint) from channel-scoped events — global subscriptions do NOT receive events from private channels, regardless of filter match. This is a deliberate security boundary: only subscriptions scoped to an accessible `channel_id` receive those events.
|
||||
|
||||
Workflow loop prevention: kinds 46001–46012 (workflow execution events) are excluded from triggering workflows. Exception: stream message kind 9 (`KIND_STREAM_MESSAGE`) always triggers regardless of other exclusion rules. Kind 40002 (`KIND_STREAM_MESSAGE_V2`) does not trigger workflows.
|
||||
Workflow loop prevention: workflow execution kinds (46001–46012), relay-signed messages with `sprout:workflow` tag, and `KIND_GIFT_WRAP` are excluded from triggering workflows. All other stored events (including kind 9 stream messages) trigger workflow evaluation.
|
||||
|
||||
### Ephemeral Sub-Pipeline (kinds 20000–29999)
|
||||
|
||||
@@ -242,14 +246,16 @@ Presence events skip membership checks and use local-only fan-out. Multi-node pr
|
||||
```
|
||||
1. VERIFY — spawn_blocking(verify_event)
|
||||
2. MEMBERSHIP — check_channel_membership (if channel-scoped)
|
||||
3. REDIS PUBLISH — pubsub.publish_event (no DB write)
|
||||
3. MARK LOCAL — state.mark_local_event (dedup before Redis round-trip)
|
||||
4. REDIS PUBLISH — pubsub.publish_event (no DB write)
|
||||
5. LOCAL FAN-OUT — sub_registry.fan_out → conn_manager.send_to
|
||||
```
|
||||
|
||||
Ephemeral events are never stored in Postgres and never appear in REQ historical queries.
|
||||
|
||||
### Handler Semaphore
|
||||
|
||||
Beyond the per-connection semaphore, a `handler_semaphore` (capacity 64) limits concurrent EVENT and REQ processing across all connections. CLOSE is not rate-limited.
|
||||
Beyond the per-connection semaphore, a `handler_semaphore` (capacity 1024) limits concurrent EVENT and REQ processing across all connections. CLOSE is not rate-limited.
|
||||
|
||||
---
|
||||
|
||||
@@ -312,7 +318,7 @@ After registering, the REQ handler queries Postgres for stored events matching t
|
||||
|
||||
### sprout-core — Shared Types and Verification
|
||||
|
||||
**726 LOC. Zero I/O.** The foundation every other crate builds on. Explicitly prohibits tokio, sqlx, redis, and axum in its `Cargo.toml`.
|
||||
**1,196 LOC. Zero I/O.** The foundation every other crate builds on. Explicitly prohibits tokio, sqlx, redis, and axum in its `Cargo.toml`.
|
||||
|
||||
**Key types:**
|
||||
|
||||
@@ -324,7 +330,7 @@ pub struct StoredEvent {
|
||||
verified: bool, // private — use is_verified()
|
||||
}
|
||||
|
||||
pub const ALL_KINDS: &[u32] // 74 entries
|
||||
pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored)
|
||||
```
|
||||
|
||||
**Key functions:**
|
||||
@@ -333,7 +339,7 @@ pub const ALL_KINDS: &[u32] // 74 entries
|
||||
|----------|---------|
|
||||
| `filters_match(filters, event)` | OR across filters, AND within each filter. Includes NIP-01 prefix matching on event IDs. |
|
||||
| `verify_event(event)` | Schnorr signature + SHA-256 ID check. CPU-bound — callers use `spawn_blocking`. |
|
||||
| `is_private_ip(ip)` | SSRF protection: IPv4 loopback/private/link-local/CGNAT/benchmarking + IPv6 loopback/ULA/link-local/multicast + IPv4-mapped IPv6. |
|
||||
| `is_private_ip(ip)` | SSRF protection: IPv4 unspecified/loopback/private/link-local/CGNAT/benchmarking/broadcast + IPv6 loopback/ULA/link-local/multicast/documentation + IPv4-mapped IPv6. |
|
||||
|
||||
**Does NOT:** store events, make network calls, spawn tasks, or depend on any async runtime.
|
||||
|
||||
@@ -341,15 +347,15 @@ pub const ALL_KINDS: &[u32] // 74 entries
|
||||
|
||||
### sprout-auth — Authentication and Authorization
|
||||
|
||||
**1,810 LOC.** Handles all four authentication paths, JWKS caching, scope enforcement, and token operations.
|
||||
**2,310 LOC.** Handles all four authentication paths, JWKS caching, scope enforcement, and token operations.
|
||||
|
||||
**Four auth paths:**
|
||||
|
||||
| Path | Entry Point | Notes |
|
||||
|------|-------------|-------|
|
||||
| NIP-42 only | `verify_auth_event()` | Dev mode; grants `[MessagesRead, MessagesWrite]` |
|
||||
| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth` tag; JWKS-validated |
|
||||
| NIP-42 + API token | `verify_auth_event()` | `auth_token` tag; constant-time hash compare |
|
||||
| NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) |
|
||||
| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated |
|
||||
| NIP-42 + API token | Relay AUTH handler → DB lookup | `auth_token` tag with `sprout_` prefix; relay intercepts before `verify_auth_event()` (which has no DB access) |
|
||||
| HTTP Bearer JWT | `validate_bearer_jwt()` | REST endpoints; skips pubkey cross-check; always adds `ChannelsRead` |
|
||||
|
||||
**Key types:**
|
||||
@@ -379,18 +385,23 @@ pub trait RateLimiter: Send + Sync { ... }
|
||||
|
||||
### sprout-db — Postgres Event Store
|
||||
|
||||
**3,698 LOC.** All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — no `.sqlx/` offline cache required.
|
||||
**7,367 LOC.** All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — no `.sqlx/` offline cache required.
|
||||
|
||||
**Key operations:**
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| `event.rs` | `insert_event` (INSERT IGNORE), `query_events` (QueryBuilder), `get_event_by_id` |
|
||||
| `event.rs` | `insert_event` (ON CONFLICT DO NOTHING), `query_events` (QueryBuilder), `get_event_by_id` |
|
||||
| `channel.rs` | Channel CRUD, membership management, role enforcement (transactional) |
|
||||
| `feed.rs` | `query_mentions` (JSON_CONTAINS), `query_needs_action`, `query_activity` |
|
||||
| `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` |
|
||||
| `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens |
|
||||
| `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables |
|
||||
| `api_token.rs` | Token creation; receives pre-hashed token from caller |
|
||||
| `dm.rs` | DM channel management |
|
||||
| `reaction.rs` | Reaction storage and retrieval |
|
||||
| `thread.rs` | Thread/reply tracking |
|
||||
| `user.rs` | User profile storage |
|
||||
| `error.rs` | Database error types |
|
||||
|
||||
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`
|
||||
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot`
|
||||
@@ -398,13 +409,14 @@ pub trait RateLimiter: Send + Sync { ... }
|
||||
**Run statuses:** `Pending`, `Running`, `WaitingApproval`, `Completed`, `Failed`, `Cancelled`
|
||||
|
||||
**Key behaviors:**
|
||||
- `INSERT IGNORE` for event dedup — returns `(StoredEvent, was_inserted: bool)`.
|
||||
- `ON CONFLICT DO NOTHING` for event dedup — returns `(StoredEvent, was_inserted: bool)`.
|
||||
- Rejects `KIND_AUTH` (22242) and ephemeral (20000–29999) with distinct error variants.
|
||||
- Transactional role enforcement in `add_member`/`remove_member`/`create_channel` — TOCTOU-safe.
|
||||
- Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it.
|
||||
- Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit.
|
||||
- `query_mentions` uses `JSON_CONTAINS(tags, '["p","<pubkey>"]', '$')` — full table scan (no JSON index). Phase 2 plan: normalized `mentions` table with composite index on `(pubkey_hex, created_at)`.
|
||||
- Approval tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`.
|
||||
- `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`.
|
||||
- API tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`.
|
||||
- Approval tokens: separate path — `create_approval` receives the raw token and hashes it internally.
|
||||
- DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators.
|
||||
|
||||
**Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres.
|
||||
@@ -413,7 +425,7 @@ pub trait RateLimiter: Send + Sync { ... }
|
||||
|
||||
### sprout-pubsub — Redis Pub/Sub, Presence, Typing
|
||||
|
||||
**735 LOC.** Manages Redis pub/sub fan-out, presence tracking, and typing indicators.
|
||||
**887 LOC.** Manages Redis pub/sub fan-out, presence tracking, and typing indicators.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
@@ -425,7 +437,7 @@ Subscriber → dedicated PubSub → PSUBSCRIBE sprout:channel:*
|
||||
|
||||
The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from the pool. This is intentional: pool connections cannot hold `PSUBSCRIBE` state.
|
||||
|
||||
**Current state:** The subscriber loop is spawned in `sprout-relay/src/main.rs` and populates the broadcast channel. A consumer task subscribes via `pubsub.subscribe_local()`, calls `sub_registry.fan_out()` on each received event, and delivers matches to local WebSocket connections via `conn_manager.send_to()`. Multi-node fan-out is now wired end-to-end. Note: local-echo deduplication is not yet implemented — events published by the local relay instance are re-delivered to local subscribers via the Redis round-trip; NIP-01 client-side dedup handles this in practice (TODO: server-side dedup in a follow-up).
|
||||
**Current state:** The subscriber loop is spawned in `sprout-relay/src/main.rs` and populates the broadcast channel. A consumer task subscribes via `pubsub.subscribe_local()`, calls `sub_registry.fan_out()` on each received event, and delivers matches to local WebSocket connections via `conn_manager.send_to()`. Multi-node fan-out is now wired end-to-end. Local-echo deduplication is implemented via `AppState.local_event_ids` — events published by the local relay instance are tracked and skipped when received via the Redis round-trip.
|
||||
|
||||
**Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt.
|
||||
|
||||
@@ -445,7 +457,7 @@ EXPIRE sprout:typing:{channel_id} 60
|
||||
|
||||
### sprout-search — Typesense Integration
|
||||
|
||||
**1,043 LOC.** Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-API-KEY`.
|
||||
**1,126 LOC.** Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-API-KEY`.
|
||||
|
||||
**Collection schema (7 fields):** `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]).
|
||||
|
||||
@@ -463,13 +475,13 @@ EXPIRE sprout:typing:{channel_id} 60
|
||||
|
||||
### sprout-audit — Hash-Chain Audit Log
|
||||
|
||||
**732 LOC.** Tamper-evident append-only log with SHA-256 hash chaining.
|
||||
**776 LOC.** Tamper-evident append-only log with SHA-256 hash chaining.
|
||||
|
||||
**Hash chain:** each entry stores `prev_hash` (hash of the previous entry). `verify_chain()` walks entries and recomputes hashes to detect tampering. Genesis entry uses `GENESIS_HASH` (64 zeros).
|
||||
|
||||
**Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash.
|
||||
|
||||
**Single-writer guarantee:** `SELECT GET_LOCK("sprout_audit", 10)` before each transaction. Lock released via `DO RELEASE_LOCK(?)` in all branches including panic (`catch_unwind`).
|
||||
**Single-writer guarantee:** `pg_advisory_lock` before each transaction. Lock released in all branches including panic (`catch_unwind`).
|
||||
|
||||
**10 audit actions:** `EventCreated`, `EventDeleted`, `ChannelCreated`, `ChannelUpdated`, `ChannelDeleted`, `MemberAdded`, `MemberRemoved`, `AuthSuccess`, `AuthFailure`, `RateLimitExceeded`.
|
||||
|
||||
@@ -479,7 +491,7 @@ EXPIRE sprout:typing:{channel_id} 60
|
||||
|
||||
### sprout-workflow — YAML-as-Code Automation Engine
|
||||
|
||||
**2,717 LOC.** Parses, validates, and executes channel-scoped workflow definitions.
|
||||
**4,012 LOC.** Parses, validates, and executes channel-scoped workflow definitions.
|
||||
|
||||
**Workflow definition structure:**
|
||||
```yaml
|
||||
@@ -520,9 +532,9 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
**Concurrency:** `Arc<Semaphore>` with 100 permits. `try_acquire()` — returns `CapacityExceeded` immediately rather than queuing.
|
||||
|
||||
**Approval gates:** `request_approval` action generates a UUID token (CSPRNG), stores hashed in DB, returns `StepResult::Suspended`. `execute_from_step()` resumes from the suspended step index with reconstructed outputs.
|
||||
**Approval gates:** `request_approval` action returns `StepResult::Suspended` with a generated UUID token, but the engine does not yet persist the token or resume execution — runs that hit an approval gate are marked as failed (🚧 WF-08). `execute_from_step()` exists for future resumption support.
|
||||
|
||||
**Cron scheduler:** loop runs every 60 seconds. **Execution is TODO** — loop body logs "not yet implemented."
|
||||
**Cron scheduler:** loop ticks every 60 seconds, evaluates cron expressions with window-based matching, and creates workflow runs for matched triggers. Fully implemented.
|
||||
|
||||
**Does NOT:** recursively resolve templates (single-pass only). Does NOT queue workflow runs when at capacity — returns `CapacityExceeded` immediately.
|
||||
|
||||
@@ -530,7 +542,7 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
### sprout-proxy — NIP-28 Compatibility Proxy
|
||||
|
||||
**~4,500 LOC.** Lets standard Nostr clients (Coracle, nak, Amethyst, nostr-tools, nostr-sdk) read and write Sprout channels using the NIP-28 Public Chat Channels protocol. Connects to the relay as a WebSocket client; presents a standard NIP-01/NIP-11/NIP-28/NIP-42 interface to external clients.
|
||||
**4,933 LOC.** Lets standard Nostr clients (Coracle, nak, Amethyst, nostr-tools, nostr-sdk) read and write Sprout channels using the NIP-28 Public Chat Channels protocol. Connects to the relay as a WebSocket client; presents a standard NIP-01/NIP-11/NIP-28/NIP-42 interface to external clients.
|
||||
|
||||
**Key modules:** `server.rs` (Axum WebSocket server, NIP-11, NIP-42 auth, filter splitting), `translate.rs` (bidirectional kind/tag translation), `upstream.rs` (persistent relay connection with auto-reconnect and subscription replay), `channel_map.rs` (bidirectional UUID ↔ kind:40 event ID mapping), `shadow_keys.rs` (deterministic keypair derivation), `guest_store.rs` (pubkey-based guest registry), `invite_store.rs` (token-based invite system).
|
||||
|
||||
@@ -538,24 +550,27 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
**Kind translation (lossy):**
|
||||
|
||||
*Inbound (client → relay):*
|
||||
`KindTranslator` defines the full mapping between standard Nostr kinds and Sprout kinds. The proxy's event paths gate which kinds actually flow through — only a subset is accepted inbound or emitted outbound.
|
||||
|
||||
*Inbound (client → relay) — accepted kinds:*
|
||||
|
||||
| Standard Kind | Sprout Kind | Note |
|
||||
|--------------|-------------|------|
|
||||
| 1, 40, 42 | KIND_STREAM_MESSAGE | Multiple → one (lossy) |
|
||||
| 41, 44 | KIND_STREAM_MESSAGE_EDIT | Multiple → one (lossy) |
|
||||
| 4 | KIND_DM_CREATED | Encrypted DM |
|
||||
| 43 | KIND_NIP29_DELETE_EVENT | NIP-29 delete |
|
||||
| 1, 42 | KIND_STREAM_MESSAGE | Multiple → one (lossy) |
|
||||
| 41 | KIND_STREAM_MESSAGE_EDIT | Channel message edit |
|
||||
| 7 | KIND_REACTION | Reaction (pass-through kind) |
|
||||
|
||||
*Outbound (relay → client):*
|
||||
Kind 5 (deletion) is intentionally blocked inbound — the relay's deletion handler lacks author-match authorization for proxy clients. Kinds 4, 40, 43, 44 are defined in `KindTranslator` but not accepted by the proxy's inbound path.
|
||||
|
||||
*Outbound (relay → client) — emitted kinds:*
|
||||
|
||||
| Sprout Kind | Standard Kind | Note |
|
||||
|-------------|--------------|------|
|
||||
| KIND_STREAM_MESSAGE | 42 | NIP-28 channel message |
|
||||
| KIND_STREAM_MESSAGE_V2 | 42 | Rich format collapses to plain kind:42 |
|
||||
| KIND_STREAM_MESSAGE_EDIT | 41 | NIP-28 channel message edit |
|
||||
| KIND_DM_CREATED | 4 | Encrypted DM |
|
||||
| KIND_NIP29_DELETE_EVENT | 43 | NIP-29 delete |
|
||||
| KIND_REACTION | 7 | Reaction |
|
||||
| KIND_DELETION | 5 | Standard NIP-09 deletion |
|
||||
|
||||
`to_sprout(to_standard(k))` is NOT lossless for secondary mappings (e.g., kind:1 → KIND_STREAM_MESSAGE → kind:42). Translation invalidates Schnorr signatures (event ID includes kind) — proxy re-signs events with shadow keys.
|
||||
|
||||
@@ -571,7 +586,7 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
### sprout-huddle — LiveKit Audio/Video Integration
|
||||
|
||||
**659 LOC.** Mints LiveKit JWT tokens and parses LiveKit webhook events. In-memory session tracking.
|
||||
**728 LOC.** Mints LiveKit JWT tokens and parses LiveKit webhook events. Defines session/participant data structures (no active registry — types only).
|
||||
|
||||
**JWT token:** HS256, 6-hour TTL (overridable). Claims: `iss` (api_key), `sub` (identity), `iat`, `exp`, `name`, `video` (VideoGrant: room, roomJoin, canPublish, canSubscribe).
|
||||
|
||||
@@ -579,7 +594,7 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
**5 webhook event types:** `RoomStarted`, `RoomFinished`, `ParticipantJoined`, `ParticipantLeft`, `TrackPublished`.
|
||||
|
||||
**Session tracking:** `HuddleSession` with `Vec<HuddleParticipant>`. Participants tracked with `joined_at`, `left_at`, and `Vec<TrackInfo>`. **Sessions are lost on process restart** — in-memory only.
|
||||
**Session types:** `HuddleSession` with `Vec<HuddleParticipant>`. Participants have `joined_at`, `left_at`, and `Vec<TrackInfo>`. These are data structures and helpers only — no session registry or lifecycle manager exists in the crate.
|
||||
|
||||
**Room naming:** `"sprout-{uuid}"` format via `create_room_name(channel_id)`.
|
||||
|
||||
@@ -589,22 +604,26 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
|
||||
|
||||
### sprout-relay — The Server
|
||||
|
||||
**4,852 LOC.** Axum WebSocket server. Ties all other crates together. The only crate that imports and orchestrates all subsystems.
|
||||
**14,327 LOC.** Axum WebSocket server. Ties all other crates together. The only crate that imports and orchestrates all subsystems.
|
||||
|
||||
**`AppState`** (Arc-wrapped, shared across all connections):
|
||||
**`AppState`** (Arc-wrapped, shared across all connections — key fields shown, not exhaustive):
|
||||
|
||||
```rust
|
||||
pub struct AppState {
|
||||
db: Db,
|
||||
audit: AuditService,
|
||||
pubsub: Arc<PubSubManager>,
|
||||
auth: AuthService,
|
||||
search: SearchService,
|
||||
sub_registry: Arc<SubscriptionRegistry>,
|
||||
conn_manager: Arc<ConnectionManager>,
|
||||
workflow_engine: WorkflowEngine,
|
||||
conn_semaphore: Arc<Semaphore>, // connection limit
|
||||
handler_semaphore: Arc<Semaphore>, // 64 concurrent handlers
|
||||
pub db: Db,
|
||||
pub audit: Arc<AuditService>,
|
||||
pub pubsub: Arc<PubSubManager>,
|
||||
pub auth: Arc<AuthService>,
|
||||
pub search: Arc<SearchService>,
|
||||
pub sub_registry: Arc<SubscriptionRegistry>,
|
||||
pub conn_manager: Arc<ConnectionManager>,
|
||||
pub workflow_engine: Arc<WorkflowEngine>,
|
||||
pub conn_semaphore: Arc<Semaphore>, // connection limit
|
||||
pub handler_semaphore: Arc<Semaphore>, // 1024 concurrent handlers
|
||||
pub relay_keypair: nostr::Keys, // relay identity
|
||||
pub local_event_ids: moka::sync::Cache, // local-echo dedup
|
||||
pub search_index_tx: mpsc::Sender, // bounded search worker queue
|
||||
// + config, redis_pool, membership_cache, media_storage, shutdown state
|
||||
}
|
||||
```
|
||||
|
||||
@@ -624,17 +643,41 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/channels` | List accessible channels |
|
||||
| GET | `/api/channels/{channel_id}` | Get channel detail + metadata |
|
||||
| GET | `/api/channels/{channel_id}/members` | List channel members |
|
||||
| GET | `/api/channels/{channel_id}/canvas` | Get channel canvas |
|
||||
| GET | `/api/channels/{channel_id}/messages` | List channel messages |
|
||||
| GET | `/api/channels/{channel_id}/threads/{event_id}` | Get message thread |
|
||||
| GET/POST | `/api/channels/{channel_id}/workflows` | List/create channel workflows |
|
||||
| GET | `/api/search` | Full-text search via Typesense |
|
||||
| GET | `/api/agents` | List agent accounts |
|
||||
| GET | `/api/presence` | Presence status (bulk) |
|
||||
| GET/PUT | `/api/presence` | Presence status (bulk) / set presence |
|
||||
| GET | `/api/feed` | Personalized feed (mentions/needs-action/activity) |
|
||||
| GET/POST | `/api/channels/{id}/workflows` | List/create channel workflows |
|
||||
| POST | `/api/events` | Submit event via REST (no WebSocket) |
|
||||
| GET | `/api/events/{id}` | Get event by ID |
|
||||
| GET/PUT/DELETE | `/api/workflows/{id}` | Workflow CRUD |
|
||||
| GET | `/api/workflows/{id}/runs` | Execution history |
|
||||
| GET | `/api/workflows/{id}/runs/{run_id}/approvals` | List run approvals |
|
||||
| POST | `/api/workflows/{id}/trigger` | Manual trigger |
|
||||
| POST | `/api/workflows/{id}/webhook` | Webhook trigger (HMAC-verified) |
|
||||
| POST | `/api/approvals/{token}/grant` | Approve a workflow step |
|
||||
| POST | `/api/approvals/{token}/deny` | Deny a workflow step |
|
||||
| POST | `/api/approvals/{token}/grant` | Approve a workflow step (🚧 unreachable — see WF-08) |
|
||||
| POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) |
|
||||
| POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) |
|
||||
| POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) |
|
||||
| GET/POST/DELETE | `/api/tokens` | List/create/delete all API tokens |
|
||||
| DELETE | `/api/tokens/{id}` | Delete specific API token |
|
||||
| GET | `/api/dms` | List DM channels |
|
||||
| POST | `/api/dms` | Open a DM channel |
|
||||
| POST | `/api/dms/{channel_id}/members` | Add DM member |
|
||||
| POST | `/api/dms/{channel_id}/hide` | Hide a DM channel |
|
||||
| GET | `/api/messages/{event_id}/reactions` | List reactions on a message |
|
||||
| GET | `/api/users/me/profile` | Get own profile |
|
||||
| PUT | `/api/users/me/channel-add-policy` | Set channel add policy |
|
||||
| GET | `/api/users/search` | Search users |
|
||||
| GET | `/api/users/{pubkey}/profile` | Get user profile by pubkey |
|
||||
| POST | `/api/users/batch` | Batch-fetch user profiles |
|
||||
| PUT | `/media/upload` | Upload media blob (Blossom, 50 MB limit) |
|
||||
| GET/HEAD | `/media/{sha256_ext}` | Retrieve/probe media blob |
|
||||
| GET | `/info` | NIP-11 relay info |
|
||||
| GET | `/.well-known/nostr.json` | NIP-05 identity |
|
||||
| GET | `/health` | Health check |
|
||||
@@ -644,9 +687,9 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
|
||||
| Constant | Value | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `MAX_FRAME_BYTES` | 65,536 | Max WebSocket frame size |
|
||||
| `MAX_SUBSCRIPTIONS` | 100 | Per-connection subscription limit |
|
||||
| `MAX_SUBSCRIPTIONS` | 1024 | Per-connection subscription limit |
|
||||
| `MAX_HISTORICAL_LIMIT` | 500 | Per-filter historical query cap |
|
||||
| `handler_semaphore` capacity | 64 | Concurrent EVENT/REQ handlers |
|
||||
| `handler_semaphore` capacity | 1024 | Concurrent EVENT/REQ handlers |
|
||||
|
||||
**Does NOT:** implement business logic — delegates to the appropriate crate for every operation.
|
||||
|
||||
@@ -654,25 +697,27 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
|
||||
|
||||
### sprout-mcp — Agent API Surface
|
||||
|
||||
**1,748 LOC.** stdio MCP server using the `rmcp` SDK. The interface through which AI agents interact with Sprout. Logs to stderr (stdout is the MCP JSON-RPC channel).
|
||||
**4,879 LOC.** stdio MCP server using the `rmcp` SDK. The interface through which AI agents interact with Sprout. Logs to stderr (stdout is the MCP JSON-RPC channel).
|
||||
|
||||
**43 tools:**
|
||||
**43 registered tools (+ 1 implemented but unregistered, + 3 deferred media/realtime toolsets):**
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| Messaging | `send_message`, `get_channel_history` |
|
||||
| Channels | `list_channels`, `create_channel` |
|
||||
| Default | `send_message`, `send_diff_message`, `edit_message`, `delete_message`, `get_messages`, `get_thread`, `search`, `get_feed`, `add_reaction`, `remove_reaction`, `get_reactions`, `list_channels`, `get_channel`, `join_channel`, `leave_channel`, `update_channel`, `set_channel_topic`, `set_channel_purpose`, `open_dm`, `get_users`, `set_profile`, `get_presence`, `set_presence`, `trigger_workflow`, `approve_step` |
|
||||
| Channel admin | `create_channel`, `archive_channel`, `unarchive_channel`, `add_channel_member`, `remove_channel_member`, `list_channel_members`, `delete_channel` |
|
||||
| DMs | `add_dm_member`, `hide_dm`, `list_dms` |
|
||||
| Canvas | `get_canvas`, `set_canvas` |
|
||||
| Workflows | `list_workflows`, `create_workflow`, `update_workflow`, `delete_workflow`, `trigger_workflow`, `get_workflow_runs`, `approve_workflow_step` |
|
||||
| Feed | `get_feed`, `get_feed_mentions`, `get_feed_actions` |
|
||||
| Workflow admin | `list_workflows`, `create_workflow`, `update_workflow`, `delete_workflow`, `get_workflow_runs` |
|
||||
| Identity | `set_channel_add_policy` |
|
||||
| Forums | `vote_on_post` |
|
||||
|
||||
**Key implementation details:**
|
||||
- Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically.
|
||||
- Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr).
|
||||
- Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect.
|
||||
- REST calls use `Authorization: Bearer <token>` when `SPROUT_API_TOKEN` is set; falls back to `X-Pubkey: <hex>` in dev mode.
|
||||
- `create_channel` sends a signed Nostr kind 40 event (not a REST call).
|
||||
- `set_canvas` sends kind 40100 with `e` tag pointing to channel.
|
||||
- `create_channel` sends a signed Nostr kind 9007 event (NIP-29 group creation, not a REST call).
|
||||
- `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID.
|
||||
- UUID validation at tool boundary before any network call.
|
||||
- `MAX_CONTENT_BYTES = 65,536` enforced in `send_message`.
|
||||
- `get_channel_history` caps at 200 results; `get_workflow_runs` caps at 100; `get_feed` max 50 per category.
|
||||
@@ -681,9 +726,45 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
|
||||
|
||||
---
|
||||
|
||||
### sprout-acp — Agent Communication Protocol Harness
|
||||
|
||||
**14,920 LOC.** Standalone binary that bridges Sprout relay events to AI agents via the [Agent Communication Protocol](https://agentclientprotocol.com/) (ACP). The active counterpart to `sprout-mcp`'s passive tool-serving role.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Sprout Relay ──WS──→ sprout-acp ──stdio (ACP/JSON-RPC)──→ Agent (goose/codex/claude)
|
||||
│
|
||||
sprout-mcp-server (subprocess)
|
||||
```
|
||||
|
||||
`sprout-acp` spawns AI agent subprocesses (1–32, default 1), connects to the relay via WebSocket with NIP-42 auth, discovers channels via REST API, and queues `@mention` events per channel. At most one prompt is in-flight per channel. Queued events are batched into a single prompt sent via `session/prompt` over ACP. The agent uses `sprout-mcp-server` tools (provided as a subprocess) to reply.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | LOC | Responsibility |
|
||||
|--------|-----|---------------|
|
||||
| `relay.rs` | 3,143 | WebSocket + REST relay connection, NIP-42 auth |
|
||||
| `queue.rs` | 2,565 | Per-channel event queue, batching, dedup |
|
||||
| `main.rs` | 2,457 | Event loop, pool orchestration, heartbeat |
|
||||
| `pool.rs` | 2,253 | N-agent pool, claim/return lifecycle |
|
||||
| `config.rs` | 1,903 | CLI/env/TOML configuration |
|
||||
| `acp.rs` | 1,785 | ACP client, stdio JSON-RPC, timeouts |
|
||||
| `filter.rs` | 814 | Subscription rules, evalexpr filtering |
|
||||
|
||||
**Key behaviors:**
|
||||
- Pool of 1–32 agent subprocesses with claim/return lifecycle.
|
||||
- Per-channel queuing: at most one prompt in-flight per channel; subsequent @mentions queue until the agent responds.
|
||||
- Crash recovery: agent subprocess crashes are detected and the agent is respawned.
|
||||
- Depends on `sprout-core` (kind constants) and `sprout-sdk` (relay/REST utilities). Does NOT depend on `sprout-mcp` at compile time.
|
||||
|
||||
**Does NOT:** persist state. Does NOT implement the MCP tool surface — that's `sprout-mcp`'s job.
|
||||
|
||||
---
|
||||
|
||||
### sprout-admin — Operator CLI
|
||||
|
||||
**144 LOC.** Two subcommands:
|
||||
**213 LOC.** Two subcommands:
|
||||
|
||||
| Subcommand | Purpose |
|
||||
|------------|---------|
|
||||
@@ -698,7 +779,7 @@ Raw token is shown exactly once and never stored. Only the SHA-256 hash reaches
|
||||
|
||||
### sprout-test-client — Integration Test Harness
|
||||
|
||||
**3,362 LOC** (including `tests/` directory — 2,559 lines of e2e tests across 4 files).
|
||||
**9,319 LOC** (832 in `src/`, remainder in `tests/` directory).
|
||||
|
||||
**`SproutTestClient`** wraps a WebSocket connection with a `VecDeque<RelayMessage>` buffer for message interleaving. Methods: `connect`, `connect_unauthenticated`, `authenticate`, `send_event`, `send_text_message`, `subscribe`, `close_subscription`, `recv_event`, `collect_until_eose`, `disconnect`.
|
||||
|
||||
@@ -706,13 +787,16 @@ Raw token is shown exactly once and never stored. Only the SHA-256 hash reaches
|
||||
|
||||
| File | Tests | Scope |
|
||||
|------|-------|-------|
|
||||
| `tests/e2e_relay.rs` | 13 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
|
||||
| `tests/e2e_rest_api.rs` | 18 | REST API (channels, search, presence, agents, feed) |
|
||||
| `tests/e2e_workflows.rs` | 4 | Workflow CRUD, trigger, and execution |
|
||||
| `tests/e2e_mcp.rs` | 7 | MCP tool integration (messaging, channels, canvas, feed) |
|
||||
| `src/lib.rs` | 4 | Unit tests (message parsing, event construction) |
|
||||
| `tests/e2e_relay.rs` | 27 | WebSocket protocol (auth, subscriptions, filters, limits, NIP-11) |
|
||||
| `tests/e2e_mcp.rs` | 14 | MCP tool integration (messaging, channels, canvas, feed) |
|
||||
| `tests/e2e_media.rs` | 7 | Media upload/download (Blossom) |
|
||||
| `tests/e2e_media_extended.rs` | 18 | Extended media scenarios |
|
||||
| `tests/e2e_nostr_interop.rs` | 15 | NIP-28 proxy interoperability |
|
||||
| `tests/e2e_rest_api.rs` | 40 | REST API (channels, search, presence, agents, feed) |
|
||||
| `tests/e2e_tokens.rs` | 20 | Token auth and scope enforcement |
|
||||
| `tests/e2e_workflows.rs` | 7 | Workflow CRUD, trigger, and execution |
|
||||
|
||||
All e2e tests are `#[ignore]` — require a running relay. Total: **42 e2e tests + 4 unit tests**.
|
||||
All e2e tests are `#[ignore]` — require a running relay. Total: **148 e2e tests**.
|
||||
|
||||
`src/main.rs` is a manual testing CLI (`sprout-test-cli`) with `--send`, `--subscribe`, `--channel`, `--url`, `--kind` flags.
|
||||
|
||||
@@ -749,8 +833,8 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic
|
||||
### SSRF Protection
|
||||
|
||||
`is_private_ip()` in `sprout-core` covers:
|
||||
- IPv4: loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15)
|
||||
- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8)
|
||||
- IPv4: unspecified (0.0.0.0/8), loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), CGNAT (100.64/10), benchmarking (198.18/15), broadcast (255.255.255.255)
|
||||
- IPv6: loopback (::1), ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), documentation (2001:db8::/32)
|
||||
- IPv4-mapped IPv6 (::ffff:0:0/96) — recursively checks the embedded IPv4 address
|
||||
|
||||
Applied in: `sprout-workflow` (CallWebhook action), `sprout-core` (shared utility).
|
||||
@@ -759,7 +843,7 @@ Applied in: `sprout-workflow` (CallWebhook action), `sprout-core` (shared utilit
|
||||
|
||||
- Hash chain: each entry's SHA-256 covers all fields including `prev_hash` — tampering any entry breaks all subsequent hashes
|
||||
- Canonical JSON: `BTreeMap` for deterministic key ordering — hash is reproducible
|
||||
- Single-writer lock: `GET_LOCK("sprout_audit", 10)` — prevents concurrent writes from breaking the chain
|
||||
- Single-writer lock: `pg_advisory_lock` — prevents concurrent writes from breaking the chain
|
||||
- Panic-safe: `catch_unwind` ensures lock release even on panic
|
||||
|
||||
### Access Control
|
||||
@@ -772,7 +856,7 @@ Applied in: `sprout-workflow` (CallWebhook action), `sprout-core` (shared utilit
|
||||
### Webhook Security
|
||||
|
||||
- LiveKit webhooks: HMAC-SHA256 of raw body bytes, hex-encoded, constant-time comparison
|
||||
- Workflow webhooks: HMAC-SHA256 secret verification before processing
|
||||
- Workflow webhooks: constant-time XOR comparison of stored UUID secret (not HMAC — compares the secret directly, not a body MAC)
|
||||
- Outbound webhooks (CallWebhook): SSRF protection + redirects disabled + 1 MiB response cap
|
||||
|
||||
---
|
||||
@@ -788,14 +872,16 @@ Docker Compose provides the full local development stack. All services include h
|
||||
| Postgres | `postgres:17-alpine` | 5432 | Primary event store — events, channels, tokens, workflows, audit |
|
||||
| Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) |
|
||||
| Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index |
|
||||
| Adminer | `adminer` | 8080 | DB web UI (dev only) |
|
||||
| Keycloak | `quay.io/keycloak/keycloak:26` | 8443 | Local OAuth/OIDC stand-in for Okta |
|
||||
| Adminer | `adminer` | 8082 | DB web UI (dev only) |
|
||||
| Keycloak | `quay.io/keycloak/keycloak:26` | 8180 | Local OAuth/OIDC stand-in for Okta |
|
||||
| MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) |
|
||||
| Prometheus | `prom/prometheus` | 9090 | Metrics collection |
|
||||
|
||||
### Postgres Schema (key tables)
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `events` | All stored Nostr events; monthly range-partitioned by `TO_DAYS(created_at)` |
|
||||
| `events` | All stored Nostr events; monthly range-partitioned by `PARTITION BY RANGE` on `created_at` |
|
||||
| `channels` | Channel records (type, visibility, canvas, topic) |
|
||||
| `channel_members` | Membership with roles; soft-delete via `removed_at` |
|
||||
| `workflows` | Workflow definitions (YAML stored as canonical JSON) |
|
||||
@@ -826,12 +912,11 @@ These are verified gaps in the current implementation — not design aspirations
|
||||
| # | Limitation | Detail |
|
||||
|---|-----------|--------|
|
||||
| 1 | **No sqlx offline query cache** | Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). No `.sqlx/` directory. Queries are not validated at compile time. |
|
||||
| 2 | **Feed mentions: full table scan** | `query_mentions` uses `JSON_CONTAINS(tags, '["p","<pubkey>"]', '$')` — no index on JSON column. Phase 2 mitigation plan documented in `sprout-db/src/feed.rs`: normalized `mentions` table with composite index on `(pubkey_hex, created_at)`. |
|
||||
| 3 | **No rate limiting implementation** | `RateLimiter` trait exists in `sprout-auth`. Only implementation is `AlwaysAllowRateLimiter` (test stub, gated behind `#[cfg(any(test, feature = "test-utils"))]`). `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) but none are enforced. |
|
||||
| 4 | **Local-echo deduplication** | Multi-node fan-out is wired: the Redis `PSUBSCRIBE` subscriber loop runs, and a consumer task fans out received events to local WebSocket connections. However, events published by the local relay instance are re-delivered to local subscribers via the Redis round-trip (no server-side dedup). NIP-01 client-side dedup handles this in practice. Server-side dedup is a TODO. |
|
||||
| 5 | **Cron scheduler is a stub** | `WorkflowEngine::run()` loops every 60 seconds but the loop body logs "not yet implemented" (TODO WF-07). Schedule-triggered workflows do not fire. |
|
||||
| 6 | **Typing indicators: cross-node only** | Typing events (kind 20002) are published to Redis via the ephemeral pipeline. The multi-node consumer task fans them out to local WS subscribers when received from Redis (cross-node path). However, there is no direct local fan-out for typing events on the originating node — they travel Redis → broadcast → WS rather than being fanned out in-process before the Redis round-trip. Typing state is also queryable via the REST `/api/presence` endpoint. |
|
||||
| 7 | **sprout-huddle is scaffolding** | `sprout-huddle` defines types, token generation, and webhook parsing, but relay-side lifecycle event emission is not implemented. Huddle state events are not wired into the relay's event pipeline. `sprout-proxy` is now functional — see its section above. |
|
||||
| 2 | **No rate limiting implementation** | `RateLimiter` trait exists in `sprout-auth`. Only implementation is `AlwaysAllowRateLimiter` (test stub, gated behind `#[cfg(any(test, feature = "test-utils"))]`). `RateLimitConfig` defines 4 tiers (human, agent-standard, agent-elevated, agent-platform) but none are enforced. |
|
||||
| 3 | **No dedicated typing REST endpoint** | Typing indicators (kind 20002) are delivered via both local fan-out and Redis pub/sub (cross-node). There is no REST endpoint to query current typers — `/api/presence` returns online/away status only, not typing state. |
|
||||
| 4 | **sprout-huddle is scaffolding** | `sprout-huddle` defines types, token generation, and webhook parsing, but relay-side lifecycle event emission is not implemented. Huddle state events are not wired into the relay's event pipeline. `sprout-proxy` is now functional — see its section above. |
|
||||
| 5 | **Approval gates not wired end-to-end** | The executor returns `StepResult::Suspended` and the relay has grant/deny API endpoints with DB CRUD, but the engine intercepts before creating `WaitingApproval` rows — runs that hit an approval gate are marked as Failed (🚧 WF-08). |
|
||||
| 6 | **Workflow actions partially stubbed** | `send_dm` and `set_channel_topic` actions log intent but do not emit events (🚧 WF-07). |
|
||||
|
||||
---
|
||||
|
||||
@@ -839,21 +924,25 @@ These are verified gaps in the current implementation — not design aspirations
|
||||
|
||||
| Crate | LOC | Layer |
|
||||
|-------|-----|-------|
|
||||
| sprout-core | 726 | Foundation |
|
||||
| sprout-auth | 1,810 | Foundation |
|
||||
| sprout-db | 3,698 | Foundation |
|
||||
| sprout-pubsub | 735 | Foundation |
|
||||
| sprout-search | 1,043 | Foundation |
|
||||
| sprout-audit | 732 | Foundation |
|
||||
| sprout-workflow | 2,717 | Foundation |
|
||||
| sprout-proxy | ~4,500 | Client compatibility |
|
||||
| sprout-huddle | 659 | Standalone |
|
||||
| sprout-relay | 4,852 | Server |
|
||||
| sprout-mcp | 1,748 | Agent API |
|
||||
| sprout-admin | 144 | Tooling |
|
||||
| sprout-test-client | 3,362 | Tooling |
|
||||
| **Total** | **~22,739** | |
|
||||
| sprout-core | 1,196 | Foundation |
|
||||
| sprout-auth | 2,310 | Foundation |
|
||||
| sprout-db | 7,367 | Foundation |
|
||||
| sprout-pubsub | 887 | Foundation |
|
||||
| sprout-search | 1,126 | Foundation |
|
||||
| sprout-audit | 776 | Foundation |
|
||||
| sprout-workflow | 4,012 | Foundation |
|
||||
| sprout-proxy | 4,933 | Client compatibility |
|
||||
| sprout-huddle | 728 | Standalone |
|
||||
| sprout-relay | 14,327 | Server |
|
||||
| sprout-mcp | 4,879 | Agent API |
|
||||
| sprout-acp | 14,920 | Agent harness |
|
||||
| sprout-sdk | 1,237 | Shared library |
|
||||
| sprout-media | 977 | Media storage |
|
||||
| sprout-cli | 2,919 | Tooling |
|
||||
| sprout-admin | 213 | Tooling |
|
||||
| sprout-test-client | 9,319 | Tooling |
|
||||
| **Total** | **~72,126** | |
|
||||
|
||||
*LOC counted with `find crates -name '*.rs' | xargs wc -l`. Includes tests. Measured 2026-03-09.*
|
||||
*LOC counted with `find crates -name '*.rs' | xargs wc -l`. Includes tests. Measured 2026-04-05.*
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user