mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: NIP-29 native compatibility — standard nostr clients can chat on Sprout (#63)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
10
NOSTR.md
10
NOSTR.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ""));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
10
migrations/20260318000001_pubkey_allowlist.sql
Normal file
10
migrations/20260318000001_pubkey_allowlist.sql
Normal 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;
|
||||
5
migrations/20260318000002_migrate_kind_40001_to_9.sql
Normal file
5
migrations/20260318000002_migrate_kind_40001_to_9.sql
Normal 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;
|
||||
10
migrations/20260318000003_addressable_event_index.sql
Normal file
10
migrations/20260318000003_addressable_event_index.sql
Normal 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);
|
||||
Reference in New Issue
Block a user