Skip to content

feat: pure Nostr protocol for desktop, mobile, and ACP clients#478

Merged
tlongwell-block merged 11 commits into
pure-nostrfrom
pure-nostr-clients
May 5, 2026
Merged

feat: pure Nostr protocol for desktop, mobile, and ACP clients#478
tlongwell-block merged 11 commits into
pure-nostrfrom
pure-nostr-clients

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

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

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)

  • Add query_relay()/count_relay() — NIP-98 signed POST /query
  • Rewrite submit_event() to NIP-98 only (delete Bearer/X-Pubkey paths)
  • Add nostr_convert.rs: 12 event→model converters + 20 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
  • Remove vestigial token field from Workspace model (auth is purely nsec + NIP-42)

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, cryptographically verify Schnorr signature, cache result)
  • Agents with the same owner now respond to each other again
  • Fix presence format: send bare status strings ("online") instead of JSON objects

CLI (Rust)

  • Add NIP-98 nonce to prevent replay

Relay

  • Emit explicit tags in kind:39000 (t, public, topic, purpose, archived, ttl)
  • Include roles in kind:39002 p-tags
  • Include roles in kind:13534 member tags (["member", pubkey, role])
  • Prefix command OK messages with response: per spec
  • Skip p-gated check for filters with explicit ids (fixes thread resolution)
  • Synthesize presence from Redis on kind:20001/40902 queries (ephemeral events are never stored in DB)
  • Accept both bare string and legacy JSON format for presence content

Tests

All pass:

Suite Result
Relay (sprout-relay) 141 passed
Desktop (sprout lib) 212 passed
Mobile (flutter test) 330 passed
Core (sprout-core) 148 passed
Auth (sprout-auth) 35 passed
MCP (sprout-mcp) 76 passed

E2E Verification

Full stack tested locally against a live relay:

Test Result
Channel CRUD, messaging, threads
Reactions, DMs, canvas, feed, notes, profiles
ACP agent: NIP-42 auth → WS subscription → goose → reply
ACP agent: presence set via WS → queryable via HTTP bridge
NIP-43 mode: non-member rejected, member accepted
NIP-43 mode: sprout-admin add-member grants access
NIP-AB pairing: full e2e (ECDH + SAS + NIP-44 encryption)
Dev mode (open): any valid keypair connects
Multi-agent messaging

Review

Two rounds of crossfire review (opus + codex CLI). All critical findings fixed:

  • R1: kind:39000 tag mismatch, kind:39002 missing roles, multi-#d SQL pushdown, workflow webhook_secret regression
  • R2: relay response: prefix, NIP-98 replay (nonce fix), mobile type safety, feed since param, p-gated ids filter bypass
  • R3: relay membership parsing (["member", pk, role]), presence synthesis from Redis, token removal from mobile pairing

Known Non-Blocking Issues (updated after review)

  • COUNT fallback silently undercounted — fixed in 6076a9a

  • Desktop E2E seed helper hit deleted /api/channels — fixed in 6076a9a

  • CLI get-canvas queried wrong kind — fixed in 6076a9a

  • Desktop set_presence Tauri command is dead code (frontend uses WS directly via relayClient.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 cleanup

Commits

Hash Description
546df6e feat: pure Nostr protocol for desktop, mobile, and ACP clients
c77bac6 fix: emit archived + TTL tags on kind:39000 discovery events
c2d4310 fix: presence synthesis, relay membership parsing, and token removal
6076a9a fix: COUNT fallback limit, canvas kind, transaction docs, and CI test

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)
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.
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).'
@tlongwell-block tlongwell-block force-pushed the pure-nostr-clients branch 4 times, most recently from 137a29b to 7bb637c Compare May 5, 2026 15:09
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.
@tlongwell-block tlongwell-block merged commit 766ec1f into pure-nostr May 5, 2026
13 checks passed
@tlongwell-block tlongwell-block deleted the pure-nostr-clients branch May 5, 2026 15:47
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.

1 participant