Skip to content

Sprout speaks Nostr. Nothing else.#475

Merged
tlongwell-block merged 6 commits intomainfrom
pure-nostr
May 5, 2026
Merged

Sprout speaks Nostr. Nothing else.#475
tlongwell-block merged 6 commits intomainfrom
pure-nostr

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented May 4, 2026

Why

Sprout has been speaking two languages: a bespoke REST API (~45 endpoints, Bearer tokens, Okta JWT) and Nostr (NIP-01 WebSocket). Every feature ships twice — once as REST JSON, once as a signed event. Every client maintains two code paths. Every new engineer learns two protocols.

This PR deletes one of them.

What

Auth is now pure cryptography. No tokens. No Okta at runtime. No JWKS cache. Your keypair is your identity — prove you own it via Schnorr signature (NIP-42 on WebSocket, NIP-98 on HTTP) and you are in. Corporate identity binding moves upstream to a one-time enrollment step that never touches the relay.

Every read is a NIP-01 REQ. Channel messages, profiles, threads, search, presence — all via ["REQ", sub_id, {filter}] over WebSocket or POST /query over HTTP.

Every write is a signed EVENT. Channel creation, messages, reactions, DM management, workflow triggers — all via ["EVENT", signed_event] over WebSocket or POST /events over HTTP.

NIP-45 COUNT for aggregates. No more N+1 queries for "how many messages in this channel."

NIP-09 a-tag deletion for addressable events. Workflows (kind:30620) can be deleted via a tag reference.

14 HTTP endpoints remain (down from ~45): 3 media (Blossom), 3 git (smart HTTP protocol), 3 bridge (POST /events, /query, /count), 4 infra (health, NIP-11, NIP-05), 1 webhook.

Numbers

Metric Before After
HTTP endpoints ~45 14
Auth systems 3 (JWT + Bearer + Nostr) 1 (Schnorr signatures)
Lines of code baseline -5,660 net (77 files, +6417/-12077)
Protocol docs needed REST API docs + Nostr NIPs Just NIPs
Client code paths per operation 2 1
New engineer onboarding "Read the REST docs and the NIPs" "It's Nostr. Read the NIPs."

Commits

  1. 4f87d05 — feat: pure Nostr protocol (67 files, +4821/-11220)

    • Delete entire REST API (17 handler files, ~8000 lines)
    • Delete Okta/JWT runtime (okta.rs, token.rs)
    • Add HTTP bridge (POST /events, /query, /count with NIP-98)
    • Add command executor (transactional processing for DM/workflow/approval kinds)
    • Add NIP-45 COUNT handler (WS + HTTP)
    • Add DB pushdown (multi-author, multi-id, e-tag)
    • Migrate CLI, ACP, MCP from REST to pure Nostr
  2. bd7d4fd — feat: NIP-09 a-tag deletion (3 files, +121/-4)

    • Support deleting parameterized-replaceable events via a tag
    • Enables workflow deletion (kind:5 with a=30620:<pubkey>:<d-tag>)
  3. c627338 — fix: correctness improvements (6 files, +412/-128)

    • Transaction atomicity: command event INSERT held in open tx until mutation succeeds; on failure tx rolls back (event never stored without side effects)
    • Multi-author/ids/e-tags SQL pushdown: filter_to_query_params now wires all three into EventQuery (fixes batch profile fetches hitting LIMIT)
    • COUNT correctness: channel_ids field for SQL-level access enforcement; filter_fully_pushable() guard routes to fast SQL COUNT or safe fallback; empty channel_ids fails closed to global-only
    • NIP-98 replay atomicity: moka entry().or_insert(()).is_fresh() for atomic insert-if-absent

Security

  • Command executor runs after full validation pipeline (signature, timestamp, pubkey match, scope checks)
  • HTTP bridge enforces channel membership on all read paths (same as WS REQ)
  • NIP-98 replay prevention via atomic bounded event-ID cache (moka entry API)
  • Relay-only kinds (40900-40902) rejected from client submission
  • NIP-43 relay membership: non-members rejected from all bridge endpoints
  • Empty channel_ids fails closed (global-only, not unrestricted)
  • No unsigned authenticated path exists in production

E2E Verification (27/27 pass)

Category Tests
Relay core NIP-11 info, NIP-42 WS auth, health probes
HTTP bridge POST /events, /query, /count — all with channel access enforcement
NIP-43 mode Non-member rejected (403), member accepted
Channel ops Create, add member, post message, query, count, reactions
Commands DM open (kind:41010), NIP-09 a-tag deletion
CLI list-channels, get-messages, send-message, add-reaction, get-reactions, list-dms, create-channel, set-profile, get-feed
Media Blossom upload (kind:24242 auth), GET, HEAD
Git Push (NIP-98 via git-credential-nostr), clone
ACP agent Connect, NIP-42 auth, discover channels, receive mention, process task, post reply

Migration guide

Client type Before After
WebSocket Token in AUTH event Just sign the AUTH challenge
HTTP (CI, CLI, serverless) Authorization: Bearer sprout_* Sign kind:27235, base64, Authorization: Nostr <b64>
Agents SPROUT_ACP_API_TOKEN env var SPROUT_AUTH_TAG (NIP-OA) or just connect with keypair
Dev mode X-Pubkey: <hex> header Same — still works on bridge endpoints

What's next (not in this PR)

  • Desktop client migration (TypeScript/Tauri)
  • Mobile client migration (Flutter/Dart)
  • Enrichment sidecars (kind:40900-40902)
  • SproutFilter wrapper (extension fields for enrichment/presence)

