diff --git a/.env.example b/.env.example index a72b052..88536e2 100644 --- a/.env.example +++ b/.env.example @@ -151,10 +151,17 @@ RELAY_URL= # deliver a gift-wrapped DM (kind 1059) to a registered user. # author-allowlist — only registered npubs may publish anything. # open — accept everything (relay resource caps still apply). +# premium-post — registered users post wallet events (DMs) free; general +# notes require an active PREMIUM_* subscription. RELAY_WRITE_POLICY=inbox-outbox # NIP-13 proof-of-work bits required on cross-ecosystem (non-registered-author) # inbound events; 0 disables the PoW gate. RELAY_INBOUND_POW_BITS=0 +# Write-exempt npubs (comma-separated; npub1… or 64-char hex): may publish ANY +# kind regardless of the write policy or premium status. Use this for an +# announcements / feed-owner account that seeds a premium-post feed without +# holding a subscription. Empty = no exemptions. +RELAY_WRITE_ALLOWLIST_NPUBS= # Loopback address the gRPC event-admission service binds (the relay calls it). # It answers "is this npub registered?" (a registration oracle) so it MUST stay # loopback; the bundled compose reaches it via the host network. diff --git a/src/config.rs b/src/config.rs index 191d051..c5d6b32 100644 --- a/src/config.rs +++ b/src/config.rs @@ -639,6 +639,12 @@ pub struct RelayConfig { pub max_event_bytes: usize, /// Event retention (days) — advisory for operator housekeeping/advertising. pub retention_days: u32, + /// Write-exempt npubs (hex or `npub1…`): may publish ANY kind regardless of + /// the write policy or premium status. The operator's use case is an + /// announcements / feed-owner account that seeds a `premium-post` feed without + /// itself holding a subscription. Empty by default. Configured via + /// `RELAY_WRITE_ALLOWLIST_NPUBS` (comma-separated). + pub write_allowlist: Vec, } /// Public curated feed (`feed.`) — an operator knob for what the @@ -858,6 +864,7 @@ impl Config { admission_allow_public: env_bool("RELAY_ADMISSION_ALLOW_PUBLIC", false), max_event_bytes: env_parse("RELAY_MAX_EVENT_BYTES", 65536usize)?, retention_days: env_parse("RELAY_RETENTION_DAYS", 30u32)?, + write_allowlist: env_list("RELAY_WRITE_ALLOWLIST_NPUBS"), }, }, premium: PremiumConfig { @@ -1419,6 +1426,7 @@ mod tests { admission_allow_public: false, max_event_bytes: 65536, retention_days: 30, + write_allowlist: Vec::new(), }, }, premium: PremiumConfig { @@ -1738,6 +1746,7 @@ mod tests { admission_allow_public: false, max_event_bytes: 65536, retention_days: 30, + write_allowlist: Vec::new(), } } diff --git a/src/infra/relay/mod.rs b/src/infra/relay/mod.rs index ff7c433..2455838 100644 --- a/src/infra/relay/mod.rs +++ b/src/infra/relay/mod.rs @@ -16,6 +16,7 @@ mod policy; pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND}; +use std::collections::HashSet; use std::sync::Arc; use crate::config::Config; @@ -41,12 +42,44 @@ pub trait RelayProvider: Send + Sync { /// Max event size (bytes) the admission service enforces as defence-in-depth /// (the relay enforces its own limit too). fn max_event_bytes(&self) -> usize; + /// Whether `author_hex` (canonical lowercase x-only pubkey hex) is on the + /// operator write-allowlist — may publish any kind regardless of policy or + /// premium. Default: never. See `RELAY_WRITE_ALLOWLIST_NPUBS`. + fn is_write_allowlisted(&self, _author_hex: &str) -> bool { + false + } /// NIPs advertised as supported. fn supported_nips(&self) -> &'static [u16] { SUPPORTED_NIPS } } +/// Normalize a write-allowlist entry (`npub1…` or 64-char hex) to canonical +/// lowercase x-only pubkey hex, or `None` if it isn't a valid pubkey. npubs are +/// bech32-decoded; hex is validated + lowercased. Keeps the admission comparison +/// (which is hex) operator-friendly (npubs) without a hex-only requirement. +fn allowlist_entry_to_hex(entry: &str) -> Option { + let s = entry.trim(); + if s.is_empty() { + return None; + } + if s.starts_with("npub1") { + // bech32 npub → 32-byte x-only pubkey → hex. + let (hrp, data) = bech32::decode(s).ok()?; + if hrp.as_str() != "npub" || data.len() != 32 { + return None; + } + return Some(hex::encode(data)); + } + // Bare hex (x-only = 32 bytes = 64 chars). + let lower = s.to_lowercase(); + if lower.len() == 64 && hex::decode(&lower).is_ok() { + Some(lower) + } else { + None + } +} + /// Config-selected relay provider, or `None` when the relay is disabled. /// Mirrors [`crate::infra::payment::from_config`]. pub fn from_config(cfg: &Config) -> Result>, AppError> { @@ -63,17 +96,34 @@ pub fn from_config(cfg: &Config) -> Result>, AppEr // From the backend's POV, `bundled` and `external` are the same: a relay // reachable at `advertised_url` that we advertise + admit for. They differ // only in who runs the process (packaging), a deploy concern. - "bundled" | "external" => Arc::new(NostrRelayProvider { - kind: if r.mode == "bundled" { - "nostr-rs-relay" - } else { - "external" - }, - advertised_url: r.advertised_url.clone(), - policy, - inbound_pow_bits: r.inbound_pow_bits, - max_event_bytes: r.max_event_bytes, - }), + "bundled" | "external" => { + // Decode the operator write-allowlist to canonical hex once, at + // startup. A malformed entry is logged + skipped rather than failing + // the whole boot (the relay still works; that npub just isn't exempt). + let mut write_allowlist: HashSet = HashSet::new(); + for entry in &r.write_allowlist { + match allowlist_entry_to_hex(entry) { + Some(hex) => { + write_allowlist.insert(hex); + } + None => tracing::warn!( + "RELAY_WRITE_ALLOWLIST_NPUBS: skipping invalid entry {entry:?}" + ), + } + } + Arc::new(NostrRelayProvider { + kind: if r.mode == "bundled" { + "nostr-rs-relay" + } else { + "external" + }, + advertised_url: r.advertised_url.clone(), + policy, + inbound_pow_bits: r.inbound_pow_bits, + max_event_bytes: r.max_event_bytes, + write_allowlist, + }) + } other => { return Err(AppError::ConfigError(format!( "unsupported RELAY_MODE: {other}" @@ -91,6 +141,39 @@ struct NostrRelayProvider { policy: WritePolicy, inbound_pow_bits: u8, max_event_bytes: usize, + /// Canonical-hex write-exempt pubkeys (decoded from config npubs/hex). + write_allowlist: HashSet, +} + +#[cfg(test)] +mod tests { + use super::allowlist_entry_to_hex; + + // Canonical NIP-19 test vector. + const NPUB: &str = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + const HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + + #[test] + fn decodes_npub_to_hex() { + assert_eq!(allowlist_entry_to_hex(NPUB).as_deref(), Some(HEX)); + } + + #[test] + fn accepts_bare_hex_and_lowercases() { + assert_eq!(allowlist_entry_to_hex(HEX).as_deref(), Some(HEX)); + assert_eq!( + allowlist_entry_to_hex(&format!(" {} ", HEX.to_uppercase())).as_deref(), + Some(HEX), + ); + } + + #[test] + fn rejects_junk() { + assert_eq!(allowlist_entry_to_hex(""), None); + assert_eq!(allowlist_entry_to_hex("not-a-key"), None); + assert_eq!(allowlist_entry_to_hex("npub1garbage"), None); + assert_eq!(allowlist_entry_to_hex("deadbeef"), None); // too short + } } impl RelayProvider for NostrRelayProvider { @@ -109,4 +192,7 @@ impl RelayProvider for NostrRelayProvider { fn max_event_bytes(&self) -> usize { self.max_event_bytes } + fn is_write_allowlisted(&self, author_hex: &str) -> bool { + !self.write_allowlist.is_empty() && self.write_allowlist.contains(author_hex) + } } diff --git a/src/infra/relay/nauthz.rs b/src/infra/relay/nauthz.rs index bf9cfd5..47ab874 100644 --- a/src/infra/relay/nauthz.rs +++ b/src/infra/relay/nauthz.rs @@ -73,26 +73,37 @@ impl Authorization for AdmissionService { let author_hex = hex::encode(&event.pubkey); let id_hex = hex::encode(&event.id); + // Operator write-allowlist (owner/announcements) — a pure in-memory set + // check, no DB. If exempt, `decide` permits any kind immediately, so we + // can skip the registration/premium/recipient lookups below. + let author_allowlisted = self.relay.is_write_allowlisted(&author_hex); + // Author registration. - let author_registered = self - .db - .is_registered_npub(&author_hex) - .await - .unwrap_or(false); + let author_registered = if author_allowlisted { + false + } else { + self.db + .is_registered_npub(&author_hex) + .await + .unwrap_or(false) + }; // Premium membership — only the `premium-post` policy consults it, so skip - // the (cheap, indexed) lookup entirely under the other policies. - let author_premium = if self.relay.write_policy() == WritePolicy::PremiumPost { + // the (cheap, indexed) lookup entirely under the other policies (and when + // the author is already write-allowlisted). + let author_premium = if !author_allowlisted + && self.relay.write_policy() == WritePolicy::PremiumPost + { self.db.is_premium_npub(&author_hex).await.unwrap_or(false) } else { false }; - // Recipient registration (only when the author isn't already registered): - // collect up to MAX_P_TAGS distinct `p` tags and resolve them in ONE - // batched query — bounds the work so a crafted event stuffed with `p` tags - // can't trigger an unbounded per-tag query storm. - let recipient_registered = if author_registered { + // Recipient registration (only when the author isn't already registered / + // allowlisted): collect up to MAX_P_TAGS distinct `p` tags and resolve them + // in ONE batched query — bounds the work so a crafted event stuffed with + // `p` tags can't trigger an unbounded per-tag query storm. + let recipient_registered = if author_registered || author_allowlisted { false } else { const MAX_P_TAGS: usize = 20; @@ -123,6 +134,7 @@ impl Authorization for AdmissionService { author_registered, recipient_registered, author_premium, + author_allowlisted, ) { Admit::Permit => permit(), Admit::Deny(msg) => deny(msg), diff --git a/src/infra/relay/policy.rs b/src/infra/relay/policy.rs index 7e512a4..34d6ca9 100644 --- a/src/infra/relay/policy.rs +++ b/src/infra/relay/policy.rs @@ -119,8 +119,10 @@ fn giftwrap_inbox(meta: &EventMeta, inbound_pow_bits: u8, recipient_registered: /// `author_registered` = the author pubkey is a registered Smirk npub. /// `recipient_registered` = at least one `p` tag is a registered Smirk npub. /// `author_premium` = the author holds an active premium subscription (only the -/// `premium-post` policy consults it). The caller resolves all three against the -/// DB before calling. +/// `premium-post` policy consults it). +/// `author_allowlisted` = the author is on the operator's write-allowlist +/// (`RELAY_WRITE_ALLOWLIST_NPUBS`) — e.g. the announcements / feed-owner account. +/// The caller resolves all of these before calling. pub fn decide( meta: &EventMeta, policy: WritePolicy, @@ -128,7 +130,14 @@ pub fn decide( author_registered: bool, recipient_registered: bool, author_premium: bool, + author_allowlisted: bool, ) -> Admit { + // Operator write-allowlist: an owner/announcements npub may publish ANY kind + // regardless of policy or premium. Checked first so the owner can seed a + // premium-post feed (and post announcements) without a subscription. + if author_allowlisted { + return Admit::Permit; + } match policy { WritePolicy::Open => Admit::Permit, WritePolicy::AuthorAllowlist => { @@ -204,38 +213,38 @@ mod tests { #[test] fn open_permits_everything() { let m = meta(A, 1, FF); - assert!(decide(&m, WritePolicy::Open, 0, false, false, false).is_permit()); + assert!(decide(&m, WritePolicy::Open, 0, false, false, false, false).is_permit()); } #[test] fn author_allowlist_gates_on_author() { let m = meta(A, 1, FF); - assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false).is_permit()); - assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false).is_permit()); + assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false, false, false).is_permit()); + assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false, false, false).is_permit()); } #[test] fn inbox_outbox_permits_registered_author_outbox() { let m = meta(A, 1, FF); // any kind, registered author - assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false, false).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false, false, false).is_permit()); } #[test] fn inbox_outbox_permits_giftwrap_to_registered_recipient() { let m = meta(A, GIFT_WRAP_KIND, FF); - assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true, false).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true, false, false).is_permit()); } #[test] fn inbox_outbox_rejects_external_non_giftwrap() { let m = meta(A, 1, FF); // external author, non-gift-wrap - assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true, false).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true, false, false).is_permit()); } #[test] fn inbox_outbox_rejects_giftwrap_to_unregistered_recipient() { let m = meta(A, GIFT_WRAP_KIND, FF); - assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false, false).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false, false, false).is_permit()); } #[test] @@ -252,8 +261,8 @@ mod tests { let id = format!("0000{}", "ff".repeat(30)); let m = meta(A, GIFT_WRAP_KIND, &id); // require 8 bits → permit; require 20 → deny. - assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true, false).is_permit()); - assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true, false).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true, false, false).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true, false, false).is_permit()); } #[test] @@ -261,7 +270,7 @@ mod tests { // A registered author's low-PoW event is still fine (PoW is inbound-only). let id = "ff".repeat(32); // 0 leading zero bits let m = meta(A, 1, &id); - assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false, false).is_permit()); + assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false, false, false).is_permit()); } // ── premium-post ───────────────────────────────────────────────────────── @@ -270,7 +279,7 @@ mod tests { fn premium_post_premium_author_posts_anything() { let m = meta(A, 1, FF); // a general kind-1 note // premium → permit even a non-wallet kind. - assert!(decide(&m, WritePolicy::PremiumPost, 0, true, false, true).is_permit()); + assert!(decide(&m, WritePolicy::PremiumPost, 0, true, false, true, false).is_permit()); } #[test] @@ -279,7 +288,7 @@ mod tests { let m = meta(A, *k, FF); // registered, NOT premium → wallet kinds still permitted. assert!( - decide(&m, WritePolicy::PremiumPost, 0, true, false, false).is_permit(), + decide(&m, WritePolicy::PremiumPost, 0, true, false, false, false).is_permit(), "wallet kind {k} should be free for registered users" ); } @@ -288,19 +297,57 @@ mod tests { #[test] fn premium_post_registered_needs_premium_for_general() { let m = meta(A, 1, FF); // general kind-1, registered but not premium - assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false).is_permit()); + assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false, false).is_permit()); } #[test] fn premium_post_allows_external_giftwrap_inbox() { let m = meta(A, GIFT_WRAP_KIND, FF); // external (unregistered) author delivering a DM to a registered user. - assert!(decide(&m, WritePolicy::PremiumPost, 0, false, true, false).is_permit()); + assert!(decide(&m, WritePolicy::PremiumPost, 0, false, true, false, false).is_permit()); } #[test] fn premium_post_rejects_external_general() { let m = meta(A, 1, FF); // external author, non-gift-wrap - assert!(!decide(&m, WritePolicy::PremiumPost, 0, false, true, false).is_permit()); + assert!(!decide(&m, WritePolicy::PremiumPost, 0, false, true, false, false).is_permit()); + } + + // ── write-allowlist (owner/announcements exemption) ────────────────────── + + #[test] + fn allowlisted_author_posts_general_note_under_premium_post() { + // The owner npub is NOT premium and NOT otherwise registered, but is on + // the write-allowlist → may post a general kind-1 note to a premium relay. + let m = meta(A, 1, FF); + assert!( + decide(&m, WritePolicy::PremiumPost, 0, false, false, false, true).is_permit(), + "an allowlisted owner must be able to seed a premium-post feed" + ); + } + + #[test] + fn allowlist_bypasses_every_policy_and_kind() { + for policy in [ + WritePolicy::Open, + WritePolicy::AuthorAllowlist, + WritePolicy::InboxOutbox, + WritePolicy::PremiumPost, + ] { + for kind in [1u64, GIFT_WRAP_KIND, 30023] { + let m = meta(A, kind, FF); + assert!( + decide(&m, policy, 8, false, false, false, true).is_permit(), + "allowlisted author should be permitted under {policy:?} for kind {kind}" + ); + } + } + } + + #[test] + fn non_allowlisted_still_gated() { + // Sanity: with allowlisted=false the premium gate still bites. + let m = meta(A, 1, FF); + assert!(!decide(&m, WritePolicy::PremiumPost, 0, true, false, false, false).is_permit()); } }