feat: pure Nostr protocol for desktop, mobile, and ACP clients#478
Merged
Conversation
Migrate all clients from REST API to pure Nostr protocol. Every read is a NIP-01 REQ. Every write is a signed Nostr event. No Bearer tokens, no X-Pubkey headers, no /api/* endpoints. ## Desktop (Tauri/Rust) - Add query_relay()/count_relay() — NIP-98 signed POST /query - Rewrite submit_event() to NIP-98 (delete Bearer/X-Pubkey paths) - Add nostr_convert.rs: 12 event→model converters + 18 unit tests - Migrate all 10 command modules to Nostr filters - Delete tokens.rs and entire token system (~700 LOC) - Delete agent token minting (agents use NIP-OA) - Add nonce to NIP-98 events to prevent replay on rapid-fire requests ## Mobile (Flutter/Dart) - Extend NostrFilter with ids/search fields (NIP-50) - Add NostrFilters helper class + model converters - Rewrite SignedEventRelay to use session.publish() over WS - Migrate all providers from REST to session.fetchHistory() - Minimize RelayClient to media upload only - Fix _handleOk to preserve OK message + type-safe cast - Update auth_provider to validate via NIP-42 handshake ## ACP (Rust) - Add NIP-98 nonce to prevent replay on reaction add/remove - Re-implement sibling detection via NIP-OA auth tag verification (query sibling's kind:0 profile, verify Schnorr signature, cache) - Agents with the same owner now respond to each other again ## CLI (Rust) - Add NIP-98 nonce to prevent replay ## Relay - Emit explicit tags in kind:39000 (t, public, topic, purpose) - Include roles in kind:39002 p-tags - Prefix command OK messages with 'response:' per spec - Skip p-gated check for filters with explicit ids (thread resolution) ## Tests All pass: relay 141, desktop 212, mobile 330, core 148, auth 35, mcp 76 ## Stats 68 files changed, +3504, -2859 (net -645 LOC)
0a953ee to
546df6e
Compare
The relay's emit_group_discovery_events() was not including archived state or ephemeral TTL metadata on kind:39000 events. The desktop's nostr_convert.rs hardcoded archived_at, ttl_seconds, and ttl_deadline to None when parsing these events. Relay changes (side_effects.rs): - Emit ["archived", "true"] tag when channel.archived_at is set - Emit ["ttl", "<seconds>"] tag when channel.ttl_seconds is set - Emit ["ttl_deadline", "<iso>"] tag when channel.ttl_deadline is set Desktop changes (nostr_convert.rs): - Parse "archived" tag → populate archived_at field - Parse "ttl" and "ttl_deadline" tags → populate ttl_seconds/ttl_deadline Without this fix, archived channels reappear in the sidebar after refetch, and ephemeral channels lose their countdown timer icon.
Three correctness fixes for the pure-nostr client migration (PR #478): ## Presence (relay + ACP + desktop) The old GET /api/presence endpoint queried Redis directly. When it was deleted in the pure-nostr migration, nothing replaced it — kind:20001 is ephemeral (never stored in DB), so POST /query returned empty. - Add synthesize_presence() to the HTTP bridge: intercepts queries for kind:20001 or kind:40902 with authors, looks up Redis, returns relay-signed synthetic events with ["p", subject_pubkey] tags - Fix ACP to send bare status strings ("online") instead of JSON objects ({"status":"online"}) — matches desktop format - Add backward-compat parsing in relay event handler for legacy JSON - Fix desktop get_presence to extract subject from p-tag (synthesized events are relay-signed, not user-signed) ## Relay membership parsing (desktop) The relay emits kind:13534 with ["member", pubkey] tags, but the desktop's nostr_convert::relay_members_from_event() only parsed ["p", pubkey, ...] tags — returning empty member lists. - Parse ["member", pubkey, role?] as primary format - Keep ["p", pubkey, relay_url?, role?] as fallback - Emit role in the member tag: ["member", pubkey, role] - Add unit test for both formats ## Token removal (mobile) The Workspace model's token field was a vestige of the old Bearer token auth system. The desktop pairing flow no longer mints/sends tokens (auth is purely nsec + NIP-42), but mobile still required the field. - Remove token from Workspace model, factory, copyWith, serialization - Remove token requirement from pairing payload parsing - Update all affected tests Verified end-to-end: agent sets presence via WS → relay stores in Redis → CLI/desktop queries via HTTP bridge → relay synthesizes from Redis → presence indicators appear. 13 files changed, +175, -62
Four fixes identified during crossfire review of PR #478: 1. COUNT fallback: query_events clamped results to 1000 rows, causing silent undercounting when filter_fully_pushable() returned false. Added max_limit field to EventQuery; COUNT fallback paths now set max_limit=100_000 to bypass the default clamp. 2. CLI get-canvas: queried kind:30023 (NIP-23 long-form) instead of kind:40100 (Sprout canvas). Fixed to match set-canvas. 3. command_executor docs: comments claimed atomic event+mutation semantics but mutations execute on the pool, not the transaction. Updated docs to accurately describe idempotent-but-not-atomic behavior and explain why this is acceptable. 4. Desktop E2E seed helper: assertRelaySeeded() hit GET /api/channels which no longer exists (deleted in pure-nostr migration). Migrated to POST /query with kind:39000 filter via the HTTP bridge.
The pure-nostr migration deleted all /api/* REST endpoints. Two CI infrastructure files still referenced GET /api/channels: 1. .github/workflows/ci.yml — relay startup check in the desktop-e2e-integration job. Now checks GET /_readiness (200 = ready). 2. desktop/tests/helpers/seed.ts — assertRelaySeeded() polled /api/channels to verify test data. The setup script inserts directly into DB tables (not as Nostr events), so POST /query can't find them. Now checks /_readiness which confirms Postgres + Redis connectivity; the setup script's own SQL verification guarantees data presence.
When the desktop queries channel metadata via {kinds:[39000], "#d": [id1, id2, ...]}
the relay was not pushing the multi-value #d constraint into SQL. The query
would fetch the N most recent kind:39000 events regardless of d-tag, then
post-filter — causing channels to disappear from the sidebar when new ones
were created (the new channel's event pushed older ones past the limit).
Fix: add d_tags (plural) field to EventQuery with IN (...) SQL pushdown.
The relay now pushes multi-#d into SQL for NIP-33-only kind filters, same
as it already did for single-#d. Also update filter_fully_pushable() to
recognize multi-#d as fully pushable for NIP-33 kinds.
The desktop E2E tests seed data via direct SQL inserts into the channels and channel_members tables. The pure-nostr desktop client queries kind:39000 (metadata) and kind:39002 (membership) events — which only exist if channels were created through the Nostr event pipeline. Add reconcile_channel_events() that runs at startup: for each channel in the DB without a corresponding kind:39000 event, emit discovery events (kind:39000 metadata + kind:39002 members). Idempotent — checks for existing events before emitting. This fixes the CI desktop-e2e-integration job where: 1. setup-desktop-test-data.sh inserts channels via SQL 2. Relay starts and reconciles (emits kind:39000/39002) 3. Desktop queries kind:39000 via POST /query — now finds them Also update seed.ts to query kind:39000 for the 'general' channel (validates both relay readiness AND data reconciliation).
In CI, the relay starts before the seed script runs. The one-shot startup reconciliation found 0 channels and did nothing. By the time seed data was inserted, reconciliation had already completed. Change to a periodic loop (every 5s for 2 minutes) gated behind SPROUT_RECONCILE_CHANNELS=true. Only enabled in CI/dev — production relays create channels through the event pipeline and never need this. The reconciliation is idempotent (checks for existing events before emitting), so repeated runs after all channels are reconciled are no-ops.
3d6155e to
3037a52
Compare
One-shot command to emit kind:39000/39001/39002 discovery events for channels that exist in the DB but don't have corresponding Nostr events. Usage: sprout-admin reconcile-channels --relay-key <hex> SPROUT_RELAY_PRIVATE_KEY=<hex> sprout-admin reconcile-channels Handles: - Pre-migration channels created via old REST API - Channels seeded via direct SQL inserts (CI/dev) - Any future gap between channels table and events table Idempotent — checks for existing kind:39000 before emitting. Safe to run multiple times. Reports counts: 'Reconciled 98 channels (351 already had events, 449 total).'
137a29b to
7bb637c
Compare
The desktop e2e bridge (relay mode) was calling deleted REST endpoints. Migrate ALL relay-mode handlers to use POST /query and POST /events: - handleGetChannels: kind:39002 (#p) → kind:39000 (#d) two-step - handleGetProfile: kind:0 (authors) - handleUpdateProfile: read-merge-write via kind:0 - handleGetUserProfile: kind:0 (authors) - handleGetUsersBatch: kind:0 (multi-author) - handleSearchUsers: NIP-50 search on kind:0 - handleGetPresence: kind:20001 (synthesized from Redis) - handleSetPresence: kind:20001 via POST /events (graceful WS-only fail) - handleCreateChannel: kind:9007 + kind:39000 re-fetch - handleOpenDm: kind:41010 + kind:39000 re-fetch - handleHideDm: kind:41012 - handleGetChannelDetails: kind:39000 + kind:39002 - handleGetChannelMembers: kind:39002 (p-tags) - handleUpdateChannel: kind:9002 + kind:39000 re-fetch - handleGetFeed: kind:9/40002 (#p mentions) - handleSearch: NIP-50 search via POST /query - handleGetEventById: ids filter via POST /query - submitSignedEvent: POST /events (was /api/events) - Token handlers: return empty/error (tokens deleted in pure-nostr) Remove unused relayEmptyRequest and RawOpenDmResponse. Zero /api/ references remain in the e2e bridge.
7bb637c to
9227b2a
Compare
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrate all clients from REST API to pure Nostr protocol. Every read is a NIP-01 REQ. Every write is a signed Nostr event. No Bearer tokens, no X-Pubkey headers, no
/api/*endpoints.Desktop (Tauri/Rust)
query_relay()/count_relay()— NIP-98 signedPOST /querysubmit_event()to NIP-98 only (delete Bearer/X-Pubkey paths)nostr_convert.rs: 12 event→model converters + 20 unit teststokens.rsand entire token system (~700 LOC)Mobile (Flutter/Dart)
NostrFilterwithids/searchfields (NIP-50)NostrFiltershelper class + model convertersSignedEventRelayto usesession.publish()over WSsession.fetchHistory()RelayClientto media upload only_handleOkto preserve OK message + type-safe castauth_providerto validate via NIP-42 handshaketokenfield fromWorkspacemodel (auth is purely nsec + NIP-42)ACP (Rust)
"online") instead of JSON objectsCLI (Rust)
Relay
t,public,topic,purpose,archived,ttl)["member", pubkey, role])response:per specids(fixes thread resolution)Tests
All pass:
sprout-relay)sproutlib)flutter test)sprout-core)sprout-auth)sprout-mcp)E2E Verification
Full stack tested locally against a live relay:
sprout-admin add-membergrants accessReview
Two rounds of crossfire review (opus + codex CLI). All critical findings fixed:
#dSQL pushdown, workflow webhook_secret regressionresponse:prefix, NIP-98 replay (nonce fix), mobile type safety, feedsinceparam, p-gatedidsfilter bypass["member", pk, role]), presence synthesis from Redis, token removal from mobile pairingKnown Non-Blocking Issues (updated after review)
COUNT fallback silently undercounted— fixed in6076a9aDesktop E2E seed helper hit deleted /api/channels— fixed in6076a9aCLI get-canvas queried wrong kind— fixed in6076a9aDesktop
set_presenceTauri command is dead code (frontend uses WS directly viarelayClient.sendPresence())CLI search without channel scope hits p-gated rejection (CLI filter issue, not relay)
Frontend TS still calls deleted Tauri commands (
mint_managed_agent_token,list_tokens) — needs separate cleanupCommits
546df6ec77bac6c2d43106076a9a