Super PR

#478 was merged on top of the original work in this PR to get CI green

Copy link
Copy Markdown
Collaborator

@baxen baxen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - reviewed the strategy i think we will want to evolve this forward if there are any specific issues

… COUNT

BREAKING CHANGE: All REST API endpoints removed except media, git, health,
NIP-05, and NIP-11. Clients must use WebSocket (REQ/EVENT/COUNT) or the new
HTTP bridge (POST /events, /query, /count with NIP-98 auth).

## What changed

### Auth (sprout-auth)
- Delete Okta OIDC runtime (JWKS cache, JWT validation)
- Delete API token system (mint, verify, revoke, hash)
- Auth is now pure cryptography: NIP-42 (WS) + NIP-98 (HTTP)
- Remove 4 dependencies: jsonwebtoken, reqwest, chrono, subtle

### Relay (sprout-relay)
- Delete 17 REST handler files (~5000 lines)
- Add HTTP bridge: POST /events, /query, /count with NIP-98
- Add NIP-45 COUNT handler (WebSocket + HTTP bridge)
- Add command executor framework (transactional, idempotent)
- Add NIP-98 replay prevention cache (moka, 120s TTL)
- Add channel access enforcement on bridge read paths
- Relocate imeta helpers from deleted messages.rs to handlers/imeta.rs
- Clean state.rs (remove MintRateLimiter, token caches)
- Simplify auth handler (pure NIP-42, no API token path)

### Foundation (sprout-core, sprout-db)
- Add 13 kind constants (10100, 24242, 27235, 30620, 40900-40902,
  41010-41012, 46020, 46030-46031)
- Add is_command_kind() and is_relay_only_kind() helpers
- Add DB pushdown: multi-author IN, multi-id IN, e-tag containment
- Add Db::begin_transaction() for atomic command execution
- Add Db::count_events() for NIP-45

### Clients
- sprout-mcp: All REST -> WS REQ/EVENT (query() convenience method)
- sprout-acp: REST -> NIP-98 HTTP bridge, owner via NIP-OA/config
- sprout-cli: All commands -> HTTP bridge with NIP-98 per-request signing
- sprout-admin: Rewrite from token minting to relay membership management

## Net effect
- 67 files changed, +3821/-11218 (net -7397 lines)
- 776 tests pass, 0 failures
- 0 clippy warnings

## Migration guide
- WS clients: No token needed. Connect -> NIP-42 AUTH -> full access.
- HTTP clients: Sign kind:27235 event, base64 encode, send as
  Authorization: Nostr <base64>. Use POST /events (writes),
  POST /query (reads), POST /count (aggregates).
- Agents: SPROUT_AUTH_TAG for NIP-OA delegation. No API token env var.
- Dev mode: X-Pubkey header still works on bridge endpoints.
Resolve conflicts by keeping our pure-Nostr implementation:
- relay_members.rs: deleted (functionality moved to api/mod.rs with NIP-OA support)
- tokens.rs: deleted (token system removed)
- auth.rs, api/mod.rs, relay_client.rs: keep our rewritten versions
- Adopted main's enforce_relay_membership 3-arg signature (auth_tag for NIP-OA)
Replace sort_by(|a, b| field.cmp()) with sort_by_key() to satisfy
clippy::unnecessary_sort_by added in Rust 1.95 (CI toolchain).

Desktop CI failure is transient (Hermit bootstrap 502 — not our code).
Support deleting parameterized-replaceable events (e.g. kind:30620 workflows)
via NIP-09 kind:5 with an a tag instead of e tag.

Changes:
- ingest.rs: accept either e-tag OR a-tag (not both) for kind:5 validation
- side_effects.rs: validate_standard_deletion_event handles a-tag author check
- side_effects.rs: handle_a_tag_deletion dispatches on target kind (30620 → DB delete)
- sprout-db: add find_workflow_by_owner_and_name for name-based lookup
1. Transaction atomicity (command_executor.rs):
   - persist_command_event() returns open transaction via PersistResult enum
   - Handlers commit tx AFTER mutations succeed; on failure tx drops → rollback
   - Event never stored without its side effects; retries work correctly

2. Multi-author/ids/e-tags SQL pushdown (req.rs):
   - filter_to_query_params now wires multi-author, ids, and e_tags into EventQuery
   - Fixes batch profile fetches and reaction/thread lookups hitting LIMIT

3. COUNT correctness (count.rs, bridge.rs, event.rs):
   - Added channel_ids field to EventQuery for SQL-level channel access
   - SECURITY: empty channel_ids → global-only (fails closed)
   - Added filter_fully_pushable() guard: fast SQL COUNT when safe,
     fallback to query+post-filter (limit=10000) for non-pushable tags
   - Both channel-scoped and non-channel paths use dual strategy

4. NIP-98 replay atomicity (bridge.rs):
   - Uses moka 0.12 entry().or_insert(()).is_fresh() for atomic check
@tlongwell-block tlongwell-block merged commit 454781c into main May 5, 2026
14 checks passed
@tlongwell-block tlongwell-block deleted the pure-nostr branch May 5, 2026 16:03
wesbillman added a commit that referenced this pull request May 5, 2026
- resolve DM participant display names client-side via batch kind:0 fetch
- dedupe kind:39000 metadata by `d` tag, keeping the latest revision
- match desktop's case-insensitive sort within each channel section
- defer the "Could not load channels" view 2s to absorb transient
  AsyncError frames from relay reconnect cancellations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants