Skip to content

feat: relay membership with NIP-43 compliance#448

Merged
tlongwell-block merged 2 commits intomainfrom
feat/nip43-relay-membership
May 1, 2026
Merged

feat: relay membership with NIP-43 compliance#448
tlongwell-block merged 2 commits intomainfrom
feat/nip43-relay-membership

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Why

Sprout's vision (VISION_SOVEREIGN.md) is a self-hosted relay that serves as your entire workspace — code, conversation, agents, automation. A workspace needs a front door: the relay operator decides who can connect. Without relay-level membership, any pubkey can read and write to the relay.

NIP-43 is the Nostr standard for relay access metadata. It defines how relays advertise their member list and how users join/leave. Implementing NIP-43 means standard Nostr clients (Amethyst, Coracle) can discover and interact with Sprout's membership system using the protocol they already speak.

This builds on the git repo permissions work (#441) — that PR gates who can push to repos; this PR gates who can connect to the relay at all. Together they form the access control stack: relay membership → channel membership → repo permissions.

What

Core relay membership system

  • relay_members table with role hierarchy (owner / admin / member)
  • Admin commands: kind 9030 (add), 9031 (remove), 9032 (change role)
  • Enforcement at all 7 auth entry points (WebSocket, REST, audio, git, media, tokens, API) — non-members are rejected before any operation
  • Owner bootstrap from RELAY_OWNER_PUBKEY on first startup
  • REST API for listing/managing members
  • Config: SPROUT_REQUIRE_RELAY_MEMBERSHIP, RELAY_OWNER_PUBKEY

NIP-43 compliance layer (Tier 1)

  • NIP-11 self field: relay signing pubkey advertised (conditional on stable key via SPROUT_RELAY_PRIVATE_KEY)
  • kind 13534: membership list snapshot, relay-signed, published on startup and after every membership change
  • kind 8000/8001: member added/removed announcements, relay-signed
  • kind 28936: leave request handler — members can remove themselves
  • All relay-signed events carry NIP-70 "-" tag (protected event)
  • Config guard: hard-fail startup if membership enabled without stable key

Security

  • Replay protection: ±120s timestamp window on admin commands and leave requests
  • Owner lockout prevention: owner cannot leave or be removed
  • Role enforcement: only admins+ can add/remove, only owner can change roles
  • Channel-scoped and proxy tokens blocked from admin commands
  • NIP-70 "-" tag validated on leave requests
  • Relay-signed events bypass client ingest pipeline (no trust escalation)

Testing

  • Unit tests for tag extraction, role validation
  • E2E test script (scripts/e2e-relay-membership.sh)
  • Red team exercise: 14/15 tests pass (5 happy path, 5 negative, 5 adversarial)

Deferred (Tier 2)

  • Invite flow (kinds 28934/28935) — separate product decision
  • NIP-70 enforcement on ingest pipeline — defense-in-depth, not blocking
  • Kind-specific subscription gating — connection-level auth already sufficient

Architecture

Admin Commands (Sprout-internal, user-signed):
  kind:9030 → add member     → triggers kind:8000 + kind:13534 publish
  kind:9031 → remove member  → triggers kind:8001 + kind:13534 publish
  kind:9032 → change role    → triggers kind:13534 publish

NIP-43 User Flows (spec-compliant, user-signed):
  kind:28936 → leave request → triggers kind:8001 + kind:13534

NIP-43 Relay Announcements (relay-signed, NIP-70 protected):
  kind:13534 → full membership list (published after any change)
  kind:8000  → member added delta
  kind:8001  → member removed delta

Files changed (23 files, ~2000 insertions)

Area Files
Core types kind.rs
DB layer relay_members.rs, lib.rs
Admin handler relay_admin.rs
NIP-43 publish side_effects.rs
Ingest pipeline ingest.rs
NIP-11 nip11.rs, router.rs
Config + startup config.rs, main.rs
Enforcement auth.rs, mod.rs, tokens.rs, media.rs, transport.rs, handler.rs
REST API relay_members.rs (api)
Migration 0001_relay_members.sql
E2E tests e2e-relay-membership.sh

## Why

Sprout's vision (VISION_SOVEREIGN.md) is a self-hosted relay that serves as
your entire workspace — code, conversation, agents, automation. A workspace
needs a front door: the relay operator decides who can connect. Without
relay-level membership, any pubkey can read and write to the relay.

NIP-43 is the Nostr standard for relay access metadata. It defines how relays
advertise their member list and how users join/leave. Implementing NIP-43
means standard Nostr clients (Amethyst, Coracle) can discover and interact
with Sprout's membership system using the protocol they already speak.

This builds on the git repo permissions work (PR #441) — that PR gates who
can push to repos; this PR gates who can connect to the relay at all. Together
they form the access control stack: relay membership → channel membership →
repo permissions.

## What

### Core relay membership system
- `relay_members` table with role hierarchy (owner/admin/member)
- Admin commands: kind 9030 (add), 9031 (remove), 9032 (change role)
- Enforcement at all 7 auth entry points (WebSocket, REST, audio, git,
  media, tokens, API) — non-members are rejected before any operation
- Owner bootstrap from RELAY_OWNER_PUBKEY on first startup
- REST API for listing/managing members
- Config: SPROUT_REQUIRE_RELAY_MEMBERSHIP, RELAY_OWNER_PUBKEY

### NIP-43 compliance layer (Tier 1)
- NIP-11 `self` field: relay signing pubkey advertised (conditional on
  stable key via SPROUT_RELAY_PRIVATE_KEY)
- kind 13534: membership list snapshot, relay-signed, published on startup
  and after every membership change
- kind 8000/8001: member added/removed announcements, relay-signed
- kind 28936: leave request handler — members can remove themselves
- All relay-signed events carry NIP-70 "-" tag (protected event)
- Config guard: hard-fail startup if membership enabled without stable key

### Security
- Replay protection: ±120s timestamp window on admin commands and leave requests
- Owner lockout prevention: owner cannot leave or be removed
- Role enforcement: only admins+ can add/remove, only owner can change roles
- Channel-scoped and proxy tokens blocked from admin commands
- NIP-70 "-" tag validated on leave requests
- Relay-signed events bypass client ingest pipeline (no trust escalation)

### Testing
- Unit tests for tag extraction, role validation
- E2E test script (scripts/e2e-relay-membership.sh)
- Red team exercise: 14/15 tests pass (5 happy path, 5 negative, 5 adversarial)

### Deferred (Tier 2)
- Invite flow (kinds 28934/28935) — separate product decision
- NIP-70 enforcement on ingest pipeline — defense-in-depth, not blocking
- Kind-specific subscription gating — connection-level auth already sufficient

23 files changed, ~2000 insertions
@tlongwell-block tlongwell-block force-pushed the feat/nip43-relay-membership branch from 5ef2acf to 4d32858 Compare May 1, 2026 21:15
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.

Looks great!

…ership

* origin/main:
  fix(desktop): header UX polish and member list improvements (#447)
  feat(relay): add NIP-38 user status support (kind:30315) (#446)
  [codex] clean up root docs and assets (#445)

# Conflicts:
#	crates/sprout-relay/src/handlers/ingest.rs
#	crates/sprout-relay/src/nip11.rs
@tlongwell-block tlongwell-block force-pushed the feat/nip43-relay-membership branch from 3668f86 to 386017a Compare May 1, 2026 22:38
@tlongwell-block tlongwell-block merged commit 3cabe94 into main May 1, 2026
13 checks passed
@tlongwell-block tlongwell-block deleted the feat/nip43-relay-membership branch May 1, 2026 23:11
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