diff --git a/CLAUDE.md b/CLAUDE.md index 4736992..2064083 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Mostro Push Server -Privacy-preserving push notification backend for the Mostro P2P trading ecosystem. Rust + Actix-web + Tokio. The server observes Nostr Gift Wrap events (`kind 1059`) on configured relays, looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). +Privacy-preserving push notification backend for the Mostro P2P trading ecosystem. Rust + Actix-web + Tokio. The server observes Nostr Gift Wrap events (`kind 1059`, Mostro protocol v1) and NIP-44 direct messages (`kind 14`, Mostro protocol v2) on configured relays, looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). For deeper context (data flow, components, ops): [docs/architecture.md](docs/architecture.md), [docs/api.md](docs/api.md), [docs/configuration.md](docs/configuration.md). @@ -68,7 +68,7 @@ src/ │ ├── notify.rs # /api/notify handler + request_id_mw │ ├── rate_limit.rs # per-IP / per-pubkey limiter middleware (governor) │ └── test_support.rs # In-process test fixtures -├── nostr/listener.rs # Persistent subscription, kind 1059 dispatch +├── nostr/listener.rs # Persistent subscription, kind 1059 / kind 14 dispatch ├── push/ │ ├── mod.rs # PushService trait │ ├── dispatcher.rs # PushDispatcher (lock-free) diff --git a/README.md b/README.md index 9c18d0d..e96e1ec 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Privacy-preserving push notification backend for the [Mostro](https://mostro.network/) P2P trading ecosystem. -The server observes Nostr Gift Wrap events (`kind 1059`), looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush so Mostro Mobile clients can wake up and process trade events. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). +The server observes Nostr Gift Wrap events (`kind 1059`, Mostro protocol v1) and NIP-44 direct messages (`kind 14`, Mostro protocol v2), looks up registered device tokens by `trade_pubkey`, and dispatches silent push notifications via Firebase Cloud Messaging (FCM) and UnifiedPush so Mostro Mobile clients can wake up and process trade events. Inspired by [MIP-05](https://github.com/MostroP2P/MIPs). ## How it works @@ -17,7 +17,7 @@ The server observes Nostr Gift Wrap events (`kind 1059`), looks up registered de │ │ trade_pubkey │ │ └─────────────────┘ └────────┬─────────┘ │ -┌─────────────────┐ 2. Publishes kind 1059 ┌────────▼─────────┐ +┌─────────────────┐ 2. Publishes kind 1059 / 14 ┌────────▼─────────┐ │ Mostro Daemon │ ──────────────────────────────────▶│ Nostr Relay │ │ / dispute │ p: trade_pubkey │ │ │ admin / peer │ └────────┬─────────┘ @@ -41,7 +41,7 @@ The server observes Nostr Gift Wrap events (`kind 1059`), looks up registered de Two ingress paths feed the same dispatcher: -1. **Listener path** — the Nostr listener subscribes to `kind 1059` on configured relays and dispatches when a `p` tag matches a registered `trade_pubkey`. +1. **Listener path** — the Nostr listener subscribes to `kind 1059` (protocol v1 Gift Wrap) and `kind 14` (protocol v2 NIP-44 direct) on configured relays and dispatches when a `p` tag matches a registered `trade_pubkey`. 2. **Sender-triggered path** — `POST /api/notify` lets a sender ask the server to wake the recipient when an event was sent peer-to-peer without going through the Mostro daemon (e.g. dispute admin DMs). ## Privacy properties diff --git a/config.toml.example b/config.toml.example index e647a24..1e49cb7 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,7 +1,7 @@ [nostr] relays = ["wss://relay.mostro.network"] subscription_id = "mostro-push-listener" -event_kinds = [1059] +event_kinds = [1059, 14] [push] fcm_enabled = true diff --git a/docs/README.md b/docs/README.md index ac49368..4888c57 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Operator and integrator documentation for the Mostro Push Server, a privacy-pres ## What this server does -- Subscribes to Nostr relays and observes Gift Wrap events (`kind 1059`). +- Subscribes to Nostr relays and observes Gift Wrap events (`kind 1059`, Mostro protocol v1) and NIP-44 direct messages (`kind 14`, Mostro protocol v2). - Maintains an in-memory map of `trade_pubkey -> device_token` populated by mobile clients via `POST /api/register`. - On a matching event, dispatches a silent push via Firebase Cloud Messaging (FCM) and/or UnifiedPush. - Exposes `POST /api/notify` for the mobile client to trigger a sender-side wake-up (silent push) when peer-to-peer chat events are sent without going through the Mostro daemon. diff --git a/docs/architecture.md b/docs/architecture.md index d71054f..3aefde5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ Five always-on endpoints (`/api/health`, `/api/info`, `/api/status`, `/api/regis ### Nostr listener (`nostr-sdk`) -Connects to all configured relays, subscribes to `kind 1059` events with no author filter, and reconnects automatically on close (5 s) or error (10 s). For each event it extracts the `p` tag and looks up the corresponding token in the store; on hit it calls `PushDispatcher::dispatch`. +Connects to all configured relays, subscribes to `kind 1059` (Mostro protocol v1 Gift Wrap) and `kind 14` (Mostro protocol v2 NIP-44 direct) events with no author filter, and reconnects automatically on close (5 s) or error (10 s). For each event it extracts the `p` tag and looks up the corresponding token in the store; on hit it calls `PushDispatcher::dispatch`. The listener generates an ephemeral `Keys::generate()` for the connection itself; this key only signs subscriptions, it never identifies a user. @@ -68,12 +68,12 @@ A salted truncated BLAKE3 keyed hash. The salt is a 32-byte random value generat ## Data flow -### Listener path (`kind 1059` from a relay) +### Listener path (`kind 1059` / `kind 14` from a relay) ``` Sender (any Nostr client) │ - │ publish kind 1059 (p tag = trade_pubkey) + │ publish kind 1059 or kind 14 (p tag = trade_pubkey) ▼ Nostr relay │ diff --git a/docs/configuration.md b/docs/configuration.md index 57ed09a..1477191 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ cp .env.example .env | Variable | Description | |-----------------|----------------------------------------------------------------------------------------------| -| `NOSTR_RELAYS` | Comma-separated list of Nostr relay URLs. Used by `NostrListener` to subscribe to kind 1059. | +| `NOSTR_RELAYS` | Comma-separated list of Nostr relay URLs. Used by `NostrListener` to subscribe to kinds 1059 and 14. | `NOSTR_RELAYS` is the only variable without a default; the server fails to boot if it is unset. diff --git a/src/config.rs b/src/config.rs index 22bc7ad..f2884af 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,7 +96,7 @@ impl Config { nostr: NostrConfig { relays, subscription_id: "mostro-push-listener".to_string(), - event_kinds: vec![1059], + event_kinds: vec![1059, 14], }, push: PushConfig { fcm_enabled: env::var("FCM_ENABLED") diff --git a/src/nostr/listener.rs b/src/nostr/listener.rs index ed7af98..a8fa079 100644 --- a/src/nostr/listener.rs +++ b/src/nostr/listener.rs @@ -70,12 +70,18 @@ impl NostrListener { // 2. Admin DMs in disputes are sent directly user-to-user, NOT through the Mostro daemon. // A mostro_pubkey author filter would silently drop every dispute notification. // See PROJECT.md anti-requirement OOS-19 / PITFALLS CRIT-1. + // + // Kind 14 is Mostro protocol v2 (NIP-44 direct): daemons advertising + // protocol_version=2 address the trade pubkey in the `p` tag of a + // signed kind-14 event instead of a Gift Wrap. It is matched by `p` + // tag only, like kind 1059 — pushes fire solely for registered trade + // pubkeys, so no author filter is needed here either. let since = Timestamp::now() - Duration::from_secs(60); - let filter = Filter::new().kinds(vec![Kind::Custom(1059)]).since(since); + let filter = Filter::new().kinds(watched_kinds()).since(since); // Subscribe to events client.subscribe(vec![filter]).await; - info!("Subscribed to kind 1059 (Gift Wrap) events on relay"); + info!("Subscribed to kind 1059 (Gift Wrap) and kind 14 (protocol v2) events on relay"); // Handle incoming events let token_store = self.token_store.clone(); @@ -85,20 +91,16 @@ impl NostrListener { client .handle_notifications(|notification| async { if let RelayPoolNotification::Event { event, .. } = notification { - if event.kind == Kind::Custom(1059) { - // Log every Gift Wrap event received - info!("Received Gift Wrap (kind 1059) event: {}", event.id); + if is_watched_kind(event.kind) { + // Log every watched event received + info!( + "Received {} event: {}", + kind_label(event.kind), + event.id + ); // Extract recipient from 'p' tag - let recipient_pubkey = event.tags.iter() - .find_map(|tag| { - let tag_vec = tag.as_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "p" { - Some(tag_vec[1].clone()) - } else { - None - } - }); + let recipient_pubkey = extract_recipient(&event); if let Some(trade_pubkey) = recipient_pubkey { let log_pk = log_pubkey(&log_salt, &trade_pubkey); @@ -132,7 +134,11 @@ impl NostrListener { debug!("No registered token pk={}", log_pk); } } else { - warn!("No 'p' tag found in Gift Wrap event {}", event.id); + warn!( + "No 'p' tag found in {} event {}", + kind_label(event.kind), + event.id + ); } } } @@ -143,3 +149,84 @@ impl NostrListener { Ok(()) } } + +/// Event kinds the listener subscribes to and dispatches on: +/// - 1059 — Gift Wrap (NIP-59), Mostro protocol v1 and dispute admin DMs. +/// - 14 — NIP-44 direct message, Mostro protocol v2 (daemons advertising +/// `protocol_version=2` reply with signed kind-14 events addressed to the +/// trade pubkey in the `p` tag instead of a Gift Wrap). +fn watched_kinds() -> Vec { + vec![Kind::Custom(1059), Kind::Custom(14)] +} + +fn is_watched_kind(kind: Kind) -> bool { + watched_kinds().contains(&kind) +} + +fn kind_label(kind: Kind) -> &'static str { + match kind { + Kind::Custom(1059) => "Gift Wrap (kind 1059)", + Kind::Custom(14) => "protocol v2 (kind 14)", + _ => "unexpected kind", + } +} + +/// Extracts the recipient trade pubkey from the first `p` tag, shared by both +/// watched kinds (v1 Gift Wrap and v2 NIP-44 direct address the recipient the +/// same way). +fn extract_recipient(event: &Event) -> Option { + event.tags.iter().find_map(|tag| { + let tag_vec = tag.as_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "p" { + Some(tag_vec[1].clone()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn watched_kinds_include_gift_wrap_and_protocol_v2() { + assert!(is_watched_kind(Kind::Custom(1059))); + assert!(is_watched_kind(Kind::Custom(14))); + } + + #[test] + fn unrelated_kinds_are_not_watched() { + assert!(!is_watched_kind(Kind::Custom(1))); + assert!(!is_watched_kind(Kind::Custom(38385))); + assert!(!is_watched_kind(Kind::Custom(10002))); + } + + #[test] + fn extract_recipient_returns_first_p_tag() { + let keys = Keys::generate(); + let recipient = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(14), + "ciphertext", + [Tag::public_key(recipient.public_key())], + ) + .to_event(&keys) + .unwrap(); + + assert_eq!( + extract_recipient(&event), + Some(recipient.public_key().to_string()) + ); + } + + #[test] + fn extract_recipient_returns_none_without_p_tag() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(14), "ciphertext", []) + .to_event(&keys) + .unwrap(); + + assert_eq!(extract_recipient(&event), None); + } +}