feat: NIP-29 native compatibility — standard nostr clients can chat on Sprout (#63)

This commit is contained in:
tlongwell-block
2026-03-14 10:51:20 -04:00
committed by GitHub
parent 1f7d56e4ee
commit 124047ef88
35 changed files with 683 additions and 159 deletions

View File

@@ -121,7 +121,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa
| Kind | Name | Description |
|------|------|-------------|
| 7 | KIND_REACTION | Emoji reaction (standard NIP-25) |
| 40001 | KIND_STREAM_MESSAGE | Chat message in a Stream channel |
| 9 | KIND_STREAM_MESSAGE | Chat message in a Stream channel (NIP-29 group chat) |
| 40002 | KIND_STREAM_MESSAGE_V2 | Stream message v2 format |
| 40003 | KIND_STREAM_MESSAGE_EDIT | Edit of a stream message |
| 43001 | KIND_JOB_REQUEST | Agent job request |
@@ -224,7 +224,7 @@ Steps 10–12 are fire-and-forget: they are spawned as independent async tasks.
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 40001 (`KIND_STREAM_MESSAGE`) always triggers regardless of other exclusion rules. Kind 40002 (`KIND_STREAM_MESSAGE_V2`) does not trigger workflows.
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.
### Ephemeral Sub-Pipeline (kinds 20000–29999)

View File

@@ -16,10 +16,10 @@ sources:
## 1. Overview
`sprout-proxy` is an optional compatibility layer that lets standard Nostr clients connect to a Sprout relay. Sprout uses custom event kinds (40001+) internally; most Nostr clients only understand NIP-28 (kind:40/41/42). The proxy translates between the two in real time.
`sprout-proxy` is an optional compatibility layer that lets standard Nostr clients connect to a Sprout relay. Sprout uses NIP-29 group chat events (kind:9) internally; most Nostr clients only understand NIP-28 (kind:40/41/42). The proxy translates between the two in real time.
**What it does:**
- Translates Sprout's kind:40001 stream messages ↔ NIP-28 kind:42 channel messages
- Translates Sprout's kind:9 (NIP-29 group chat) messages ↔ NIP-28 kind:42 channel messages
- Maps Sprout channel UUIDs (`#h` tags) ↔ kind:40 event IDs (`#e` tags)
- Synthesizes kind:40 (channel create) and kind:41 (channel metadata) events from Sprout's REST API
- Enforces access control via two authentication paths (see below)
@@ -36,7 +36,7 @@ sources:
- NIP-29 group navigation (group list, group join/leave)
- NIP-50 full-text search
- Direct Messages (DMs)
- kind:40001 workflow/forum events (non-message kinds)
- kind:9 workflow/forum events (non-message kinds)
- Inbound edits (kind:41 from client → kind:40003) — outbound only for MVP (kind:40003 edits from the relay are translated to kind:41 for clients)
### Authentication Paths
@@ -313,7 +313,7 @@ nak event \
nak req -k 41 -l 10 --auth "ws://localhost:4869"
```
> **Note:** kind:40 and kind:41 events are served directly from the proxy's channel map (synthesized from the Sprout REST API). They are never forwarded to the upstream relay. kind:42 queries are translated to kind:40001 + `#h` tags and forwarded upstream.
> **Note:** kind:40 and kind:41 events are served directly from the proxy's channel map (synthesized from the Sprout REST API). They are never forwarded to the upstream relay. kind:42 queries are translated to kind:9 + `#h` tags and forwarded upstream.
---
@@ -586,10 +586,10 @@ The proxy translates between Sprout's internal event kinds and NIP-28:
| Direction | Sprout Internal | NIP-28 External | Notes |
|-----------|----------------|-----------------|-------|
| Outbound (relay → client) | kind:40001 | kind:42 | Stream message |
| Outbound (relay → client) | kind:9 | kind:42 | NIP-29 group chat message |
| Outbound (synthesized) | — | kind:40 | Channel create (from REST API) |
| Outbound (synthesized) | — | kind:41 | Channel metadata (from REST API) |
| Inbound (client → relay) | kind:40001 | kind:42 | Re-signed with shadow key |
| Inbound (client → relay) | kind:9 | kind:42 | Re-signed with shadow key |
### Channel ID Mapping

View File

@@ -1,6 +1,6 @@
# Using Third-Party Nostr Clients with Sprout
Sprout speaks the Nostr wire protocol internally, but uses custom event kinds (40001+) and
Sprout speaks the Nostr wire protocol internally, but uses NIP-29 group chat events (kind:9) and
channel-scoped `#h` tags that standard NIP-28 clients don't understand. **sprout-proxy** bridges
this gap — it translates between Sprout's internal protocol and standard NIP-28 (Public Chat
Channels), so any Nostr client that supports NIP-28 and NIP-42 can read and write Sprout channels.
@@ -46,7 +46,7 @@ curl -X POST http://localhost:4869/admin/guests \
| **NIP-42 authentication** | ✅ | Proactive challenge + reactive-auth compatible |
| **Channel discovery (kind:40)** | ✅ | Synthesized from Sprout REST API; served locally |
| **Channel metadata (kind:41)** | ✅ | Name, description, picture; served locally |
| **Channel messages (kind:42)** | ✅ | Translated to/from Sprout kind:40001 |
| **Channel messages (kind:42)** | ✅ | Translated to/from Sprout kind:9 |
| **Message editing** | ✅ | Sprout kind:40003 ↔ kind:41 (NIP-28 uses kind:41 for both metadata and edits; the proxy routes to both local metadata and upstream edits) |
| **Real-time streaming** | ✅ | Live event delivery via open subscriptions |
| **Multi-channel access** | ✅ | Guests can be granted access to multiple channels |
@@ -225,15 +225,15 @@ curl -X POST http://localhost:4869/admin/invite \
│ Nostr Client │ │ sprout-proxy │ │ Sprout Relay │
│ (Coracle, nak, │◄──────►│ :4869 │◄──────►│ :3000 │
│ nostr-tools, etc.) │ NIP-28 │ │internal│ │
└─────────────────────┘ │ kind:42 ↔ kind:40001 │ └──────────────────┘
└─────────────────────┘ │ kind:42 ↔ kind:9 │ └──────────────────┘
│ #e(id) ↔ #h(uuid) │
│ shadow key re-signing │
└───────────────────────┘
```
**Translation pipeline:**
- **Outbound** (relay → client): kind:40001 + `#h(uuid)` → kind:42 + `#e(event_id)`
- **Inbound** (client → relay): kind:42 + `#e(event_id)` → kind:40001 + `#h(uuid)`
- **Outbound** (relay → client): kind:9 + `#h(uuid)` → kind:42 + `#e(event_id)`
- **Inbound** (client → relay): kind:42 + `#e(event_id)` → kind:9 + `#h(uuid)`
- **Channel metadata**: kind:40/41 synthesized locally from Sprout REST API (never forwarded upstream)
- **Shadow keys**: Each guest gets a deterministic keypair via HMAC-SHA256 for re-signing translated events

View File

@@ -112,7 +112,7 @@ All configuration is via environment variables.
1. **Startup** — Spawns the agent subprocess, sends ACP `initialize`, connects to the relay with NIP-42 auth.
2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
3. **Event loop** — Listens for @mention events (kind 40001 with the agent's pubkey in a `#p` tag). Events queue per channel.
3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel.
4. **Prompting** — When events are pending and no prompt is in flight, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
5. **Agent response** — The agent processes the prompt and uses Sprout MCP tools (`send_message`, `get_channel_history`, etc.) to interact with Sprout.
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.

View File

@@ -515,12 +515,12 @@ mod tests {
#[test]
fn test_all_mode_with_kinds_override() {
let mut config = test_config(SubscribeMode::All);
config.kinds_override = Some(vec![40001, 7]);
config.kinds_override = Some(vec![9, 7]);
let channels = vec![Uuid::new_v4()];
let result = resolve_channel_filters(&config, &channels, &[]);
let f = result.get(&channels[0]).unwrap();
assert_eq!(f.kinds.as_ref().unwrap(), &[40001, 7]);
assert_eq!(f.kinds.as_ref().unwrap(), &[9, 7]);
}
// ── resolve_channel_filters: channels_override ───────────────────────────
@@ -553,14 +553,14 @@ mod tests {
let rules = vec![make_rule(
"catch-all",
ChannelScope::All("all".into()),
vec![40001],
vec![9],
false,
)];
let result = resolve_channel_filters(&config, &[ch], &rules);
assert_eq!(result.len(), 1);
let f = result.get(&ch).unwrap();
assert_eq!(f.kinds.as_ref().unwrap(), &[40001]);
assert_eq!(f.kinds.as_ref().unwrap(), &[9]);
assert!(!f.require_mention);
}
@@ -572,7 +572,7 @@ mod tests {
let rules = vec![make_rule(
"only-a",
ChannelScope::List(vec![ch_a.to_string()]),
vec![40001],
vec![9],
false,
)];
@@ -588,20 +588,15 @@ mod tests {
let ch = Uuid::new_v4();
// Two rules both match the same channel with different kinds.
let rules = vec![
make_rule(
"messages",
ChannelScope::All("all".into()),
vec![40001],
true,
),
make_rule("messages", ChannelScope::All("all".into()), vec![9], true),
make_rule("reactions", ChannelScope::All("all".into()), vec![7], false),
];
let result = resolve_channel_filters(&config, &[ch], &rules);
let f = result.get(&ch).unwrap();
// Kinds should be the union: [40001, 7].
// Kinds should be the union: [9, 7].
let kinds = f.kinds.as_ref().expect("should have merged kinds");
assert!(kinds.contains(&40001));
assert!(kinds.contains(&9));
assert!(kinds.contains(&7));
// require_mention should be false (most permissive wins).
assert!(!f.require_mention);
@@ -613,12 +608,7 @@ mod tests {
let ch = Uuid::new_v4();
// First rule has specific kinds, second has empty (wildcard).
let rules = vec![
make_rule(
"narrow",
ChannelScope::All("all".into()),
vec![40001],
false,
),
make_rule("narrow", ChannelScope::All("all".into()), vec![9], false),
make_rule("broad", ChannelScope::All("all".into()), vec![], false),
];
@@ -637,7 +627,7 @@ mod tests {
let rules = vec![make_rule(
"other",
ChannelScope::List(vec![other_ch.to_string()]),
vec![40001],
vec![9],
false,
)];
@@ -651,7 +641,7 @@ mod tests {
let ch = Uuid::new_v4();
// First rule requires mention, second doesn't.
let rules = vec![
make_rule("strict", ChannelScope::All("all".into()), vec![40001], true),
make_rule("strict", ChannelScope::All("all".into()), vec![9], true),
make_rule("lax", ChannelScope::All("all".into()), vec![7], false),
];
@@ -710,7 +700,7 @@ mod tests {
[[rules]]
name = "catch-all"
channels = "all"
kinds = [40001]
kinds = [9]
require_mention = false
"#,
)

View File

@@ -341,13 +341,13 @@ mod tests {
#[test]
fn test_filter_context_from_event() {
let event = make_event(40001, "hello world");
let event = make_event(9, "hello world");
let channel_id = any_channel();
let ctx = FilterContext::from_event(&event, channel_id);
assert_eq!(ctx.content, "hello world");
assert_eq!(ctx.author, event.pubkey.to_hex());
assert_eq!(ctx.kind, 40001);
assert_eq!(ctx.kind, 9);
assert_eq!(ctx.channel_id, channel_id.to_string());
assert_eq!(ctx.timestamp, event.created_at.as_u64());
}
@@ -356,7 +356,7 @@ mod tests {
#[tokio::test]
async fn test_evaluate_filter_str_contains() {
let event = make_event(40001, "P1 incident in production");
let event = make_event(9, "P1 incident in production");
let ctx = FilterContext::from_event(&event, any_channel());
let result = evaluate_filter(r#"str_contains(content, "P1")"#, &ctx)
@@ -372,10 +372,10 @@ mod tests {
#[tokio::test]
async fn test_evaluate_filter_kind_check() {
let event = make_event(40001, "some content");
let event = make_event(9, "some content");
let ctx = FilterContext::from_event(&event, any_channel());
let result = evaluate_filter("kind == 40001", &ctx).await.unwrap();
let result = evaluate_filter("kind == 9", &ctx).await.unwrap();
assert!(result);
let result = evaluate_filter("kind == 1", &ctx).await.unwrap();
@@ -384,7 +384,7 @@ mod tests {
#[tokio::test]
async fn test_evaluate_filter_too_long() {
let event = make_event(40001, "content");
let event = make_event(9, "content");
let ctx = FilterContext::from_event(&event, any_channel());
let long_expr = "a".repeat(MAX_EXPR_LEN + 1);
@@ -401,7 +401,7 @@ mod tests {
#[tokio::test]
async fn test_match_event_first_match_wins() {
let event = make_event(40001, "hello");
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![
@@ -430,7 +430,7 @@ mod tests {
#[tokio::test]
async fn test_match_event_kind_filter() {
let event = make_event(40001, "hello");
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![
@@ -445,7 +445,7 @@ mod tests {
SubscriptionRule {
name: "right-kind".into(),
channels: ChannelScope::All("all".into()),
kinds: vec![40001],
kinds: vec![9],
require_mention: false,
filter: None,
prompt_tag: Some("matched".into()),
@@ -461,8 +461,8 @@ mod tests {
async fn test_match_event_require_mention() {
let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let event_no_mention = make_event(40001, "hello");
let event_with_mention = make_event_with_p_tag(40001, "hello", agent_pubkey);
let event_no_mention = make_event(9, "hello");
let event_with_mention = make_event_with_p_tag(9, "hello", agent_pubkey);
let channel_id = any_channel();
let rules = vec![SubscriptionRule {
@@ -491,9 +491,9 @@ mod tests {
let channel_id = any_channel();
let rules = vec![SubscriptionRule {
name: "kind-40001-only".into(),
name: "kind-9-only".into(),
channels: ChannelScope::All("all".into()),
kinds: vec![40001],
kinds: vec![9],
require_mention: false,
filter: None,
prompt_tag: None,
@@ -539,7 +539,7 @@ mod tests {
#[tokio::test]
async fn test_prompt_tag_falls_back_to_name() {
let event = make_event(40001, "hello");
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![SubscriptionRule {

View File

@@ -207,7 +207,7 @@ const STREAM_MESSAGE_KINDS: &[u32] = &[
/// Format the per-event block lines for a single [`BatchEvent`].
///
/// Non-stream-message kinds (anything not in `[40001, 40002]`) include a
/// Non-stream-message kinds (anything not in `[9, 40002]`) include a
/// `Tags:` line with the raw Nostr tags serialised as a JSON array-of-arrays.
fn format_event_block(channel_id: Uuid, be: &BatchEvent) -> String {
let npub = be
@@ -298,7 +298,7 @@ mod tests {
/// Build a test event with the given content and kind.
fn make_event(content: &str) -> Event {
let keys = Keys::generate();
EventBuilder::new(Kind::Custom(40001), content, [])
EventBuilder::new(Kind::Custom(9), content, [])
.sign_with_keys(&keys)
.unwrap()
}

View File

@@ -69,9 +69,9 @@ pub const KIND_PRESENCE_UPDATE: u32 = 20001;
/// Ephemeral: typing indicator for a channel.
pub const KIND_TYPING_INDICATOR: u32 = 20002;
// Stream messaging (40000–40999)
/// V1 used kind:10001 (replaceable range — wrong).
pub const KIND_STREAM_MESSAGE: u32 = 40001;
// Stream messaging
/// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001.
pub const KIND_STREAM_MESSAGE: u32 = 9;
/// V1 used kind:10002 (replaceable range — wrong).
pub const KIND_STREAM_MESSAGE_V2: u32 = 40002;
/// V1 used kind:10004 (replaceable range + NIP-51 collision — wrong).

View File

@@ -184,6 +184,11 @@ impl Db {
Ok(())
}
/// Returns `true` if the database is reachable (used by readiness probes).
pub async fn ping(&self) -> bool {
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()
}
// ── Events ───────────────────────────────────────────────────────────────
/// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate.
@@ -918,6 +923,142 @@ impl Db {
Ok(out)
}
// ── Pubkey Allowlist ─────────────────────────────────────────────────────
/// Returns `true` if the given pubkey is in the allowlist.
pub async fn is_pubkey_allowed(&self, pubkey: &[u8]) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM pubkey_allowlist WHERE pubkey = ?")
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
// ── Addressable Event Replacement ────────────────────────────────────────
/// Atomically replace the active addressable event for a (kind, pubkey, channel)
/// tuple. Insert-first ordering guarantees at least one active row at all times:
///
/// 1. INSERT the new event (IGNORE on duplicate ID — a no-op, not an error).
/// 2. Soft-delete all OTHER active events for the same address, excluding the
/// just-inserted row by ID.
///
/// If the INSERT was a no-op (duplicate event ID), the delete step is skipped
/// entirely — the existing row is already the latest state.
///
/// Used for NIP-29 group discovery events (39000/39001/39002) which should
/// only have one active version per group.
///
/// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate.
pub async fn replace_addressable_event(
&self,
event: &nostr::Event,
channel_id: Uuid,
) -> Result<(StoredEvent, bool)> {
use sprout_core::kind::event_kind_i32;
let id_bytes = event.id.as_bytes();
let pubkey_bytes = event.pubkey.to_bytes();
let sig_bytes = event.sig.serialize();
let tags_json = serde_json::to_value(&event.tags)?;
let kind_i32 = event_kind_i32(event);
let created_at_secs = event.created_at.as_u64() as i64;
let created_at = DateTime::from_timestamp(created_at_secs, 0)
.ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?;
let received_at = Utc::now();
let channel_id_bytes: [u8; 16] = *channel_id.as_bytes();
let mut tx = self.pool.begin().await?;
// Step 0: Acquire an exclusive next-key lock on the logical address
// (kind, pubkey, channel_id) via idx_events_addressable. This serializes
// concurrent replacements — a second transaction blocks here until the
// first commits. The index is required so InnoDB can take a gap lock
// even on a cold address (no existing rows), preventing two concurrent
// first-time emissions from both inserting.
sqlx::query(
"SELECT id FROM events \
WHERE kind = ? AND pubkey = ? AND channel_id = ? AND deleted_at IS NULL \
FOR UPDATE",
)
.bind(kind_i32)
.bind(pubkey_bytes.as_slice())
.bind(channel_id_bytes.as_slice())
.fetch_all(&mut *tx)
.await?;
// Step 1: Insert the new event first — guarantees at least one active row.
let result = sqlx::query(
"INSERT IGNORE INTO events \
(id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id_bytes.as_slice())
.bind(pubkey_bytes.as_slice())
.bind(created_at)
.bind(kind_i32)
.bind(&tags_json)
.bind(&event.content)
.bind(sig_bytes.as_slice())
.bind(received_at)
.bind(channel_id_bytes.as_slice())
.execute(&mut *tx)
.await?;
let was_inserted = result.rows_affected() > 0;
// Step 2: Soft-delete previous active events for this address, excluding
// the row we just inserted. Skipped on duplicate (was_inserted == false)
// because the existing row is already the current state.
if was_inserted {
sqlx::query(
"UPDATE events SET deleted_at = NOW(6) \
WHERE kind = ? AND pubkey = ? AND channel_id = ? \
AND deleted_at IS NULL AND id != ?",
)
.bind(kind_i32)
.bind(pubkey_bytes.as_slice())
.bind(channel_id_bytes.as_slice())
.bind(id_bytes.as_slice())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
if was_inserted {
if let Err(e) = insert_mentions(&self.pool, event, Some(channel_id)).await {
tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}");
}
}
Ok((
StoredEvent::with_received_at(event.clone(), received_at, Some(channel_id), true),
was_inserted,
))
}
/// Soft-delete all NIP-29 discovery events (39000/39001/39002) for a channel.
///
/// Used during group deletion to clean up addressable discovery events.
pub async fn soft_delete_discovery_events(
&self,
channel_id: Uuid,
relay_pubkey: &[u8],
) -> Result<u64> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let result = sqlx::query(
"UPDATE events SET deleted_at = NOW(6) \
WHERE kind IN (39000, 39001, 39002) AND pubkey = ? AND channel_id = ? \
AND deleted_at IS NULL",
)
.bind(relay_pubkey)
.bind(&channel_id_bytes)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
// ── Partitions ───────────────────────────────────────────────────────────
/// Ensures monthly partition tables exist for the next `months_ahead` months.
@@ -925,11 +1066,6 @@ impl Db {
partition::ensure_future_partitions(&self.pool, months_ahead).await
}
/// Returns `true` if the database is reachable (used by readiness probes).
pub async fn ping(&self) -> bool {
sqlx::query("SELECT 1").execute(&self.pool).await.is_ok()
}
// ── Workflows ─────────────────────────────────────────────────────────────
/// Creates a new workflow definition and returns its UUID.

View File

@@ -46,7 +46,7 @@
//! ## Available Tools
//!
//! ### Messaging
//! - **`send_message`** — Post a message to a channel (Nostr kind 40001 by default).
//! - **`send_message`** — Post a message to a channel (Nostr kind 9 by default).
//! - **`get_channel_history`** — Fetch recent messages from a channel (default 50, max 200).
//!
//! ### Channels

View File

@@ -52,7 +52,7 @@ pub struct SendMessageParams {
pub channel_id: String,
/// Message body text.
pub content: String,
/// Nostr event kind. Defaults to 40001 (channel message).
/// Nostr event kind. Defaults to KIND_STREAM_MESSAGE (NIP-29 group chat message).
#[serde(default = "default_kind")]
pub kind: Option<u16>,
/// Optional parent event ID. If provided, sends a reply via REST instead of WebSocket.
@@ -60,7 +60,7 @@ pub struct SendMessageParams {
pub parent_event_id: Option<String>,
}
fn default_kind() -> Option<u16> {
Some(40001)
Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16)
}
/// Parameters for the `get_channel_history` tool.
@@ -640,7 +640,10 @@ impl SproutMcpServer {
// Threaded replies still go through REST because that path handles the
// reply ancestry tags and DB bookkeeping for us.
if p.parent_event_id.is_none() {
let kind = Kind::from(p.kind.unwrap_or(40001));
let kind = Kind::from(
p.kind
.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16),
);
let tags = vec![match Tag::parse(&["h", &p.channel_id]) {
Ok(tag) => tag,
Err(e) => return format!("Error: failed to build channel tag: {e}"),

View File

@@ -179,7 +179,7 @@ nak event -k 42 -c "Hello!" --tag "e=<kind40_event_id>" --sec <nsec> "ws://local
## How It Works
- **Kind translation:** kind:42 ↔ kind:40001, `#e(event_id)` ↔ `#h(uuid)`
- **Kind translation:** kind:42 ↔ kind:9, `#e(event_id)` ↔ `#h(uuid)`
- **Shadow keys:** Each external pubkey gets a deterministic shadow keypair (HMAC-SHA256 of salt + pubkey). The relay sees the shadow key; the external user's real key is never exposed.
- **Channel map:** Loaded at startup from the relay's REST API. kind:40/41 events are synthesized locally — never forwarded upstream.
- **Invite tokens:** In-memory, scoped to specific channels, time-limited, use-limited. Lost on proxy restart.

View File

@@ -395,7 +395,7 @@ async fn handle_ws(mut socket: WebSocket, state: ProxyState, token: String) {
}
// Strip the connection prefix before sending to client.
let client_sub_id = SubscriptionId::new(&sub_str[conn_prefix.len() + 1..]);
// Translate outbound: kind:40001 → kind:42, #h → #e
// Translate outbound: kind:9 → kind:42, #h → #e
match state
.translator
.translate_outbound(&event, &allowed_channels)
@@ -549,7 +549,7 @@ async fn handle_client_message(
return;
}
// Translate inbound: kind:42 → kind:40001, #e → #h, re-sign with shadow key.
// Translate inbound: kind:42 → kind:9, #e → #h, re-sign with shadow key.
match state.translator.translate_inbound(
&event,
&client_pubkey.to_hex(),

View File

@@ -3,13 +3,13 @@
//! # Overview
//!
//! The proxy sits between standard Nostr clients (NIP-28) and the Sprout relay.
//! Sprout stores messages as kind:40001 events with `#h <uuid>` channel tags.
//! Sprout stores messages as kind:9 events with `#h <uuid>` channel tags.
//! NIP-28 clients expect kind:42 events with `#e <kind40_event_id>` channel tags.
//!
//! This module handles bidirectional translation:
//!
//! - **Outbound** (Sprout → client): kind:40001 + `#h(uuid)` → kind:42 + `#e(event_id)`
//! - **Inbound** (client → Sprout): kind:42 + `#e(event_id)` → kind:40001 + `#h(uuid)`
//! - **Outbound** (Sprout → client): kind:9 + `#h(uuid)` → kind:42 + `#e(event_id)`
//! - **Inbound** (client → Sprout): kind:42 + `#e(event_id)` → kind:9 + `#h(uuid)`
//!
//! All translated events are re-signed with deterministic shadow keys so that
//! each external user maps to a consistent shadow pubkey across sessions.
@@ -369,7 +369,7 @@ impl Translator {
///
/// # Translation rules
///
/// - kind:40001 / kind:40002 → kind:42
/// - kind:9 / kind:40002 → kind:42
/// - `#h <uuid>` tag → `#e <kind40_event_id>` tag
/// - All other tags are preserved unchanged
/// - Re-signed with the shadow key for the original author's pubkey
@@ -402,7 +402,7 @@ impl Translator {
///
/// # Translation rules
///
/// - kind:42 (or kind:1) → kind:40001
/// - kind:42 (or kind:1) → kind:9
/// - `#e <kind40_event_id>` tag → `#h <uuid>` tag
/// - All other tags are preserved unchanged
/// - Re-signed with the shadow key for `external_pubkey`
@@ -502,11 +502,11 @@ impl Translator {
// Re-sign with the shadow key for the external user.
let shadow_keys = self.shadow_keys.get_or_create(external_pubkey)?;
// SAFETY: sprout_kind is derived from KindTranslator which maps to known Sprout kinds (40001, 40002, 40003) that fit in u16
// SAFETY: sprout_kind is derived from KindTranslator which maps to known Sprout kinds (9, 40002, 40003) that fit in u16
let translated = EventBuilder::new(
Kind::Custom(
u16::try_from(sprout_kind)
.expect("SAFETY: sprout kind values (40001, 40002, 40003) always fit in u16"),
.expect("SAFETY: sprout kind values (9, 40002, 40003) always fit in u16"),
),
&event.content,
new_tags,
@@ -668,7 +668,7 @@ impl Translator {
impl Translator {
/// Translate a NIP-28 REQ filter to Sprout format.
///
/// - kind:42 / kind:1 → kind:40001
/// - kind:42 / kind:1 → kind:9
/// - Injects `#h` tag filters from `allowed_channels` so the client can
/// only receive events from channels they have access to.
///
@@ -683,7 +683,7 @@ impl Translator {
.map(|k| {
let k_u32 = k.as_u16() as u32;
let sprout_k = self.kind_translator.to_sprout(k_u32);
// SAFETY: sprout kind values (40001, 40002, 40003) always fit in u16
// SAFETY: sprout kind values (9, 40002, 40003) always fit in u16
Kind::Custom(
u16::try_from(sprout_k)
.expect("SAFETY: sprout kind values always fit in u16"),
@@ -749,7 +749,7 @@ impl Translator {
/// Translate a Sprout REQ filter to NIP-28 format (for outbound subscription
/// forwarding, e.g. when the proxy subscribes on behalf of a client).
///
/// - kind:40001 / kind:40002 → kind:42
/// - kind:9 / kind:40002 → kind:42
pub fn translate_filter_outbound(&self, filter: &Filter) -> Filter {
let mut f = filter.clone();
@@ -843,14 +843,14 @@ mod tests {
vec![]
}
// ── Test 1: Outbound — kind:40001 + #h → kind:42 + #e ───────────────────
// ── Test 1: Outbound — kind:9 + #h → kind:42 + #e ───────────────────
#[tokio::test]
async fn outbound_translates_stream_message() {
let (translator, kind40_event_id) = make_translator();
let author_keys = Keys::generate();
// Build a synthetic kind:40001 event with an #h tag.
// Build a synthetic kind:9 event with an #h tag.
let h_tag = Tag::parse(&["h", TEST_UUID]).unwrap();
let sprout_event = EventBuilder::new(
Kind::Custom(KIND_STREAM_MESSAGE as u16),
@@ -896,7 +896,7 @@ mod tests {
.expect("translated event signature must be valid");
}
// ── Test 2: Inbound — kind:42 + #e → kind:40001 + #h ───────────────────
// ── Test 2: Inbound — kind:42 + #e → kind:9 + #h ───────────────────
#[test]
fn inbound_translates_channel_message() {
@@ -1030,7 +1030,7 @@ mod tests {
assert_eq!(content, "not json at all");
}
// ── Test 6: Filter translation — kind:42 → kind:40001 ───────────────────
// ── Test 6: Filter translation — kind:42 → kind:9 ───────────────────
#[test]
fn filter_inbound_translates_kind() {

View File

@@ -153,6 +153,14 @@ pub async fn create_channel(
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
// Emit NIP-29 group discovery events so standard clients can find this channel.
// Non-fatal — channel creation succeeds even if discovery emission fails.
if let Err(e) =
crate::handlers::side_effects::emit_group_discovery_events(&state, channel.id).await
{
tracing::warn!(channel_id = %channel.id, error = %e, "NIP-29 discovery emission failed");
}
Ok((
StatusCode::CREATED,
Json(channel_record_to_json(

View File

@@ -244,7 +244,7 @@ pub struct SendMessageBody {
/// When `true`, a reply is also surfaced in the channel feed (broadcast).
#[serde(default)]
pub broadcast_to_channel: bool,
/// Nostr kind for this message. Defaults to `KIND_STREAM_MESSAGE` (40001).
/// Nostr kind for this message. Defaults to `KIND_STREAM_MESSAGE` (9).
pub kind: Option<u32>,
// Diff metadata fields (only used when kind == KIND_STREAM_MESSAGE_DIFF)
/// Repository URL for the diff (required for kind:40008; must be http/https).
@@ -306,7 +306,7 @@ pub async fn send_message(
return Err(api_error(StatusCode::BAD_REQUEST, "content is required"));
}
// Resolve kind — default to KIND_STREAM_MESSAGE (40001).
// Resolve kind — default to KIND_STREAM_MESSAGE (9).
let kind_u32 = body.kind.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE);
if kind_u32 == sprout_core::kind::KIND_STREAM_MESSAGE_DIFF && body.content.len() > 60 * 1024 {

View File

@@ -48,6 +48,11 @@ pub struct Config {
/// Optional Unix Domain Socket path. When set, the relay also listens on this
/// UDS for traffic (e.g. service mesh sidecar). Health probes still use TCP.
pub uds_path: Option<String>,
/// When true, NIP-42 pubkey-only authentication (no JWT or API token) is
/// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid
/// API tokens or Okta JWTs bypass the allowlist entirely.
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
pub pubkey_allowlist_enabled: bool,
}
impl Config {
@@ -92,6 +97,10 @@ impl Config {
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let pubkey_allowlist_enabled = std::env::var("SPROUT_PUBKEY_ALLOWLIST")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let mut auth = sprout_auth::AuthConfig::default();
auth.okta.require_token = require_auth_token;
@@ -141,6 +150,7 @@ impl Config {
cors_origins,
relay_private_key,
uds_path,
pubkey_allowlist_enabled,
})
}
}
@@ -163,6 +173,10 @@ mod tests {
assert!(!config.redis_url.is_empty());
assert!(config.max_connections > 0);
assert!(config.send_buffer_size > 0);
assert!(
!config.pubkey_allowlist_enabled,
"pubkey_allowlist_enabled should default to false"
);
}
#[test]

View File

@@ -112,6 +112,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
scopes,
auth_method: sprout_auth::AuthMethod::Nip42ApiToken,
};
// API token users have already proven authorization via their token —
// the pubkey allowlist does not apply here.
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
conn.send(RelayMessage::ok(&event_id_hex, true, ""));
}
@@ -137,6 +139,30 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
{
Ok(auth_ctx) => {
let pubkey = auth_ctx.pubkey;
// Pubkey allowlist gate — only for pubkey-only auth (no JWT/token).
// Users with valid API tokens or Okta JWTs bypass the allowlist.
if state.config.pubkey_allowlist_enabled
&& auth_ctx.auth_method == sprout_auth::AuthMethod::Nip42PubkeyOnly
{
let allowed = match state.db.is_pubkey_allowed(&pubkey.serialize()).await {
Ok(v) => v,
Err(e) => {
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e,
"allowlist DB lookup failed, denying (fail-closed)");
false
}
};
if !allowed {
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist");
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: verification failed",
));
return;
}
}
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
conn.send(RelayMessage::ok(&event_id_hex, true, ""));

View File

@@ -510,12 +510,14 @@ async fn check_channel_membership(
match state.db.is_member(ch_id, pubkey_bytes).await {
Ok(true) => Ok(()),
Ok(false) => {
let is_open = state
.db
.get_channel(ch_id)
.await
.map(|ch| ch.visibility == "open")
.unwrap_or(false);
let is_open = match state.db.get_channel(ch_id).await {
Ok(ch) => ch.visibility == "open",
Err(e) => {
tracing::warn!(conn_id = %conn_id, channel = %ch_id, error = %e,
"channel lookup failed during membership fallback, denying");
false
}
};
if is_open {
Ok(())
} else {

View File

@@ -6,9 +6,13 @@ use nostr::{Event, EventBuilder, Kind, Tag};
use tracing::{info, warn};
use uuid::Uuid;
use sprout_core::kind::KIND_REACTION;
use sprout_core::kind::{
event_kind_u32, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA,
KIND_REACTION,
};
use sprout_db::channel::MemberRole;
use super::event::dispatch_persistent_event;
use crate::state::AppState;
/// Check if a kind is an admin kind (9000-9022) that needs pre-storage validation.
@@ -308,7 +312,9 @@ pub async fn emit_system_message(
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign system message: {e}"))?;
let _ = state.db.insert_event(&event, Some(channel_id)).await;
if let Err(e) = state.db.insert_event(&event, Some(channel_id)).await {
warn!(channel = %channel_id, error = %e, "system message insert failed");
}
// Fan out to subscribers
if let Err(e) = state.pubsub.publish_event(channel_id, &event).await {
@@ -318,6 +324,109 @@ pub async fn emit_system_message(
Ok(())
}
/// Sign, store (replacing previous), and fan-out a single addressable discovery event.
async fn emit_addressable_discovery_event(
state: &Arc<AppState>,
channel_id: Uuid,
kind: u32,
tags: Vec<Tag>,
relay_pubkey_hex: &str,
) -> anyhow::Result<()> {
let event = EventBuilder::new(Kind::Custom(kind as u16), "", tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?;
let (stored, _) = state
.db
.replace_addressable_event(&event, channel_id)
.await?;
let kind_u32 = event_kind_u32(&stored.event);
dispatch_persistent_event(state, &stored, kind_u32, relay_pubkey_hex).await;
Ok(())
}
/// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair.
/// Called after group creation, metadata changes, or membership changes.
/// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing
/// access control applies — private channel member lists are only visible to members.
///
/// NOTE: Channel-scoped storage means live global subscriptions (e.g. `{kinds:[39000]}`)
/// won't receive these events via fan-out. Clients discover groups via historical REQ
/// queries. Live push for open-channel discovery is a future enhancement.
pub async fn emit_group_discovery_events(
state: &Arc<AppState>,
channel_id: Uuid,
) -> anyhow::Result<()> {
let channel = state.db.get_channel(channel_id).await?;
let members = state.db.get_members(channel_id).await?;
let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().serialize());
let group_id = channel_id.to_string();
// ── kind:39000 group metadata ────────────────────────────────────────────
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &group_id])?];
tags.push(Tag::parse(&["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(&["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(&["private"])?);
}
// Sprout channels always require explicit membership
tags.push(Tag::parse(&["closed"])?);
emit_addressable_discovery_event(
state,
channel_id,
KIND_NIP29_GROUP_METADATA,
tags,
&relay_pubkey_hex,
)
.await?;
}
// ── kind:39001 group admins ──────────────────────────────────────────────
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &group_id])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
{
let pubkey_hex = hex::encode(&m.pubkey);
tags.push(Tag::parse(&["p", &pubkey_hex, &m.role])?);
}
emit_addressable_discovery_event(
state,
channel_id,
KIND_NIP29_GROUP_ADMINS,
tags,
&relay_pubkey_hex,
)
.await?;
}
// ── kind:39002 group members ─────────────────────────────────────────────
{
let mut tags: Vec<Tag> = vec![Tag::parse(&["d", &group_id])?];
for m in &members {
let pubkey_hex = hex::encode(&m.pubkey);
tags.push(Tag::parse(&["p", &pubkey_hex])?);
}
emit_addressable_discovery_event(
state,
channel_id,
KIND_NIP29_GROUP_MEMBERS,
tags,
&relay_pubkey_hex,
)
.await?;
}
Ok(())
}
// ── NIP-01 Kind:0 Handler ────────────────────────────────────────────────────
/// Kind:0 (NIP-01 profile metadata) side effect — sync profile fields to users table.
@@ -425,6 +534,10 @@ async fn handle_put_user(event: &Event, state: &Arc<AppState>) -> anyhow::Result
)
.await?;
if let Err(e) = emit_group_discovery_events(state, channel_id).await {
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
info!(channel = %channel_id, target = %target_hex, "NIP-29 PUT_USER processed");
Ok(())
}
@@ -472,6 +585,10 @@ async fn handle_remove_user(event: &Event, state: &Arc<AppState>) -> anyhow::Res
)
.await?;
if let Err(e) = emit_group_discovery_events(state, channel_id).await {
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
Ok(())
}
@@ -535,6 +652,11 @@ async fn handle_edit_metadata(event: &Event, state: &Arc<AppState>) -> anyhow::R
}
}
}
if let Err(e) = emit_group_discovery_events(state, channel_id).await {
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
Ok(())
}
@@ -657,6 +779,10 @@ async fn handle_create_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
)
.await?;
if let Err(e) = emit_group_discovery_events(state, channel.id).await {
warn!(channel = %channel.id, error = %e, "NIP-29 group discovery emission failed");
}
info!(channel_id = %channel.id, name = %name, "NIP-29 CREATE_GROUP processed");
Ok(())
}
@@ -677,6 +803,15 @@ async fn handle_delete_group(event: &Event, state: &Arc<AppState>) -> anyhow::Re
warn!(channel = %channel_id, "channel already deleted or not found");
}
// Clean up NIP-29 discovery events for the deleted group.
if let Err(e) = state
.db
.soft_delete_discovery_events(channel_id, &state.relay_keypair.public_key().serialize())
.await
{
warn!(channel = %channel_id, error = %e, "failed to clean up NIP-29 discovery events");
}
let actor_hex = nostr::util::hex::encode(&actor_bytes);
emit_system_message(
state,
@@ -725,6 +860,10 @@ async fn handle_leave_request(event: &Event, state: &Arc<AppState>) -> anyhow::R
)
.await?;
if let Err(e) = emit_group_discovery_events(state, channel_id).await {
warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed");
}
Ok(())
}

View File

@@ -84,7 +84,7 @@ impl ActionSink for RelayActionSink {
));
}
// 3. Build kind:40001 Nostr event
// 3. Build kind:9 Nostr event
// - Signed by relay keypair (event.pubkey = relay pubkey)
// - `p` tag attributes the message to the workflow owner
// - `h` tag scopes to the channel (NIP-29, canonical UUID)

View File

@@ -24,7 +24,7 @@ async fn main() -> anyhow::Result<()> {
let h_tag = Tag::parse(&["h", channel_id])?;
let p_tag = Tag::parse(&["p", target_pubkey])?;
let event =
EventBuilder::new(Kind::Custom(40001), message, [h_tag, p_tag]).sign_with_keys(&keys)?;
EventBuilder::new(Kind::Custom(9), message, [h_tag, p_tag]).sign_with_keys(&keys)?;
let ok = client.send_event(event).await?;
if ok.accepted {

View File

@@ -538,11 +538,11 @@ mod tests {
let keys = Keys::generate();
let channel_id = "my-channel-123";
let h_tag = Tag::parse(&["h", channel_id]).unwrap();
let event = EventBuilder::new(Kind::Custom(40001), "hello", [h_tag])
let event = EventBuilder::new(Kind::Custom(9), "hello", [h_tag])
.sign_with_keys(&keys)
.unwrap();
assert_eq!(event.kind, Kind::Custom(40001));
assert_eq!(event.kind, Kind::Custom(9));
let tags: Vec<Vec<String>> = event
.tags
.iter()

View File

@@ -10,7 +10,7 @@
//! --send <MESSAGE> Send a text message to a channel
//! --channel <ID> Channel ID for send/subscribe
//! --subscribe Subscribe to a channel and print events
//! --kind <KIND> Event kind [default: 40001]
//! --kind <KIND> Event kind [default: 9]
//! ```
//!
//! # Examples
@@ -45,7 +45,7 @@ async fn main() {
let url = opts.url.as_deref().unwrap_or("ws://localhost:3000");
let channel = opts.channel.as_deref().unwrap_or("default");
let kind = opts.kind.unwrap_or(40001);
let kind = opts.kind.unwrap_or(9);
let keys = match std::env::var("SPROUT_PRIVATE_KEY") {
Ok(sk) => Keys::parse(&sk).expect("invalid SPROUT_PRIVATE_KEY"),
@@ -219,7 +219,7 @@ OPTIONS:
--send <MESSAGE> Send a text message to a channel
--channel <ID> Channel ID for send/subscribe [default: default]
--subscribe Subscribe to a channel and print events
--kind <KIND> Event kind [default: 40001]
--kind <KIND> Event kind [default: 9]
--help Print this help message
EXAMPLES:

View File

@@ -20,7 +20,7 @@
use std::time::Duration;
use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
use sprout_test_client::{RelayMessage, SproutTestClient, TestClientError};
fn relay_url() -> String {
@@ -77,7 +77,7 @@ async fn test_connect_and_authenticate() {
#[ignore]
async fn test_send_event_and_receive_via_subscription() {
let url = relay_url();
let kind: u16 = 40001;
let kind: u16 = 9;
let keys_a = Keys::generate();
let keys_b = Keys::generate();
@@ -136,7 +136,7 @@ async fn test_send_event_and_receive_via_subscription() {
#[ignore]
async fn test_subscription_filters_by_kind() {
let url = relay_url();
let target_kind: u16 = 40001;
let target_kind: u16 = 9;
let other_kind: u16 = 40002;
let keys = Keys::generate();
@@ -208,7 +208,7 @@ async fn test_subscription_filters_by_kind() {
#[ignore]
async fn test_close_subscription_stops_delivery() {
let url = relay_url();
let kind: u16 = 40001;
let kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
@@ -272,7 +272,7 @@ async fn test_unauthenticated_rejected() {
tokio::time::sleep(Duration::from_millis(200)).await;
let result = client
.send_text_message(&keys, "some-channel", "unauthenticated message", 40001)
.send_text_message(&keys, "some-channel", "unauthenticated message", 9)
.await;
match result {
@@ -300,7 +300,7 @@ async fn test_unauthenticated_rejected() {
#[ignore]
async fn test_multiple_concurrent_clients() {
let url = relay_url();
let kind: u16 = 40001;
let kind: u16 = 9;
let keys: Vec<Keys> = (0..3).map(|_| Keys::generate()).collect();
let channel = create_test_channel(&keys[0]).await;
@@ -357,7 +357,7 @@ async fn test_multiple_concurrent_clients() {
#[ignore]
async fn test_stored_events_returned_before_eose() {
let url = relay_url();
let kind: u16 = 40001;
let kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
@@ -485,7 +485,7 @@ async fn test_subscription_limit_enforced() {
for i in 0..100 {
let sid = format!("limit-sub-{i}");
let filter = Filter::new().kind(Kind::Custom(40001));
let filter = Filter::new().kind(Kind::Custom(9));
client
.subscribe(&sid, vec![filter])
.await
@@ -601,7 +601,7 @@ async fn test_pubkey_mismatch_rejected() {
.expect("connect as keys_a");
let ok = client
.send_text_message(&keys_b, &channel, "impersonation attempt", 40001)
.send_text_message(&keys_b, &channel, "impersonation attempt", 9)
.await
.expect("send");
@@ -617,7 +617,7 @@ async fn test_pubkey_mismatch_rejected() {
#[ignore]
async fn test_eose_sent_for_empty_subscription() {
let url = relay_url();
let kind: u16 = 40001;
let kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
@@ -990,3 +990,183 @@ async fn test_nip29_put_user_owner_only_blocks() {
ws.disconnect().await.expect("disconnect");
}
/// End-to-end test of the standard NIP-29 client flow:
/// connect, authenticate, discover groups, subscribe, send/receive messages,
/// react, and delete.
#[tokio::test]
#[ignore]
async fn test_nip29_standard_client_flow() {
let url = relay_url();
let keys = Keys::generate();
let channel_id = create_test_channel(&keys).await;
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect and authenticate via NIP-42");
// 1. Query group discovery events (kind:39000)
// The channel was just created, so the relay should have emitted a 39000 event.
let discovery_sid = sub_id("discovery");
let discovery_filter = Filter::new().kind(Kind::Custom(39000));
client
.subscribe(&discovery_sid, vec![discovery_filter])
.await
.expect("subscribe to group discovery");
let discovery_events = client
.collect_until_eose(&discovery_sid, Duration::from_secs(5))
.await
.expect("collect discovery events");
// Find our channel's 39000 event by checking d tags.
let our_group = discovery_events.iter().find(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "d" && s[1] == channel_id
})
});
assert!(
our_group.is_some(),
"should find kind:39000 for our channel among {} events",
discovery_events.len()
);
let group_meta = our_group.unwrap();
// Verify it has a name tag.
let has_name = group_meta.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "name"
});
assert!(has_name, "39000 event should have a name tag");
// 1b. Verify kind:39001 (group admins) was also emitted.
let admins_sid = sub_id("admins");
let admins_filter = Filter::new().kind(Kind::Custom(39001));
client
.subscribe(&admins_sid, vec![admins_filter])
.await
.expect("subscribe to group admins");
let admins_events = client
.collect_until_eose(&admins_sid, Duration::from_secs(5))
.await
.expect("collect admins events");
let our_admins = admins_events.iter().find(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "d" && s[1] == channel_id
})
});
assert!(
our_admins.is_some(),
"should find kind:39001 for our channel among {} events",
admins_events.len()
);
// 1c. Verify kind:39002 (group members) was also emitted.
let members_sid = sub_id("members");
let members_filter = Filter::new().kind(Kind::Custom(39002));
client
.subscribe(&members_sid, vec![members_filter])
.await
.expect("subscribe to group members");
let members_events = client
.collect_until_eose(&members_sid, Duration::from_secs(5))
.await
.expect("collect members events");
let our_members = members_events.iter().find(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "d" && s[1] == channel_id
})
});
assert!(
our_members.is_some(),
"should find kind:39002 for our channel among {} events",
members_events.len()
);
// 2. Subscribe to channel messages (kind:9 + h tag).
let msg_sid = sub_id("messages");
let msg_filter = Filter::new().kind(Kind::Custom(9)).custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
[channel_id.as_str()],
);
client
.subscribe(&msg_sid, vec![msg_filter])
.await
.expect("subscribe to channel messages");
let _historical = client
.collect_until_eose(&msg_sid, Duration::from_secs(5))
.await
.expect("collect historical messages");
// 3. Send a kind:9 message with h tag.
let content = format!("nip29-test-{}", uuid::Uuid::new_v4());
let ok = client
.send_text_message(&keys, &channel_id, &content, 9)
.await
.expect("send kind:9 message");
assert!(
ok.accepted,
"relay should accept kind:9 with h tag: {}",
ok.message
);
// 4. Receive the message on the subscription and capture the event ID.
let msg = client
.recv_event(Duration::from_secs(5))
.await
.expect("receive kind:9 event");
let message_event_id = match msg {
RelayMessage::Event { ref event, .. } => {
assert_eq!(event.kind, Kind::Custom(9));
assert_eq!(event.content, content);
event.id.to_hex()
}
other => panic!("expected EVENT, got: {:?}", other),
};
// 5. Send a kind:7 reaction targeting the message.
let h_tag = Tag::parse(&["h", &channel_id]).expect("h tag");
let e_tag = Tag::parse(&["e", &message_event_id]).expect("e tag");
let reaction_event = EventBuilder::new(Kind::Custom(7), "+", [h_tag, e_tag])
.sign_with_keys(&keys)
.expect("sign reaction");
let ok = client
.send_event(reaction_event)
.await
.expect("send reaction");
assert!(
ok.accepted,
"relay should accept kind:7 reaction: {}",
ok.message
);
// 6. Send a kind:5 deletion targeting the message.
let h_tag2 = Tag::parse(&["h", &channel_id]).expect("h tag");
let e_tag2 = Tag::parse(&["e", &message_event_id]).expect("e tag");
let delete_event = EventBuilder::new(Kind::Custom(5), "test delete", [h_tag2, e_tag2])
.sign_with_keys(&keys)
.expect("sign deletion");
let ok = client
.send_event(delete_event)
.await
.expect("send deletion");
assert!(
ok.accepted,
"relay should accept kind:5 deletion: {}",
ok.message
);
// 7. Verify kind:9 without h tag is rejected.
let no_h_event = EventBuilder::new(Kind::Custom(9), "no h tag", [])
.sign_with_keys(&keys)
.expect("sign no-h event");
let ok = client
.send_event(no_h_event)
.await
.expect("send no-h event");
assert!(!ok.accepted, "relay should reject kind:9 without h tag");
client.disconnect().await.expect("clean disconnect");
}

View File

@@ -308,7 +308,7 @@ async fn test_rest_send_message_reaches_websocket_channel_subscriptions() {
.expect("WebSocket connect failed");
let sid = format!("rest-live-{}", uuid::Uuid::new_v4().simple());
let filter = Filter::new().kind(Kind::Custom(40001)).custom_tag(
let filter = Filter::new().kind(Kind::Custom(9)).custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
[channel_id.as_str()],
);
@@ -457,7 +457,7 @@ async fn test_search_returns_indexed_event() {
.expect("WebSocket connect failed");
let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed");
let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag])
let event = nostr::EventBuilder::new(Kind::Custom(9), &content, [h_tag])
.sign_with_keys(&keys)
.expect("event sign failed");
@@ -965,7 +965,7 @@ async fn test_feed_returns_activity() {
.expect("WebSocket connect failed");
let h_tag = Tag::parse(&["h", &channel_id]).expect("tag parse failed");
let event = nostr::EventBuilder::new(Kind::Custom(40001), &content, [h_tag])
let event = nostr::EventBuilder::new(Kind::Custom(9), &content, [h_tag])
.sign_with_keys(&keys)
.expect("event sign failed");

View File

@@ -310,7 +310,7 @@ async fn test_trigger_workflow_and_check_run() {
// ── Test 5: Event-driven workflow execution ───────────────────────────────────
/// Send a kind:40001 message to a channel that has a `message_posted` workflow.
/// Send a kind:9 message to a channel that has a `message_posted` workflow.
/// Verify that the workflow engine creates a run record.
///
/// NOTE: Uses `SEEDED_PUBKEY` for workflow ownership due to the FK constraint
@@ -342,18 +342,17 @@ steps:
.expect("created workflow must have 'id'")
.to_string();
// ── Step 2: Connect via WebSocket and send a kind:40001 message ───────────
// ── Step 2: Connect via WebSocket and send a kind:9 message ───────────
// Use fresh keys for the sender (channel is open, no auth required to post).
let sender_keys = Keys::generate();
let mut ws_client = SproutTestClient::connect(&relay_ws_url(), &sender_keys)
.await
.expect("ws connect failed");
let e_tag = Tag::parse(&["e", CHANNEL_GENERAL]).expect("tag parse failed");
let event =
nostr::EventBuilder::new(Kind::Custom(40001), "trigger this workflow please", [e_tag])
.sign_with_keys(&sender_keys)
.expect("sign event");
let h_tag = Tag::parse(&["h", CHANNEL_GENERAL]).expect("tag parse failed");
let event = nostr::EventBuilder::new(Kind::Custom(9), "trigger this workflow please", [h_tag])
.sign_with_keys(&sender_keys)
.expect("sign event");
ws_client
.send_event(event)
@@ -376,7 +375,7 @@ steps:
let runs: Vec<serde_json::Value> = runs_resp.json().await.expect("runs must be JSON array");
assert!(
!runs.is_empty(),
"expected at least one workflow run after sending kind:40001 event"
"expected at least one workflow run after sending kind:9 event"
);
let run = &runs[0];
@@ -444,11 +443,11 @@ steps:
.expect("ws connect failed");
// ── Step 2: Send a message that does NOT match the filter ─────────────────
let e_tag = Tag::parse(&["e", CHANNEL_GENERAL]).expect("tag parse failed");
let h_tag = Tag::parse(&["h", CHANNEL_GENERAL]).expect("tag parse failed");
let non_matching = nostr::EventBuilder::new(
Kind::Custom(40001),
Kind::Custom(9),
"this is a routine update, nothing urgent",
[e_tag.clone()],
[h_tag.clone()],
)
.sign_with_keys(&sender_keys)
.expect("sign event");
@@ -477,10 +476,9 @@ steps:
);
// ── Step 3: Send a message that DOES match the filter ─────────────────────
let matching =
nostr::EventBuilder::new(Kind::Custom(40001), "P1 alert: database is down", [e_tag])
.sign_with_keys(&sender_keys)
.expect("sign event");
let matching = nostr::EventBuilder::new(Kind::Custom(9), "P1 alert: database is down", [h_tag])
.sign_with_keys(&sender_keys)
.expect("sign event");
ws_client
.send_event(matching)

View File

@@ -941,10 +941,10 @@ steps:
// ── Trigger matching edge cases ───────────────────────────────────────────
#[test]
fn message_posted_matches_kind_40001_only() {
fn message_posted_matches_kind_9_only() {
let trigger = TriggerDef::MessagePosted { filter: None };
// Must match KIND_STREAM_MESSAGE = 40001.
assert!(trigger_matches_event(&trigger, 40001));
// Must match KIND_STREAM_MESSAGE = 9.
assert!(trigger_matches_event(&trigger, 9));
// Must NOT match reaction (kind 7).
assert!(!trigger_matches_event(&trigger, 7));
// Must NOT match forum post (kind 45001).
@@ -958,8 +958,8 @@ steps:
let trigger = TriggerDef::ReactionAdded { emoji: None };
// Must match KIND_REACTION = 7.
assert!(trigger_matches_event(&trigger, 7));
// Must NOT match stream message (kind 40001).
assert!(!trigger_matches_event(&trigger, 40001));
// Must NOT match stream message (kind 9).
assert!(!trigger_matches_event(&trigger, 9));
// Must NOT match forum post (kind 45001).
assert!(!trigger_matches_event(&trigger, 45001));
}
@@ -972,16 +972,16 @@ steps:
emoji: Some("thumbsup".to_owned()),
};
assert!(trigger_matches_event(&trigger, 7));
assert!(!trigger_matches_event(&trigger, 40001));
assert!(!trigger_matches_event(&trigger, 9));
}
#[test]
fn message_posted_with_filter_still_matches_kind_40001() {
fn message_posted_with_filter_still_matches_kind_9() {
// The filter expression is evaluated at execution time, not trigger-matching time.
let trigger = TriggerDef::MessagePosted {
filter: Some("str_contains(trigger_text, \"P1\")".to_owned()),
};
assert!(trigger_matches_event(&trigger, 40001));
assert!(trigger_matches_event(&trigger, 9));
assert!(!trigger_matches_event(&trigger, 7));
}
@@ -1029,7 +1029,7 @@ steps:
fn diff_posted_matches_kind_40008_only() {
let trigger = TriggerDef::DiffPosted { filter: None };
assert!(trigger_matches_event(&trigger, 40008));
assert!(!trigger_matches_event(&trigger, 40001));
assert!(!trigger_matches_event(&trigger, 9));
assert!(!trigger_matches_event(&trigger, 7));
}
@@ -1037,7 +1037,7 @@ steps:
fn message_posted_does_not_match_kind_40008() {
let trigger = TriggerDef::MessagePosted { filter: None };
assert!(!trigger_matches_event(&trigger, 40008));
assert!(trigger_matches_event(&trigger, 40001));
assert!(trigger_matches_event(&trigger, 9));
}
#[test]
@@ -1056,7 +1056,7 @@ steps:
use nostr::{EventBuilder, Keys, Kind};
use uuid::Uuid;
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(40001), "hello world", [])
let event = EventBuilder::new(Kind::Custom(9), "hello world", [])
.sign_with_keys(&keys)
.expect("sign");
sprout_core::StoredEvent::new(event, Some(Uuid::new_v4()))
@@ -1069,7 +1069,7 @@ steps:
let keys = Keys::generate();
// Create a dummy target message ID (64-char hex).
let target_keys = Keys::generate();
let target_event = EventBuilder::new(Kind::Custom(40001), "target msg", [])
let target_event = EventBuilder::new(Kind::Custom(9), "target msg", [])
.sign_with_keys(&target_keys)
.expect("sign target");
let target_id_hex = target_event.id.to_hex();
@@ -1118,7 +1118,7 @@ steps:
fn build_trigger_context_no_channel_id() {
use nostr::{EventBuilder, Keys, Kind};
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(40001), "msg", [])
let event = EventBuilder::new(Kind::Custom(9), "msg", [])
.sign_with_keys(&keys)
.expect("sign");
// channel_id = None (global/DM event)

View File

@@ -13,6 +13,7 @@ import {
sendChannelMessage,
} from "@/shared/api/tauri";
import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds";
type MessageQueryContext = {
optimisticId: string;
@@ -84,7 +85,7 @@ function createOptimisticMessage(
id: `optimistic-${crypto.randomUUID()}`,
pubkey: identity.pubkey,
created_at: Math.floor(Date.now() / 1_000),
kind: 4_0001,
kind: KIND_STREAM_MESSAGE,
tags,
content,
sig: "",
@@ -245,7 +246,7 @@ export function useSendMessageMutation(
id: result.eventId,
pubkey: identity.pubkey,
created_at: result.createdAt,
kind: 4_0001,
kind: KIND_STREAM_MESSAGE,
tags: buildReplyTags(
channel.id,
identity.pubkey,

View File

@@ -6,7 +6,10 @@ import {
signRelayEvent,
} from "@/shared/api/tauri";
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
import { CHANNEL_EVENT_KINDS } from "@/shared/constants/kinds";
import {
CHANNEL_EVENT_KINDS,
KIND_STREAM_MESSAGE,
} from "@/shared/constants/kinds";
import {
getTextPayload,
sortEvents,
@@ -76,7 +79,7 @@ class RelayClient {
}
const event = await signRelayEvent({
kind: 40001,
kind: KIND_STREAM_MESSAGE,
content: content.trim(),
tags,
});

View File

@@ -1,6 +1,6 @@
export const KIND_DELETION = 5;
export const KIND_REACTION = 7;
export const KIND_STREAM_MESSAGE = 40001;
export const KIND_STREAM_MESSAGE = 9;
export const KIND_STREAM_MESSAGE_V2 = 40002;
export const KIND_STREAM_MESSAGE_EDIT = 40003;
export const KIND_STREAM_MESSAGE_DIFF = 40008;
@@ -8,6 +8,7 @@ export const KIND_STREAM_MESSAGE_DIFF = 40008;
export const CHANNEL_EVENT_KINDS = [
KIND_DELETION, // 5 — NIP-09 event deletions
KIND_REACTION, // 7 — NIP-25 reactions
KIND_STREAM_MESSAGE, // 40001 — channel messages
KIND_STREAM_MESSAGE, // 9 — NIP-29 group chat messages
40001, // legacy: pre-migration stream messages
KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs
] as const;

View File

@@ -1024,7 +1024,7 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
id: "mock-general-welcome",
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
created_at: Math.floor(Date.now() / 1000),
kind: 40001,
kind: 9,
tags: [["h", channelId]],
content: "Welcome to #general",
sig: "mocksig".repeat(20).slice(0, 128),
@@ -1071,7 +1071,7 @@ function recordMockMessage(channelId: string, event: RelayEvent) {
}
function emitMockChannelMessage(channelId: string, content: string) {
const event = createMockEvent(40001, content, [["h", channelId]]);
const event = createMockEvent(9, content, [["h", channelId]]);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
return event;
@@ -1740,7 +1740,7 @@ async function handleGetFeed(
? [
{
id: "mock-feed-mention",
kind: 40001,
kind: 9,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
content: "Please review the release checklist.",
@@ -1780,7 +1780,7 @@ async function handleGetFeed(
? [
{
id: "mock-feed-self-activity",
kind: 40001,
kind: 9,
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
content: "I posted a note about the launch checklist.",
created_at: now - 25 * 60,
@@ -1791,7 +1791,7 @@ async function handleGetFeed(
},
{
id: "mock-feed-activity",
kind: 40001,
kind: 9,
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
content: "Engineering shipped the desktop build.",
@@ -2190,7 +2190,7 @@ async function handleSearchMessages(
{
event_id: "mock-general-welcome",
content: "Welcome to #general",
kind: 40001,
kind: 9,
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
@@ -2200,7 +2200,7 @@ async function handleSearchMessages(
{
event_id: "mock-engineering-shipped",
content: "Engineering shipped the desktop build.",
kind: 40001,
kind: 9,
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
@@ -2211,7 +2211,7 @@ async function handleSearchMessages(
{
event_id: "mock-design-critique",
content: "Design critique notes for the browse flow.",
kind: 40001,
kind: 9,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
@@ -2279,9 +2279,7 @@ async function handleSendChannelMessage(
const createdAt = Math.floor(Date.now() / 1000);
if (!args.parentEventId) {
const event = createMockEvent(40001, args.content, [
["h", args.channelId],
]);
const event = createMockEvent(9, args.content, [["h", args.channelId]]);
recordMockMessage(args.channelId, event);
emitMockLiveEvent(args.channelId, event);
@@ -2330,7 +2328,7 @@ async function handleSendChannelMessage(
id: crypto.randomUUID().replace(/-/g, ""),
pubkey: getMockMemberPubkey(config),
created_at: createdAt,
kind: 40001,
kind: 9,
tags:
rootEventId === args.parentEventId
? [
@@ -2389,7 +2387,7 @@ async function handleGetEvent(
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
created_at: Math.floor(Date.now() / 1000) - 42 * 60,
kind: 40001,
kind: 9,
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
content: "Engineering shipped the desktop build.",
sig: "mocksig".repeat(20).slice(0, 128),
@@ -2399,7 +2397,7 @@ async function handleGetEvent(
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
created_at: Math.floor(Date.now() / 1000) - 75 * 60,
kind: 40001,
kind: 9,
tags: [["h", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"]],
content: "Design critique notes for the browse flow.",
sig: "mocksig".repeat(20).slice(0, 128),

View File

@@ -0,0 +1,10 @@
-- Pubkey allowlist for NIP-42 authentication.
-- When SPROUT_PUBKEY_ALLOWLIST=true, pubkeys in this table may authenticate
-- via NIP-42 keypair-only (no JWT or API token required).
CREATE TABLE IF NOT EXISTS pubkey_allowlist (
pubkey BINARY(32) NOT NULL,
added_by BINARY(32),
added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
note TEXT,
PRIMARY KEY (pubkey)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,5 @@
-- Migrate legacy kind:40001 (pre-NIP-29 stream messages) to kind:9 (NIP-29 group chat).
-- This is a one-time migration. The application code already uses kind:9 for new messages.
-- The desktop client includes 40001 in CHANNEL_EVENT_KINDS for backward compat during
-- the transition window; this migration makes that unnecessary once complete.
UPDATE events SET kind = 9 WHERE kind = 40001;

View File

@@ -0,0 +1,10 @@
-- Composite index for addressable event replacement locking.
-- replace_addressable_event() uses SELECT ... FOR UPDATE on
-- (kind, pubkey, channel_id, deleted_at IS NULL) to serialize concurrent
-- writers for the same logical address. Without this index, InnoDB cannot
-- take a gap lock on a cold address (no existing rows), allowing two
-- concurrent first-time emissions to both insert.
--
-- The index covers the exact WHERE clause and enables next-key locking
-- on the (kind, pubkey, channel_id) range even when no active row exists.
CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at);